Собираем читающий и пишущий поток
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В прошлой статье собрали излучатель: таблицу «имя события → список функций», по которой emit раздаёт аргументы. Им сокет сообщает «пришли байты». Но тело запроса приходит не одним событием, а кусками, файл уходит клиенту кусками, ответ пишется кусками — и голого излучателя тут мало. Кусок, пришедший раньше подписки, пропадает. Конец данных легко объявить раньше, чем данные дошли. Запись, начатая первой, может закончиться последней. Всё это решает поток — излучатель с буфером. Сегодня: зачем вообще работать порциями, читающий поток с буфером и событием end, пишущий — с очередью и колбэком, их соединение и сверка с настоящим node:stream.
Файл в Node можно прочитать двумя способами. readFile из node:fs/promises отдаёт его целиком — одним Buffer, объектом Node для байтов. createReadStream из node:fs отдаёт тот же файл кусками: каждый кусок приходит событием data, а о конце сообщает событие end. Код с readFile короче, и на файле настроек разницы не видно. Она видна на большом файле.
Файл в примере — видео на 512 МиБ, которого нет: truncate растягивает пустой файл до нужной длины, не записывая байты. Читается такой файл как настоящий, а на дисках Linux и macOS места почти не занимает. Память под байты Node считает отдельно от кучи JavaScript, в поле arrayBuffers у process.memoryUsage(). Прогноз: на сколько вырастет эта память при каждом способе, сколько кусков отдаст поток и что readFile сделает с файлом на 3 ГиБ?
import { createReadStream } from "node:fs"; import { readFile, rm, truncate, writeFile } from "node:fs/promises"; const MiB = 1024 * 1024; const file = "video.bin"; // truncate дописывает пустоту, а не байты: в Linux и macOS файл на 512 МиБ почти не занимает диска await writeFile(file, ""); await truncate(file, 512 * MiB); // байты Buffer лежат вне кучи V8, их считает отдельный счётчик const buffers = () => process.memoryUsage().arrayBuffers; let before = buffers(); const whole = await readFile(file); console.log("readFile: кусков 1, длина", whole.length); console.log("readFile: память под буферы выросла на весь файл:", buffers() - before >= 512 * MiB); before = buffers(); let pieces = 0; let biggest = 0; let peak = 0; await new Promise((resolve, reject) => { createReadStream(file) .on("data", (chunk) => { pieces += 1; biggest = Math.max(biggest, chunk.length); peak = Math.max(peak, buffers() - before); }) .on("end", resolve) .on("error", reject); }); console.log("поток: кусков", pieces, "самый большой", biggest); console.log("поток: память выросла меньше чем на четверть файла:", peak < 128 * MiB); await truncate(file, 3 * 1024 * MiB); try { await readFile(file); } catch (err) { console.log("readFile на 3 ГиБ:", err.message); } await rm(file);
readFile: кусков 1, длина 536870912 readFile: память под буферы выросла на весь файл: true поток: кусков 8192 самый большой 65536 поток: память выросла меньше чем на четверть файла: true readFile на 3 ГиБ: File size (3221225472) is greater than 2 GiB
readFile отдал файл одним куском в 536 870 912 байт, и память под буферы выросла на весь файл. Поток отдал те же байты 8192 кусками по 64 КиБ, и память выросла меньше чем на четверть файла. Поток куски не копит: кусок живёт, пока его обрабатывает слушатель, а дальше становится мусором, и рост памяти — это прочитанные куски, до которых сборщик мусора ещё не дошёл.
Последняя строка — предел: файл больше 2 ГиБ readFile не отдаёт вовсе. Но и до предела счёт идёт не на один файл. Сервер, который отдаёт видео через readFile, держит в памяти по файлу на каждого, кто скачивает: двадцать клиентов — 10 ГиБ. И каждый ждёт первый байт, пока файл не прочитан целиком, хотя первые 64 КиБ можно было отправить сразу. Поток меняет обе величины: память — на кусок, ожидание — до первого куска. Платить приходится кодом, который умеет работать с данными порциями. Такой код и соберём.
Поток в этой главе — stream, данные, которые приходят кусками. Главный поток и пул потоков из статьи про среду Node — это threads, исполнители кода. По-русски оба зовутся потоками, и путать их легко.
Поток на излучателе напрашивается сам: источник зовёт emit("data", кусок), потребитель подписывается через on. Излучатель берём целиком из последнего раздела прошлой статьи — с копией списка, once, броском на error и счётчиком слушателей. Дальше он лежит в подложке и в коде примеров не повторяется.
Обычный обработчик запроса сначала проверяет токен — идёт в базу — и только потом читает тело. Пока база отвечает, сеть успевает прислать тело. Прогноз: что напечатает пример и дойдёт ли тело до обработчика?
Оба emit вернули false: слушателей не было, и излучатель, как ему и положено, ничего не запомнил. Обработчик подписался через десять миллисекунд, когда тело уже пролетело мимо, и будет ждать его вечно. На сервере это не редкий случай, а обычный порядок: между приходом байтов и подпиской почти всегда стоит await.
Лечение — буфер. Источник не испускает кусок, а кладёт его в массив методом push. Раздавать куски поток начинает, когда его попросили, и просьба в Node — сама подписка на data: on зовёт resume. resume ставит флаг flowing — поток течёт — и зовёт flow, который отдаёт слушателям накопленное по одному куску в порядке прихода. Пока поток течёт, push тоже зовёт flow, и новый кусок уходит сразу. Одна тонкость: resume включает течение не на месте, а микрозадачей через queueMicrotask. Прогноз: в каком порядке встанут четыре строки?
Тело дошло. Пока обработчик ждал базу, два куска лежали в буфере, а когда он подписался, поток отдал их по порядку. Строка «подписались на data» стоит раньше кусков, хотя подписка случилась строкой выше: течение началось микрозадачей, после синхронного кода обработчика.
Зачем откладывать? Обработчик подписывается на несколько событий подряд: data, следом end и error. Раздай поток буфер прямо внутри первого on, короткое тело пролетело бы целиком — вместе с концом данных, о котором следующий раздел, — пока подписки на end ещё нет. Микрозадача ждёт, пока синхронный код допишет подписки. Node откладывает так же, только через process.nextTick — свою очередь, которую разбирает глава про цикл событий.
Буфер отдаёт кусок один раз: shift вынимает его из массива. Слушатель, подписанный позже, получит только новые куски. Отсюда правило, знакомое по Request: тело запроса вычерпывается один раз.
Как сказать, что кусков больше не будет? В Node источник зовёт push(null), а потребитель слушает end — там обработчик разбирает собранное тело и отвечает. Первое, что приходит в голову, — испустить end прямо в push(null). Readable из прошлого примера лежит в подложке, наивный конец дописан в наследнике. Короткое тело пришло одним пакетом: два куска и конец — подряд. Прогноз: что обработчик увидит в end?
Обработчик получил end с пустым телом, а через 10 мс тело оказалось целым. Куски ждали в буфере — течение начинается микрозадачей, — а push(null) испустил end сразу, раньше них. Сервер ответил бы «нужно поле title» на правильный запрос. Ошибка не в порядке вызовов, а в смысле события: end должен значить не «источник сказал, что всё», а «потребитель получил всё».
Поэтому push(null) только ставит пометку ended, а end испускает flow, когда буфер опустел. Флаг endEmitted не даёт испустить конец дважды: flow зовут и push, и resume. После пометки push кусков не принимает: опоздавший кусок — ошибка источника, и поток испускает error. Ниже Readable целиком, со всеми правками. Прогноз: какая строка напечатается первой и что станет с опоздавшим куском?
Тело целое. А первой напечаталась строка про опоздавший кусок: слушателя error нет, и излучатель бросил ошибку из emit — правило про error без слушателя из прошлой статьи. Этот push — синхронный код, а data и end пришли после него, из микрозадачи.
У нового смысла end есть обратная сторона: поток, который никто не читает, не кончается. Скажем, обработчик отклонил запрос по токену, тело ему не нужно, и на data он не подписался — только на end, чтобы после него закрыть запрос. Readable со всеми правками теперь в подложке. Прогноз: придёт ли end и что изменит resume?
Без читателя кусок лежит в буфере, и end не приходит, сколько ни жди. resume без слушателя data пускает течение вхолостую: flow отдаёт куски в emit, которому некого звать, буфер пустеет, и конец приходит. В Node метод тот же, и req.resume() — штатный способ сказать «тело не нужно»: без него обработчик, который ждёт end, ждёт вечно.
Теперь в обратную сторону. Ответ сервера и файл на диске — пишущие потоки: HTTP-ответ можно отдавать кусками, ещё не зная, чем он кончится, и файл пишется так же. Само устройство — сокет или диск — работает асинхронно, и каждая запись занимает своё время. Запись файла Node и вовсе отправляет в пул, где несколько записей идут одновременно. Наивный пишущий поток отдаёт каждый кусок устройству сразу, а finish — событие «всё записано» — испускает в end. Три куска пишутся за 30, 5 и 15 мс. Прогноз: что будет в файле к finish и через 50 мс?
К finish файл пуст, а через 50 мс куски в нём стоят в порядке готовности, а не в порядке вызовов: второй записался быстрее первого и встал впереди. С настоящим файлом бывает то же. Документация fs.write прямо называет небезопасным повторный вызов на том же файле, пока не пришёл колбэк предыдущего, и для такого случая советует пишущий поток.
Пишущий поток чинит обе беды одним приёмом — очередью. write кладёт кусок в буфер, а next отдаёт устройству следующий, только когда предыдущий записан. Узнаёт поток об этом из колбэка: функция записи получает кусок и done и обязана позвать done, когда закончила. end ставит пометку ending, а finish приходит, когда пометка стоит, записи нет и очередь пуста. Функцию записи поток получает в конструкторе, так же как в Node. Прогноз: сколько кусков ждёт в очереди сразу после end и что будет в файле к finish?
Первый кусок ушёл на запись сразу, внутри write, а два ждали в очереди. Записи шли по одной и в порядке вызовов, и finish пришёл после последнего done. Медленная первая запись задержала остальные, но не перемешала их. В этом и договор finish: всё, что передали в write, устройство приняло.
Очередь держится на колбэке, и отсюда самая тихая поломка пишущего потока. Функция записи ниже пропускает пустой кусок — писать его незачем — и выходит, не позвав done. Emitter, Readable и Writable со всеми правками лежат в подложке. Прогноз: что будет в файле, в очереди и с finish?
Записался только первый кусок. Пустой занял запись и не отпустил её: writing остался true, и «[2]» ждёт за записью, которая никогда не кончится. finish нет, ошибки нет, исключения нет. На сервере так выглядит ответ, который «иногда не доходит»: клиент ждёт до таймаута, а в журнале пусто. Правило простое: у функции записи каждая ветка кончается вызовом done, и та, где записывать нечего, тоже.
Осталось соединить. Отдать файл клиенту — значит связать читающий поток диска с пишущим потоком сокета: каждый data превращается в write, end — в end. Диск отдаёт куски быстро, а клиент на мобильном интернете принимает медленно. В примере 64 куска по 65 536 символов — как 64 КиБ в куске у createReadStream. Прогноз: сколько кусков ждёт в очереди записи, когда источник закончил?
readFile лишь в том, что память кончается не сразу, а по мере того, как клиент отстаёт.В Node у write есть возвращаемое значение, которого в миниатюре нет: false значит «очередь полна, подожди». Как источник его слушает и зачем нужны pause и событие drain — следующая статья, про обратное давление. А ещё ручное соединение не видит ошибок: оборвись сокет, источник продолжит читать диск впустую. Соединение, которое убирает за собой, — в статье про pipeline.
Сверим правила с Node. В файле ниже шесть проверок на Readable и Writable из node:stream: тело, пришедшее раньше подписки, поток без читателя и resume, push после конца, три записи разной продолжительности, ветка без done и write после end.
Код проверок чуть длиннее миниатюры. Настоящему Readable передают функцию read: Node зовёт её, когда хочет данных, — здесь она пустая, куски кладёт сам пример. Опция encoding: "utf8" отдаёт куски строками, а decodeStrings: false не даёт Writable превращать строки в Buffer. У функции записи между куском и done стоит кодировка. once(поток, "end") из node:events — промис на первое событие. Прогноз: совпадёт ли вывод с примерами статьи?
import { once } from "node:events"; import { Readable, Writable } from "node:stream"; const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // 1: тело пришло раньше подписки const req = new Readable({ read() {}, encoding: "utf8" }); req.push('{"title":'); req.push('"хлеб"}'); req.push(null); let body = ""; req.on("data", (chunk) => { body += chunk; }); req.on("end", () => console.log(`1. end, тело: «${body}»`)); console.log(`1. подписались, тело пока: «${body}»`); await once(req, "end"); // 2: поток, который никто не читает const unread = new Readable({ read() {} }); unread.push("тело, которое обработчику не нужно"); unread.push(null); let ended = false; unread.on("end", () => { ended = true; }); await wait(20); console.log("2. end без читателя пришёл:", ended); unread.resume(); await wait(20); console.log("2. после resume end пришёл:", ended); // 3: push после конца const late = new Readable({ read() {} }); late.on("error", (err) => console.log("3. error:", err.code)); late.push(null); late.push("опоздавший кусок"); await wait(20); // 4: три записи разной длины const delays = { "[1]": 30, "[2]": 5, "[3]": 15 }; let file = ""; const out = new Writable({ decodeStrings: false, write(chunk, encoding, done) { setTimeout(() => { file += chunk; done(); }, delays[chunk]); }, }); out.write("[1]"); out.write("[2]"); out.write("[3]"); out.end(); await once(out, "finish"); console.log(`4. finish, в файле: «${file}»`); // 5: ветка без done let stuckFile = ""; const stuck = new Writable({ decodeStrings: false, write(chunk, encoding, done) { if (chunk === "") return; setTimeout(() => { stuckFile += chunk; done(); }, 5); }, }); let finished = false; stuck.on("finish", () => { finished = true; }); stuck.write("[1]"); stuck.write(""); stuck.write("[2]"); stuck.end(); await wait(50); console.log(`5. в файле: «${stuckFile}» | finish пришёл: ${finished}`); // 6: write после end const closed = new Writable({ write(chunk, encoding, done) { done(); }, }); closed.on("error", (err) => console.log("6. error:", err.code)); closed.end(); closed.write("поздно"); await wait(20);
1. подписались, тело пока: «»
1. end, тело: «{"title":"хлеб"}»
2. end без читателя пришёл: false
2. после resume end пришёл: true
3. error: ERR_STREAM_PUSH_AFTER_EOF
4. finish, в файле: «[1][2][3]»
5. в файле: «[1]» | finish пришёл: false
6. error: ERR_STREAM_WRITE_AFTER_ENDСовпало всё, что показывали примеры. Куски, пришедшие до подписки, дождались её, и строка про подписку стоит раньше тела. Поток без читателя не кончился, а resume его дочитал. Записи легли по порядку, а на ветке без done запись встала: в файле один «[1]», и finish не пришёл. push после конца и write после end — ошибки с кодами ERR_STREAM_PUSH_AFTER_EOF и ERR_STREAM_WRITE_AFTER_END. Только миниатюра испускает error прямо внутри вызова, а Node — после текущего кода, и без слушателя error исключение роняет не вызов, а весь процесс.
Чем ещё оригинал отличается от миниатюры — в таблице.
| Миниатюра | node:stream 26.5.0 | |
|---|---|---|
| кусок | строка | Buffer; строки — с encoding у читающего потока и decodeStrings: false у пишущего; любые значения — с objectMode |
| откуда данные | источник сам зовёт push | поток зовёт функцию read, когда ему нужны данные; push отвечает, стоит ли продолжать |
| начало течения | queueMicrotask | process.nextTick |
end и finish | внутри вызова, который опустошил буфер или очередь | откладываются, как и начало течения |
push после конца, write после end | error сразу; без слушателя исключение вылетает из вызова | error после текущего кода; без слушателя падает процесс |
отписка от data | поток течёт дальше, куски пропадают | так же: отписка не ставит поток на паузу, для этого есть pause() |
| очередь записи | без предела, write ничего не возвращает | порог highWaterMark: write возвращает false, а когда очередь разойдётся, поток испускает drain |
| чего в миниатюре нет | — | событие readable и чтение через read(), destroy и событие close, Duplex и Transform, pipe и pipeline |
Потоки файлов и сокетов, Web Streams браузера и for await по потоку — в статье про настоящие потоки. А тело запроса, которое сегодня приходило двумя строками, в главе про HTTP придёт настоящими кусками сокета, и разбирать его придётся самим.
- Поток отдаёт данные кусками:
readFileдержит в памяти весь файл и больше 2 ГиБ не читает,createReadStreamотдаёт тот же файл кусками по 64 КиБ, и память растёт на прочитанные куски, а не на файл. - Читающий поток — излучатель с буфером:
pushкладёт кусок в массив, подписка наdataвключает течение, и накопленное уходит слушателям по порядку. Кусок, пришедший до подписки, ждёт, а не пропадает, как у гологоemit. - Течение начинается не внутри
on, а после текущего синхронного кода — в Node черезprocess.nextTick: слушателиendиerror, подписанные следующими строками, успевают встать. push(null)только ставит пометку, аendприходит, когда буфер опустел: конец — это «потребитель получил всё». Поток, который никто не читает, не кончается; ненужное тело выбрасывают черезresume().- Пишущий поток — очередь: следующий кусок уходит устройству только после колбэка предыдущего, поэтому записи не перемешиваются, а
finishприходит после последней. Ветка функции записи без вызова колбэка вешает поток молча. - Очередь записи без предела: быстрый источник, соединённый с медленным приёмником через
dataиwrite, снова складывает файл в память. В Node уwriteдля этого есть сигнал «подожди» — это обратное давление.
1/4Обработчик запроса сначала ждёт ответ базы, а потом подписывается на data читающего потока. Тело пришло, пока он ждал. Что получит слушатель?
2/4Обработчик подписался только на end, на data — нет. Источник прислал тело и push(null). Что будет?
3/4Функция записи у Writable в одной из веток не зовёт колбэк. Что увидит код, который ждёт finish?
4/4Быстрый читающий поток соединён с медленным сокетом: каждый data — write, end — end. Где окажется большой файл, пока клиент его принимает?
Строки из кусков
Журнал сервера приходит потоком, и граница куска режет строки где попало: «GET /health 2» в одном куске, «00» — в другом. Напишите lines(stream, onLine): функция зовёт onLine с каждой целой строкой без перевода строки, а последнюю строку, у которой перевода нет, отдаёт, когда поток кончится. Сейчас функция пустая, и проверка молчит: на data никто не подписан, поэтому и end не приходит.
Хвост после последнего перевода строки отдавать нельзя: продолжение приедет со следующим куском. Поэтому хвост переходит к следующему data, а в end отдаётся, если он не пустой.
Вывод:
Порядок подписок здесь важен: lines подписывается на end раньше проверки, поэтому «строк: 3» печатается после третьей строки. Без end третья строка потерялась бы: после неё нет перевода, и она так и осталась бы хвостом. Тем же приёмом разборщик HTTP ищет конец заголовков в кусках сокета. С настоящими байтами к нему добавится ещё одна забота: граница куска режет и многобайтовую букву.
Диск переполнен
Функция записи сообщает об ошибке так же, как в Node: передаёт её в колбэк, done(err). Пишущий поток из статьи на аргумент колбэка не смотрит. Место на диске кончилось на третьем куске, а поток всё равно объявил finish, как будто всё записано. Перепишите next в наследнике: получив ошибку, поток испускает error, выбрасывает очередь и больше ничего не пишет, а finish не испускает. Прежний next — в разделе про пишущий поток.
Колбэк получает аргумент, а флаг failed запоминает, что поток сломан:
Вывод:
Флаг проверяется в начале next: иначе write после ошибки снова отдал бы кусок на диск, где места уже нет. Слушатель error в проверке стоит не зря: без него излучатель бросил бы ошибку из колбэка таймера, и поймать её было бы некому. Настоящий Writable на done(err) делает то же и чуть больше: уничтожает поток — destroyed становится true, — испускает error, потом close, сбрасывает очередь, а finish не испускает.
Один кусок через once
Предскажите обе строки вывода и только потом запускайте. Обработчик берёт первый кусок через once, а остальное тело собирает позже.
Тело пустое, хотя оба его куска пришли. once подписал на data обёртку, и подписка включила течение. Первый кусок достался обёртке, она сняла себя, но поток не остановился: течение — свойство потока, а не слушателя. Два следующих куска flow отдал в emit, которому звать было некого, и они пропали. Через 10 мс новый слушатель застал только конец. Настоящий Readable ведёт себя так же: снятие слушателя data не ставит поток на паузу. Взять кусок и остановиться — это pause(), её разбирает следующая статья. Да и первый кусок — не первая строка запроса: где кончается кусок, решает сеть, а не протокол.