Арена вместо malloc: девять раз на парсере и полтора гигабайта, которые я проглядел
Разбор замены поштучных malloc на bump-аллокатор в парсере журналов. С числами, кодом, выравниванием и описанием того, как я чуть не отправил в прод ускорение, которое утроило бы потребление памяти.
Что болело
Утилита parse читает журнальные файлы по строке, разбирает каждую в дерево узлов и складывает агрегаты. Один файл за сутки - это 2,3 млн строк и примерно 11,9 млн узлов, то есть столько же вызовов malloc и столько же free в конце. На моём стенде прогон занимал 1,42 с, и это никого не устраивало, потому что файлов за месяц тридцать.
Профиль был необычно честным - ровно тот случай, когда вершина и есть причина:
$ perf record -e cycles:pp -g ./parse logs/2026-09-14.jsonl
$ perf report --stdio --percent-limit 3
32,1% parse libc.so.6 [.] _int_malloc
18,7% parse libc.so.6 [.] _int_free
10,4% parse parse [.] node_new
6,2% parse libc.so.6 [.] malloc_consolidate
Половина времени уходит в сам аллокатор. Узлы живут до конца разбора файла и умирают все вместе - классическая ситуация, под которую и придумана арена.
Что я написал
Арена - это список блоков и смещение внутри текущего. Выделение сдвигает смещение, освобождения по одному объекту нет вовсе.
typedef struct Block {
struct Block *next;
size_t used, cap;
unsigned char mem[];
} Block;
typedef struct { Block *head; size_t blk; } Arena;
static void *arena_alloc(Arena *a, size_t n, size_t align)
{
size_t off = (a->head->used + (align - 1)) & ~(align - 1);
if (off + n > a->head->cap) {
size_t cap = n > a->blk ? n : a->blk;
Block *b = malloc(sizeof *b + cap);
if (!b) return NULL;
b->next = a->head;
b->used = 0;
b->cap = cap;
a->head = b;
off = 0;
}
a->head->used = off + n;
return a->head->mem + off;
}
Про выравнивание, о котором я сначала не подумал
Первая версия считала смещение как off = used + (align - used % align) % align. Работало, но деление в самом горячем месте стоило 4 % времени прогона. Трюк с маской - (used + align - 1) & ~(align - 1) - делает то же самое двумя инструкциями, но требует, чтобы align была степенью двойки. Я добавил проверку в отладочной сборке, потому что иначе эта функция тихо выдаёт мусорные адреса при align == 24, и ловить такое потом крайне неприятно.
Второе: заголовок Block и гибкий массив mem[] не гарантируют 16-байтного выравнивания начала mem сами по себе. У меня sizeof(Block) равен 24, malloc даёт 16-выровненный адрес, и начало mem оказывается кратно 8, но не 16. Поэтому арена всегда получает align явно, а не подразумевает максимум.
Что получилось
| вариант | время, с | RSS, МиБ | malloc |
|---|---|---|---|
| malloc на узел | 1,42 | 211 | 11 903 441 |
| арена, блок 1 МиБ | 0,158 | 1 524 | 1 452 |
| арена + сброс на файл | 0,171 | 243 | 1 452 |
Девять раз по времени - ровно столько, сколько обещал профиль. Число вызовов malloc упало с одиннадцати миллионов до полутора тысяч: по одному на блок.
Чего я не заметил
Вторую строку таблицы я получил сразу, а третью - через неделю, и не сам. На стенде я гонял один файл, а в проде утилита идёт по тридцати в одном процессе. Арена не отдаёт память, пока её не освободить целиком, поэтому RSS рос от файла к файлу и дошёл до полутора гигабайт. Заметил это не я, а алерт по памяти на сборочном узле.
Лечится одной строкой: между файлами делать не освобождение, а сброс - пройти список блоков, оставить первый, остальные вернуть, у первого выставить used = 0. Память переиспользуется, возврат в ОС не нужен, и это те самые 13 мс разницы между второй и третьей строкой, которые я с радостью заплатил.
static void arena_reset(Arena *a)
{
Block *b = a->head->next;
while (b) { Block *n = b->next; free(b); b = n; }
a->head->next = NULL;
a->head->used = 0;
}
Когда арену брать не надо
Если у объектов разное время жизни - не надо. Арена в долгоживущем демоне, из которого объекты уходят по одному, превращается в утечку по построению: формально вся память достижима, практически она занята мёртвыми узлами до перезапуска. Я один раз так и сделал в фоновом сервисе, и потом полдня объяснял себе, почему «утечки нет, а память растёт».
Если размер объекта большой и редкий - тоже не надо: блок под него заводится отдельный, и сэкономить на вызове нечего. И если вам нужен realloc - арена его не умеет, только копирование в новое место.
Остальное про возврат памяти операционной системе я разбирал в записи про RSS после free: там тот же механизм, но с точки зрения системного аллокатора.
Комментарии (2)
gornostay - 18 сентября 2026, 21:40
Про сброс вместо free между файлами - ровно то, на чём сам обжёгся в демоне, спасибо что с цифрами RSS.
anon - 19 сентября 2026, 08:02
А realloc в арену так и не завезли? Приходится копировать в новый блок, или есть трюк?
Войти, чтобы комментировать.
Регистрация закрыта.