Настоящий цикл, libuv и пул потоков
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Четыре статьи главы мы строили планировщик и сверяли его с настоящим node — и всякий раз он оказывался прав. Пора закрыть главу с другой стороны: посмотреть на машину, которую модель изображает, и назвать полным списком то, о чём она молчит. Цикл событий Node — это функция uv_run из библиотеки libuv (в node 26.5.0 — версия 1.52.1), и в ней есть две вещи, которых в модели нет вовсе: настоящий сон в фазе опроса и пул потоков, куда уходит вся работа с файлами. Сегодня посмотрим, сколько стоит процессу секунда ожидания, что случается с пятым заданием, когда потоков в пуле четыре, почему сокеты в этот пул не попадают — и разберём единственное место, где наша модель обещает больше, чем Node может дать.
Оборот в нашем планировщике состоит из трёх фаз: сроки, опрос, проверка. У настоящего цикла их шесть. Хорошая новость в том, что три недостающие вашего кода почти не касаются, — но знать о них стоит хотя бы затем, чтобы не искать в них объяснение, когда порядок окажется не тем.
| Фаза | Что в ней разбирается | Видно ли её вашему коду |
|---|---|---|
| сроки | колбэки setTimeout и setInterval, у которых наступил срок | да, это ваши таймеры |
| отложенные колбэки | немногие системные колбэки, отложенные с прошлого оборота, — например, ошибка записи в сокет | почти никогда |
| подготовка и холостой ход | внутренние дела libuv и нативных расширений | нет |
| опрос | готовые сокеты и ответы пула потоков — и сон, пока не готово ничего | да, сюда приходит почти весь ввод-вывод |
| проверка | колбэки setImmediate | да |
| закрытия | события close у закрытых сокетов и других дескрипторов, за которыми следил цикл | да, если на них подписались |
Модель взяла из шести фаз три — ровно те, между которыми приходится выбирать, когда пишешь «продолжить потом». Остальные три в обычной программе либо пусты, либо заняты делами, которых из JavaScript не видно. Поэтому вывод прошлых статей остаётся в силе целиком: внутри колбэка setImmediate идёт раньше таймера на ноль, потому что фаза проверки стоит сразу за опросом, а до фазы сроков надо дожить до следующего оборота.
Между колбэками фаз нет вовсе: там два перерыва — очередь process.nextTick и очередь микрозадач, разобранные отдельно. Путаница здесь стоит дорого: «фаза микрозадач» — выражение, которого в libuv нет, и человек, который его произносит, обычно ждёт от цикла того, чего тот не делает.
Имён фаз в вашем коде не встретить: это ветки внутри одной функции libuv. Полезны они не сами по себе, а как ответ на вопрос, между чем и чем выбирает планировщик.
Отдельного разговора стоят две вещи, потому что как раз в них живёт всё, чего в модели нет. Первая есть в таблице — это фаза опроса, единственное место, где процесс на самом деле ждёт. Второй в таблице нет вовсе: пул потоков — не фаза, а четыре отдельных потока, которые приносят свои ответы в ту же фазу опроса.
Что делает процесс, когда делать нечего? Вопрос не праздный: «цикл событий» звучит как while (true), а такой цикл сжёг бы ядро процессора на пустом месте. На деле в фазе опроса libuv зовёт системный вызов, который сам усыпляет процесс, пока не случится одно из двух: в каком-нибудь из дескрипторов появятся байты или истечёт отпущенный срок. Вызовы у всех свои — kqueue в macOS, epoll в Linux, порты завершения в Windows, — а договор один: разбуди, когда будет что делать, но не позже чем через столько-то миллисекунд.
Сколько он готов спать, libuv считает перед каждым заходом: до ближайшего срока таймера. А если в очереди фазы проверки уже что-то лежит, спать нельзя вовсе, и отпущенный срок равен нулю — работа есть, и она ждёт этого же оборота. Оба правила в модели уже записаны, той самой строкой, которая перед фазами двигает часы вперёд. Посмотрим на неё глазами. Прогноз: сколько оборотов сделает цикл, если всей работы у него — один immediate и один таймер на сто тиков?
Сто тиков ожидания обошлись в один оборот. Цикл не пересчитывал срок сто раз — он один раз посмотрел на ближайший и перевёл часы. И обратите внимание на первый оборот: часы на нём остались на нуле, потому что работа в фазе проверки уже была и спать было нельзя. Вот она, та самая длительность оборота, о которой модель молчит: у неё оборот стоит один тик всегда, а в жизни — либо столько, сколько выполнялись колбэки, либо столько, сколько цикл проспал.
Теперь то же самое на настоящем процессе. process.cpuUsage возвращает время, которое процессу выдал процессор, и его можно сравнить с временем, которое просто прошло. Секунду подождём таймер, секунду посчитаем в главном потоке. Прогноз: во сколько раз разойдутся эти две доли?
// Что стоит процессу секунда ожидания и что — секунда счёта. // process.cpuUsage считает время, которое процессу выдал процессор. import { setTimeout as подождать } from 'node:timers/promises'; const доля = async (работа) => { const процессор = process.cpuUsage(); const начало = process.hrtime.bigint(); await работа(); const прошло = Number(process.hrtime.bigint() - начало) / 1e6; const { user, system } = process.cpuUsage(процессор); return (user + system) / 1000 / прошло; }; const ожидание = await доля(() => подождать(1000)); const счёт = await доля(async () => { const до = Date.now() + 1000; let сумма = 0; while (Date.now() < до) сумма += Math.sqrt(сумма + 1); return сумма; }); console.log('секунда ожидания таймера:'); console.log(` процессорного времени ушло меньше сотой доли ожидания: ${ожидание < 0.01 ? 'да' : 'нет'}`); console.log('секунда счёта в главном потоке:'); console.log(` процессорного времени ушло больше девяти десятых: ${счёт > 0.9 ? 'да' : 'нет'}`);
секунда ожидания таймера: процессорного времени ушло меньше сотой доли ожидания: да секунда счёта в главном потоке: процессорного времени ушло больше девяти десятых: да
Разница не в разы, а на три порядка: на машине автора доля процессорного времени в секунде ожидания от захода к заходу выходила от одной до пяти десятитысячных — полмиллисекунды на всю секунду в самом дорогом заходе. Ждущий процесс не стоит почти ничего, и это не бережливость Node, а свойство системного вызова: пока он не вернулся, планировщик операционной системы процессу процессор просто не выдаёт.
Отсюда же ответ на вопрос, с которого началась часть. Тысяча соединений, которые чего-то ждут, — это тысяча записей в таблице дескрипторов, а не тысяча потоков со стеками. Разбудить процесс система может по любой из них, и стоит это ровно одного пробуждения.
А знакомая граница — обратная сторона того же самого: упирается такой сервер не в число клиентов, а в суммарный счёт своих колбэков. Теперь под ней есть измерение, и видно, с какой стороны она держится: ждущие процессорного времени не отнимают вовсе, расходуют его только те, чьи колбэки в эту секунду считают.
Вторая вещь, которой в модели нет вовсе, — пул потоков. Модель знает только главный поток, и «работа, которая идёт не здесь» изображается в ней таймером. В libuv это настоящие потоки операционной системы, и по умолчанию их четыре на весь процесс. Что бывает, когда заданий больше, мы уже видели на модели сервера: быстрое чтение файла ответило последним, потому что оба потока держали хеши паролей. Теперь посчитаем волны.
Достроим планировщик пулом: сколько-то потоков, общая очередь и правило «освободился поток — возьми следующее». Работа идёт не в главном потоке, поэтому часы цикла она не двигает, а готовый результат приходит фазе опроса, как приходят байты из сокета. Восемь заданий по десять тиков, потоков четыре. Прогноз: на каких тиках задания окажутся готовы?
Две волны по четыре, и число в волне — это в точности размер пула. Восемь заданий по десять тиков заняли не десять тиков и не восемьдесят, а двадцать: пул не ускоряет каждое задание, он ограничивает, сколько их идёт разом. Пятому досталось не «чуть позже», а ровно вдвое позже: оно дождалось, пока первая волна сойдёт целиком.
На настоящем пуле волны ловить по времени бесполезно: задания одинаковой длины расползаются по ядрам и финишируют вразнобой. Зато прекрасно ловится сам факт ожидания. Займём пул четырьмя долгими заданиями и следом отправим пятое, крошечное — на тысячу итераций вместо четырёх миллионов, — а через полсекунды посмотрим, выполнилось ли оно. Один и тот же файл запускается дважды: с UV_THREADPOOL_SIZE=4 и с UV_THREADPOOL_SIZE=8. Прогноз: в каком из двух случаев крошечное задание успеет?
// Пул занимают четыре долгих задания, следом в очередь встаёт пятое — крошечное. // Файл запускает сам себя дважды: с пулом на четыре потока и на восемь. import { pbkdf2 } from 'node:crypto'; import { execFileSync } from 'node:child_process'; const размер = process.env.UV_THREADPOOL_SIZE; if (размер === undefined) { // размер пула читается один раз при его создании, поэтому менять его на ходу // нечем: каждому значению — свой процесс for (const потоков of ['4', '8']) { process.stdout.write( execFileSync(process.execPath, [import.meta.filename], { env: { ...process.env, UV_THREADPOOL_SIZE: потоков } }), ); } } else { let долгих = 0; let крошечное = false; let ударов = 0; for (let i = 1; i <= 4; i += 1) pbkdf2(`долгое ${i}`, 'соль', 4000000, 32, 'sha512', () => { долгих += 1; }); pbkdf2('крошечное', 'соль', 1000, 32, 'sha512', () => { крошечное = true; }); const сердце = setInterval(() => { ударов += 1; }, 10); setTimeout(() => { clearInterval(сердце); console.log(`UV_THREADPOOL_SIZE=${размер}: четыре долгих задания и крошечное пятым`); console.log(` за полсекунды долгих завершилось: ${долгих}`); console.log(` крошечное задание за это время выполнилось: ${крошечное ? 'да' : 'нет'}`); console.log(` главный поток успел ударить больше тридцати раз: ${ударов > 30 ? 'да' : 'нет'}`); }, 500); }
UV_THREADPOOL_SIZE=4: четыре долгих задания и крошечное пятым за полсекунды долгих завершилось: 0 крошечное задание за это время выполнилось: нет главный поток успел ударить больше тридцати раз: да UV_THREADPOOL_SIZE=8: четыре долгих задания и крошечное пятым за полсекунды долгих завершилось: 0 крошечное задание за это время выполнилось: да главный поток успел ударить больше тридцати раз: да
При четырёх потоках крошечное задание за полсекунды не выполнилось — не потому, что оно долгое, а потому, что его не начинали: свободного потока не было. При восьми оно выполнилось сразу, и обратите внимание на вторую строку каждого блока: долгих за это время не завершилось ни одного ни там, ни там. Значит, разницу дал не темп работы, а наличие свободного потока. В этом весь пул: не «медленнее», а «не начато». Последняя же строка — про главный поток: он всё это время бил таймером как ни в чём не бывало, и четыре занятые работы ему не мешали.
Крошечное задание очередь не обгоняет: приоритетов в пуле нет. Пришёл позже — ждёшь, даже если работы на десятую долю миллисекунды.
UV_THREADPOOL_SIZE — переменная окружения, и обычно её выставляют перед запуском. Но читает её libuv не при старте процесса, а когда пул создаётся, то есть на первой же операции, которая в него попала. Значит, из JavaScript на неё повлиять можно, и вот где проходит граница (проверено 22.09.2026, node 26.5.0):
Импорты — даже своих файлов — пул не создают: загрузчик ES-модулей читает их синхронно. А одного асинхронного readFile выше по файлу хватает, чтобы переменная опоздала и не изменила ничего. Ловушка тихая: ошибки не будет, пул просто останется на четырёх потоках.
Сколько заказывать — вопрос не к нагрузке, а к железу. Когда пул считает (хеши, сжатие), двадцать потоков на восьми ядрах быстрее не посчитают — они посчитают по очереди. Когда ждёт диск, лишние потоки процессор не отнимают, но и диск от их числа быстрее не становится. Разумная верхняя граница в обоих случаях — число ядер.
Остался главный вопрос главы: почему файл читают потоком, а сокет — нет. Ответ прячется в договоре того самого системного вызова из фазы опроса. Он отвечает на вопрос «готов ли дескриптор», и для сокета вопрос осмысленный: байты либо пришли, либо нет. Для обычного файла на диске ответ всегда «готов» — и это чистая правда, файл никуда не едет, он лежит на месте. Вот только чтение после такого ответа честно упрётся в диск, и упрётся синхронно. Спрашивать у системы про файл бесполезно: ждать придётся всё равно, а ждать в фазе опроса нельзя — там ждёт сразу весь процесс. Остаётся отдать ожидание отдельному потоку.
Значит, граница проходит не между словами «ввод-вывод» и «счёт», а по тому, умеет ли система сообщить о готовности. Проверим, где она проходит на самом деле: займём пул теми же четырьмя долгими заданиями и попробуем разом всё — чтение файла и stat, сжатие, случайные байты, разрешение имени, соединение с собственным сервером и обычный таймер. Прогноз: что из этого встанет в очередь за хешами?
// Пул занят четырьмя долгими заданиями. Кто встанет в очередь за ними, а кто пойдёт мимо? import { pbkdf2, randomBytes } from 'node:crypto'; import { readFile, stat } from 'node:fs'; import { lookup } from 'node:dns'; import { connect, createServer } from 'node:net'; import { gzip } from 'node:zlib'; for (let i = 1; i <= 4; i += 1) pbkdf2(`долгое ${i}`, 'соль', 4000000, 32, 'sha512', () => {}); const ИМЕНА = ['fs.readFile', 'fs.stat', 'zlib.gzip', 'crypto.randomBytes', 'dns.lookup', 'net.connect', 'setTimeout']; const выполнилось = new Set(); const отметить = (имя) => () => выполнилось.add(имя); readFile(import.meta.filename, отметить('fs.readFile')); stat(import.meta.dirname, отметить('fs.stat')); gzip(Buffer.alloc(64), отметить('zlib.gzip')); randomBytes(32, отметить('crypto.randomBytes')); lookup('localhost', отметить('dns.lookup')); setTimeout(отметить('setTimeout'), 100); const сервер = createServer((сокет) => сокет.end()); сервер.listen(0, '127.0.0.1', () => { const сокет = connect(сервер.address().port, '127.0.0.1', отметить('net.connect')); сокет.on('close', () => сервер.close()); }); setTimeout(() => { console.log('пул занят четырьмя долгими заданиями; за полсекунды выполнилось:'); for (const имя of ИМЕНА) console.log(` ${имя}: ${выполнилось.has(имя) ? 'да' : 'нет'}`); }, 500);
пул занят четырьмя долгими заданиями; за полсекунды выполнилось: fs.readFile: нет fs.stat: нет zlib.gzip: нет crypto.randomBytes: нет dns.lookup: нет net.connect: да setTimeout: да
Пять операций из семи встали в очередь за хешами, и среди них две неожиданные. zlib.gzip и crypto.randomBytes — не ожидание вовсе, а счёт, и в пул они попали именно поэтому: считать в главном потоке им нельзя, а отдать работу больше некуда. А dns.lookup в списке ждущих — та самая ловушка, из-за которой пул кончается в самом неожиданном месте.
| Через пул потоков | Мимо пула |
|---|---|
почти весь node:fs: чтение, запись, stat, обход каталогов | сокеты TCP и UDP: соединение, приём, отправка |
node:zlib вместе со сжатием Brotli | таймеры setTimeout и setInterval |
crypto.pbkdf2, scrypt, randomBytes, генерация ключей | dns.resolve и остальные функции резолвера |
dns.lookup — а им по умолчанию пользуются fetch и node:http | сигналы, process.nextTick, микрозадачи |
Про dns.lookup стоит сказать отдельно: это единственная сетевая по смыслу операция в левой колонке. Имя в адрес она превращает не сама, а системным вызовом getaddrinfo, и он синхронный — значит, нужен поток. Родственница dns.resolve ходит за тем же ответом по сети своими силами и в пул не попадает. Разница проявляется ровно тогда, когда её меньше всего ждёшь: сотня входов с pbkdf2 занимает пул, и вместе с чтением файлов встают все исходящие запросы по именам — хотя сеть при этом свободна.
Синхронных двойников в пуле нет: readFileSync и pbkdf2Sync считают в главном потоке и занимают именно его. Пул — это про асинхронные версии, и только про них.
В Linux у ядра есть механизм, который умеет и файлы, — io_uring. libuv его поддерживает, но в версии 1.52.1, той самой, что стоит в node 26.5.0, по умолчанию через него идёт только пакетная работа с epoll; файловые операции — лишь там, где io_uring включили особым образом, и одного флага мало: libuv требует и настройки цикла, и переменной окружения. Так что «файлы читает пул» — описание поведения по умолчанию на всех трёх системах, а не упрощение.
Практический вывод от этого не зависит: код, который упирается в четыре потока, упрётся в них и на ядре с io_uring, пока его не включили.
Что делать, зависит от того, чем именно занят пул. Если счётом — его место не в пуле, а в отдельном потоке: пул и воркеры — разные вещи, и пул, в отличие от воркера, у вас один на весь процесс вместе со всеми чтениями файлов. Если настоящим диском — поднимают размер пула, но не выше числа ядер. А самое частое лекарство обыкновенное: не звать дорогой хеш чаще, чем нужно, — стоимость pbkdf2 назначаете вы сами, и что именно она покупает, разбирает глава про вход.
Осталось вернуть долг. Статья про фазы честно предупредила: есть место, где модель отвечает твёрдо, а Node не отвечает вовсе, — это setTimeout на ноль и setImmediate, назначенные на верхнем уровне главного модуля, до первого оборота цикла. Модель говорит: immediate первым. Node говорит: как повезёт.
Механика простая, и она вся в договоре о таймерах. Ноль — это не «сейчас», а «не раньше чем», и Node поднимает его до одной миллисекунды. Своё представление о времени цикл обновляет один раз за оборот, перед фазой сроков. Дальше решает арифметика: если от момента, когда таймер завели, до этого обновления прошла целая миллисекунда, срок уже наступил — и фаза сроков выполнит таймер прежде, чем дело дойдёт до фазы проверки. Не прошла — первым будет immediate. А сколько миллисекунд уйдёт на остаток запуска, не знает никто: это зависит от машины, от загрузки диска и от того, что ещё успел сделать ваш файл.
Почему такой опыт не записывают одним прогоном, сказано там же: череда одинаковых строк подряд выглядела бы как правило, а не как удачная серия. Поэтому прогон считает сам. Он четыреста восемьдесят раз запускает один и тот же файл из двух строк и печатает не проценты, а три утверждения, которые от процентов не зависят. Прогноз: какие из трёх окажутся верными?
// Один и тот же файл запускается много раз: что сработает первым в главном модуле — // setTimeout(0) или setImmediate. import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; const запустить = promisify(execFile); const ЗАПУСКОВ = 480; const ПАРАЛЛЕЛЬНО = 8; const итоги = []; for (let с = 0; с < ЗАПУСКОВ; с += ПАРАЛЛЕЛЬНО) { const пачка = Array.from({ length: Math.min(ПАРАЛЛЕЛЬНО, ЗАПУСКОВ - с) }, () => запустить(process.execPath, ['race-one.mjs'], { cwd: import.meta.dirname })); for (const { stdout } of await Promise.all(пачка)) итоги.push(stdout.trim()); } const таймером = итоги.filter((с) => с === 'таймер').length; console.log(`запусков одного и того же файла: ${ЗАПУСКОВ}`); console.log(` setImmediate был первым больше чем в трёх четвертях запусков: ${таймером < ЗАПУСКОВ / 4 ? 'да' : 'нет'}`); console.log(` хотя бы раз первым оказался таймер: ${таймером > 0 ? 'да' : 'нет'}`); console.log(` порядок был одним и тем же во всех запусках: ${таймером === 0 || таймером === ЗАПУСКОВ ? 'да' : 'нет'}`);
запусков одного и того же файла: 480 setImmediate был первым больше чем в трёх четвертях запусков: да хотя бы раз первым оказался таймер: да порядок был одним и тем же во всех запусках: нет
Монета падает на setImmediate гораздо чаще — но падает и на другую сторону. На машине автора 22.09.2026 таймер оказывался первым от двух до четырёх десятков раз из 480 — примерно в четырёх-восьми случаях на сотню; замеры 21.09.2026 и 29.09.2026, четыре и восемь заходов той же длины, дали ту же вилку, причём загруженность машины на долю не влияла. Числа от захода к заходу разные, вывод один: «почти всегда» договором не бывает. Код, который опирается на этот порядок, — это код, который падает на чужой машине примерно раз в двадцать запусков, а на вашей не падает никогда.
Лечится это не перестановкой строк, а уходом со старта. Внутри любого колбэка ввода-вывода тот же вопрос имеет твёрдый ответ: immediate первым, всегда, потому что фаза проверки идёт сразу за опросом, — это доказано прогоном в статье про фазы. Поэтому «сделай после старта, но до всякого ввода-вывода» пишут одним setImmediate вокруг всего, а не двумя соседними назначениями, про которые предполагается, что они сработают в нужном порядке.
Проверить, опираетесь ли вы на жребий, просто: если ответ на вопрос «а что здесь раньше» зависит от того, на каком уровне файла стоят две строки, — опираетесь.
Глава кончается там же, где началась, — тремя вопросами к любой модели: синхронна ли она, замкнут ли её мир и названы ли упрощения. Первые два наш планировщик прошёл, а список умолчаний мы там же и составили — но тогда это были обещания на слово. Теперь под каждым пунктом есть измерение, а в списке прибавилось главное: пула в модели нет вовсе. Вот он целиком, и у каждой строки появился ответ, можно ли на модель в этом месте опираться, — чтобы дальше по части ею пользоваться, не гадая.
| В модели | На самом деле | Можно ли опираться |
|---|---|---|
| три фазы: сроки, опрос, проверка | шесть фаз, а между колбэками ещё два перерыва | да: три недостающие вашего кода не касаются |
| оборот стоит один тик | оборот стоит столько, сколько выполнялись колбэки, а в фазе опроса — сколько цикл проспал | нет: модель отвечает про порядок, а не про время |
| пула нет, «работа не здесь» — это таймер | четыре потока на весь процесс и общая очередь к ним | нет: пул держат в голове отдельно |
| фаза опроса — просто очередь готовых колбэков | единственное место, где процесс спит в системном вызове | частично: порядок тот же, стоимость совсем другая |
на старте immediate раньше таймера на ноль | жребий | нет: единственное место, где модель строже Node |
Последняя строка выделяется: обычно модель проще жизни, а здесь она строже. Такое случается с любой моделью, у которой мир замкнут: в ней нет места для «сколько-то миллисекунд ушло на запуск», и она отвечает там, где ответа нет. Заметить это можно было только сверкой с настоящим node, и это главный довод за то, чтобы каждую модель сверять, а не только строить.
Пользоваться планировщиком дальше можно и нужно, но с оговоркой, которая теперь звучит точно: он отвечает на вопрос «что за чем внутри колбэка» и не отвечает ни на «сколько это стоит», ни на «что будет на старте». Первое закрывает измерение, второе — привычка не назначать порядок на верхнем уровне файла.
Глава про цикл на этом закрыта, а вместе с ней — и разговор о том, кто выполняет колбэк. Следующая начинается с вопроса, который мы обходили молча: колбэк сокета не знает, кому нужны пришедшие байты, и раздаёт их излучатель событий — его мы тоже соберём сами.
- Оборот настоящего цикла — шесть фаз, из которых вашему коду видны три: сроки, опрос, проверка. Очереди
process.nextTickи микрозадач не фазы, а перерыв между колбэками. - В фазе опроса процесс спит в системном вызове — ровно до ближайшего срока, а если в фазе проверки уже есть работа, не спит вовсе. Секунда ожидания стоит меньше половины миллисекунды процессорного времени.
- Файлы читает пул потоков, потому что на вопрос «готов ли файл» система отвечает «да» всегда, а читать всё равно придётся с ожиданием. Сокет на этот вопрос отвечает осмысленно и потока не требует.
- Пул один на процесс и по умолчанию состоит из четырёх потоков; в нём же считаются
pbkdf2,scrypt,zlibи разрешение имён черезdns.lookup. Пятое задание не выполняется медленнее — оно не начато. UV_THREADPOOL_SIZEчитается один раз, когда пул создаётся: из кода его выставить можно, но только до первой операции пула. Поднимать имеет смысл до числа ядер, не выше.- Порядок
setTimeoutна ноль иsetImmediateна верхнем уровне главного модуля — жребий:setImmediateвыигрывает больше чем в девяти случаях из десяти, но не во всех. Внутри колбэка тот же порядок — договор.
1/4Процесс ждёт таймер на секунду и больше ничего не делает. Сколько процессорного времени он на это потратит?
2/4В пуле четыре потока, и все четыре заняты долгими pbkdf2. Что из перечисленного НЕ будет ждать освобождения потока?
3/4Когда libuv читает переменную UV_THREADPOOL_SIZE?
4/4setTimeout(f, 0) и setImmediate(g) стоят рядом на верхнем уровне главного модуля. Что выполнится первым?
Пятый поток
Пул тот же, что в разделе про волны, а заданий пять: четыре долгих по сто тиков и одно быстрое на один тик. Быстрое отправлено последним и потому ждёт, хотя работы в нём на один тик. Сделайте так, чтобы оно было готово на первом тике, не трогая ни сами задания, ни их порядок, ни устройство очереди. Менять можно ровно одну строку.
Менять надо не задания, а пул: пятый поток — и быстрому заданию больше не за кем стоять.
Вывод:
Изменилось ровно одно: быстрому заданию достался свой поток, и оно перестало быть пятым в очереди. Долгие при этом не ускорились и не замедлились — как считали сто тиков, так и считают. Пул вообще ничего не ускоряет, он только решает, сколько заданий идёт разом.
В Node эта строка называется UV_THREADPOOL_SIZE, и у неё есть цена, которой в модели нет: каждый поток настоящий, со своим стеком, и на восьми ядрах двадцать потоков считают не быстрее четырёх — они считают по очереди. Поэтому поднимать пул имеет смысл, когда его потоки ждут диск, и почти бесполезно, когда они считают. Во втором случае правильный ответ другой: унести счёт из пула совсем, в отдельный поток, — тогда чтения файлов перестанут стоять за хешами.
Что из этого займёт поток пула
Вот обработчик входа: шесть строк, и все шесть асинхронные. Для каждой ответьте, займёт ли она поток пула. А потом скажите, что произойдёт с этим обработчиком, если сто человек нажмут «Войти» в одну секунду, а пул остался в четыре потока.
db.query— мимо пула. Соединение с базой уже открыто, ответ придёт байтами в сокет, а о готовности сокета система сообщает сама.readFile— поток пула. На вопрос «готов ли файл» система отвечает «да» всегда, поэтому ждать диск приходится потоку.pbkdf2Async— поток пула, и самый дорогой из всех: шестьсот тысяч итераций считаются десятки миллисекунд, и всё это время поток занят счётом, а не ожиданием.lookup— поток пула, хотя строка выглядит сетевой. За ней стоит синхронныйgetaddrinfo, и обойти его нечем. Будь здесьdns.resolve, поток бы не понадобился.gzipAsync— поток пула: сжатие это счёт, а считать в главном потоке нельзя.res.end— мимо пула: запись в сокет.
Четыре строки из шести стоят в одной очереди — и очередь эта не на обработчик и даже не на запрос, а на весь процесс: четыре потока на всё сразу.
Теперь сто входов в секунду. Сто хешей по шестьсот тысяч итераций займут все четыре потока на секунды, и за ними встанут не только остальные хеши, но и чтение шаблонов писем, и сжатие вложений, и разрешение имени почтового сервера — а заодно чтение файлов во всех остальных обработчиках, которые к входу отношения не имеют. Снаружи это выглядит как «сервер подвис на отдаче статики», и по логам входа причину не найти.
Лечится это не словом «асинхронно» — все шесть строк и так асинхронные. Лечится оно тем, что дорогой счёт убирают из пула (отдельный поток или отдельный процесс) или ограничивают число одновременных проверок пароля, чтобы в пуле всегда оставался свободный поток для тех, кто просто читает файл.
Тест, который падает раз в двадцать запусков
Этот тест проходит девятнадцать раз из двадцати, а на двадцатый падает — и чаще всего не у автора, а в сборке. Объясните, почему, и перепишите его так, чтобы порядок стал договором, а не жребием.
Падает он на жребии. Обе строки стоят на верхнем уровне главного модуля, до первого оборота цикла, и порядок между таймером на ноль и setImmediate там не определён: всё решает, успел ли процесс потратить на остаток запуска целую миллисекунду. На машине автора таймер выигрывает в четырёх-восьми случаях из ста — то есть примерно раз в двадцать запусков.
Два setImmediate друг относительно друга упорядочены строго: они в одной очереди фазы проверки и выполняются в порядке назначения. Поэтому проверка видит либо «и», либо «ти» — третьего не дано, и это ровно две стороны монеты.
Починка — увести всё со старта внутрь колбэка ввода-вывода, где порядок фаз становится договором:
Колбэк чтения выполняется в фазе опроса, фаза проверки идёт сразу за ней в том же обороте, а до фазы сроков дело дойдёт только на следующем — это и есть твёрдый ответ, который статья про фазы доказала прогоном.
И всё-таки лучший вариант третий: не проверять порядок цикла там, где вы проверяете свой код. Тест, которому важно, кто из двух колбэков первый, почти всегда проверяет не то, что собирался, — а если важно по-настоящему, порядок задают явно, вызовом из вызова, а не соседством строк.