voidbytes.dev

системное программирование вслух: аллокаторы, сисколлы, профиль, разбор падений - и места, где я сам намерил неправильно

7 миналлокаторы

Арена вместо 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;
}
блок 1 · 1 МиБ · заполнен блок 2 · 1 МиБ · заполнен блок 3 · текущий used свободно arena_alloc сдвигает used вправо; освободить можно только все блоки сразу
Три блока по мегабайту. Новый объект всегда ложится в текущий блок справа от used; когда места нет, заводится следующий блок. Вернуть память в середине нельзя - это и есть цена.

Про выравнивание, о котором я сначала не подумал

Первая версия считала смещение как 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,4221111 903 441
арена, блок 1 МиБ0,1581 5241 452
арена + сброс на файл0,1712431 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 в арену так и не завезли? Приходится копировать в новый блок, или есть трюк?

Войти, чтобы комментировать.

Регистрация закрыта.