Skip to content

Commit aa2ce51

Browse files
authored
Node.js worker thread API (#380)
1 parent 2b7785b commit aa2ce51

11 files changed

Lines changed: 1119 additions & 84 deletions

File tree

.editorconfig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,9 @@ csharp_space_between_square_brackets = false
156156
dotnet_diagnostic.CA1510.severity = none
157157
dotnet_diagnostic.CA1513.severity = none
158158

159+
# Stream APIs with Memory parameters are not available in .NET Framework
160+
dotnet_diagnostic.CA1835.severity = none
161+
159162
dotnet_diagnostic.IDE0290.severity = none # Use primary constructor
160163
dotnet_diagnostic.IDE0065.severity = none # Using directives must be placed outside of namespace
161164

docs/.vitepress/config.mts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ export default defineConfig({
4242
{ text: 'JS / .NET Marshalling', link: '/features/js-dotnet-marshalling' },
4343
{ text: 'JS types in .NET', link: '/features/js-types-in-dotnet' },
4444
{ text: 'JS value scopes', link: '/features/js-value-scopes' },
45-
{ text: 'JS threading & async', link: '/features/js-threading-async' },
4645
{ text: 'JS references', link: '/features/js-references' },
46+
{ text: 'JS threading & async', link: '/features/js-threading-async' },
47+
{ text: 'Node worker threads', link: '/features/node-workers' },
4748
{ text: '.NET Native AOT', link: '/features/dotnet-native-aot' },
4849
{ text: 'Performance', link: '/features/performance' },
4950
]

docs/features/node-workers.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Node Worker Threads
2+
3+
[Node worker threads](https://nodejs.org/api/worker_threads.html) enable parallel execution of
4+
JavaScript in the same process. They are ideal for CPU-intensive JavaScript operations. They are
5+
less suited to I/O-intensive work, where the Node.js built-in asynchronous I/O operations are more
6+
efficient than Workers.
7+
8+
The [NodeWorker](../reference/dotnet/Microsoft.JavaScript.NodeApi.Interop/NodeWorker) class enables
9+
C# code to create Node worker threads in the same process, and communicate with them.
10+
11+
## JS worker threads
12+
13+
To create a worker, construct a new `NodeWorker` instance with the path to the worker JavaScript
14+
file:
15+
16+
```C#
17+
var worker = new NodeWorker(@".\myWorker.js", new NodeWorker.Options());
18+
```
19+
20+
Or provide the worker script directly as a string, using the `Eval` option:
21+
```C#
22+
var worker = new NodeWorker(@"
23+
const assert = require('node:assert');
24+
const { isMainThread } = require('node:worker_threads');
25+
assert(!isMainThread); // This script is running as a worker.
26+
", new NodeWorker.Options { Eval = true });
27+
```
28+
29+
Messages (any serializable JS values) can be passed back and forth between the C# host and the JS
30+
worker:
31+
```C#
32+
var worker = new NodeWorker(@"
33+
const { parentPort } = require('node:worker_threads');
34+
parentPort.on('message', (msg) => {
35+
parentPort.postMessage(msg); // echo
36+
});
37+
", new NodeWorker.Options { Eval = true });
38+
39+
// Wait for the worker to start before sending a message.
40+
TaskCompletionSource<bool> onlineCompletion = new();
41+
worker.Online += (sender, e) => onlineCompletion.TrySetResult(true);
42+
worker.Error += (sender, e) => onlineCompletion.TrySetException(new JSException(e.Error));
43+
await onlineCompletion.Task;
44+
45+
// Send a message and verify the response.
46+
TaskCompletionSource<string> echoCompletion = new();
47+
worker.Message += (_, e) => echoCompletion.TrySetResult((string)e.Value);
48+
worker.Error += (_, e) => echoCompletion.TrySetException(
49+
new JSException(e.Error));
50+
worker.Exit += (_, e) => echoCompletion.TrySetException(
51+
new InvalidOperationException("Worker exited without echoing!"));
52+
worker.PostMessage("hello");
53+
string echo = await echoCompletion.Task;
54+
Assert.Equal("hello", echo);
55+
```
56+
57+
## C# worker threads
58+
59+
::: warning :construction: COMING SOON
60+
This functionality is not available yet, but is coming soon.
61+
:::
62+
63+
Instead of starting a worker with a JavaScript file, it will be possible to provide a C# delegate.
64+
The delegate callback will be invoked on the JS worker thread; then it can orchestrate importing
65+
JavaScript packages, callilng JS functions, or whatever is needed to do the work on the thread.

src/NodeApi.DotNetHost/JSMarshaller.cs

Lines changed: 22 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -3163,8 +3163,12 @@ private IEnumerable<Expression> BuildFromJSToCollectionInterfaceExpressions(
31633163
* (key) => (JSValue)key,
31643164
* (value) => (JSValue)value);
31653165
*/
3166-
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions).GetStaticMethod(
3167-
nameof(JSCollectionExtensions.AsDictionary))!.MakeGenericMethod(keyType, valueType);
3166+
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions)
3167+
.GetMethods(BindingFlags.Public | BindingFlags.Static)
3168+
.Where((m) => m.Name == nameof(JSCollectionExtensions.AsDictionary) &&
3169+
m.GetParameters()[0].ParameterType == typeof(JSMap))
3170+
.Single()
3171+
.MakeGenericMethod(keyType, valueType);
31683172
MethodInfo asJSMapMethod = typeof(JSMap).GetExplicitConversion(
31693173
typeof(JSValue), typeof(JSMap));
31703174
yield return Expression.Coalesce(
@@ -3189,9 +3193,12 @@ private IEnumerable<Expression> BuildFromJSToCollectionInterfaceExpressions(
31893193
* (value) => (TValue)value,
31903194
* (key) => (JSValue)key);
31913195
*/
3192-
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions).GetStaticMethod(
3193-
nameof(JSCollectionExtensions.AsReadOnlyDictionary))
3194-
!.MakeGenericMethod(keyType, valueType);
3196+
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions)
3197+
.GetMethods(BindingFlags.Public | BindingFlags.Static)
3198+
.Where((m) => m.Name == nameof(JSCollectionExtensions.AsReadOnlyDictionary) &&
3199+
m.GetParameters()[0].ParameterType == typeof(JSMap))
3200+
.Single()
3201+
.MakeGenericMethod(keyType, valueType);
31953202
MethodInfo asJSMapMethod = typeof(JSMap).GetExplicitConversion(
31963203
typeof(JSValue), typeof(JSMap));
31973204
yield return Expression.Coalesce(
@@ -3248,71 +3255,6 @@ private IEnumerable<Expression> BuildFromJSToCollectionClassExpressions(
32483255
Expression.Convert(valueExpression, jsIterableType, asJSIterableMethod),
32493256
GetFromJSValueExpression(elementType))));
32503257
}
3251-
else if (typeDefinition == typeof(Dictionary<,>))
3252-
{
3253-
Type keyType = elementType;
3254-
Type valueType = toType.GenericTypeArguments[1];
3255-
3256-
/*
3257-
* value.TryUnwrap() as Dictionary<TKey, TValue> ??
3258-
* new Dictionary<TKey, TValue>(((JSMap)value).AsDictionary<TKey, TValue>(
3259-
* (key) => (TKey)key,
3260-
* (value) => (TValue)value,
3261-
* (key) => (JSValue)key,
3262-
* (value) => (JSValue)value);
3263-
*/
3264-
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions).GetStaticMethod(
3265-
nameof(JSCollectionExtensions.AsDictionary))!.MakeGenericMethod(
3266-
keyType, valueType);
3267-
MethodInfo asJSMapMethod = typeof(JSMap).GetExplicitConversion(
3268-
typeof(JSValue), typeof(JSMap));
3269-
ConstructorInfo dictionaryConstructor = toType.GetConstructor(
3270-
new[] { typeof(IDictionary<,>).MakeGenericType(keyType, valueType) })!;
3271-
yield return Expression.Coalesce(
3272-
Expression.TypeAs(Expression.Call(valueExpression, s_tryUnwrap), toType),
3273-
Expression.New(
3274-
dictionaryConstructor,
3275-
Expression.Call(
3276-
asDictionaryMethod,
3277-
Expression.Convert(valueExpression, typeof(JSMap), asJSMapMethod),
3278-
GetFromJSValueExpression(keyType),
3279-
GetFromJSValueExpression(valueType),
3280-
GetToJSValueExpression(keyType),
3281-
GetToJSValueExpression(valueType))));
3282-
}
3283-
else if (typeDefinition == typeof(SortedDictionary<,>))
3284-
{
3285-
Type keyType = elementType;
3286-
Type valueType = toType.GenericTypeArguments[1];
3287-
3288-
/*
3289-
* value.TryUnwrap() as SortedDictionary<TKey, TValue> ??
3290-
* new SortedDictionary<TKey, TValue>(((JSMap)value).AsDictionary<TKey, TValue>(
3291-
* (key) => (TKey)key,
3292-
* (value) => (TValue)value,
3293-
* (key) => (JSValue)key,
3294-
* (value) => (JSValue)value));
3295-
*/
3296-
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions).GetStaticMethod(
3297-
nameof(JSCollectionExtensions.AsDictionary))!.MakeGenericMethod(
3298-
keyType, valueType);
3299-
MethodInfo asJSMapMethod = typeof(JSMap).GetExplicitConversion(
3300-
typeof(JSValue), typeof(JSMap));
3301-
// SortedDictionary doesn't have a constructor that takes IEnumerable<KeyValuePair<>>.
3302-
ConstructorInfo dictionaryConstructor = toType.GetConstructor(
3303-
new[] { typeof(IDictionary<,>).MakeGenericType(keyType, valueType) })!;
3304-
yield return Expression.Coalesce(
3305-
Expression.TypeAs(Expression.Call(valueExpression, s_tryUnwrap), toType),
3306-
Expression.New(
3307-
dictionaryConstructor,
3308-
Expression.Call(
3309-
asDictionaryMethod,
3310-
Expression.Convert(valueExpression, typeof(JSMap), asJSMapMethod),
3311-
GetFromJSValueExpression(keyType),
3312-
GetFromJSValueExpression(valueType),
3313-
GetToJSValueExpression(keyType),
3314-
GetToJSValueExpression(valueType))));
3315-
}
33163258
else if (typeDefinition == typeof(Collection<>) ||
33173259
typeDefinition == typeof(ReadOnlyCollection<>))
33183260
{
@@ -3342,21 +3284,27 @@ private IEnumerable<Expression> BuildFromJSToCollectionClassExpressions(
33423284
GetToJSValueExpression(elementType))));
33433285

33443286
}
3345-
else if (typeDefinition == typeof(ReadOnlyDictionary<,>))
3287+
else if (typeDefinition == typeof(Dictionary<,>) ||
3288+
typeDefinition == typeof(SortedDictionary<,>) ||
3289+
typeDefinition == typeof(ReadOnlyDictionary<,>))
33463290
{
33473291
Type keyType = elementType;
33483292
Type valueType = toType.GenericTypeArguments[1];
33493293

33503294
/*
3351-
* value.TryUnwrap() as ReadOnlyDictionary<TKey, TValue> ??
3295+
* value.TryUnwrap() as Dictionary<TKey, TValue> ??
33523296
* new Dictionary<TKey, TValue>(((JSMap)value).AsDictionary<TKey, TValue>(
33533297
* (key) => (TKey)key,
33543298
* (value) => (TValue)value,
33553299
* (key) => (JSValue)key,
33563300
* (value) => (JSValue)value));
33573301
*/
3358-
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions).GetStaticMethod(
3359-
nameof(JSCollectionExtensions.AsDictionary))!.MakeGenericMethod(keyType, valueType);
3302+
MethodInfo asDictionaryMethod = typeof(JSCollectionExtensions)
3303+
.GetMethods(BindingFlags.Public | BindingFlags.Static)
3304+
.Where((m) => m.Name == nameof(JSCollectionExtensions.AsDictionary) &&
3305+
m.GetParameters()[0].ParameterType == typeof(JSMap))
3306+
.Single()
3307+
.MakeGenericMethod(keyType, valueType);
33603308
MethodInfo asJSMapMethod = typeof(JSMap).GetExplicitConversion(
33613309
typeof(JSValue), typeof(JSMap));
33623310
ConstructorInfo dictionaryConstructor = toType.GetConstructor(

src/NodeApi.DotNetHost/ManagedHost.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,10 @@ JSValue removeListener(JSCallbackArgs args)
113113
JSPropertyDescriptor.Function("load", LoadAssembly),
114114

115115
JSPropertyDescriptor.Function("addListener", addListener),
116-
JSPropertyDescriptor.Function("removeListener", removeListener));
116+
JSPropertyDescriptor.Function("removeListener", removeListener),
117+
118+
JSPropertyDescriptor.Function("runWorker", RunWorker));
119+
117120

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

566+
private JSValue RunWorker(JSCallbackArgs args)
567+
{
568+
nint callbackHandleValue = (nint)args[0].ToBigInteger();
569+
Trace($"> ManagedHost.RunWorker({callbackHandleValue})");
570+
571+
GCHandle callbackHandle = GCHandle.FromIntPtr(callbackHandleValue);
572+
Action callback = (Action)callbackHandle.Target!;
573+
callbackHandle.Free();
574+
575+
try
576+
{
577+
// Worker data and argv are available to the callback as NodejsWorker static properties.
578+
callback();
579+
return JSValue.Undefined;
580+
}
581+
finally
582+
{
583+
Trace($"< ManagedHost.RunWorker({callbackHandleValue})");
584+
}
585+
}
586+
563587
protected override void Dispose(bool disposing)
564588
{
565589
if (disposing)

0 commit comments

Comments
 (0)