Операции с Buffer в Node.js: кодирование, slice и копирование¶
Источник: theNodeBook — Working With Buffers
Операции с Buffer в Node.js — это операции над байтами. Повседневная работа: декодировать байты в строки, кодировать строки в байты, вырезать представления (views) поверх уже выделенной памяти, копировать байты в независимое хранилище, сравнивать полезную нагрузку и склеивать чанки, пришедшие по частям. Хороший код с буферами отслеживает владение памятью: slice() и subarray() создают представления; copy() и многие варианты Buffer.from() — отдельное хранилище.
Кодирование — граница, где байты становятся текстом. buf.toString() декодирует байты. Buffer.from(text) кодирует текст. Операции-представления сохраняют общий backing store: запись через одно представление может изменить другое. Копирование выделяет отдельную память. Эта разница критична в парсерах, сетевых прокси, передаче данных в worker и при долгоживущих срезах.
Глава углубляется в Buffer. Если что-то кажется перегруженным — перечитайте раздел или вернитесь после соседних глав.
Скорее всего, вы здесь, чтобы применить знания из предыдущих глав — или потому что сервис жрёт память, а бинарный парсер на высокой нагрузке тормозит. Виновник почти всегда один: неверное понимание того, как Buffer управляет памятью. Мы разберём самое опасное заблуждение в Node.js: Buffer.slice() ведёт себя не как Array.prototype.slice(). Массив даёт независимую копию; буфер — представление в ту же underlying-память. Это основа zero-copy.
Buffer.slice помечен устаревшим в пользу Buffer.subarray(). Поведение slice всё равно нужно знать для legacy-кода и понимания механики представлений.
Представления, использованные правильно, позволяют обрабатывать огромные объёмы данных почти без лишней памяти. Использованные наобум — дают утечки: срез на 10 байт удерживает буфер на 1 GB, и GC не может его освободить. Вы узнаете разницу между view (slice, subarray) и настоящей копией (Buffer.copy()), связь Buffer с TypedArray и общим ArrayBuffer, а также почему сервис может показывать 10 GB RSS при 1 GB «полезных» данных — и как это исправить.
Работа с данными Buffer в Node.js¶
Утечка памяти на гигабайты¶
Сервис, которому хватало 500 MB RAM, внезапно требует 10 GB? Часто это не «магия V8», а непонимание памяти. Разберём, как из аккуратного кода получить именно такую катастрофу — и как её не повторить.
Паттерны удержания памяти через Buffer, описанные в этой главе — главная причина production-утечек в Node.js. Один Buffer.slice() может удерживать гигабайты бесконечно.
Типичный сценарий: сервис принимает большие батчи (логи, multipart-загрузки). На каждый чанк в несколько мегабайт нужно прочитать фиксированный заголовок — например, session ID из первых 16 байт.
1 2 3 4 | |
Остановитесь здесь. Строка logBuffer.slice(0, 16) — начало утечки. При slice() Node не выделяет новую память: создаётся маленький JS-объект (~72 байта в V8) с указателем на ArrayBuffer родителя, смещением (0) и длиной (16). Объект на куче V8, но держит сильную ссылку на внешнюю память, где лежат данные logBuffer.
GC видит эту ссылку и помечает весь родительский буфер как достижимый. Вам нужны 16 байт — удерживаются мегабайты. После двух scavenges буфер часто попадает в old generation и собирается ещё тяжелее. В проде так удерживали 100 MB ради десятков 16-байтных ID.
1 2 3 | |
Код выглядит безобидно, но при 100 MB логов в минуту RSS растёт гигабайтами: вы храните 10 GB ради мегабайтов session ID. Heap snapshot показывает тысячи крошечных Buffer по 16 байт, которые «удерживают» гигабайты. Профайлер не сломан: срез — не копия, а view. Пока headerSlice в кеше, GC не освободит многомегабайтный logBuffer.
Вы утекали не байтами — вы утекали родительским буфером на каждый запрос. Умножьте на тысячи запросов — получите разобранную здесь утечку на 10 GB.
Как в предыдущей главе: сырые бинарные данные через строки JavaScript — плохая идея. Запомните: память Buffer в Node.js не живёт на куче V8.
Архитектура памяти Buffer¶
V8 заточен под мелкие связанные объекты. GC хорошо чистит строки и объекты, но «захлёбывается» на огромных монолитных бинарных блоках — чтение гигантского файла могло бы вызвать stop-the-world и убить latency.
Node выделяет большие блоки вне кучи V8, в C++ (off-heap / external memory). JS-объект Buffer — лёгкий handle на куче V8 со ссылкой на сырой блок снаружи.
Две стороны одной медали:
- Node передаёт гигантские блоки в FS/сеть без копирования в мир JS — очень эффективно.
- GC видит только handle. Держите handle в замыкании или долгоживущей структуре — внешняя плита не освободится. Утечка не «несколько байт объекта», а весь slab, на который он указывает.
Пул буферов 8 KB¶
Для буферов меньше 4 KB Node режет куски из предвыделенного slab Buffer.poolSize (8 KB), не дёргая ОС на каждый allocate. Это ускоряет приложения с множеством мелких буферов — и объясняет опасность Buffer.allocUnsafe(): вы получаете переиспользованный кусок пула, где секунды назад могли лежать чужие токены.
Представления: slice, subarray и Buffer.from¶
Три функции, где чаще всего ошибаются: Buffer.slice(), Buffer.subarray(), Buffer.from() (от другого буфера или ArrayBuffer).
Привычка с массивов: Array.prototype.slice() — shallow copy, новый массив, изменения независимы. Для буферов это ложь.
Buffer.prototype.slice() не копирует. Он создаёт view — новый объект Buffer на те же байты того же ArrayBuffer.
Buffer.slice() — не как Array.slice(). Массивы копируют, буферы — делят память. Изменение среза меняет оригинал. Это источник большинства утечек и тихой порчи данных с Buffer в проде.
1 2 3 | |
Buffer.alloc(50 * 1024 * 1024) обходит пул (размер больше Buffer.poolSize >>> 1, т.е. 4096): выделение в C++, на Linux часто через mmap() с demand paging; alloc обнуляет память, заставляя ОС выделить физические страницы.
1 2 3 | |
1 | |
Запись идёт по абсолютному смещению в родительском ArrayBuffer (offset среза + позиция записи). Copy-on-write нет — порча 50 MB буфера, заголовков протокола, метаданных запросов.
1 2 | |
subarray() в современных версиях Node функционально то же, что slice() — view, не копия. Документация рекомендует subarray() для согласованности с TypedArray.
Buffer.slice() и Buffer.subarray() идентичны по смыслу: оба — views. Предпочитайте subarray() в новом коде.
1 2 3 4 | |
Поведение Buffer.from() по типу аргумента¶
Buffer.from(string)— новая память, копия строки.Buffer.from(array)— новая память, копия байтов.Buffer.from(arrayBuffer[, byteOffset, length])— view, zero-copy.Buffer.from(buffer)— полная копия данных.
Buffer.from(arrayBuffer) — view; Buffer.from(buffer) — copy. Разное поведение при одном имени функции — частый источник багов. Всегда смотрите тип входа.
Zero-copy¶
«Zero-copy» звучит как бесплатная скорость — но вы платите сложностью управления памятью. Zero-copy = не копируете payload, но создаёте новый JS-объект (view) на куче V8 — это на порядки быстрее, чем alloc + побайтовое копирование.
1 2 3 4 5 6 7 8 9 10 11 | |
Типично: view ~0.007 ms, copy ~0.024 ms (большая часть — overhead console.time).
Для точных замеров в проде используйте performance.timerify() или модуль perf_hooks. console.time() удобен, но груб для субмиллисекунд.
Создание view — O(1) по размеру данных. Копирование — O(n). В hot path замена лишних копий на views иногда снимает ~30% CPU — но view прикрепляет родительский буфер к жизненному циклу view.
Оптимизация «везде views» меняет CPU на риск OOM. Правильная оптимизация — понимать, когда микрокопия дешевле удержания гигантского родителя.
Buffer, TypedArray и общая память¶
С Node.js v3 Buffer — подкласс Uint8Array. Сырой блок — ArrayBuffer; Buffer, Int32Array и т.д. — разные views на один slab.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
Несколько views на один ArrayBuffer могут молча портить данные друг друга. Runtime не проверяет перекрытия. Одна ошибка в offset — недели отладки.
Когда память общая, а когда нет¶
Правило: если в API не сказано «copy» / «alloc» — по умолчанию shared memory.
Views (zero-copy):
Buffer.prototype.slice(start, end)Buffer.prototype.subarray(start, end)new Uint8Array(arrayBuffer, byteOffset, length)(и другиеTypedArrayотArrayBuffer)Buffer.from(arrayBuffer, byteOffset, length)
Ключевое слово — временно: view живёт недолго внутри функции — выигрыш без риска удержания.
Копии (новая память):
Buffer.alloc(size)Buffer.from(string)/Buffer.from(array)/Buffer.from(buffer)Buffer.prototype.copy()— пишет в уже существующий targetUint8Array.prototype.slice()— копирует (в отличие отBuffer.slice()!)
TypedArray.prototype.slice() — COPY; Buffer.prototype.slice() — VIEW. Вызов Uint8Array.prototype.slice.call(buf, ...) даст противоположное поведение.
Пример: метаданные 1 KB из видео 1 GB.
1 2 3 4 5 6 7 8 9 10 11 | |
Views — для временной обработки в функции. Копии — для кеша, async и любого долгоживущего хранения. Небольшой CPU за копию спасает гигабайты RSS.
Семантика копирования и Buffer.copy()¶
buf.copy(targetBuffer, targetStart, sourceStart, sourceEnd) — аналог memcpy: пишет в уже выделенный target.
1 2 3 4 5 6 7 | |
Удобная копия целиком: Buffer.from(buffer) — внутри alloc + memcpy, независимый backing store.
1 2 3 4 | |
Buffer.copy() требует заранее выделенный target: const copy = Buffer.alloc(size); source.copy(copy, 0, start, end);. Для одной строки: Buffer.from(source.subarray(start, end)).
Исправление парсера логов:
1 2 3 4 5 | |
16 байт + наносекунды memcpy — и многомегабайтный logBuffer собирается сразу после выхода из scope.
Не используйте Buffer.allocUnsafe() для копий с чувствительными данными — в памяти могут остаться секреты с прошлых allocation. Для security-sensitive кода — Buffer.alloc().
SharedArrayBuffer и views между потоками¶
worker_threads дают параллелизм; передача обычного ArrayBuffer в worker клонирует данные. SharedArrayBuffer (SAB) — память, доступная нескольким потокам одновременно.
SAB в браузерах временно отключали из‑за Spectre. В Node для многопоточности используйте Atomics, иначе гонки и порча данных.
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
1 2 3 4 5 6 7 8 9 10 11 12 | |
Без Atomics доступ к SAB не потокобезопасен. array[0] = value может гонять. Используйте Atomics.store(), Atomics.load() и т.д.
Тот же принцип views, что у slice/subarray, только через границу потока.
Удержание памяти и сборка мусора¶
View через slice()/subarray() связывает:
- View — маленький
Bufferна куче V8. - Parent — исходный
Bufferс большим externalArrayBuffer.
Пока view достижим — parent тоже. GC не знает, что вам нужны только 16 байт из 50 MB.
- Shallow size — размер самого объекта (десятки байт обёртки).
- Retained size — всё, что удерживается только из‑за этого объекта (часто весь parent).
1 2 3 4 5 6 7 8 9 | |
Паттерн Buffer.from(buf.slice(...)) — обрезанная копия маленького фрагмента большого буфера.
Парсинг бинарного протокола через views¶
Пример layout сообщения:
- байты 0–1: тип (Uint16)
- 2–3: длина (Uint16)
- 4: flags (Uint8)
- 5–20: session ID (16 байт)
- 21–end: payload
Наивный вариант — лишние views на каждое поле:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
Пять views на сообщение × 1000 msg/s × 1 MB payload — гигабайты удержанной памери даже если нужны только 16 байт ID.
Эффективный разбор:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
Zero-copy версия быстрее (~10×), но возвращает views, удерживающие весь parent. В JSDoc: вызывающий обязан скопировать, если хранит данные дольше текущего scope.
Парсер отдаёт views; потребитель решает: сразу обработать (view) или сохранить (copy).
Endianness и TypedArray¶
- Big-endian (BE) — старший байт первым (сетевой порядок).
0x12345678→12 34 56 78. - Little-endian (LE) — младший первым (x86/ARM). Тот же number →
78 56 34 12.
readUInt16BE, writeInt32LE и т.п. — явный порядок байт.
TypedArray читает в native endian хоста:
1 2 3 4 5 6 7 8 9 | |
Для сетевых данных не используйте сырой TypedArray без учёта endianness. Buffer BE/LE или DataView с явным флагом.
1 2 3 4 | |
Production-паттерны zero-copy¶
Паттерн 1: временный view в синхронной функции
1 2 3 4 5 6 7 8 | |
Паттерн 2: защитная копия для async и хранения
1 2 3 4 5 6 7 8 | |
Паттерн 3: парсер отдаёт views, копирует потребитель
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
Отладка утечек через views¶
- Snapshot в стабильном состоянии.
- Нагрузка, подозрительная на утечку.
- Второй snapshot, третий — для тренда.
- Comparison в DevTools: рост числа
Buffer.
node --inspect-brk + Chrome DevTools. Колонка Retained Size: мелкий Buffer с огромным retained — сигнатура view-утечки. Смотрите retainers / [[backing_store]].
С Node.js 13.9+ отслеживайте process.memoryUsage().arrayBuffers — точнее для Buffer, чем общий external.
При утечке лог-парсера heapUsed рос медленно, а external и rss — взрывом: классика external memory / Buffer retention.
Практики работы с Buffer¶
- Профилируйте удержание памяти перед деплоем кода с интенсивными буферами.
- Views — синхронная временная обработка; копии — долгоживущие, async, коллекции.
- Документируйте API: возвращаете view — пишите это в JSDoc.
- Запах:
slice/subarrayв поле объекта, module-level переменной, кеше — «а не copy ли нужен?» - Zero-copy — не бесплатный буст, а контракт с runtime о жизненном цикле parent.
Данные профилирования памяти¶
100 000 объектов из одного буфера 50 MB.
Сценарий 1: slice() (views)
1 2 3 4 5 | |
rss: ~78 MBheapUsed: ~8 MBexternal: ~50.5 MB- Retained: ~50 MB (весь
largeBuffer)
Сценарий 2: стратегическая копия
1 2 3 4 5 6 | |
rss: ~32 MB (после GC)heapUsed: ~9 MBexternal: ~1.5 MB- 100 000 независимых 10-байтных буферов ≈ 1 MB
Views сэкономили CPU в цикле, но удержали 50 MB. Копии чуть дороже по CPU, footprint — на порядки меньше.
В Node.js 22+ можно запускать TypeScript с node --experimental-strip-types — типы помогают ловить misuse буферов на этапе компиляции.
Заключение¶
«Почему не копировать везде?» — инженерный компромисс. Только копии — проще рассуждать, но дороже CPU и RAM на масштабе. Цель — не бояться zero-copy, а уважать shared memory: view — обещание runtime, что вы понимаете жизненный цикл view и parent.
Строка const view = buf.slice(0, 10) — не синтаксис, а ссылка на гигантский parent. Когда ответ «да, я готов это удерживать» приходит мгновенно — вы освоили семантику памяти Buffer.
Связанное чтение¶
- Предыдущая: Node.js Buffer Allocation (theNodeBook)
- Далее: Node.js Buffer Fragmentation (theNodeBook)
- См. также: Жизненный цикл процесса Node.js (external memory и
Buffer)