Этот урок «как устроено» идёт после уроков «как пользоваться» намеренно. Пользоваться файберами можно, не зная, что внутри: ты это делал девять уроков. Но три вопроса без устройства не решить. Почему один тяжёлый расчёт останавливает всю башню. Почему Node иногда выходит, хотя файбер ещё ждёт. И куда девать работу, которой мало одного ядра.
Один поток, много файберов
JavaScript исполняет один шаг за раз, на одном потоке. Файберы не меняют этого: они делят один поток между собой. Исполнитель Effect, планировщик, держит очередь готовых файберов. Он берёт первый, исполняет его шаги, а когда файбер уступает, ставит его в конец и берёт следующий.
Файбер уступает в трёх случаях:
- Ждёт.
sleep, ответ провода,Deferred.await, место в очереди. Ждущий не стоит в очереди готовых вовсе: его разбудят, когда дождётся. - Попросил сам.
Effect.yieldNowставит файбер в конец очереди, даже если ему есть что делать. - Исчерпал бюджет. Планировщик считает шаги и после 2048 подряд отбирает поток сам. Число задаёт ссылка
MaxOpsBeforeYieldиз модуляScheduler; это та же механика ссылок, что в уроке 52.
Третий случай защищает от файбера, который забыл уступить. Но защищает только между шагами Effect.
Где бюджет не помогает
Сосед делает шаг и уступает; посчитаем его шаги, пока идёт работа:
const sum = (n: number) => { let total = 0; for (let k = 1; k <= n; k++) total += k; return total; };
console.log('один Effect.sync:', await neighborDuring(Effect.sync(() => sum(5_000_000))));
const steps = Effect.gen(function* () {
let total = 0;
for (let k = 1; k <= 20_000; k++) total += yield* Effect.sync(() => k);
return total;
});
console.log('двадцать тысяч шагов Effect.sync подряд:', await neighborDuring(steps));
один Effect.sync: 0
двадцать тысяч шагов Effect.sync подряд: 5
Пять миллионов сложений внутри одного Effect.sync для планировщика это один шаг. Он не может отобрать поток посреди шага: это обычная функция JavaScript, и её не прервёшь. Сосед не сделал ни одного шага, и не сделал бы, будь в этом sync минута работы. Двадцать тысяч мелких шагов планировщик видит по одному и сам отдаёт поток соседу между ними: сосед прошёл пять раз, хотя никто не звал yieldNow.
Отсюда первое правило. Тяжёлый расчёт на одном потоке режут на куски и уступают между кусками: так башня продолжает отвечать на кадры, пока считает. Это разминка урока.
Почему Node вышел
Effect.runFork(Effect.andThen(Effect.never, Effect.sync(() => console.log('никогда'))));
console.log('runFork вернул управление');
runFork вернул управление
процесс вышел с кодом 0
Файбер ждёт вечно, а Node вышел. Node живёт, пока у него есть дела: таймеры, сокеты, файлы. Ждущий файбер это запись в списке ячейки, Node о нём не знает. Effect.never не держит ни одного таймера, и Node решил, что работы нет.
Поэтому runMain из урока 58 держит процесс сам: заводит длинный таймер, который ничего не делает, и убирает его, когда главный файбер кончился. Это keep-alive. Если запускать программу через runFork или runPromise и она ждёт только Effect, процесс может выйти раньше времени. С runMain нет.
Когда одного потока мало
Резать расчёт на куски можно, но он от этого не станет быстрее: все куски по-прежнему идут на одном ядре. Если работы правда много, проверить печать тысячи отчётов, сжать архив, её уносят в другой поток: в браузере это Web Worker, в Node это worker_threads. У Effect для этого есть модуль effect/workers: Worker с одной стороны и WorkerRunner с другой, сообщения туда и обратно по схеме.
Нестабильно. Модули
effect/workersвeffect4.1 помечены@stability unstable, их устройство может поменяться в минорной версии. Урок написан на 4.1.0; задач на них в разделе нет.
Правило выбора короткое. Ждёт ввода и вывода, провода, соседа, файла: файберы, их хватит тысячам. Считает долго: куски с уступками, чтобы не держать остальных. Считает долго и часто: отдельный поток.
Под капотом
Цикл исполнителя из урока 33 брал инструкцию, исполнял, клал продолжение на стек и брал следующую. Добавь счётчик: после каждой инструкции плюс один, а на 2048 файбер кладётся в конец очереди планировщика, и цикл берёт другой. Добавь ожидание: инструкция «жди ячейку» не кладёт продолжение на стек, а прячет весь стек в список ждущих этой ячейки. Добавь флажок прерывания из урока 57. Это и есть файбер Effect, без украшений. Порции работы планировщик по умолчанию запускает через setImmediate в Node и setTimeout(0) там, где его нет: между порциями успевают отработать ввод и вывод, таймеры и отрисовка страницы.
Разминка
sumTo(n) считает сумму одним Effect.sync, и пока считает, соседи стоят. Перепиши так, чтобы она считала кусками и уступала очередь между ними.
Практика
Глубже в архиве
Очередь задач, микрозадачи и то, почему JavaScript исполняет один шаг за раз, разобраны в уроке «Колбеки и event loop».
Дальше
Рыдкулл спускается в подвал с листком.
Заказ ждёт в следующем уроке.