Фрагментация Buffer в Node.js: slabs и external memory¶
Источник: theNodeBook — Buffer Fragmentation
Фрагментация Buffer в Node.js возникает из размера выделения, времени жизни и удерживаемых ссылок. Проблема обычно проявляется, когда RSS растёт быстрее JavaScript heap или сервис держит память после спада нагрузки. Buffer учитываются в external memory V8, но байты живут вне обычных полей объектов. Маленькие буферы могут делить slabs. Крупные получают отдельные backing store.
Удержанный slice может держать живой более крупный блок, потому что view всё ещё указывает на ту же память. Фрагментация видна как рост RSS, давление на GC, задержки при выделении и графики памяти, которые выглядят хуже, чем heap snapshot. Типичные меры: ограниченные очереди, своевременное снятие ссылок, копии для маленьких удерживаемых регионов и streams для больших payload.
К этой главе мы подошли с прочной моделью: что такое Buffer, где он живёт в памяти, различие view и copy, сила zero-copy и утечки при неосторожном использовании. Мы говорили о внутреннем buffer pool и о том, как Node оптимизирует частые мелкие выделения, чтобы не платить за постоянные системные вызовы.
Здесь всё сходится. Разберём классическую низкоуровневую проблему, о которой большинство JavaScript‑разработчиков не думают: фрагментацию памяти. Она кажется абстрактной, пока не рухнет production‑сервер — при том что дашборды показывают «достаточно RAM». Разберём, что это, почему случается и как архитектура памяти Node и помогает, и мешает.
Вторая, большая часть главы — практические задачи на код. Нужно применить всё из предыдущих глав: байты, endianness, view vs copy, pooling. Вы соберёте парсер бинарного протокола, сами увидите утечки в профиле памяти, реализуете stateful stream‑процессор и свой application-level buffer pool.
Решений здесь нет намеренно. Читать — одно; писать — другое. К концу главы вы не просто «знаете про Buffer», а умеете применять их безопасно и эффективно в production — в Node.js и в любом другом языке с похожими примитивами.
Фрагментация памяти¶
Хотите углубиться в основы памяти? См. Memory: The Stack & Heap — RAM и виртуальная память, stack frames, heap allocation, кэш, утечки, висячие указатели, фрагментация и стратегии управления памятью в разных языках.
Фрагментация — один из тихих убийц долгоживущих приложений. Суть проста: память процесса со временем разбивается на множество мелких несмежных кусков. Суммарно свободной памяти может быть много, но если она размазана тысячами мелких фрагментов, ею нельзя удовлетворить один крупный запрос. У процесса может быть 100 MB свободной RAM, а запрос на один буфер 1 MB провалится — нигде нет единого непрерывного блока 1 MB.
Чтобы это почувствовать, нужно понять, как ОС отдаёт память процессу Node.js.
Виртуальная и физическая память¶
Процесс Node.js не ходит напрямую в физические планки RAM. Он работает в виртуальном адресном пространстве — большом линейном диапазоне, который ОС выделяет каждому процессу. На 64‑битной системе оно теоретически огромно (до 16 эксабайт). Когда код просит память, ОС находит свободный кусок в этом пространстве и отдаёт его процессу.
За кулисами MMU (блок управления памятью в CPU) вместе с ОС сопоставляет виртуальные адреса с физическими в RAM. Маппинг идёт страницами, обычно по 4 KB. Так работают swap на диск и изоляция процессов друг от друга.
Важный вывод: когда Node выделяет крупный buffer, он просит у ОС непрерывный блок виртуальной памяти. ОС должна найти достаточно свободных физических страниц под это выделение.
Дилемма аллокатора¶
Вызов Buffer.alloc(65536) для чтения файла 64 KB обходит внутренний pool Node на 8 KB — память берётся у системы через mmap (Linux/macOS) или VirtualAlloc (Windows). Системный аллокатор (malloc в glibc на Linux) находит подходящий блок в виртуальном пространстве процесса.
Когда файл обработан и на buffer больше нет ссылок, GC V8 забирает JS‑handle, C++‑слой Node освобождает backing store (munmap / free). Блок 64 KB возвращается аллокатору.
Проблема начинается, когда приложение тысячи раз выделяет и освобождает буферы разного размера. Постоянный churn разбивает свободное пространство: суммарно памяти может хватать, но следующий запрос на крупный непрерывный блок не находит «дыру» нужного размера.
Различают два вида фрагментации:
- Внешняя (external) — достаточно свободной памяти в сумме, но она разбита на несмежные дыры. Запрос на крупный блок не находит одну дыру достаточного размера. Главная боль для приложений с частым alloc/free крупных непулированных буферов.
- Внутренняя (internal) — выдаётся блок фиксированного размера больше запроса. Нужно 33 байта, аллокатор отдаёт 64 — 31 байт «внутри» блока потеряны. Внутренний pool Node на 8 KB — осознанный компромисс: меньше внешней фрагментации и меньше syscall’ов, ценой мелких потерь внутри slab.
flowchart LR
subgraph s1["Начало"]
A1["Свободная виртуальная память"]
end
subgraph s2["После bufA 1 MB"]
B1["bufA"]
B2["свободно"]
end
subgraph s3["+ bufB 512 KB"]
C1["bufA"]
C2["bufB"]
C3["свободно"]
end
subgraph s4["Освобождён bufA"]
D1["дыра 1 MB"]
D2["bufB"]
D3["свободно"]
end
s1 --> s2 --> s3 --> s4 После освобождения bufA появляется дыра 1 MB, а свободная память разбита на два несмежных куска. Приходит запрос на буфер 1.2 MB для дампа БД — выделение падает: суммарно свободно больше 1.2 MB, но нет одного непрерывного блока. На сервере, работающем днями, картина повторяется тысячи раз; в итоге — ENOMEM и падение процесса.
Что можно сделать¶
Риск фрагментации растёт для буферов крупнее pool (по умолчанию порог около 4 KB). Много крупных буферов переменного размера превращает процесс в «клиента с высоким churn» у аллокатора ОС.
Поведение аллокатора ОС вы не перепишете, но можете изменить поведение приложения. Ключ — снизить churn памяти.
Повторное использование буфера¶
Один из самых сильных приёмов: выделить один крупный буфер заранее и переиспользовать его на горячем пути — в обработчике data сокета или в плотном цикле.
Сервер кадрирует TCP‑пакеты 4‑байтовым заголовком длины.
Плохо: высокий churn
1 2 3 4 5 6 7 8 | |
При 10 000 пакетов/с — до 20 000 выделений/с (часть мелких может попасть во внутренний pool). GC и аллокатор работают на износ; растёт риск фрагментации.
Лучше: один переиспользуемый буфер
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | |
Крупные backing store на пакет больше не выделяются (было два на пакет — стало ноль). Остаётся лёгкий JS‑объект view; исчезает копирование Buffer.concat.
Опасность общей памяти
framedPacketView от subarray() делит память с reusableBuffer. Если sendToNextService асинхронен (сеть, очередь, pipeline), следующий пакет может перезаписать буфер, пока предыдущий потребитель ещё читает — тихая порча данных.
Безопасно только когда потребитель обрабатывает данные синхронно до возврата из обработчика (редко) или когда вы явно координируете время жизни буферов.
Безопаснее: pool буферов
Для асинхронных потребителей — кольцевой pool:
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 | |
Пока не пройдёте все POOL_SIZE слотов, конкретный backing buffer не переиспользуется — перезаписи in-flight пакетов не будет, если POOL_SIZE покрывает вашу параллельность.
Когда что выбирать (на практике редко упираются во фрагментацию, но схема полезна):
- Один переиспользуемый буфер — только при строго синхронном потребителе.
- Pool — асинхронные потребители с ограниченной параллельностью.
- Копия в новый буфер — если in-flight работу нельзя ограничить:
Buffer.from(framedPacketView)на отправке; дороже по alloc, но просто и безопасно.
У Node есть внутренний pool для мелких выделений. chunk.copy() всё равно стоит CPU — вы меняете стоимость выделения на стоимость копирования (часто выгодно на GC‑чувствительных путях).
Главный вывод: повторное использование сильно ускоряет hot path, но общая память требует дисциплины времени жизни, иначе — порча данных.
Понимание фрагментации — это видеть Buffer.alloc() не как «дешёвую операцию», а как запрос с накопительной ценой на lifetime сервера. Осознанный reuse и pooling дают системы, которые месяцами держат нагрузку без сюрпризов по RSS.
Практические задачи¶
Теория без кода слаба. Задачи ниже заставляют применить темы прошлых глав в контексте, близком к production. Сложность растёт от задачи к задаче.
Решений нет — цель в том, чтобы вы их написали. Смотрите документацию Node.js. Инсайт от своей реализации стоит любого copy-paste.
Задача №1¶
IoT‑проект шлёт по TCP пакеты сенсоров фиксированного размера — ровно 24 байта:
| Смещение (байт) | Длина (байт) | Тип | Описание |
|---|---|---|---|
| 0–3 | 4 | UInt32BE | ID сенсора |
| 4–11 | 8 | Float64BE | Timestamp (Unix epoch, мс) |
| 12–13 | 2 | UInt16BE | Код типа сенсора |
| 14 | 1 | UInt8 | Флаги статуса (битовая маска) |
| 15 | 1 | Int8 | Температура (°C) |
| 16–19 | 4 | Float32BE | Влажность (%) |
| 20–23 | 4 | Float32BE | Давление (кПа) |
Задание
Напишите функцию parseSensorData, принимающую 24‑байтовый Buffer и возвращающую объект с расшифрованными полями.
Тестовый пакет:
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 | |
Ожидаемый результат parseSensorData(samplePacket):
1 2 3 4 5 6 7 8 9 | |
У float возможны мелкие отличия в последних знаках — это нормально.
На что обратить внимание
- Какие методы
read*нужны для каждого поля? - Типы (
UInt,Int,Float64,Float32) и endianness (BE). - Смещения фиксированы — протокол constant-size.
- Проверяйте длину входного буфера до разбора.
Задача №2¶
Маленький view может удерживать огромный родительский buffer. Пора измерить это в коде.
Задание
Скрипт с двумя тестами:
-
Тест «View»
- Один крупный
Buffer(например 50 MB). - В цикле ~100 000 view по 16 байт через
subarray()(предпочтительнееslice()). - Сохранить view в массиве, чтобы GC их не собрал.
process.memoryUsage()— смотретьexternal.
- Один крупный
-
Тест «Copy»
- Такой же крупный буфер 50 MB.
- ~100 000 копий по 16 байт (не view).
- Массив копий; исходный большой буфер должен стать eligible для GC; по возможности вызвать GC.
- Снова
process.memoryUsage().
Цель
external в тесте view — чуть больше 50 MB; в тесте copy — заметно меньше (порядка 1.6 MB для 100 000 × 16 байт).
Подсказки
- Запуск с
--expose-gcиglobal.gc()для стабильности. - Почему для эксперимента важнее
external, чемrss/heapUsed? - Вспомогательная функция форматирования байт в KB/MB упростит вывод.
Задача №3¶
Нужен парсер переменной длины TLV из TCP‑потока: в одном data может быть несколько сообщений или кусок одного. Парсер stateful — хранит хвост между chunk’ами.
Спецификация протокола¶
Заголовок 3 байта + value длиной L:
| Смещение | Длина | Тип | Описание |
|---|---|---|---|
| 0 | 1 | UInt8 | Тип сообщения (1–255) |
| 1–2 | 2 | UInt16BE | Длина value (0–65535) |
| 3…3+L | L | Buffer | Payload |
Задание
Следующая большая тема NodeBook — Streams. Если с streams неуверенны, задачу можно пропустить или сначала прочитать главу про streams.
Класс TlvParser, наследник stream.Transform:
- Внутренний буфер для неполных сообщений.
- В
_transform— дописывать входящие данные во внутренний буфер. - В цикле пытаться разобрать полные TLV.
- На полное сообщение —
push({ type, value }), гдеvalue— копия payload, не view во внутренний буфер. - Остаток оставить во внутреннем буфере.
Пример потока chunk’ов:
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 | |
Цель — два объекта по порядку:
{ type: 1, value: <Buffer "hello"> }{ type: 2, value: <Buffer "goodbye!"> }
Подсказки
- Внутренний буфер:
Buffer.concat. - Сначала 3 байта заголовка, потом L, потом проверка достаточности данных.
- Отрезать разобранное —
subarray. - Зачем emit именно копию value?
Задача №4¶
Сервис обработки видео страдает от фрагментации: постоянные alloc/free буферов 64 KB, через дни — OOM. Нужен свой BufferPool.
Задание
Класс BufferPool:
- Конструктор
(bufferSize, poolSize)— заранее выделитьpoolSizeбуферов размераbufferSize(например 65536 и 100). get()— вернуть свободный буфер; если pool пуст — warning и временный новый буфер нужного размера.release(buffer)— вернуть в pool; не раздувать pool сверх изначального размера (лишние «аварийные» буферы не копить бесконечно).- Геттер
used— сколько буферов сейчас «взято».
Напишите класс и симуляцию: взять несколько буферов, проверить used, вернуть, опустошить pool, проверить release для «лишнего» буфера из get() при пустом pool.
Подсказки
- Стек свободных: массив +
push/pop. - При
release— проверка размера. - В worker threads понадобилась бы синхронизация (мысленный эксперимент).
try…finallyпри работе с pool.
Задача №5¶
Legacy‑железо шлёт пакет 16 байт со смешанным endianness. Для ясности — парсинг через DataView.
| Смещение | Длина | Тип | Endianness | Описание |
|---|---|---|---|---|
| 0–1 | 2 | UInt16 | Big | Сигнатура (должна быть 0xCAFE) |
| 2–5 | 4 | Int32 | Little | Device ID |
| 6–9 | 4 | Float32 | Big | Напряжение |
| 10 | 1 | UInt8 | — | Код статуса |
| 11 | 1 | UInt8 | — | Checksum |
| 12–15 | 4 | UInt32 | Little | Uptime (сек) |
Задание
parseLegacyPacket(buffer) на 16 байт: DataView поверх ArrayBuffer буфера, методы getUint16, getInt32, getFloat32 и т.д.; последний аргумент — true для LE, false/опущен для BE.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
Ожидаемый результат:
1 2 3 4 5 6 7 8 | |
Подсказки
- У
Bufferесть.buffer,.byteOffset,.byteLengthдляDataView. - Третий аргумент методов
DataView— little-endian flag.
Задача №6 (продвинутая)¶
Опционально. Нужны worker threads, SharedArrayBuffer, Atomics. Можно вернуться после соответствующих глав NodeBook.
Несколько worker’ов должны инкрементировать общий счётчик без сообщений на каждый шаг — используйте SharedArrayBuffer и Atomics.
Задание
main.js
SharedArrayBufferна 4 байта (одинInt32).Int32Array, счётчик = 0.- Два
Worker, каждый +1 000 000 раз. - Дождаться
doneот обоих. Atomics.load— вывести итог (ожидается 2 000 000).
worker.js
- Получить
SharedArrayBuffer, свойInt32Array. - В цикле
Atomics.add(view, 0, 1). - По завершении — сообщение
done.
Без атомарности view[0]++ даст гонки и итог меньше 2 000 000.
Подсказки
- Единственная задача на два файла.
Promise.allдля ожидания worker’ов.- Исследуйте race condition на read-modify-write.
Связанное чтение¶
- Предыдущая: Операции с Buffer: кодировки, slice, copy
- Далее: Основы streams в Node.js: backpressure и поток данных