Что выполняет ваш код: один поток, очередь и пул медленных работ
Здесь будет встроен видео-разбор темы (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 — ведь его файл читается быстрее всех?
Первым ответил /feed, на втором тике: ожиданию сети пул не нужен, и дорогу ему никто не перегородил. А /avatar со своим одним тиком ответил последним, на пятом. Прочитать файл можно было с нулевого тика, но оба потока пула до четвёртого держали хеши, и работа стояла в pool.waiting. Главный поток всё это время был свободен — и помочь не мог: работу, отданную пулу, он себе не забирает. Асинхронное чтение не значит немедленное, оно значит «когда освободится поток пула».
Обратите внимание, кто отправляет ответы. Не поток пула, дочитавший файл, а главный — через очередь. Поэтому колбэк после readFile не выполняется одновременно с остальным вашим кодом: колбэки сменяют друг друга на одном потоке, и два из них не пишут в одну переменную в один и тот же момент.
В настоящем Node пул — четыре потока, а другой размер задают при запуске переменной окружения UV_THREADPOOL_SIZE. Почему у файлов так, а у сокетов иначе, и как увидеть пул на настоящих хешах, разберёт статья «Настоящий цикл, libuv и пул потоков».
Пул один на весь процесс. Хеши паролей через crypto.pbkdf2 на входе и чтение шаблонов писем стоят в одной очереди, поэтому сотня входов разом замедляет даже отдачу файлов.
В прошлой модели колбэки выполнялись мгновенно. Настоящий колбэк работает какое-то время, и пока он работает, цикл не может ни проверить таймеры, ни забрать готовые сокеты: он ждёт, когда колбэк вернётся. Добавим это в модель. У таймера есть срок due и цена cost — сколько тиков его колбэк держит поток. Цикл берёт самый ранний из наступивших таймеров и сдвигает часы на его цену.
Таймеров четыре. /a и два соседа, /b и /c, работают по тику, а отчёт на третьем тике считает шесть. Прогноз: на каких тиках начнут /b и /c, если их сроки — четвёртый и пятый?
/b начал на девятом тике вместо четвёртого, /c — на десятом вместо пятого. Их сроки наступили, пока шёл отчёт, но заглянуть в таймеры цикл смог, только когда отчёт вернулся. Опоздание к тому же копится: /c ждал ещё и тик самого /b. Всё, что стоит в очереди за долгим колбэком, наследует его длину.
В браузере это выглядело как замершая страница. На сервере жертва — не одна вкладка, а все клиенты разом: таймер одного, ответ базы для другого, новое соединение третьего ждут одного и того же отчёта. Лекарств два. Можно резать счёт на порции — тем же приёмом в статье про цикл событий спасали отрисовку, — и между порциями цикл успеет обслужить остальных: это первая задача в конце статьи. Можно вынести счёт из главного потока — об этом статья «Заблокировать цикл и вынести счёт в отдельный поток».
Это упрощение. У настоящего цикла Node несколько очередей: какой колбэк пойдёт раньше — таймера или готового сокета, — решает фаза, в которой цикл сейчас находится, а между колбэками ещё разбираются process.nextTick и промисы. Для урока этой статьи разница не важна: долгий колбэк задерживает всех в любой фазе. Планировщик с фазами соберём в статье «Собираем планировщик: фазы вместо одной очереди».
Модель наша, поэтому проверим её настоящим сервером. Файл поднимает сервер из node:http на свободном порту и сам же обращается к нему через fetch. Ручка /wait ждёт 100 мс таймером — назовём это окном: так ждала бы ответа база. Ручка /count 300 мс крутит пустой цикл, как отчёт из модели. Разбирать HTTP своими руками будем в главе 4, начиная со статьи «HTTP — это текст»; здесь от сервера нужно одно — отвечать.
Опыта два. Сначала пятьдесят запросов к /wait разом. Потом десять к /wait и, когда все десять уже дошли до сервера, один к /count. Счёт отправляется только после этого: запрос, пришедший во время счёта, завёл бы таймер уже после него и опоздания не показал бы. Опоздание меряет сам сервер: клиент живёт в том же процессе и делит с ним поток, и время на стороне клиента смешало бы две задержки. Прогноз: во сколько окон уложатся пятьдесят ожиданий и сколько таймеров опоздает больше чем на полокна в каждом опыте?
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(); });
слушаю http://127.0.0.1:<порт> 50 запросов, каждый ждёт 100 мс все ответили быстрее трёх окон: да опоздали больше чем на полокна: 0 из 50 10 запросов ждут, одиннадцатый считает 300 мс опоздали больше чем на полокна: 10 из 10
Пятьдесят ожиданий по 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 опаздывают на пять. Замените строку с отчётом так, чтобы он считался шестью порциями по тику и ни один запрос не опоздал больше чем на тик. Цикл не трогайте.
Порция занимает один тик и в then заводит следующую со сроком «сейчас»:
Вывод:
Между порциями цикл снова выбирает самый ранний из наступивших сроков, и соседи проскакивают: /b начал вовремя, /c — на тик позже. Работы меньше не стало: отчёт закончил на одиннадцатом тике вместо девятого, зато его длину больше не наследуют остальные. Отчёт сам платит за то, что никого не держит.
В настоящем Node следующую порцию ставят через setImmediate — чем он отличается от таймера, покажет глава о цикле событий. А счёт, который дробить неудобно, выносят из главного потока целиком.
Кто ждёт в каждой строке
Четыре строки из обработчика на Node; bodyText — тело запроса размером 20 МБ. Для каждой ответьте, кто делает работу или ждёт: главный поток, поток пула или никто — потому что о готовности сообщит система. Одна строка делится на два этапа.
fetch— два этапа. Сначала имяapi.example.comпревращается в адрес черезdns.lookup, и это работа пула: если все четыре потока заняты хешами, ждёт даже запрос в сеть. Потом ожидание ответа — без потока, байты в сокете увидит система. У запроса по IP-адресу первого этапа нет.crypto.subtle.digest— поток пула. Асинхронная криптография в Node считается там же, где хеши паролей, и занимает один из четырёх потоков; главный поток свободен.buildReport— главный поток. Промис не переносит работу в другой поток:thenлишь откладывает вызов в очередь микрозадач, а считается отчёт на том же потоке и так же долго держит всех.JSON.parse— главный поток. Тело пришло асинхронно, но разбор синхронный: пока разбираются 20 МБ, не выполняется ничего другого.
Первые два пункта проверяются, если занять пул. Запустите файл с UV_THREADPOOL_SIZE=1 и одним долгим crypto.pbkdf2: fetch к localhost и digest дождутся конца хеша, а fetch к 127.0.0.1 и таймер — нет.