Конкурентность и многопоточность в JavaScript: Event Loop и Worker Threads

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Мифы и реальность многопоточности в JavaScript

JavaScript традиционно ассоциируется с однопоточной моделью выполнения. Многие разработчики, особенно приходящие из мира C++ или Java, считают это ограничением. Однако, современная экосистема Node.js предлагает мощные инструменты для работы с конкурентностью и, что более важно, с настоящей многопоточностью. В этой статье мы разберем, как работает Event Loop, когда использовать Worker Threads и как правильно строить высоконагруженные приложения.

В отличие от классической многопоточности, где каждый поток имеет свой стек и может выполняться параллельно на разных ядрах процессора, JavaScript в браузере и Node.js использует модель конкурентности на основе событий. Однако с появлением модуля worker_threads в Node.js, мы получили возможность настоящего параллелизма.

Основная часть

1. Event Loop: Сердце конкурентности в Node.js

Event Loop — это механизм, который позволяет Node.js выполнять неблокирующие операции ввода-вывода (I/O), несмотря на однопоточность JavaScript. Он постоянно проверяет очередь задач и выполняет их по мере поступления.

Основные фазы Event Loop:

  • Timers — выполнение колбэков, запланированных через setTimeout() и setInterval().
  • I/O callbacks — обработка завершенных операций ввода-вывода.
  • Idle, prepare — внутренние фазы Node.js.
  • Poll — получение новых I/O событий и выполнение их колбэков.
  • Check — выполнение колбэков setImmediate().
  • Close callbacks — обработка событий закрытия (например, socket.on('close', ...)).

Пример работы Event Loop:

const fs = require('fs');

console.log('Начало программы');

setTimeout(() => {
    console.log('Таймер сработал');
}, 0);

setImmediate(() => {
    console.log('setImmediate выполнен');
});

fs.readFile(__filename, () => {
    console.log('Файл прочитан');
});

console.log('Конец программы');

// Вывод:
// Начало программы
// Конец программы
// setImmediate выполнен
// Таймер сработал
// Файл прочитан

Важно понимать: Event Loop не делает код многопоточным, он лишь эффективно распределяет время между задачами. Если одна задача занимает CPU слишком долго (например, сложный цикл), весь Event Loop блокируется.

2. Проблема блокировки и решение: Worker Threads

Для CPU-интенсивных задач (шифрование, обработка изображений, парсинг больших данных) Event Loop не подходит — он заблокирует весь сервер. Здесь на помощь приходят Worker Threads.

Worker Threads — это модуль Node.js, который позволяет создавать настоящие потоки операционной системы. Каждый воркер имеет свой собственный экземпляр V8 и Event Loop, что позволяет выполнять код параллельно.

Базовый пример использования Worker Threads:

// main.js
const { Worker } = require('worker_threads');

function runWorker(workerData) {
    return new Promise((resolve, reject) => {
        const worker = new Worker('./worker.js', { workerData });
        
        worker.on('message', resolve);
        worker.on('error', reject);
        worker.on('exit', (code) => {
            if (code !== 0) {
                reject(new Error(`Worker остановился с кодом ${code}`));
            }
        });
    });
}

async function main() {
    console.log('Запуск воркера...');
    const result = await runWorker(42);
    console.log('Результат:', result);
}

main().catch(console.error);

// worker.js
const { parentPort, workerData } = require('worker_threads');

// CPU-интенсивная задача
function fibonacci(n) {
    if (n < 2) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

const result = fibonacci(workerData);
parentPort.postMessage(result);

Обратите внимание: воркеры общаются с главным потоком через сообщения (postMessage и событие message). Они не имеют доступа к общей памяти (если не использовать SharedArrayBuffer), что делает их безопасными для использования.

3. Сравнение подходов: Cluster vs Worker Threads

Многие путают модуль cluster и worker_threads. Давайте разберем разницу:

  • Cluster — создает несколько процессов (не потоков). Каждый процесс имеет свою память. Используется для масштабирования HTTP-серверов на несколько ядер CPU.
  • Worker Threads — создает потоки внутри одного процесса. Потоки разделяют память процесса (но не глобальные объекты). Используется для CPU-интенсивных задач.

Пример использования Cluster для HTTP-сервера:

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
    console.log(`Мастер-процесс PID: ${process.pid}`);
    
    for (let i = 0; i < numCPUs; i++) {
        cluster.fork();
    }
    
    cluster.on('exit', (worker, code, signal) => {
        console.log(`Воркер ${worker.process.pid} умер`);
    });
} else {
    http.createServer((req, res) => {
        res.writeHead(200);
        res.end('Hello, World!');
    }).listen(8000);
    
    console.log(`Воркер-процесс PID: ${process.pid}`);
}

Когда что использовать?

  • Cluster — для HTTP-серверов, WebSocket-серверов, любых I/O-интенсивных приложений.
  • Worker Threads — для вычислений, шифрования, обработки данных в реальном времени.

4. Практические советы по оптимизации конкурентности

Чтобы ваше Node.js приложение работало максимально эффективно, следуйте этим рекомендациям:

  1. Избегайте блокировки Event Loop. Все длительные синхронные операции (например, JSON.parse больших строк) выносите в воркеры или разбивайте на части.
  2. Используйте асинхронные API. Всегда предпочитайте fs.promises вместо fs.readFileSync.
  3. Пул воркеров. Не создавайте новый Worker на каждую задачу. Используйте пул (например, библиотеку workerpool).

Блоги

Рекомендаций по книгам