Using Workers (Threads)
QuickJS supports worker threads through the os.Worker API in quickjs-libc, enabling multi-threaded JavaScript execution with message passing between workers.
Worker support requires building with thread support (default when pthreads is available).
Worker Basics
Creating Workers
Workers run JavaScript in separate threads with isolated memory:
import * as os from 'os';
// Create a worker from a file
const worker = new os.Worker('worker.js');
// Send a message to the worker
worker.postMessage({ type: 'start', data: 42 });
// Receive messages from the worker
worker.onmessage = (msg) => {
console.log('Received from worker:', msg);
};
Worker Script
worker.js:
import * as os from 'os';
// Receive messages from parent
os.Worker.parent.onmessage = (msg) => {
console.log('Worker received:', msg);
// Process data
const result = doWork(msg.data);
// Send result back to parent
os.Worker.parent.postMessage({ result });
};
function doWork(data) {
// Heavy computation here
return data * 2;
}
Memory Isolation
Each worker has its own:
- JSRuntime - Separate heap and garbage collector
- JSContext - Isolated global object
- Memory limit - Independent from parent
// Workers do NOT share memory by default
// Each worker is completely isolated
Message Passing
Structured Clone
Messages are serialized/deserialized (structured clone algorithm):
// Parent
const data = {
number: 42,
string: 'hello',
array: [1, 2, 3],
object: { nested: true },
};
worker.postMessage(data); // Serialized
// Worker receives a copy (not a reference)
os.Worker.parent.onmessage = (msg) => {
// msg is a deep copy of data
msg.number = 100; // Doesn't affect parent
};
SharedArrayBuffer
For true shared memory, use SharedArrayBuffer:
// Parent
const shared = new SharedArrayBuffer(1024);
const view = new Int32Array(shared);
worker.postMessage({ buffer: shared });
// Both parent and worker can access the same memory
Atomics.store(view, 0, 42);
// Worker
os.Worker.parent.onmessage = (msg) => {
const view = new Int32Array(msg.buffer);
const value = Atomics.load(view, 0); // 42
};
C API for Workers
Custom Runtime Factory
Override worker runtime creation:
#include <quickjs-libc.h>
static JSRuntime *my_worker_runtime_factory(void)
{
JSRuntime *rt = JS_NewRuntime();
if (!rt)
return NULL;
// Configure worker runtime
JS_SetMemoryLimit(rt, 64 * 1024 * 1024); // 64 MB per worker
JS_SetGCThreshold(rt, 1024 * 1024); // 1 MB GC threshold
return rt;
}
// Set before creating first worker
js_std_set_worker_new_runtime_func(my_worker_runtime_factory);
Signature (from quickjs-libc.h):
JS_LIBC_EXTERN void js_std_set_worker_new_runtime_func(JSRuntime *(*func)(void));
Custom Context Factory
static JSContext *my_worker_context_factory(JSRuntime *rt)
{
JSContext *ctx = JS_NewContext(rt);
if (!ctx)
return NULL;
// Add custom global objects to worker context
JSValue global = JS_GetGlobalObject(ctx);
JS_SetPropertyStr(ctx, global, "customAPI", create_custom_api(ctx));
JS_FreeValue(ctx, global);
return ctx;
}
js_std_set_worker_new_context_func(my_worker_context_factory);
Signature:
JS_LIBC_EXTERN void js_std_set_worker_new_context_func(JSContext *(*func)(JSRuntime *rt));
Worker Lifecycle
Creating Workers
import * as os from 'os';
try {
const worker = new os.Worker('worker.js');
console.log('Worker created successfully');
} catch (err) {
console.error('Failed to create worker:', err);
}
Terminating Workers
Workers don’t have a built-in terminate method. Design workers to exit gracefully via message passing.
// Parent
worker.postMessage({ type: 'exit' });
// Worker
os.Worker.parent.onmessage = (msg) => {
if (msg.type === 'exit') {
// Clean up resources
os.Worker.parent.postMessage({ type: 'goodbye' });
// Exit the worker (implementation-specific)
// The worker thread will end when the script completes
}
};
Thread Safety
Runtime Isolation
Each worker has its own runtime - no shared runtime between threads:
// WRONG: Don't share runtime between threads
JSRuntime *rt = JS_NewRuntime(); // Used by main thread
// Worker thread should NOT use this rt
// RIGHT: Each thread gets its own runtime
// Worker threads create their own via worker_runtime_factory
Stack Top Updates
If moving a runtime between threads:
// Update stack top when changing threads
JS_UpdateStackTop(rt);
Signature:
JS_EXTERN void JS_UpdateStackTop(JSRuntime *rt);
Atomic Operations
Use Atomics for SharedArrayBuffer synchronization:
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);
// Atomic operations are thread-safe
Atomics.store(view, 0, 42);
const value = Atomics.load(view, 0);
// Compare and exchange
Atomics.compareExchange(view, 0, 42, 100);
// Wait/notify for synchronization
Atomics.wait(view, 0, 0); // Block until value changes
Atomics.notify(view, 0, 1); // Wake one waiting thread
Thread Sanitizer
During development, use ThreadSanitizer to catch race conditions:
# Build with TSan
cmake -DQJS_ENABLE_TSAN=ON ..
make
# Run tests
./qjs worker-test.js
TSan will report data races if you accidentally share memory incorrectly.
Worker Pool Pattern
import * as os from 'os';
class WorkerPool {
constructor(size, scriptPath) {
this.workers = [];
this.queue = [];
this.nextWorker = 0;
for (let i = 0; i < size; i++) {
const worker = new os.Worker(scriptPath);
worker.busy = false;
worker.onmessage = (msg) => this.handleMessage(worker, msg);
this.workers.push(worker);
}
}
submit(task) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this.procesQueue();
});
}
processQueue() {
while (this.queue.length > 0) {
const worker = this.workers.find(w => !w.busy);
if (!worker) break;
const { task, resolve, reject } = this.queue.shift();
worker.busy = true;
worker.currentResolve = resolve;
worker.currentReject = reject;
worker.postMessage(task);
}
}
handleMessage(worker, msg) {
worker.busy = false;
if (worker.currentResolve) {
worker.currentResolve(msg);
worker.currentResolve = null;
worker.currentReject = null;
}
this.processQueue();
}
}
// Usage
const pool = new WorkerPool(4, 'compute-worker.js');
for (let i = 0; i < 100; i++) {
pool.submit({ data: i }).then(result => {
console.log('Result:', result);
});
}
Debugging Workers
Enable Debug Output
// In worker runtime factory
JSRuntime *rt = JS_NewRuntime();
JS_SetRuntimeInfo(rt, "Worker Runtime");
JS_SetDumpFlags(rt, JS_DUMP_LEAKS | JS_DUMP_MEM);
Worker Errors
import * as os from 'os';
const worker = new os.Worker('worker.js');
worker.onerror = (error) => {
console.error('Worker error:', error);
};
worker.onmessageerror = (error) => {
console.error('Message deserialization error:', error);
};
Best Practices
- Keep workers simple - One worker per task type
- Avoid creating too many workers - Typically 1-2x CPU core count
- Use SharedArrayBuffer sparingly - Message passing is safer
- Handle worker errors gracefully - Workers can crash independently
- Set memory limits - Prevent one worker from consuming all memory
- Profile carefully - Workers add overhead; ensure they’re worth it
- Test with TSan - Catch threading bugs early
Limitations
- Workers cannot share JSContext or JSRuntime
- No DOM or browser-specific APIs
- Message serialization has overhead
- Worker creation has startup cost
- No built-in worker pool - implement your own
Functions used internally by worker implementation (from quickjs.h):
// Module loading in workers
JS_EXTERN JSValue JS_LoadModule(JSContext *ctx, const char *basename,
const char *filename);
// Get script/module name for error reporting
JS_EXTERN JSAtom JS_GetScriptOrModuleName(JSContext *ctx, int n_stack_levels);
// Serialization for message passing
JS_EXTERN uint8_t *JS_WriteObject2(JSContext *ctx, size_t *psize,
JSValueConst obj, int flags,
JSSABTab *psab_tab);
JS_EXTERN JSValue JS_ReadObject2(JSContext *ctx, const uint8_t *buf,
size_t buf_len, int flags,
JSSABTab *psab_tab);
Next Steps