Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Node.js worker thread API #380

Merged
merged 4 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading