Что выполняет ваш код: один поток, очередь и пул медленных работ

платформа≈ 9 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

В статье про цикл событий у браузера был один поток на всё, а таймеры и сеть вёл кто-то снаружи. Сервер на Node устроен так же, и поначалу это звучит как приговор: у вкладки один пользователь, у сервера — тысячи, а поток по-прежнему один. Держит он их спокойно, пока соблюдается одно условие. Сегодня — из чего сделан Node, модель «главный поток, очередь, пул» и проверка настоящим сервером: пятьдесят ожиданий, которые идут одновременно, и один счёт, который задерживает всех.

Разделы темы:·

Посмотрите, из чего складывается обычный запрос к API. Разобрать адрес и заголовки, сходить в базу за строками, собрать из них JSON и отправить. Считает сервер только в начале и в конце, а между ними ждёт: база думает, ответ едет по сети, клиент на медленном мобильном интернете принимает байты. Сходить в базу через сеть почти всегда дольше, чем разобрать заголовки, — поэтому жизнь запроса делится на короткую работу и длинное ожидание.

Классическая схема сервера — поток или процесс на соединение. Код в ней читается сверху вниз: позвали базу — поток встал и ждёт, пришёл ответ — пошёл дальше. Но ожидание там стоит целого потока: каждый держит свой стек в памяти, а когда тысячи потоков просыпаются по очереди, система тратит время ещё и на переключения между ними. На десяти тысячах одновременных соединений эта схема упиралась так заметно, что у задачи в 1999 году появилось имя — C10K. Ответом стали механизмы системы, которые одним вызовом сообщают, какие из тысяч соединений готовы, и серверы, которые ждут не потоком, а записью в таблице.

Node построен на этой ставке. Соединение, которое ждёт, — это запись «когда придут байты, позови этот колбэк», и поток ему не нужен. Поток нужен только коротким кускам работы между ожиданиями, и одного хватает на всех — при условии, что каждый кусок короткий. Это та же схема, что в браузере: стек пуст — цикл берёт следующую задачу из очереди. Только вместо кликов в очереди встают пришедшие запросы и ответы базы.

Предел такого сервера — не число соединений, а сумма работы в колбэках. Если каждый ответ считается 5 мс, за секунду поток успеет двести ответов, сколько бы клиентов ни ждало.

Ваш JavaScript в Node выполняет V8 — тот же движок, что в Chrome. Сам по себе движок ждать не умеет: ни таймеров, ни сокетов, ни файлов в языке нет. В браузере их, как говорилось в части I, берёт на себя окружение со своими потоками. В Node окружение — это библиотека libuv, написанная на C, и модули вроде node:fs и node:http: обёртки на JavaScript, которые передают работу в libuv и получают назад колбэки.

Ждать libuv умеет двумя способами, и от этого зависит всё остальное. Сокетам и таймерам потоки не нужны. Система — epoll в Linux, kqueue в macOS, IOCP в Windows — одним вызовом сообщает, в каких из тысяч соединений появились байты, а таймеры лежат по порядку сроков, и цикл засыпает до ближайшего. Файлы же libuv читает в пуле потоков: поток пула честно ждёт диск, пока главный занят своим. Туда же уходят хеш пароля через crypto.pbkdf2 и сжатие через node:zlib — это и вовсе не ожидание, а работа процессора. Пул по умолчанию — четыре потока на весь процесс. Каким бы путём ни пришёл результат, дальше он становится колбэком в очереди, и выполняет этот колбэк главный поток.

РаботаКто её делаетГлавный поток
ваш колбэк: условия, циклы, сборка ответаV8занят, пока колбэк не вернётся
ожидание таймерацикл libuv, который спит до ближайшего срокасвободен
ожидание байтов в сокете — от клиента, базы, другого сервисасистема сообщает libuv о готовых сокетахсвободен
чтение файла, хеш пароля в crypto.pbkdf2поток пула libuvсвободен, но поток пула занят

Отсюда поправка к таблице части I, где про fs.readFile сказано «чтение ведёт операционная система». Читает действительно система, но ждёт её не главный поток и не «никто», а один из четырёх потоков пула. Когда это важно, покажет модель.

Соберём это в модель. Время в ней идёт тиками, а не миллисекундами: так порядок не зависит от скорости вашего компьютера. Мест три: pool — пул потоков и очередь работ, которым поток ещё не достался, network — ожидания сети, которым поток не нужен, и queue — готовые колбэки, которые ждут главный поток. Потоков в пуле два, а не четыре, как в Node: так нехватку видно уже на четырёх запросах.

Запросы приходят разом. /login и /signup считают хеш пароля по четыре тика, /avatar читает маленький файл за один тик, /feed два тика ждёт ответа базы. Прогноз: кто ответит первым и на каком тике уйдёт ответ /avatar — ведь его файл читается быстрее всех?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Первым ответил /feed, на втором тике: ожиданию сети пул не нужен, и дорогу ему никто не перегородил. А /avatar со своим одним тиком ответил последним, на пятом. Прочитать файл можно было с нулевого тика, но оба потока пула до четвёртого держали хеши, и работа стояла в pool.waiting. Главный поток всё это время был свободен — и помочь не мог: работу, отданную пулу, он себе не забирает. Асинхронное чтение не значит немедленное, оно значит «когда освободится поток пула».

Обратите внимание, кто отправляет ответы. Не поток пула, дочитавший файл, а главный — через очередь. Поэтому колбэк после readFile не выполняется одновременно с остальным вашим кодом: колбэки сменяют друг друга на одном потоке, и два из них не пишут в одну переменную в один и тот же момент.

В настоящем Node пул — четыре потока, а другой размер задают при запуске переменной окружения UV_THREADPOOL_SIZE. Почему у файлов так, а у сокетов иначе, и как увидеть пул на настоящих хешах, разберёт статья «Настоящий цикл, libuv и пул потоков».

Пул один на весь процесс. Хеши паролей через crypto.pbkdf2 на входе и чтение шаблонов писем стоят в одной очереди, поэтому сотня входов разом замедляет даже отдачу файлов.

В прошлой модели колбэки выполнялись мгновенно. Настоящий колбэк работает какое-то время, и пока он работает, цикл не может ни проверить таймеры, ни забрать готовые сокеты: он ждёт, когда колбэк вернётся. Добавим это в модель. У таймера есть срок due и цена cost — сколько тиков его колбэк держит поток. Цикл берёт самый ранний из наступивших таймеров и сдвигает часы на его цену.

Таймеров четыре. /a и два соседа, /b и /c, работают по тику, а отчёт на третьем тике считает шесть. Прогноз: на каких тиках начнут /b и /c, если их сроки — четвёртый и пятый?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

/b начал на девятом тике вместо четвёртого, /c — на десятом вместо пятого. Их сроки наступили, пока шёл отчёт, но заглянуть в таймеры цикл смог, только когда отчёт вернулся. Опоздание к тому же копится: /c ждал ещё и тик самого /b. Всё, что стоит в очереди за долгим колбэком, наследует его длину.

В браузере это выглядело как замершая страница. На сервере жертва — не одна вкладка, а все клиенты разом: таймер одного, ответ базы для другого, новое соединение третьего ждут одного и того же отчёта. Лекарств два. Можно резать счёт на порции — тем же приёмом в статье про цикл событий спасали отрисовку, — и между порциями цикл успеет обслужить остальных: это первая задача в конце статьи. Можно вынести счёт из главного потока — об этом статья «Заблокировать цикл и вынести счёт в отдельный поток».

Это упрощение. У настоящего цикла Node несколько очередей: какой колбэк пойдёт раньше — таймера или готового сокета, — решает фаза, в которой цикл сейчас находится, а между колбэками ещё разбираются process.nextTick и промисы. Для урока этой статьи разница не важна: долгий колбэк задерживает всех в любой фазе. Планировщик с фазами соберём в статье «Собираем планировщик: фазы вместо одной очереди».

Модель наша, поэтому проверим её настоящим сервером. Файл поднимает сервер из node:http на свободном порту и сам же обращается к нему через fetch. Ручка /wait ждёт 100 мс таймером — назовём это окном: так ждала бы ответа база. Ручка /count 300 мс крутит пустой цикл, как отчёт из модели. Разбирать HTTP своими руками будем в главе 4, начиная со статьи «HTTP — это текст»; здесь от сервера нужно одно — отвечать.

Опыта два. Сначала пятьдесят запросов к /wait разом. Потом десять к /wait и, когда все десять уже дошли до сервера, один к /count. Счёт отправляется только после этого: запрос, пришедший во время счёта, завёл бы таймер уже после него и опоздания не показал бы. Опоздание меряет сам сервер: клиент живёт в том же процессе и делит с ним поток, и время на стороне клиента смешало бы две задержки. Прогноз: во сколько окон уложатся пятьдесят ожиданий и сколько таймеров опоздает больше чем на полокна в каждом опыте?

прогон node 26.5.0 · 15 сентября 2026
Строки об опоздании в двух опытах считаются по одному и тому же порогу
import { createServer } from 'node:http';

const WINDOW = 100; // столько каждый запрос «ждёт базу», мс
const late = []; // на сколько мс опоздал таймер каждого ждущего запроса
let arrived = 0;
let onArrive = () => {};

const server = createServer((req, res) => {
  if (req.url === '/wait') {
    const due = Date.now() + WINDOW;
    arrived += 1;
    onArrive();
    setTimeout(() => {
      late.push(Date.now() - due);
      res.end('подождал');
    }, WINDOW);
  } else if (req.url === '/count') {
    const end = Date.now() + 300;
    while (Date.now() < end) {} // занятой цикл вместо настоящего счёта: 300 мс работы без единого ожидания
    res.end('посчитал');
  }
});

// Порт 0 — свободный порт выбирает система, два запуска подряд не спорят за номер
server.listen(0, '127.0.0.1', async () => {
  const base = `http://127.0.0.1:${server.address().port}`;
  const get = (path) => fetch(base + path).then((res) => res.text());
  // миллисекунды скачут от запуска к запуску, поэтому печатаем вывод с порогом
  const report = (total) => {
    const count = late.filter((ms) => ms > WINDOW / 2).length;
    console.log(`  опоздали больше чем на полокна: ${count} из ${total}`);
    late.length = 0;
  };
  console.log(`слушаю ${base}`);

  console.log('50 запросов, каждый ждёт 100 мс');
  const start = Date.now();
  await Promise.all(Array.from({ length: 50 }, () => get('/wait')));
  const windows = (Date.now() - start) / WINDOW;
  console.log(`  все ответили быстрее трёх окон: ${windows < 3 ? 'да' : 'нет'}`);
  report(50);

  console.log('10 запросов ждут, одиннадцатый считает 300 мс');
  arrived = 0;
  // счёт отправляем, только когда все десять уже у сервера и их таймеры заведены
  const tenArrived = new Promise((resolve) => {
    onArrive = () => arrived === 10 && resolve();
  });
  const waits = Array.from({ length: 10 }, () => get('/wait'));
  await tenArrived;
  await Promise.all([...waits, get('/count')]);
  report(10);

  server.close();
});
node server.mjs

Пятьдесят ожиданий по 100 мс закончились быстрее трёх окон. По очереди это заняло бы пятьдесят окон, пять секунд. Поток же только принимал запросы, заводил таймеры и отправлял ответы — каждое дело короткое, а между ними он спал. Ни один из пятидесяти таймеров не опоздал больше чем на полокна: к их сроку поток был свободен.

Во втором опыте опоздали все десять. Их таймеры были заведены, счёт начался на мгновение позже, и сроки наступили, когда поток ещё крутил цикл. По арифметике модели опоздание — около двухсот миллисекунд: счёт шёл триста, а сто таймеры ждали бы и так. Одна ручка на триста миллисекунд задержала всех, кто ждал рядом, хотя сами они ничего не считали.

Почему печатаются выводы, а не миллисекунды: время от запуска к запуску разное, и запись с числами не совпала бы сама с собой. Порог при этом выбран так, чтобы разделять опыты с запасом. В пробных запусках, в том числе на нагруженной машине, таймеры первого опыта опаздывали на единицы и десятки миллисекунд, второго — около двухсот, и полокна лежит далеко от обоих. Три окна вместо одного — тоже запас: первые запросы тратят время ещё и на соединения.

Поставить Node и запустить такой файл у себя — в последней статье главы, «Мост: ставим Node и запускаем первый файл настоящим node».

  • Node — это движок V8 и библиотека libuv: V8 выполняет ваш JavaScript в одном главном потоке, libuv ведёт цикл — таймеры, сокеты и пул потоков для медленных работ.
  • Ожидание сети не занимает поток: соединение — это запись, о готовых сокетах сообщает система. Поэтому один поток держит тысячу соединений — ему достаются только короткие колбэки между ожиданиями.
  • Файлы, dns.lookup, асинхронная криптография и сжатие идут в пул — по умолчанию четыре потока на процесс. Когда пул занят, асинхронное чтение ждёт, даже если главный поток свободен.
  • Результат из пула возвращается колбэком через очередь, и выполняет его главный поток: колбэки сменяют друг друга, а не бегут одновременно.
  • Работающий колбэк цикл не прерывает. Синхронный счёт на 300 мс задержал в прогоне все десять соседних таймеров: на сервере блокировка — это задержка всех клиентов разом, а не одной вкладки.
  • Долгий счёт режут на порции или выносят из главного потока. А замеры времени печатают выводом с порогом, который разделяет случаи с запасом: один и тот же порог дал «0 из 50» и «10 из 10».

1/4На сервере тысяча открытых соединений, и каждое ждёт ответа базы. Сколько потоков Node занято этим ожиданием?

2/4Все четыре потока пула считают хеши паролей, главный поток свободен. Приходит запрос, обработчик которого читает маленький файл через fs.readFile. Что будет?

3/4Обработчик синхронно считает 300 мс. Таймер соседнего запроса на 100 мс заведён прямо перед этим. Когда выполнится его колбэк?

4/4Ответы, которые ждут базу по сети, приходят медленно, и пул потоков увеличили с четырёх до шестнадцати. Что это даст именно этим ответам?

Порежьте отчёт на порции

Это модель таймеров из раздела про долгий колбэк с одним дополнением: у таймера есть then, и модель зовёт его, когда колбэк отработал, — в нём можно завести следующий таймер. Отчёт по-прежнему считает шесть тиков одним куском, и /b с /c опаздывают на пять. Замените строку с отчётом так, чтобы он считался шестью порциями по тику и ни один запрос не опоздал больше чем на тик. Цикл не трогайте.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Порция занимает один тик и в then заводит следующую со сроком «сейчас»:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Между порциями цикл снова выбирает самый ранний из наступивших сроков, и соседи проскакивают: /b начал вовремя, /c — на тик позже. Работы меньше не стало: отчёт закончил на одиннадцатом тике вместо девятого, зато его длину больше не наследуют остальные. Отчёт сам платит за то, что никого не держит.

В настоящем Node следующую порцию ставят через setImmediate — чем он отличается от таймера, покажет глава о цикле событий. А счёт, который дробить неудобно, выносят из главного потока целиком.

Кто ждёт в каждой строке

Четыре строки из обработчика на Node; bodyText — тело запроса размером 20 МБ. Для каждой ответьте, кто делает работу или ждёт: главный поток, поток пула или никто — потому что о готовности сообщит система. Одна строка делится на два этапа.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
  1. fetch — два этапа. Сначала имя api.example.com превращается в адрес через dns.lookup, и это работа пула: если все четыре потока заняты хешами, ждёт даже запрос в сеть. Потом ожидание ответа — без потока, байты в сокете увидит система. У запроса по IP-адресу первого этапа нет.
  2. crypto.subtle.digest — поток пула. Асинхронная криптография в Node считается там же, где хеши паролей, и занимает один из четырёх потоков; главный поток свободен.
  3. buildReport — главный поток. Промис не переносит работу в другой поток: then лишь откладывает вызов в очередь микрозадач, а считается отчёт на том же потоке и так же долго держит всех.
  4. JSON.parse — главный поток. Тело пришло асинхронно, но разбор синхронный: пока разбираются 20 МБ, не выполняется ничего другого.

Первые два пункта проверяются, если занять пул. Запустите файл с UV_THREADPOOL_SIZE=1 и одним долгим crypto.pbkdf2: fetch к localhost и digest дождутся конца хеша, а fetch к 127.0.0.1 и таймер — нет.