Заблокировать цикл и вынести счёт в отдельный поток
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Разбор очередей кончился обещанием: когда порции не спасают, счёт уносят из цикла совсем. Пора его выполнить — но сначала научиться мерить. «Сервер подвисает» — не диагноз, а жалоба: пока не сказано, на сколько сдвинулись сроки и надолго ли, чинить нечего и проверять нечем. Заведём прибор — таймер, который только и делает, что записывает собственное опоздание, — посмотрим на него рядом с долгим счётом в модели, повторим замер настоящим node и унесём тот же счёт в отдельный поток: в песочнице браузерным Worker, в прогоне — из worker_threads.
Что долгий колбэк задерживает всех, мы уже видели: цикл не прерывает работающий код и заглядывает в таймеры, только когда колбэк вернулся. Чего мы пока не умеем — сказать, насколько это плохо в конкретном месте. Вопрос «подвисает ли» бесполезен; полезен вопрос «на сколько опаздывает то, что должно было случиться вовремя». На него отвечает прибор в пять строк: таймер с известным заранее сроком, который при каждом срабатывании записывает разницу между сроком и тем, что показывают часы.
Назовём его сердцем. Сердце бьётся каждые два тика и заводит себя заново из собственного колбэка, а не из расписания. Это не упрощение ради модели: setInterval устроен так же — следующий срок он отмеряет от того момента, когда колбэк сработал.
Колбэки модели возвращаются мгновенно, поэтому «поток занят» в ней приходится сказать прямо — прибавкой к часам цикла. Двадцать тиков счёта, сердце каждые два. Прогноз: сколько ударов опоздает и на сколько каждый?
Опоздал ровно один удар и ровно на длину счёта. Не десять опоздавших ударов подряд и не «сердце сбилось до конца прогона» — одна яма глубиной в двадцать тиков, и сразу за ней ровный ряд нулей. Это и есть мера: наибольшее опоздание равно длине самого долгого синхронного куска. Всё остальное — производные: сколько таких кусков в секунду и какой запас у соседей.
Из той же строки следует и то, чего не случилось. За двадцать тиков сердце должно было ударить десять раз, а недосчиталось девяти ударов: пропущенные не копятся и не обрушиваются пачкой, когда поток освободится. С кликами в браузере ровно наоборот — те ждут в очереди все. Разница в том, кто заводит следующий срок: клик приходит от человека, а следующий удар заводит предыдущий.
Прибор не обязан быть таймером на два тика. Важно одно: срок известен заранее, а опоздание считается по факту. Единицы — подробность.
В модели колбэк — обычный вызов, он возвращается сразу, и «двадцать тиков работы» выразить нечем: времени у неё ровно столько, сколько она сама себе насчитала. Поэтому счёт двигает loop.now сам — это честная запись того, что в это время делал бы настоящий поток.
Соблазн сделать «правильнее» — например, разложить счёт на двадцать обычных колбэков — здесь именно соблазн: модель стала бы показывать не то, что происходит, а порции, которых в коде нет. Где модель врёт и как это ловить, разбирает статья «Где модель врёт: планировщик на промисах и замкнутость мира».
Прибор переносится на Node почти без изменений: setInterval на десять миллисекунд, который только увеличивает счётчик. Работа — триста миллисекунд арифметики, сначала в главном потоке, потом в отдельном. Печатаем не миллисекунды (у каждой машины свои), а то, что от них не зависит: сколько ударов уместилось.
Прогноз перед прогоном — три строки. Сколько ударов насчитает сердце за триста миллисекунд синхронного счёта? Наверстает ли оно пропущенное, когда поток освободится? И изменится ли ответ, если ровно тот же счёт уедет в отдельный поток?
// «Сердце» цикла: таймер на 10 мс, который только считает свои удары. Рядом — // триста миллисекунд арифметики: сначала в главном потоке, потом в отдельном. import { Worker } from 'node:worker_threads'; const СЧЁТ_МС = 300; let ударов = 0; const сердце = setInterval(() => { ударов += 1; }, 10); // ровно та же работа, что делает count.mjs в отдельном потоке function считать(мс) { const конец = Date.now() + мс; let сумма = 0; for (let i = 0; Date.now() < конец; i += 1) сумма += Math.sqrt(i); return сумма; } function вГлавномПотоке() { ударов = 0; считать(СЧЁТ_МС); // печать стоит внутри того же колбэка, до возврата из него console.log(`счёт в главном потоке: ударов сердца за время счёта — ${ударов}`); setTimeout(() => { console.log(`пропущенные удары наверстались пачкой: ${ударов > 5 ? 'да' : 'нет'}`); вОтдельномПотоке(); }, 0); } function вОтдельномПотоке() { ударов = 0; const поток = new Worker(new URL('./count.mjs', import.meta.url)); поток.on('message', () => { console.log(`счёт в отдельном потоке: ударов больше десяти — ${ударов > 10 ? 'да' : 'нет'}`); clearInterval(сердце); поток.terminate(); }); поток.postMessage(СЧЁТ_МС); } вГлавномПотоке();
счёт в главном потоке: ударов сердца за время счёта — 0 пропущенные удары наверстались пачкой: нет счёт в отдельном потоке: ударов больше десяти — да
Ноль — это не «мало», это точно ноль, и он не зависит от машины: печать стоит внутри того же колбэка, до возврата из него, а значит, цикл ещё ни разу не заходил в фазу сроков. Сколько бы миллисекунд ни шёл счёт, ударов за это время не будет ни одного.
Вторая строка отвечает «нет» — и это тот же эффект, что ровный ряд нулей в модели: цикл освободился, а пачки накопленных ударов за этим не последовало. Просроченный setInterval долгов не отдаёт: он бьёт один раз и отмеряет следующий срок от этого удара, а не от пропущенного расписания. И это к лучшему — иначе после каждой блокировки поток получал бы пачку накопленных колбэков поверх обычной нагрузки.
Третья строка — тот же счёт, та же арифметика, но выполняет её отдельный поток из worker_threads. Ударов за это время набирается три десятка (на машине автора 28—31), поэтому в выводе стоит порог, а не число: важно не «тридцать», а то, что сердце било как ни в чём не бывало. Работы меньше не стало — она просто перестала быть работой главного потока.
Лекарств, как и было обещано, два. Первое — резать счёт на порции и отдавать ход между ними: оно ничего не уносит из процесса и годится там, где работу можно разрезать. Второе — отдать работу целиком другому потоку; ему и посвящён остаток статьи. Как выбирать между ними — в последнем разделе.
Своим сердцем обвешивать каждый сервис не нужно: в Node есть штатный прибор — monitorEventLoopDelay из node:perf_hooks. Он собирает гистограмму задержки цикла в наносекундах, и спрашивают у неё не среднее, а максимум и высокие процентили.
Почему не среднее, видно на том же опыте: при блокировке в триста миллисекунд максимум гистограммы — те же триста с небольшим, а среднее за полсекунды — меньше тридцати. Среднее размазывает одну яму по всему окну и показывает спокойствие там, где его нет. Максимум же читается ровно как мера из этого раздела: это длина самого долгого синхронного куска.
Worker читатель уже заводил в главе про service worker, и урок там был про то, чего у потока нет: document. Здесь урок обратный — про то, что у него есть: собственный поток операционной системы, своя копия движка, своя куча и свой цикл событий. Отдельный поток не ускоряет счёт. Он меняет ответ на вопрос «чей поток сейчас занят».
В Node поток заводят файлом — new Worker(new URL('./count.mjs', import.meta.url)) из node:worker_threads, это видно в прогоне выше. Браузеру тоже нужен файл по адресу, а у страницы учебника второго файла нет; поэтому текст потока заворачивают в Blob и получают на него адрес через URL.createObjectURL. Приём не учебный: так же собирают воркеров сборщики и библиотеки, которым не хочется тащить рядом второй файл.
Считать будем одно и то же двумя способами, а мерить — не работу, а занятость главного потока. Прогноз: какая строка напечатается первой и совпадут ли два ответа?
Ответы совпали — это важнее, чем кажется: в поток уехал текст той же самой функции, подставленный в шаблонную строку, и посчитал он ровно то же. А главный поток потратил на всё про всё столько, сколько нужно на одно postMessage с числом внутри. Порог «хотя бы в десять раз меньше» взят с большим запасом: на машине автора счёт держал поток около сотни миллисекунд, а отправка — меньше одной.
Первая строка печатается раньше ответа — и не потому, что «поток быстрый», а потому, что ответ приходит сообщением, то есть обычным событием цикла, и ждёт своей фазы наравне со всеми. Отправка не ждёт ничего.
Текст функции уехал в поток подстановкой в шаблонную строку. Это сработало лишь потому, что внутри неё нет ни одного внешнего имени: в поток едет текст, а не замыкание.
Границу между потоками читатель уже переходил: между ними ходят только данные, копия снимается в момент отправки, а функцию postMessage не пропускает вовсе. Повторять это незачем — важнее, во что оно обходится на сервере.
Первое следствие — чего в поток не отдать. Ни объект запроса и ответа, ни соединение с базой, ни открытый файл: это не значения, а ресурсы процесса, за ними стоят дескрипторы операционной системы, и копировать там нечего. В поток отдают задание — данные, которых довольно, чтобы посчитать, — и получают обратно результат. Обработчик со своим сокетом остаётся в главном потоке и отвечает сам, когда придёт сообщение.
Второе следствие — цена. Копия не бесплатна, и на короткой работе она дороже самой работы: миллион чисел копируется целиком — сначала туда, потом обратно. Для больших кусков памяти есть второй способ: ArrayBuffer — это просто кусок памяти на столько-то байт — можно не копировать, а передать. Прогноз: сколько байт останется у отправителя после каждой из двух отправок?
Второй аргумент postMessage — список того, что нужно передать, а не скопировать. После передачи буфер у отправителя отсоединён: длина ноль, читать нечего и писать некуда. Это не поломка, а условие сделки — память осталась одна, и владелец у неё теперь один. Копия проще, передача дешевле; выбирают по размеру, а не по вкусу.
У worker_threads всё то же самое и немного сверх: данные при запуске (workerData), отдельные каналы сообщений (MessagePort), пределы по памяти на поток и настоящая общая память — SharedArrayBuffer вместе с Atomics. Последнего в песочнице учебника не показать: общая память требует от страницы особых заголовков, которых у песочницы нет. Так что пример выше — предел того, что здесь можно потрогать руками.
Потоки в Node появились задолго до worker_threads: чтение файлов и pbkdf2 давно выполняются не в главном потоке. Но пул libuv и воркеры — разные вещи, и путать их дорого. В пул уходят готовые куски C++, написанные авторами Node; вашего JavaScript там нет и быть не может, а размер пула по умолчанию — четыре потока на весь процесс. Воркер — это второй интерпретатор, и в нём выполняется именно ваш код.
Отсюда практическая разница: занятый пул задерживает чтение файлов и хеширование паролей, но не ваш счёт; занятый главный поток задерживает всё. Подробный разбор пула — в статье «Настоящий цикл, libuv и пул потоков».
Правило одно и скучное: сначала мера, потом лекарство. Порог берут не из ощущений, а из требований к соседям — сколько миллисекунд опоздания переживёт самый нетерпеливый из них. Дальше выбирают по таблице.
| Что делать | Когда подходит | Чем платим |
|---|---|---|
| Ничего | самый долгий синхронный кусок укладывается в допустимое опоздание | ничем — и это самый частый честный ответ |
Порции через setImmediate | работу можно разрезать, и между порциями ничего не портится | общее время растёт, а состояние между порциями успевает устареть |
| Отдельный поток | счёт долгий и цельный, задание и ответ умещаются в сообщение | запуск потока, копия данных в обе стороны, отладка в двух местах |
| Отдельный процесс или очередь | результат не нужен в ответе на этот запрос | сеть, очередь и отдельная выкладка |
Про третью строку стоит сказать отдельно. Поток не бесплатен: его запуск — это новый экземпляр движка со своей кучей, и на машине автора он обходится около двадцати миллисекунд — дороже, чем весь обычный обработчик целиком. Заводить поток на каждый запрос — способ сделать хуже; заводят несколько заранее и раздают им задания. Число потоков считают по ядрам, а не по запросам: десять потоков на четырёх ядрах не считают быстрее, они считают по очереди.
Порции и поток не соперники. Порции оставляют работу в процессе и годятся там, где её можно разрезать; поток уносит её целиком и годится там, где резать нечего. А половина случаев не требует ни того, ни другого.
И последнее, чего не делает ни одно из лекарств: работы они не уменьшают. Порции растягивают её во времени, поток переносит в соседний поток, очередь — в другой процесс. Меняется только то, кто ждёт её стоя. Поэтому первый вопрос к долгому счёту в обработчике — не «как его ускорить», а «зачем он вообще выполняется прямо сейчас».
- «Подвисает» — не диагноз. Диагноз — число: наибольшее опоздание таймера с заранее известным сроком, и оно равно длине самого долгого синхронного куска.
- Пока колбэк не вернулся, цикл не заходит ни в одну фазу: за триста миллисекунд счёта
setIntervalна десять миллисекунд не ударит ни разу. Пропущенные удары не наверстываются — приходит один. - Отдельный поток не ускоряет работу, а меняет того, чей поток занят: у него своя копия движка, своя куча и свой цикл событий. В Node это
worker_threads, в браузере —Worker, который без второго файла заводят изBlob. - Через границу едут только данные. Ресурсы процесса — сокет запроса, соединение с базой, открытый файл — не едут вовсе: в поток отдают задание, а отвечает по-прежнему главный поток.
- Большие куски памяти не копируют, а передают вторым аргументом
postMessage: у отправителя буфер отсоединяется и его длина становится нулём. - Лекарство выбирают после замера: чаще всего не нужно никакого, дальше по возрастанию цены — порции через
setImmediate, отдельный поток, отдельный процесс. Работы ни одно из них не уменьшает.
1/4Обработчик считает триста миллисекунд синхронно. Рядом работает setInterval на десять миллисекунд, который увеличивает счётчик. Сколько ударов насчитает счётчик за время счёта?
2/4Счёт кончился, и цикл вернулся к таймерам. Просроченный setInterval на десять миллисекунд ждал триста. Что произойдёт?
3/4Что станет с ArrayBuffer у отправителя после postMessage(буфер, [буфер])?
4/4Долгий счёт решили унести в отдельный поток. Объект запроса нужен, чтобы отправить ответ. Как это устроить?
Счёт, который не двигает часы
Это модель из первого раздела с одной добавкой: счёт вызывается не напрямую, а через вПоток — и сейчас вПоток обманывает, потому что зовёт вГлавном и двигает часы цикла сам. Напишите настоящий вПоток: работа по-прежнему занимает двадцать тиков, но часы цикла она не двигает, а ответ приходит колбэком, как приходит любое готовое сообщение. Опоздания ударов должны стать нулевыми, а строка про ответ счёта — остаться на том же тике. Остальное не трогайте.
Поток — это работа, которая идёт не здесь: часы цикла она не двигает, а ответ возвращается так же, как любое готовое сообщение, — колбэком фазы опроса:
Вывод:
Главное здесь — вторая строка: ответ пришёл на том же двадцать шестом тике, что и раньше. Работы не стало меньше, счёт не стал быстрее, двадцать тиков никуда не делись. Изменилось одно: пока они шли, цикл продолжал крутиться, и сердце не пропустило ни удара.
loop.timeout здесь изображает время самой работы, а loop.io — то, что ответ приходит сообщением и ждёт своей фазы наравне с готовыми сокетами. Настоящий worker.on('message') устроен ровно так же, и потому подмена одной строки в модели описывает настоящий вынос счёта в поток целиком.
Три строки, три разных лекарства
Обработчик отвечает за двадцать пять миллисекунд, а соседи по обороту согласны опаздывать не больше чем на двадцать. Рядом с каждой строкой стоит измеренное время. Для каждой ответьте: держит ли она главный поток и что с ней делать — ничего, резать на порции или уносить в поток.
JSON.parseдержит главный поток, но 0,4 мс. Делать нечего: порог — двадцать миллисекунд, и разбор укладывается в него полсотни раз. На теле в двадцать мегабайт ответ был бы другим, поэтому мерить надо на настоящих телах, а не на тестовых.db.queryне держит ничего: шесть миллисекунд здесь ждут, а не считают, и всё это время поток свободен для других запросов. Ничего.хешПароля— девяносто миллисекунд счёта, но не в главном потоке: асинхронныйpbkdf2считает поток пула. Главный свободен, уносить нечего — оно уже унесено. Ловушка здесь в имени: у этой функции есть синхронный двойникpbkdf2Sync, и он те же девяносто миллисекунд отнял бы у всех сразу.строки.map— вот единственное настоящее нарушение: двести сорок миллисекунд в главном потоке, в двенадцать раз больше порога. Годятся оба лекарства. Порции проще: строки уже в памяти, между порциями ничего не меняется. Поток лучше, если такие отчёты считаются часто — пятьдесят тысяч строк доедут копией, а обработчик тем временем ответит другим.отрисоватьШаблон— пятнадцать миллисекунд в главном потоке: под порогом, но впритык. Само по себе ничего; но если в обработчике окажутся две такие строки, порог будет перейдён, а по отдельности обе выглядели невинно. Мерить надо весь обработчик, а не строку.
Итог, который стоит запомнить: из пяти подозрительных строк настоящая беда одна, а вторая по длительности — вообще не беда. Без замера ловят не тех.
Отправьте, не копируя
В поток уходят три куска памяти по четыре мегабайта. Сейчас каждый копируется: поток получает свой экземпляр, а у отправителя остаётся собственный — двенадцать мегабайт превращаются в двадцать четыре. Сделайте так, чтобы куски уезжали передачей. Проверка — вторая строка вывода: у отправителя не должно остаться ни байта.
Передаваемое перечисляют вторым аргументом — списком, а не флагом:
Вывод:
В потоке байты те же — передача ничего не теряет. А у отправителя буферы отсоединены, и это единственная плата: читать их больше нельзя, повторно отправить — тоже. Если содержимое нужно и здесь, передавать нечего: тогда это честная копия, и её цену надо считать наравне со счётом.