正在加载,请稍候…

Web Workers for Parallel JavaScript: Optimizing CPU-Intensive Tasks

Master Web Workers and SharedArrayBuffer for parallel JavaScript execution: worker pools, transferable objects, Atomics for synchronization, Comlink for ergonomic APIs, and real-world performance gains.

Web Workers: True Parallelism in the Browser

JavaScript is single-threaded - every computation blocks the UI thread. Web Workers run scripts in background threads, enabling true parallelism for CPU-intensive work without freezing the browser.

The Problem: Main Thread Saturation

// This freezes the UI for several seconds
function processLargeDataset(data) {
    return data.map(item => heavyComputation(item)); // Blocks UI!
}

// Users see:
// - Frozen scrolling
// - Unresponsive buttons
// - 'Page Unresponsive' dialog
// Chrome DevTools: Long Task > 50ms = jank

Basic Worker Pattern

// worker.js - runs in separate thread
self.onmessage = function(event) {
    const { type, data } = event.data;
    if (type === 'PROCESS') {
        const result = heavyComputation(data);
        self.postMessage({ type: 'RESULT', result });
    }
};

function heavyComputation(data) {
    // CPU-intensive work here - won't block UI
    return data.map(x => x * x).filter(x => x % 2 === 0).reduce((a, b) => a + b, 0);
}

// main.js
const worker = new Worker('/worker.js');

worker.onmessage = ({ data }) => {
    if (data.type === 'RESULT') {
        displayResult(data.result);
    }
};

worker.onerror = (error) => {
    console.error('Worker error:', error.message);
    worker.terminate();
};

worker.postMessage({ type: 'PROCESS', data: largeArray });

Transferable Objects: Zero-Copy Data Transfer

By default, postMessage copies data (expensive for large buffers). Transfer ownership instead:

// BAD: Copies 100MB buffer (takes ~100ms)
worker.postMessage({ buffer: largeArrayBuffer });

// GOOD: Transfers ownership (takes ~0.1ms, but buffer unusable in sender)
worker.postMessage({ buffer: largeArrayBuffer }, [largeArrayBuffer]);
// largeArrayBuffer.byteLength === 0 after transfer!

// Pattern: transfer back when done
// worker.js
self.onmessage = ({ data }) => {
    const { buffer } = data;
    const view = new Float32Array(buffer);
    for (let i = 0; i < view.length; i++) view[i] *= 2.0;
    self.postMessage({ buffer }, [buffer]); // Transfer back
};

Worker Pool for Parallelism

class WorkerPool {
    constructor(workerScript, poolSize = navigator.hardwareConcurrency) {
        this.workers = Array.from({ length: poolSize }, () =>
            new Worker(workerScript)
        );
        this.queue = [];
        this.idle = [...this.workers];
        this.workers.forEach(w => w.onmessage = this._onMessage.bind(this));
    }

    execute(data, transferables = []) {
        return new Promise((resolve, reject) => {
            const task = { data, transferables, resolve, reject };
            if (this.idle.length > 0) {
                this._dispatch(task);
            } else {
                this.queue.push(task);
            }
        });
    }

    _dispatch(task) {
        const worker = this.idle.pop();
        worker._currentTask = task;
        worker.postMessage(task.data, task.transferables);
    }

    _onMessage({ target: worker, data }) {
        worker._currentTask.resolve(data);
        if (this.queue.length > 0) {
            this._dispatch(this.queue.shift());
        } else {
            this.idle.push(worker);
        }
    }

    async processAll(items) {
        return Promise.all(items.map(item => this.execute(item)));
    }

    terminate() { this.workers.forEach(w => w.terminate()); }
}

const pool = new WorkerPool('/image-processor.js', 4);
const results = await pool.processAll(imageChunks);

SharedArrayBuffer and Atomics

For true shared memory between threads (requires COOP/COEP headers):

// Shared memory - both main thread and workers read/write this
const sharedBuffer = new SharedArrayBuffer(4 * 1024 * 1024); // 4MB
const sharedArray = new Float32Array(sharedBuffer);

// Atomic counter for work distribution
const counterBuffer = new SharedArrayBuffer(4);
const counter = new Int32Array(counterBuffer);

// Worker: claim a chunk atomically
self.onmessage = ({ data }) => {
    const { sharedBuffer, counterBuffer, total } = data;
    const arr = new Float32Array(sharedBuffer);
    const cnt = new Int32Array(counterBuffer);
    const CHUNK_SIZE = 1000;

    while (true) {
        const start = Atomics.add(cnt, 0, CHUNK_SIZE); // Atomic fetch-and-add
        if (start >= total) break;
        const end = Math.min(start + CHUNK_SIZE, total);
        for (let i = start; i < end; i++) {
            arr[i] = Math.sqrt(arr[i]); // Process chunk
        }
    }
    self.postMessage('done');
};

// Atomics.wait/notify for synchronization
const lockBuffer = new SharedArrayBuffer(4);
const lock = new Int32Array(lockBuffer);

// Wait until value changes (worker blocks here)
Atomics.wait(lock, 0, 0); // Waits until lock[0] !== 0

// Signal from main thread
Atomics.store(lock, 0, 1);
Atomics.notify(lock, 0, Infinity); // Wake all waiting workers

Comlink: Ergonomic Worker API

// processor.worker.js
import { expose } from 'comlink';

const api = {
    async processImage(imageData) {
        // Runs in worker thread
        return applyGrayscaleFilter(imageData);
    },

    async computeHash(data) {
        const buffer = await crypto.subtle.digest('SHA-256', data);
        return Array.from(new Uint8Array(buffer))
            .map(b => b.toString(16).padStart(2, '0')).join('');
    }
};
expose(api);

// main.js - looks like regular async function calls!
import { wrap } from 'comlink';
const worker = new Worker('/processor.worker.js', { type: 'module' });
const processor = wrap(worker);

// Call worker methods like regular async functions
const hash = await processor.computeHash(fileBuffer);
const processed = await processor.processImage(imageData);

Real-World Use Cases

// CSV parsing with Papa Parse in worker
// worker.js
importScripts('https://unpkg.com/papaparse/papaparse.min.js');
self.onmessage = ({ data }) => {
    const result = Papa.parse(data.csv, { header: true, dynamicTyping: true });
    self.postMessage(result.data);
};

// Markdown compilation
// worker.js
import { marked } from 'marked';
self.onmessage = ({ data }) => {
    self.postMessage(marked(data.markdown));
};

// Bundle: vite/webpack worker syntax
// main.js
const csvWorker = new Worker(new URL('./csv.worker.js', import.meta.url));

Performance Guidelines

  • Use workers for: Tasks >16ms that block the UI, image/video processing, CSV/JSON parsing, cryptography, simulations
  • Avoid workers for: DOM manipulation (not allowed), quick operations (<1ms), tasks dominated by network I/O
  • Pool size: navigator.hardwareConcurrency workers maximum (usually 4-16)
  • Message size: Transfer for >10KB buffers; copy for small objects

Web Workers are the right tool for CPU-bound JavaScript. With Comlink, the ergonomic barrier is minimal. Profile first, then offload specific slow paths.