Zero-copy потоки Node.js: writev и scatter/gather I/O¶
Источник: theNodeBook — Zero-Copy Streams
Zero-copy в потоках Node.js — это сокращение лишних копий байтов между буферами, объектами JavaScript, нативными биндингами и вызовами ядра. Проблема обычно проявляется на путях с высокой пропускной способностью, где копирование доминирует. Полезные инструменты: представления Buffer, владение чанками в stream, writev, cork(), uncork() и transform-код, который не склеивает каждый чанк через конкатенацию.
Паттерны zero-copy в потоках Node.js¶
Scatter/gather I/O позволяет объединить несколько буферов в одну нативную операцию записи. Пулинг буферов снижает churn аллокаций. Оба подхода требуют дисциплины времени жизни: чанк, переданный вниз по потоку, должен оставаться стабильным, пока потребитель с ним не закончит; view поверх большого Buffer может удерживать всё выделение в памяти.
Эта глава — продвинутая оптимизация производительности. Если читать некомфортно, можно пропустить и вернуться позже. Техники здесь важнее всего, когда вы обрабатываете большие объёмы данных и уже профилированием выявили I/O как узкое место.
Каждый раз, когда вы копируете файл в Node.js, те же данные, скорее всего, копируются четыре раза. Сначала с диска в память ядра. Затем из памяти ядра в память процесса Node.js. Потом из памяти процесса обратно в память ядра для назначения. Наконец — из памяти ядра на диск назначения. Четыре копии там, где концептуально достаточно одной операции.
Это важно, потому что копирование дорого. Каждая копия тратит время CPU, полосу памяти и загрязняет кэш. Когда через pipeline из потоков проходят гигабайты, лишние копии становятся узким местом. Диск может тянуть 2 ГБ/с, а вы получаете 500 МБ/с, потому что CPU тратит циклы на перекладывание байтов в памяти вместо полезной работы.
Техники этой главы — zero-copy, scatter/gather I/O, пулинг буферов — не академическая экзотика. Это разница между pipeline, который выжимает железо, и pipeline, который тратит ресурсы на «бухгалтерию». Мы разберём, как данные реально движутся в системе, где происходят копии и как их убрать. Затем — батчинг I/O для снижения накладных расходов syscall и стратегии управления буферами для снижения давления на GC.
В конце вы поймёте, как ускорить потоки, почему они медленные и какие оптимизации реально важны для вашей нагрузки.
Что такое zero-copy?¶
Термин «zero-copy» используют свободно — важен точный путь копирования.
Традиционный I/O копирует данные между разными областями памяти. ОС строго разделяет пространство ядра (где работает kernel) и пользовательское пространство (где работает приложение). Это нужно для стабильности: приложения не должны портить память ядра и мешать друг другу.
Когда вы читаете файл в Node.js, обычно происходит следующее. ОС читает данные с диска в буфер ядра — первая копия (диск → память ядра). Процесс Node.js не может напрямую читать память ядра, поэтому ОС копирует данные из буфера ядра в память процесса — вторая копия. При записи в другой файл процесс обратный: запись в буфер в user space, копия в буфер ядра (третья), запись на диск (четвёртая).
Четыре копии для простого копирования файла.
Каждая копия включает дорогие шаги: CPU читает из одного адреса и пишет в другой, тратя циклы на вычисления. Загрязняется кэш — при копировании мегабайт из кэша вытесняются полезные данные других частей программы, позже — промахи кэша.
Полоса памяти конечна. При повторном копировании тех же байтов вы многократно расходуете bandwidth для одних и тех же данных — на больших объёмах это становится bottleneck.
Пример: веб-сервер отдаёт видео 1 ГБ. При традиционном I/O 1 ГБ копируется четыре раза, но каждая копия через CPU — это чтение и запись по шине памяти. Разбивка bandwidth:
- Диск → буфер ядра: DMA write (1 ГБ)
- Ядро → user space: CPU read + write (2 ГБ)
- User space → ядро: CPU read + write (2 ГБ)
- Ядро → сеть: DMA read (1 ГБ)
Итого 6 ГБ bandwidth памяти на 1 ГБ полезных данных. На системе с ~50 ГБ/с по памяти один такой transfer может съесть ~120 мс только на операции с памятью — до учёта latency диска и сети.
Zero-copy — техника устранения части или всех промежуточных копий. «Ноль» условен: данные всё равно должны переместиться, но можно избежать копий между ядром и user space — там основная CPU-нагрузка.
Если вы просто переносите данные из файла в файл без анализа и изменений, зачем тащить их в память процесса? Данные уже в ядре — kernel может переложить их из буфера источника в буфер назначения, минуя процесс.
Syscall sendfile() в Linux делает именно это: «скопируй с дескриптора A на дескриптор B» — целиком в kernel space, без копии в user space и без memcpy в процессе.
Реализация зависит от ОС: Linux — sendfile() и splice(); FreeBSD и macOS — sendfile() с другой семантикой; Windows — TransmitFile().
На системах с DMA (Direct Memory Access) ещё лучше: контроллеры переносят данные между устройствами и памятью без участия CPU. Диск читает прямо в память, сетевая карта читает из памяти и шлёт в сеть. CPU только настраивает transfer.
В идеале CPU только инициирует перенос; данные идут через DMA и копии kernel-to-kernel, не попадая в user space и не расходуя циклы на memcpy. На практике копии остаются (диск → буфер ядра → регион DMA сетевой карты), но это лучше, чем четыре копии через user space.
Zero-copy работает, пока данные идут без изменений. Как только нужен transform — парсинг, сжатие, шифрование — нужен доступ в user space. Общие kernel API двигают байты; transform принадлежит процессу. Вы копируете в процесс, меняете, копируете обратно.
Zero-copy даёт максимум throughput для «прозрачного» прокси: HTTP-прокси, раздача статики, reverse proxy без модификации тела. Ограничение: zero-copy не для всех пар источник–назначение. Linux sendfile() требует обычный файл-источник с mmap(); socket-to-socket на Linux — splice() через pipe, сложнее.
Понимание ограничений помогает понять, когда потоки будут быстрыми (zero-copy применим) и когда медленнее (fallback на обычный I/O). На высокообъёмных путях zero-copy возможен, когда источник, назначение, протокол и платформа это поддерживают.
Дальше — как это соотносится с потоками Node.js.
Zero-copy в потоках Node.js¶
Распространённое заблуждение: Node.js автоматически использует sendfile() при pipe() файла в сокет. На деле стандартный путь потоков — буферизованный I/O через JavaScript.
1 2 3 4 | |
Стандартный pipe() читает через буферы user space обычными read() и write(). Данные: диск → буфер ядра → память процесса Node.js → буфер ядра → сокет. Классический путь с четырьмя копиями.
Несмотря на статьи в сети, потоки Node.js — буферизованный I/O. Что верно:
В libuv есть uv_fs_sendfile(), Node использует его для file-to-file, например fs.copyFile(). На Linux это может быть sendfile() или copy-on-write reflink (COPYFILE_FICLONE). Это копирование файл–файл.
Потоки Node.js идут через JavaScript. При pipe() вешаются слушатели: readable шлёт data с Buffer, writable получает write() на каждый чанк. Всё в JS, данные в heap V8. Обхода ядра «магией» нет.
Почему Node не использует sendfile() для file→socket:
sendfile()ведёт себя по-разному на Linux, macOS, FreeBSD; Windows —TransmitFile(). Абстракция дорога.- HTTPS усложняет. Zero-copy требует неизменённого потока в ядре. Классический TLS шифрует в user space. Linux 4.13+ — kTLS в ядре, но Node.js kTLS пока не использует; HTTPS всё ещё шифруется в процессе. Для продакшена выгода kernel zero-copy в Node ограничена.
- Backpressure потоков завязан на JS-колбэки и
drain. Интеграция сsendfile()сложна. - Поддержка
sendfile()в раннем Node была, но убрана после перехода libeio → libuv из‑за багов и кроссплатформенности.
Когда в Node.js всё же есть выгода zero-copy:
fs.copyFile() для файл–файл. libuv uv_fs_copyfile() может использовать sendfile() или reflink:
1 2 3 4 | |
На Btrfs, XFS, APFS reflink — мгновенная копия с общими блоками до изменения — настоящий zero-copy.
Нативные аддоны. Для file→socket можно вызвать sendfile() напрямую, самим обрабатывая partial writes, backpressure и платформы. Редко окупается.
HTTP/2 respondWithFile(). Модуль http2 оптимизирует отдачу файлов; данные всё равно проходят user space, но эффективнее ручного стриминга.
Практический вывод: оптимизируйте то, что контролируете — размеры буферов, лишние копии в коде, _writev() для батчинга. Это измеримые победы.
Абстракция потоков ставит корректность, гибкость и кроссплатформенность выше сырого throughput. Сценарии kernel zero-copy (огромные файлы, тысячи клиентов) часто лучше отдают nginx или CDN, а не Node.js.
Идеи zero-copy полезны и без обхода ядра: минимизируйте копии в своём коде — об этом дальше.
Отображение памяти (memory mapping)¶
Ещё один zero-copy подход: вместо чтения файла в буфер — отобразить файл в адресное пространство процесса. Содержимое файла — регион памяти; доступ — чтение из этого региона.
mmap использует виртуальную память ОС: kernel мапит страницы файла в адресное пространство и подгружает их при обращении. Запись помечает страницу dirty и сбрасывает на диск позже.
Это zero-copy в смысле отсутствия явного копирования в отдельный буфер — вы работаете с данными файла через map.
В ядре Node.js нет встроенного mmap(). Пакеты вроде node-mmap и mmap-io устарели. Нужны поддерживаемые форки или оценка: часто хватает fs.read() с offset.
mmap удобен для случайного доступа к огромным файлам — как к массиву байтов без seek/read чанками. Но readahead при mmap реактивен (page faults), для чисто последовательного стриминга createReadStream обычно быстрее.
Для стриминга mmap редко уместен; для БД со случайным доступом — да. Измеряйте на своей нагрузке.
Как избежать лишних копий Buffer в коде¶
Даже без OS zero-copy можно убрать лишние копии в приложении.
Частый виновник — Buffer.concat():
1 2 3 4 5 6 7 8 9 | |
Сбор чанков и Buffer.concat() — новый буфер и копия всех чанков.
Если нужен один непрерывный буфер — копия неизбежна. Если можно обрабатывать по чанкам:
1 2 3 | |
Без промежуточного буфера и конкатенации.
Антипаттерн — строка туда-обратно:
1 2 3 | |
Каждая конверсия аллоцирует и копирует. Если хватает buffer.indexOf(), buffer.subarray() — оставайтесь в байтах.
Срез буфера — zero-copy при правильном использовании:
1 | |
Данные не копируются: view на байты 10–50, общая память с оригиналом. Изменение slice меняет оригинал.
Используйте subarray(), не slice(). Buffer.slice() с тем же поведением устарел (DEP0158) и расходится с TypedArray.prototype.slice(), который копирует. В Node.js v25 slice() даёт deprecation warning.
Опасность: крошечные slice от многих больших буферов удерживают большие буферы в GC.
Безопасный паттерн: slice для временной обработки, копия для долгого хранения:
1 2 3 4 5 6 | |
Избегайте Buffer.from(buffer) без необходимости в копии:
1 2 3 4 5 6 | |
Writable обычно не мутирует буфер — копия не нужна.
Каждый Buffer.concat(), Buffer.from(), buffer.toString() может аллоцировать и копировать. Делайте это только когда семантика требует. Для view — subarray().
Scatter/gather I/O¶
Scatter/gather сокращает число syscall при работе с несколькими буферами.
Классика: три буфера — три write:
1 2 3 | |
Каждый syscall — переход user↔kernel, валидация, настройка I/O. На мелких записях накладные расходы syscall могут превысить стоимость самой записи.
Оценка: syscall ~50–200 нс на переключение режима + ~100–500 нс работы ядра — порядка 150–700 нс до переноса данных. Три записи по 1 КБ — 450–2100 нс только overhead; запись 3 КБ на быстром SSD может быть сопоставима или меньше.
На 1000 мелких буферов — сотни микросекунд только на syscall; на миллионах операций в секунду это ощутимый CPU.
Scatter/gather передаёт несколько буферов одним syscall. Gather (запись) собирает данные из буферов; scatter (чтение) раскладывает входящие данные по буферам.
В Linux gather — writev() (массив iovec). Scatter — readv(): заполняет буферы по порядку, переходя ко второму, если первый заполнен. Удобно для фиксированного заголовка и переменного тела.
Node.js: gather через _writev() на writable; scatter в stream API нет — поток не знает заранее, сколько буферов. Низкоуровнево — fs.readv() и fs.writev().
Если writable реализует _writev(), Node батчит и вызывает _writev() с массивом чанков вместо многократного _write():
1 2 3 4 5 6 7 8 9 10 11 | |
Вместо N syscall — один. Для HTTP с множеством мелких write выигрыш заметен.
Node вызывает _writev() только при буферизации нескольких чанков. Медленный поток чанков — отдельные _write(). Принудительный батч — cork():
1 2 3 4 5 | |
Cork подавляет немедленные _write() и копит чанки. Uncork сбрасывает, желательно через _writev().
Для scatter в stream API нет readv() hook: readable тянет данные по запросу, число буферов не фиксировано. С fs — fs.readv().
Батчинг I/O в один syscall снижает overhead; scatter/gather — механизм для нескольких буферов.
Реализация _writev() для максимального throughput¶
Пример оптимизации writable при записи в сокет:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
Здесь _writev() конкатенирует — одна копия, но часто выгоднее N syscall.
Без _writev() — N syscall (дорого). С конкатенацией — одна копия и один syscall. Если syscall дороже копии — батч выигрывает.
Лучше — настоящий vectored I/O:
1 2 3 4 5 6 7 | |
Ядро пишет все буферы без склейки в user space — настоящий gather.
Для сокетов net.Socket на уровне JS — обычные stream write; libuv внутри использует vectored write где поддерживается. Реализуйте _writev() и дайте сокету батчить — выиграете от оптимизаций libuv.
Всегда реализуйте _writev(), если назначение поддерживает батч. Даже с конкатенацией часто быстрее множества syscall.
Адаптивный батчинг: на крупных чанках батч мало помогает; на мелких — критичен:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | |
Упрощённая эвристика: cork на первом мелком write, uncork когда очередь опустела. В продакшене — тайминг, размеры, обработка close/error.
Пулинг буферов¶
Каждая аллокация буфера — работа для V8 и GC. На высокопропускных потоках миллионы мелких буферов создают GC pressure: чаще паузы.
Buffer.alloc(size) — поиск памяти (возможен GC), метаданные, обнуление, учёт. Мелкий 1 КБ буфер — порядка 500–2000 нс в зависимости от heap. 100 000 чанков/с — 5–20% CPU только на аллокации.
При unreachable буферах GC их собирает; частые allocate/free дергают поколения, продвигают в old generation — дороже.
Пулинг — переиспользование буферов вместо новых аллокаций. Выделили N буферов, выдаёте из пула, возвращаете вместо GC. Pop/push из массива на порядки быстрее GC.
Сложность — время жизни. Вернуть буфер в пул можно только когда нигде нет ссылок. Use-after-free в JS — порча данных при повторном использовании.
Безопасно пулить буферы с коротким, предсказуемым циклом: прочитали → обработали сразу → вернули в пул.
Простейший вариант — один переиспользуемый буфер:
1 2 3 4 5 6 | |
Один буфер 64 КБ: копия чанка, обработка, снова тот же буфер — без аллокации на чанк.
Buffer.allocUnsafe() не обнуляет память — быстрее, но в буфере могут остаться старые байты. Безопасно, если сразу перезаписываете всё нужное и отдаёте только slice по фактической длине:
1 2 3 | |
Гибкий пул:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | |
Пул пуст — новая аллокация; пул переполнен — буфер не возвращаем, чтобы не копить память.
Базовый пул не обнуляет буфер при release. Для паролей, токенов, PII — buffer.fill(0) перед возвратом или не пулите буферы с чувствительными данными.
Readable с пулом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | |
Buffer.from(subarray) — явная копия: subarray разделяет память с пуловым буфером; если push subarray и сразу release, потребитель может увидеть порчу при переиспользовании буфера.
Настоящий zero-copy пулинг требует передавать пуловые буферы вниз и освобождать после потребления — координация с consumer. На практике пулинг чаще при своём протоколе с обеих сторон.
Пулинг снижает аллокации и GC. allocUnsafe — для буферов, которые сразу перезапишете; осторожно со slice и утечкой неинициализированных байт.
Батчинг записей: cork и uncork¶
Cork говорит writable копить записи вместо немедленного сброса. Uncork сбрасывает, желательно одним _writev().
Выгода — меньше операций записи. Цена — latency (данные ждут в буфере).
Cork перед серией мелких записей:
1 2 3 4 5 | |
Если processItem() бросит исключение, uncork() не вызовется — поток останется corked. Всегда try/finally:
1 2 3 4 5 6 | |
Node считает вложенные cork: каждый cork() +1, uncork() −1; flush при нуле:
1 2 3 4 | |
Вложенные функции могут cork/uncork локально; внешний cork держит буфер до финального uncork:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
Не corkите, если чанки уже мегабайтные — overhead буфера может съесть выгоду. Cork — для множества мелких записей.
Не держите cork на весь жизненный цикл длинного потока — растёт память и latency. Cork только вокруг burst.
Адаптивный cork: если записи идут чаще 10 мс — cork; пауза 10 мс — uncork:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | |
Порог подбирайте по нагрузке.
Избегайте накладных расходов конкатенации строк в потоках¶
Конкатенация строк при накоплении большого текста может быть неэффективной. V8 оптимизирует через cons strings (ropes) — отложенное копирование, flattening при доступе. В потоках с множеством чанков дерево cons strings растёт, flattening в итоге дорогой, память на дерево тоже.
Проблемный паттерн:
1 2 3 4 | |
Каждый += — cons string или flattening предыдущих; на большом файле — глубокое дерево или повторный flatten, близко к O(N²).
Исправление — массив чанков и один join:
1 2 3 4 5 6 7 8 9 | |
push дёшев; join — одна аллокация и один проход. Линейно.
Лучше копить Buffer и склеить в конце:
1 2 3 4 5 6 7 8 9 | |
Buffer.concat() — одна аллокация. toString() — только когда нужна строка.
Инкрементальная обработка без накопления:
1 2 3 | |
Антипаттерн — строка для простого поиска:
1 2 3 4 | |
В буфере:
1 2 3 | |
Строки неизменяемы; конкатенация создаёт новые. В потоках минимизируйте строки, работайте с Buffer, для накопления — массивы чанков.
Трюк stream.read(0)¶
Редкий приём: read(0) на readable.
Обычно read(size) забирает size байт из внутреннего буфера. read(0) проверяет буфер и может вызвать _read(), если:
- Внутренний буфер ниже
highWaterMark - Поток не в середине другого
_read()
Полезно в paused mode — подтолкнуть заполнение без потребления:
1 2 3 4 | |
_read() почти всегда асинхронен — read(0) только инициирует запрос:
1 2 3 4 5 6 7 8 9 10 11 | |
Ниша для низкоуровневой обвязки потоков. Для обычного кода можно не трогать.
Для отладки: если _read() не вызвался — смотрите readable.readableLength и highWaterMark.
Избегайте промежуточных transform¶
Каждый transform в pipeline — буферизация, _transform(), снова буфер. Много стадий — накладные расходы суммируются.
Объединение transform сокращает стадии:
1 2 3 4 5 6 7 8 9 10 11 | |
Меньше буферов и вызовов. Цена — модульность.
Компромисс: отдельные transform для ясности; при профилировании hot path — объединить:
1 2 3 4 5 6 7 8 9 | |
Уберите no-op transform: если transform не нужен — не вставляйте passthrough «на всякий случай»:
1 2 3 4 5 | |
Каждая стадия стоит денег. На hot path объединяйте; для переиспользуемых кирпичей — отдельные модули.
readable.readableFlowing для ручного контроля¶
readable.readableFlowing: true — flowing, false — paused, null — режим ещё не задан.
1 2 3 4 5 6 7 8 9 10 11 | |
Проверка перед resume() избегает лишнего вызова (микрооптимизация на миллионах чанков).
Адаптация к состоянию:
1 2 3 4 5 | |
Для отладки: нет данных — смотрите readableFlowing (false — на паузе, null — flowing ещё не включён, true — ищите проблему elsewhere).
Профилирование производительности¶
Оптимизации имеют смысл только если улучшают вашу нагрузку. Измеряйте.
Базовый throughput:
1 2 3 4 5 6 7 8 9 10 11 12 | |
Запишите baseline. Меняйте по одной оптимизации и пересчитывайте.
Память:
1 2 3 4 5 6 7 8 | |
Пулинг снизил heap без потери throughput — win. Снизил throughput сильнее экономии памяти — откатите.
Профайлер Node:
1 2 | |
Ищите время в Buffer.concat, syscall, transform. 50% в Buffer.concat() — оптимизируйте это.
Профилируйте реальную нагрузку: JSON — JSON, файлы — реальные размеры. Мелкие чанки и крупные ведут себя по-разному.
Преждевременная оптимизация — зло. Сначала измерение.
Паттерны производительности в продакшене¶
Идеи zero-copy важны при высокой полосе и малом transform (статика, видео, прокси). Node streams сами не дают kernel sendfile(); выигрыш — меньше копий в user space: без лишнего Buffer.concat(), subarray(), _writev(). CDN-масштаб file→socket — чаще nginx, не Node.
JSON API до ~100 КБ — zero-copy не поможет: ответ генерируется, transform неизбежен. Оптимизируйте сериализацию и БД.
Scatter/gather (writev) — при множестве мелких записей (HTTP-заголовки). Без writev — десятки syscall; с writev — один или несколько. На высоконагруженном HTTP latency может упасть на 10–30%.
Крупные чанки (64 КБ с файла) — writev почти не меняет картину: и так один syscall на чанк.
Пулинг — при экстремальной частоте аллокаций (пакеты, IoT, тики). GC pause −50–80% возможен. При ~1000 буферов/с в типичном вебе — польза сомнительна, сложность не окупается.
Cork/uncork — burst записей (батч из БД). 1000 записей → 10–50 операций. Непрерывный tail лога — batch не снижает суммарный I/O, может добавить latency.
Алгоритм:
- Профиль под реальной нагрузкой (
perf, Instruments,--prof). Высокий CPU при низкой утилизации диска/сети — bottleneck в копировании/syscall. Насыщенный I/O при низком CPU — лимит канала, не CPU overhead. - Аллокации и GC (
process.memoryUsage(),--trace-gc). Много МБ/с аллокаций и паузы >10 мс — пулинг. Скромные аллокации и паузы <1 мс — пулинг лишний. - Число syscall (
strace,dtruss). Тысячи мелкихwrite—writev/cork. В основном крупные I/O — батч не важен. - A/B бенчмарк каждой оптимизации. +20% throughput без роста latency — оставляем. Нет эффекта или хуже — убираем.
Оптимизированный pipeline копирования файла¶
Пример с крупными буферами, _writev() и аккуратной работой с буферами:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | |
64 КБ — с Node.js 22 это дефолт для createReadStream/createWriteStream; явный highWaterMark для ясности. Базовый stream.Readable по умолчанию 16 КБ.
Без обработки данных — fs.copyFile() с OS-оптимизациями:
1 2 3 | |
Итог: крупные буферы + _writev() + copyFile, когда stream processing не нужен.
Измерение и отладка¶
Трассировка syscall — strace с суммарной статистикой:
1 | |
Много отдельных write при реализованном _writev() — cork не сработал или чанки не буферизуются. writev с тем же числом операций, что и чанки — батч работает.
Детали:
1 | |
CPU profiling perf:
1 2 | |
Высокий memcpy / Buffer.concat — лишние копии. Высокий syscall entry — batching. sendfile в профиле — kernel zero-copy активен; нет — не используется.
Heap — Chrome DevTools с --inspect, снимки до/after; миллионы мелких Buffer — пулинг. node --heap-prof для анализа в DevTools.
GC — node --trace-gc; частые minor GC — высокая аллокация; реже major GC после пулинга — хороший знак. --trace-gc-verbose — promotion, выжившие объекты.
Задержка event loop — perf_hooks:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
Скачки p99 при стримах — синхронный Buffer.concat на огромных буферах или слишком крупные чанки. Дробите работу.
NODE_DEBUG:
1 | |
DEBUG=* — для npm-пакета debug, не для внутренностей Node. Syscall — strace / dtruss.
Вместе: syscall — I/O, CPU profile — вычисления, heap — аллокации, GC — память, event loop — отзывчивость.
Когда применять эти техники¶
Минимизировать копии буферов:
- большие файлы в stream pipeline;
- высокопропускная обработка данных;
- раздача статики (kernel zero-copy в Node не автоматичен).
fs.copyFile() с COPYFILE_FICLONE — настоящий zero-copy дубликат на Btrfs, XFS, APFS.
Не переусердствовать с копиями, если:
- нужен transform (копии неизбежны);
- данные малы;
- CPU не упирается в буферные операции (сначала профиль).
_writev() / scatter-gather:
- много мелких чанков;
- высокий overhead syscall (подтверждён профилем);
- назначение поддерживает vectored write.
Пропустить, если:
- чанки уже крупные;
- пишете в in-memory буфер без выгоды от batch.
Пулинг:
- миллионы буферов, сильный GC;
- одинаковый размер;
- контролируете lifecycle.
Пропустить:
- переменный размер;
- GC не bottleneck.
Cork/uncork:
- burst мелких записей;
- известные границы burst;
- latency в burst приемлема.
Пропустить:
- записи уже естественно батчатся;
- критична минимальная latency.
Сначала измерение, потом оптимизация. Техники добавляют сложность — применяйте там, где профиль показывает выигрыш.
Связанное чтение¶
- Предыдущая: Современные pipeline потоков и обработка ошибок
- Далее: Дескрипторы файлов в Node.js: FileHandle, флаги и EMFILE