Skip to main content

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

  1. Keep workers simple - One worker per task type
  2. Avoid creating too many workers - Typically 1-2x CPU core count
  3. Use SharedArrayBuffer sparingly - Message passing is safer
  4. Handle worker errors gracefully - Workers can crash independently
  5. Set memory limits - Prevent one worker from consuming all memory
  6. Profile carefully - Workers add overhead; ensure they’re worth it
  7. 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

Build docs developers (and LLMs) love