Skip to content

Commit

Permalink
Node.js worker thread API (#380)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasongin authored Sep 19, 2024
1 parent 2b7785b commit aa2ce51
Show file tree
Hide file tree
Showing 11 changed files with 1,119 additions and 84 deletions.
3 changes: 3 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ csharp_space_between_square_brackets = false
dotnet_diagnostic.CA1510.severity = none
dotnet_diagnostic.CA1513.severity = none

# Stream APIs with Memory parameters are not available in .NET Framework
dotnet_diagnostic.CA1835.severity = none

dotnet_diagnostic.IDE0290.severity = none # Use primary constructor
dotnet_diagnostic.IDE0065.severity = none # Using directives must be placed outside of namespace

Expand Down
3 changes: 2 additions & 1 deletion docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ export default defineConfig({
{ text: 'JS / .NET Marshalling', link: '/features/js-dotnet-marshalling' },
{ text: 'JS types in .NET', link: '/features/js-types-in-dotnet' },
{ text: 'JS value scopes', link: '/features/js-value-scopes' },
{ text: 'JS threading & async', link: '/features/js-threading-async' },
{ text: 'JS references', link: '/features/js-references' },
{ text: 'JS threading & async', link: '/features/js-threading-async' },
{ text: 'Node worker threads', link: '/features/node-workers' },
{ text: '.NET Native AOT', link: '/features/dotnet-native-aot' },
{ text: 'Performance', link: '/features/performance' },
]
Expand Down
65 changes: 65 additions & 0 deletions docs/features/node-workers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Node Worker Threads

[Node worker threads](https://nodejs.org/api/worker_threads.html) enable parallel execution of
JavaScript in the same process. They are ideal for CPU-intensive JavaScript operations. They are
less suited to I/O-intensive work, where the Node.js built-in asynchronous I/O operations are more
efficient than Workers.

The [NodeWorker](../reference/dotnet/Microsoft.JavaScript.NodeApi.Interop/NodeWorker) class enables
C# code to create Node worker threads in the same process, and communicate with them.

## JS worker threads

To create a worker, construct a new `NodeWorker` instance with the path to the worker JavaScript
file:

```C#
var worker = new NodeWorker(@".\myWorker.js", new NodeWorker.Options());
```

Or provide the worker script directly as a string, using the `Eval` option:
```C#
var worker = new NodeWorker(@"
const assert = require('node:assert');
const { isMainThread } = require('node:worker_threads');
assert(!isMainThread); // This script is running as a worker.
", new NodeWorker.Options { Eval = true });
```

Messages (any serializable JS values) can be passed back and forth between the C# host and the JS
worker:
```C#
var worker = new NodeWorker(@"
const { parentPort } = require('node:worker_threads');
parentPort.on('message', (msg) => {
parentPort.postMessage(msg); // echo
});
", new NodeWorker.Options { Eval = true });

// Wait for the worker to start before sending a message.
TaskCompletionSource<bool> onlineCompletion = new();
worker.Online += (sender, e) => onlineCompletion.TrySetResult(true);
worker.Error += (sender, e) => onlineCompletion.TrySetException(new JSException(e.Error));
await onlineCompletion.Task;

// Send a message and verify the response.
TaskCompletionSource<string> echoCompletion = new();
worker.Message += (_, e) => echoCompletion.TrySetResult((string)e.Value);
worker.Error += (_, e) => echoCompletion.TrySetException(
new JSException(e.Error));
worker.Exit += (_, e) => echoCompletion.TrySetException(
new InvalidOperationException("Worker exited without echoing!"));
worker.PostMessage("hello");
string echo = await echoCompletion.Task;
Assert.Equal("hello", echo);
```

## C# worker threads

::: warning :construction: COMING SOON
This functionality is not available yet, but is coming soon.
:::

Instead of starting a worker with a JavaScript file, it will be possible to provide a C# delegate.
The delegate callback will be invoked on the JS worker thread; then it can orchestrate importing
JavaScript packages, callilng JS functions, or whatever is needed to do the work on the thread.
96 changes: 22 additions & 74 deletions src/NodeApi.DotNetHost/JSMarshaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3163,8 +3163,12 @@ private IEnumerable<Expression> BuildFromJSToCollectionInterfaceExpressions(
* (key) => (JSValue)key,
* (value) => (JSValue)value);
*/
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions).GetStaticMethod(
nameof(JSCollectionExtensions.AsDictionary))!.MakeGenericMethod(keyType, valueType);
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions)
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.Where((m) => m.Name == nameof(JSCollectionExtensions.AsDictionary) &&
m.GetParameters()[0].ParameterType == typeof(JSMap))
.Single()
.MakeGenericMethod(keyType, valueType);
MethodInfo asJSMapMethod = typeof(JSMap).GetExplicitConversion(
typeof(JSValue), typeof(JSMap));
yield return Expression.Coalesce(
Expand All @@ -3189,9 +3193,12 @@ private IEnumerable<Expression> BuildFromJSToCollectionInterfaceExpressions(
* (value) => (TValue)value,
* (key) => (JSValue)key);
*/
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions).GetStaticMethod(
nameof(JSCollectionExtensions.AsReadOnlyDictionary))
!.MakeGenericMethod(keyType, valueType);
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions)
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.Where((m) => m.Name == nameof(JSCollectionExtensions.AsReadOnlyDictionary) &&
m.GetParameters()[0].ParameterType == typeof(JSMap))
.Single()
.MakeGenericMethod(keyType, valueType);
MethodInfo asJSMapMethod = typeof(JSMap).GetExplicitConversion(
typeof(JSValue), typeof(JSMap));
yield return Expression.Coalesce(
Expand Down Expand Up @@ -3248,71 +3255,6 @@ private IEnumerable<Expression> BuildFromJSToCollectionClassExpressions(
Expression.Convert(valueExpression, jsIterableType, asJSIterableMethod),
GetFromJSValueExpression(elementType))));
}
else if (typeDefinition == typeof(Dictionary<,>))
{
Type keyType = elementType;
Type valueType = toType.GenericTypeArguments[1];

/*
* value.TryUnwrap() as Dictionary<TKey, TValue> ??
* new Dictionary<TKey, TValue>(((JSMap)value).AsDictionary<TKey, TValue>(
* (key) => (TKey)key,
* (value) => (TValue)value,
* (key) => (JSValue)key,
* (value) => (JSValue)value);
*/
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions).GetStaticMethod(
nameof(JSCollectionExtensions.AsDictionary))!.MakeGenericMethod(
keyType, valueType);
MethodInfo asJSMapMethod = typeof(JSMap).GetExplicitConversion(
typeof(JSValue), typeof(JSMap));
ConstructorInfo dictionaryConstructor = toType.GetConstructor(
new[] { typeof(IDictionary<,>).MakeGenericType(keyType, valueType) })!;
yield return Expression.Coalesce(
Expression.TypeAs(Expression.Call(valueExpression, s_tryUnwrap), toType),
Expression.New(
dictionaryConstructor,
Expression.Call(
asDictionaryMethod,
Expression.Convert(valueExpression, typeof(JSMap), asJSMapMethod),
GetFromJSValueExpression(keyType),
GetFromJSValueExpression(valueType),
GetToJSValueExpression(keyType),
GetToJSValueExpression(valueType))));
}
else if (typeDefinition == typeof(SortedDictionary<,>))
{
Type keyType = elementType;
Type valueType = toType.GenericTypeArguments[1];

/*
* value.TryUnwrap() as SortedDictionary<TKey, TValue> ??
* new SortedDictionary<TKey, TValue>(((JSMap)value).AsDictionary<TKey, TValue>(
* (key) => (TKey)key,
* (value) => (TValue)value,
* (key) => (JSValue)key,
* (value) => (JSValue)value));
*/
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions).GetStaticMethod(
nameof(JSCollectionExtensions.AsDictionary))!.MakeGenericMethod(
keyType, valueType);
MethodInfo asJSMapMethod = typeof(JSMap).GetExplicitConversion(
typeof(JSValue), typeof(JSMap));
// SortedDictionary doesn't have a constructor that takes IEnumerable<KeyValuePair<>>.
ConstructorInfo dictionaryConstructor = toType.GetConstructor(
new[] { typeof(IDictionary<,>).MakeGenericType(keyType, valueType) })!;
yield return Expression.Coalesce(
Expression.TypeAs(Expression.Call(valueExpression, s_tryUnwrap), toType),
Expression.New(
dictionaryConstructor,
Expression.Call(
asDictionaryMethod,
Expression.Convert(valueExpression, typeof(JSMap), asJSMapMethod),
GetFromJSValueExpression(keyType),
GetFromJSValueExpression(valueType),
GetToJSValueExpression(keyType),
GetToJSValueExpression(valueType))));
}
else if (typeDefinition == typeof(Collection<>) ||
typeDefinition == typeof(ReadOnlyCollection<>))
{
Expand Down Expand Up @@ -3342,21 +3284,27 @@ private IEnumerable<Expression> BuildFromJSToCollectionClassExpressions(
GetToJSValueExpression(elementType))));

}
else if (typeDefinition == typeof(ReadOnlyDictionary<,>))
else if (typeDefinition == typeof(Dictionary<,>) ||
typeDefinition == typeof(SortedDictionary<,>) ||
typeDefinition == typeof(ReadOnlyDictionary<,>))
{
Type keyType = elementType;
Type valueType = toType.GenericTypeArguments[1];

/*
* value.TryUnwrap() as ReadOnlyDictionary<TKey, TValue> ??
* value.TryUnwrap() as Dictionary<TKey, TValue> ??
* new Dictionary<TKey, TValue>(((JSMap)value).AsDictionary<TKey, TValue>(
* (key) => (TKey)key,
* (value) => (TValue)value,
* (key) => (JSValue)key,
* (value) => (JSValue)value));
*/
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions).GetStaticMethod(
nameof(JSCollectionExtensions.AsDictionary))!.MakeGenericMethod(keyType, valueType);
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions)
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.Where((m) => m.Name == nameof(JSCollectionExtensions.AsDictionary) &&
m.GetParameters()[0].ParameterType == typeof(JSMap))
.Single()
.MakeGenericMethod(keyType, valueType);
MethodInfo asJSMapMethod = typeof(JSMap).GetExplicitConversion(
typeof(JSValue), typeof(JSMap));
ConstructorInfo dictionaryConstructor = toType.GetConstructor(
Expand Down
26 changes: 25 additions & 1 deletion src/NodeApi.DotNetHost/ManagedHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ JSValue removeListener(JSCallbackArgs args)
JSPropertyDescriptor.Function("load", LoadAssembly),

JSPropertyDescriptor.Function("addListener", addListener),
JSPropertyDescriptor.Function("removeListener", removeListener));
JSPropertyDescriptor.Function("removeListener", removeListener),

JSPropertyDescriptor.Function("runWorker", RunWorker));


// Create a marshaller instance for the current thread. The marshaller dynamically
// generates adapter delegates for calls to and from JS, for assemblies that were not
Expand Down Expand Up @@ -560,6 +563,27 @@ private Assembly LoadAssembly(string assemblyNameOrFilePath, bool allowNativeLib
return assembly;
}

private JSValue RunWorker(JSCallbackArgs args)
{
nint callbackHandleValue = (nint)args[0].ToBigInteger();
Trace($"> ManagedHost.RunWorker({callbackHandleValue})");

GCHandle callbackHandle = GCHandle.FromIntPtr(callbackHandleValue);
Action callback = (Action)callbackHandle.Target!;
callbackHandle.Free();

try
{
// Worker data and argv are available to the callback as NodejsWorker static properties.
callback();
return JSValue.Undefined;
}
finally
{
Trace($"< ManagedHost.RunWorker({callbackHandleValue})");
}
}

protected override void Dispose(bool disposing)
{
if (disposing)
Expand Down
Loading

0 comments on commit aa2ce51

Please sign in to comment.