Собираем читающий и пишущий поток

потоки≈ 15 минут

Здесь будет встроен видео-разбор темы (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 ГиБ?

прогон node 26.5.0 · 15 сентября 2026
readFile и createReadStream на одном файле в 512 МиБ; рост памяти сверяется с порогами
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);
node read.mjs

readFile отдал файл одним куском в 536 870 912 байт, и память под буферы выросла на весь файл. Поток отдал те же байты 8192 кусками по 64 КиБ, и память выросла меньше чем на четверть файла. Поток куски не копит: кусок живёт, пока его обрабатывает слушатель, а дальше становится мусором, и рост памяти — это прочитанные куски, до которых сборщик мусора ещё не дошёл.

Последняя строка — предел: файл больше 2 ГиБ readFile не отдаёт вовсе. Но и до предела счёт идёт не на один файл. Сервер, который отдаёт видео через readFile, держит в памяти по файлу на каждого, кто скачивает: двадцать клиентов — 10 ГиБ. И каждый ждёт первый байт, пока файл не прочитан целиком, хотя первые 64 КиБ можно было отправить сразу. Поток меняет обе величины: память — на кусок, ожидание — до первого куска. Платить приходится кодом, который умеет работать с данными порциями. Такой код и соберём.

Поток в этой главе — stream, данные, которые приходят кусками. Главный поток и пул потоков из статьи про среду Node — это threads, исполнители кода. По-русски оба зовутся потоками, и путать их легко.

Поток на излучателе напрашивается сам: источник зовёт emit("data", кусок), потребитель подписывается через on. Излучатель берём целиком из последнего раздела прошлой статьи — с копией списка, once, броском на error и счётчиком слушателей. Дальше он лежит в подложке и в коде примеров не повторяется.

Обычный обработчик запроса сначала проверяет токен — идёт в базу — и только потом читает тело. Пока база отвечает, сеть успевает прислать тело. Прогноз: что напечатает пример и дойдёт ли тело до обработчика?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Оба emit вернули false: слушателей не было, и излучатель, как ему и положено, ничего не запомнил. Обработчик подписался через десять миллисекунд, когда тело уже пролетело мимо, и будет ждать его вечно. На сервере это не редкий случай, а обычный порядок: между приходом байтов и подпиской почти всегда стоит await.

Лечение — буфер. Источник не испускает кусок, а кладёт его в массив методом push. Раздавать куски поток начинает, когда его попросили, и просьба в Node — сама подписка на data: on зовёт resume. resume ставит флаг flowing — поток течёт — и зовёт flow, который отдаёт слушателям накопленное по одному куску в порядке прихода. Пока поток течёт, push тоже зовёт flow, и новый кусок уходит сразу. Одна тонкость: resume включает течение не на месте, а микрозадачей через queueMicrotask. Прогноз: в каком порядке встанут четыре строки?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тело дошло. Пока обработчик ждал базу, два куска лежали в буфере, а когда он подписался, поток отдал их по порядку. Строка «подписались на data» стоит раньше кусков, хотя подписка случилась строкой выше: течение началось микрозадачей, после синхронного кода обработчика.

Зачем откладывать? Обработчик подписывается на несколько событий подряд: data, следом end и error. Раздай поток буфер прямо внутри первого on, короткое тело пролетело бы целиком — вместе с концом данных, о котором следующий раздел, — пока подписки на end ещё нет. Микрозадача ждёт, пока синхронный код допишет подписки. Node откладывает так же, только через process.nextTick — свою очередь, которую разбирает глава про цикл событий.

Буфер отдаёт кусок один раз: shift вынимает его из массива. Слушатель, подписанный позже, получит только новые куски. Отсюда правило, знакомое по Request: тело запроса вычерпывается один раз.

Как сказать, что кусков больше не будет? В Node источник зовёт push(null), а потребитель слушает end — там обработчик разбирает собранное тело и отвечает. Первое, что приходит в голову, — испустить end прямо в push(null). Readable из прошлого примера лежит в подложке, наивный конец дописан в наследнике. Короткое тело пришло одним пакетом: два куска и конец — подряд. Прогноз: что обработчик увидит в end?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обработчик получил end с пустым телом, а через 10 мс тело оказалось целым. Куски ждали в буфере — течение начинается микрозадачей, — а push(null) испустил end сразу, раньше них. Сервер ответил бы «нужно поле title» на правильный запрос. Ошибка не в порядке вызовов, а в смысле события: end должен значить не «источник сказал, что всё», а «потребитель получил всё».

Поэтому push(null) только ставит пометку ended, а end испускает flow, когда буфер опустел. Флаг endEmitted не даёт испустить конец дважды: flow зовут и push, и resume. После пометки push кусков не принимает: опоздавший кусок — ошибка источника, и поток испускает error. Ниже Readable целиком, со всеми правками. Прогноз: какая строка напечатается первой и что станет с опоздавшим куском?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тело целое. А первой напечаталась строка про опоздавший кусок: слушателя error нет, и излучатель бросил ошибку из emit — правило про error без слушателя из прошлой статьи. Этот push — синхронный код, а data и end пришли после него, из микрозадачи.

У нового смысла end есть обратная сторона: поток, который никто не читает, не кончается. Скажем, обработчик отклонил запрос по токену, тело ему не нужно, и на data он не подписался — только на end, чтобы после него закрыть запрос. Readable со всеми правками теперь в подложке. Прогноз: придёт ли end и что изменит resume?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Без читателя кусок лежит в буфере, и end не приходит, сколько ни жди. resume без слушателя data пускает течение вхолостую: flow отдаёт куски в emit, которому некого звать, буфер пустеет, и конец приходит. В Node метод тот же, и req.resume() — штатный способ сказать «тело не нужно»: без него обработчик, который ждёт end, ждёт вечно.

Теперь в обратную сторону. Ответ сервера и файл на диске — пишущие потоки: HTTP-ответ можно отдавать кусками, ещё не зная, чем он кончится, и файл пишется так же. Само устройство — сокет или диск — работает асинхронно, и каждая запись занимает своё время. Запись файла Node и вовсе отправляет в пул, где несколько записей идут одновременно. Наивный пишущий поток отдаёт каждый кусок устройству сразу, а finish — событие «всё записано» — испускает в end. Три куска пишутся за 30, 5 и 15 мс. Прогноз: что будет в файле к finish и через 50 мс?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

К finish файл пуст, а через 50 мс куски в нём стоят в порядке готовности, а не в порядке вызовов: второй записался быстрее первого и встал впереди. С настоящим файлом бывает то же. Документация fs.write прямо называет небезопасным повторный вызов на том же файле, пока не пришёл колбэк предыдущего, и для такого случая советует пишущий поток.

Пишущий поток чинит обе беды одним приёмом — очередью. write кладёт кусок в буфер, а next отдаёт устройству следующий, только когда предыдущий записан. Узнаёт поток об этом из колбэка: функция записи получает кусок и done и обязана позвать done, когда закончила. end ставит пометку ending, а finish приходит, когда пометка стоит, записи нет и очередь пуста. Функцию записи поток получает в конструкторе, так же как в Node. Прогноз: сколько кусков ждёт в очереди сразу после end и что будет в файле к finish?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первый кусок ушёл на запись сразу, внутри write, а два ждали в очереди. Записи шли по одной и в порядке вызовов, и finish пришёл после последнего done. Медленная первая запись задержала остальные, но не перемешала их. В этом и договор finish: всё, что передали в write, устройство приняло.

Очередь держится на колбэке, и отсюда самая тихая поломка пишущего потока. Функция записи ниже пропускает пустой кусок — писать его незачем — и выходит, не позвав done. Emitter, Readable и Writable со всеми правками лежат в подложке. Прогноз: что будет в файле, в очереди и с finish?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Записался только первый кусок. Пустой занял запись и не отпустил её: writing остался true, и «[2]» ждёт за записью, которая никогда не кончится. finish нет, ошибки нет, исключения нет. На сервере так выглядит ответ, который «иногда не доходит»: клиент ждёт до таймаута, а в журнале пусто. Правило простое: у функции записи каждая ветка кончается вызовом done, и та, где записывать нечего, тоже.

Осталось соединить. Отдать файл клиенту — значит связать читающий поток диска с пишущим потоком сокета: каждый data превращается в write, end — в end. Диск отдаёт куски быстро, а клиент на мобильном интернете принимает медленно. В примере 64 куска по 65 536 символов — как 64 КиБ в куске у createReadStream. Прогноз: сколько кусков ждёт в очереди записи, когда источник закончил?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Источник закончил, а в очереди записи 63 куска из 64 — почти весь файл. Очередь пишущего потока не знает предела и молча берёт всё, что даёт быстрый источник. На видео из первого раздела она выросла бы почти до 512 МиБ — ровно то, от чего поток должен был спасать. Разница с 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 — промис на первое событие. Прогноз: совпадёт ли вывод с примерами статьи?

прогон node 26.5.0 · 15 сентября 2026
Шесть проверок статьи одним файлом на Readable и Writable из node:stream
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);
node streams.mjs

Совпало всё, что показывали примеры. Куски, пришедшие до подписки, дождались её, и строка про подписку стоит раньше тела. Поток без читателя не кончился, а 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 отвечает, стоит ли продолжать
начало теченияqueueMicrotaskprocess.nextTick
end и finishвнутри вызова, который опустошил буфер или очередьоткладываются, как и начало течения
push после конца, write после enderror сразу; без слушателя исключение вылетает из вызова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 не приходит.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Хвост после последнего перевода строки отдавать нельзя: продолжение приедет со следующим куском. Поэтому хвост переходит к следующему data, а в end отдаётся, если он не пустой.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Порядок подписок здесь важен: lines подписывается на end раньше проверки, поэтому «строк: 3» печатается после третьей строки. Без end третья строка потерялась бы: после неё нет перевода, и она так и осталась бы хвостом. Тем же приёмом разборщик HTTP ищет конец заголовков в кусках сокета. С настоящими байтами к нему добавится ещё одна забота: граница куска режет и многобайтовую букву.

Диск переполнен

Функция записи сообщает об ошибке так же, как в Node: передаёт её в колбэк, done(err). Пишущий поток из статьи на аргумент колбэка не смотрит. Место на диске кончилось на третьем куске, а поток всё равно объявил finish, как будто всё записано. Перепишите next в наследнике: получив ошибку, поток испускает error, выбрасывает очередь и больше ничего не пишет, а finish не испускает. Прежний next — в разделе про пишущий поток.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Колбэк получает аргумент, а флаг failed запоминает, что поток сломан:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Флаг проверяется в начале next: иначе write после ошибки снова отдал бы кусок на диск, где места уже нет. Слушатель error в проверке стоит не зря: без него излучатель бросил бы ошибку из колбэка таймера, и поймать её было бы некому. Настоящий Writable на done(err) делает то же и чуть больше: уничтожает поток — destroyed становится true, — испускает error, потом close, сбрасывает очередь, а finish не испускает.

Один кусок через once

Предскажите обе строки вывода и только потом запускайте. Обработчик берёт первый кусок через once, а остальное тело собирает позже.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Тело пустое, хотя оба его куска пришли. once подписал на data обёртку, и подписка включила течение. Первый кусок достался обёртке, она сняла себя, но поток не остановился: течение — свойство потока, а не слушателя. Два следующих куска flow отдал в emit, которому звать было некого, и они пропали. Через 10 мс новый слушатель застал только конец. Настоящий Readable ведёт себя так же: снятие слушателя data не ставит поток на паузу. Взять кусок и остановиться — это pause(), её разбирает следующая статья. Да и первый кусок — не первая строка запроса: где кончается кусок, решает сеть, а не протокол.