Системное программирование на C Динамическая память: malloc, free, утечки, фрагментация, свои аллокаторы
0%

Динамическая память: malloc, free, утечки, фрагментация, свои аллокаторы

Динамическая память: malloc, free, утечки, фрагментация, свои аллокаторы

В языках со сборщиком мусора вопрос «кто и когда освободит этот объект» не возникает — за вас его решает рантайм. В C он возникает в каждой строчке, где вы создаёте что-то, чей размер или время жизни неизвестны при компиляции. Здесь живёт большая часть эксплуатируемых уязвимостей в системном софте: по данным MSRC, около 70% CVE в продуктах Microsoft — ошибки безопасности памяти, и та же цифра годами воспроизводится в отчётах Chromium.

Хорошая новость: работа с кучей — не «магия, где надо быть осторожным», а инженерная дисциплина с понятными правилами, проверяемыми инструментами и небольшим набором паттернов владения. Предполагается, что вы уже прошли память и указатели и раскладку структур.

Три места, где может жить объект

Статическое Автоматическое (стек) Динамическое (куча)
Как объявить глобальная, static локальная переменная malloc / calloc
Время жизни вся программа до конца блока {} до явного free
Кто освобождает никто компилятор, ret вы
Размер известен при компиляции обычно известен любой, в рантайме
Стоимость выделения ноль одна инструкция sub rsp десятки–сотни наносекунд
Типичный лимит размер бинаря 8 МБ (ulimit -s) вся доступная память
Вернуть из функции безопасно нельзя (висячий указатель) безопасно

Куча нужна ровно в четырёх случаях: размер известен только в рантайме; время жизни переживает кадр стека; объект слишком велик для стека (массив на 4 МБ — это SIGSEGV); время жизни определяется данными, а не структурой кода. Во всех остальных ситуациях стек лучше — он быстрее, чистит себя сам и локален по кэшу. «Сначала попробуй стек» — здоровая привычка; разница в стоимости доступа разобрана в иерархии памяти и в статье про кэш и локальность.

Контракт стандартной библиотеки

Стандарт C17 (7.22.3, черновик N2310) даёт четыре функции и очень скупые гарантии. Скупость важна: всё, что не обещано, зависит от реализации — а значит, от машины, где вы запускаете код.

void *malloc(size_t size);                       // память НЕ инициализирована
void *calloc(size_t n, size_t size);             // n*size байт, ЗАПОЛНЕНЫ НУЛЯМИ
void *realloc(void *p, size_t new_size);         // изменить размер, возможно с переездом
void  free(void *p);                             // free(NULL) легален и ничего не делает
void *aligned_alloc(size_t align, size_t size);  // C11, повышенное выравнивание

Гарантировано: указатель выровнен под любой тип с фундаментальным выравниванием (_Alignof(max_align_t), на x86-64 это 16 байт); блоки не пересекаются и не двигаются, пока вы их не освободили; при неудаче возвращается NULL.

Не гарантировано и стоит выучить наизусть:

  • malloc(0) может вернуть NULL, а может — уникальный указатель, который нельзя разыменовывать, но нужно освободить. Оба варианта законны.
  • Содержимое mallocмусор. То, что при первом запуске там оказались нули (страница только пришла от ядра и была обнулена по требованиям безопасности), — совпадение, которое исчезнет, как только память начнёт переиспользоваться.
  • Порядок адресов не ваше дело: сравнивать через < указатели на разные блоки — UB.

Две мелочи, по которым сразу видно уровень автора. Пишите malloc(n * sizeof *a), а не sizeof(int) — размер не рассинхронизируется при смене типа. И не приводите результат к типу: (int *)malloc(...) — привычка из C++, которая в C лишь маскирует пропущенный #include <stdlib.h>. После free обнуляйте переменную, если она переживает освобождение: разыменование NULL падает сразу и предсказуемо, а висячего указателя — как повезёт.

realloc и арифметика размеров

// ПЛОХО: если realloc вернёт NULL, старый указатель потерян навсегда — утечка
buf = realloc(buf, new_size);

// ХОРОШО: сохраняем старый указатель до подтверждения успеха
char *tmp = realloc(buf, new_size);
if (tmp == NULL) {
    free(buf);          // старый блок ещё жив и всё ещё наш
    return -1;
}
buf = tmp;

Второй капкан: realloc может переехать, и тогда все указатели внутрь старого блока становятся висячими — включая сохранённые «на потом» в других структурах.

char *p = malloc(16);
char *inner = p + 4;        // указатель внутрь блока
p = realloc(p, 1024);       // блок мог переехать
*inner = 'x';               // UB: inner смотрит в освобождённую память

Третий капкан — арифметика. malloc(n * sizeof *v) при большом n переполняет size_t: вы получаете крошечный буфер и спокойно пишете за его границы. calloc(n, sizeof *v) в любой живой реализации (glibc, musl, jemalloc) проверяет переполнение сам и возвращает NULL с ENOMEM.

// Растущий буфер: амортизированная вставка O(1) по времени, перерасход до 2x по памяти
typedef struct { char *data; size_t len, cap; } Vec;

static int vec_push(Vec *v, char c) {
    if (v->len == v->cap) {
        size_t ncap = v->cap ? v->cap * 2 : 16;
        if (ncap < v->cap) return -1;               // переполнение size_t
        char *tmp = realloc(v->data, ncap);
        if (tmp == NULL) return -1;                 // v->data цел, решает вызывающий
        v->data = tmp; v->cap = ncap;
    }
    v->data[v->len++] = c;
    return 0;
}

Почему удвоение, а не «плюс 100 байт»: при росте на константу n вставок стоят O(n²) копирований, при росте в k раз — O(n). Разбор амортизированного анализа — в сложности и памяти.

Что аллокатор делает у себя внутри

malloc — не системный вызов, а библиотечная функция с собственным «оптовым складом» памяти, выпрошенной у ядра большими кусками. Рядом с вашими данными она хранит служебные поля.

Раскладка чанка кучи в glibc malloc

Три следствия из этой картинки. Каждая аллокация стоит дороже, чем вы просили: размер чанка в glibc — align16(запрос + 8), но не меньше 32 байт. Освобождённый чанк использует ваше бывшее тело данных под указатели fd/bk — отсюда прямая связь между use-after-free и повреждением структур аллокатора, на чём построены почти все heap-эксплойты. Копия размера в конце (boundary tag) позволяет за O(1) найти соседа слева и слить с ним свободный блок.

Проверить накладные расходы можно прямо сейчас:

#include <malloc.h>     // glibc-специфично: malloc_usable_size

for (size_t s = 1; s <= 128; s *= 2) {
    void *p = malloc(s);
    printf("malloc(%3zu) -> реально доступно %3zu\n", s, malloc_usable_size(p));
    free(p);
}
malloc(  1) -> реально доступно  24      malloc( 32) -> реально доступно  40
malloc(  8) -> реально доступно  24      malloc( 64) -> реально доступно  72
malloc( 16) -> реально доступно  24      malloc(128) -> реально доступно 136

Миллион структур по 24 байта — это не 24 МБ, а 32 МБ плюс фрагментация; для списков и деревьев с мелкими узлами это часто решающий аргумент в пользу пула. И да, malloc_usable_size возвращает больше запрошенного, но писать туда всё равно UB: компилятор и санитайзеры знают только про запрошенный размер.

Стоимость: попадание в tcache — несколько инструкций, O(1); промах с разбором unsorted bin — десятки-сотни наносекунд; поход к ядру — микросекунды плюс page fault на каждой странице при первом касании. Детали — в официальной wiki glibc: Malloc Internals, а канонический разбор алгоритма — статья Дуга Ли про dlmalloc, предка glibc-аллокатора.

Откуда память приходит от ядра

Механизма получения памяти два. brk/sbrk сдвигает вершину единого сегмента данных: дёшево, но память возвращается ядру только с конца — если занят хоть один байт наверху, всё, что ниже, освободить нельзя. mmap создаёт отдельную анонимную область; используется для запросов больше M_MMAP_THRESHOLD (по умолчанию 128 КБ, порог динамически растёт до 32 МБ) и для арен потоков, а free такого блока делает munmap и возвращает память ядру сразу.

strace -e trace=brk,mmap,munmap ./demo
brk(0x61e639b03000) = 0x61e639b03000                    # malloc(100 КБ) — из кучи
mmap(NULL, 208896, PROT_READ|PROT_WRITE, ...) = 0x7f7b… # malloc(200 КБ) — отдельный регион
munmap(0x7f7bfa069000, 208896) = 0                      # free() того же блока

Механика виртуальной памяти, page fault и разницы VSZ/RSS подробно разобрана в управлении памятью ОС. Здесь важно одно следствие: free почти никогда не означает «память вернулась операционной системе».

Жизненный цикл блока

Главное здесь: между «занят» и «свободен» есть промежуточные состояния, которые аллокатор использует для скорости, и именно они превращают безобидную на вид ошибку в тихое переиспользование чужих данных. Санитайзеры ломают эту схему намеренно: освобождённый блок отправляется в карантин и не отдаётся следующему malloc, чтобы обращение к нему было поймано, а не «сработало».

Как это ломается: каталог ошибок

Утечка — блок жив, но ни одна переменная на него не указывает. Опасны не только «забыл free», но и раннее return из середины функции, и потеря указателя при realloc.

Use-after-free — указатель пережил блок: free(p); int v = *p; — это UB, которое очень часто «работает» и печатает старое значение, потому что содержимое ещё не переиспользовали. Ломается под нагрузкой, в другом потоке или на другой версии libc.

Double free — glibc ловит частные случаи и аварийно завершает процесс сообщением free(): double free detected in tcache 2. Но это защита, а не гарантия: при сценарии free → malloc → free проверка не сработает, а структуры аллокатора будут повреждены.

Heap overflow — здесь особенно ярко видно, почему «у меня всё работает» ничего не доказывает. Программа char *p = malloc(32); memset(p, 'A', 64); free(p); пишет вдвое больше, чем выделила, — и на моей машине завершилась с кодом 0, ничего не напечатав: она просто затёрла заголовок соседнего чанка. GCC 13, впрочем, ругнулся ещё при компиляции, и это первая линия обороны, которую надо включать всегда:

warning: 'memset' writing 64 bytes into a region of size 32 overflows the destination [-Wstringop-overflow=]
    note: destination object of size 32 allocated by 'malloc'

Классический off-by-one — забытый байт под терминирующий ноль: malloc(strlen(src)) вместо malloc(strlen(src) + 1), а дальше strcpy пишет на байт дальше конца блока. Лучше просто strdup(src).

free не того указателя — освободить можно только ровно тот адрес, который вернул malloc: не p + 1, не адрес поля структуры, не статический объект. glibc обычно ловит (munmap_chunk(): invalid pointer), но полагаться на это нельзя.

Почему «работает на моей машине» здесь особенно опасно

Неопределённое поведение — это не «непредсказуемый результат». Это разрешение компилятору считать, что такой ситуации не бывает, и оптимизировать исходя из этого. Отсюда главное свойство ошибок с кучей: они меняют поведение при смене компилятора, уровня оптимизации, версии libc или просто соседнего кода.

Возьмём безобидную функцию и посмотрим, что с ней делают два компилятора:

// dce.c
int f(void) {
    int *p = malloc(sizeof *p);
    if (!p) return -1;
    *p = 42;  int v = *p;
    free(p);
    return v;
}
gcc   -O2 -S -masm=intel dce.c -o -    # GCC 13: вызовы остались, работы с памятью нет
clang -O2 -S -masm=intel dce.c -o -    # Clang 21: пары malloc/free нет вообще
; gcc 13                          ; clang 21
f:  mov     edi, 4                f:  mov     eax, 42
    call    malloc@PLT                ret
    test    rax, rax
    je      .L3
    mov     rdi, rax
    call    free@PLT
    mov     eax, 42   ; из воздуха: память не читалась

Оба правы: malloc/free для компилятора — не обычные функции, а известные примитивы с известной семантикой, и удалять их разрешено. А теперь представьте, что в этой функции была ошибка «читаем после free»: под GCC вы прочитаете что-то из реальной памяти, под Clang доступа к памяти не будет вовсе, а под -O0 результат будет третьим. Такой баг «чинится» переключением флага и возвращается на проде.

Тот же эффект бьёт по замерам: malloc(4 МБ) + memset + free под -O2 не увеличил RSS ни на килобайт — GCC удалил memset, потому что записанное никто не читает.

Ещё тоньше — значение самого указателя:

int *p = malloc(4);
free(p);
int *q = malloc(4);
if (p == q) puts("тот же адрес");   // UB: значение p после free — indeterminate

По стандарту (C17, 6.2.4p2) значение указателя становится неопределённым, когда объект заканчивает жизнь. Не «указывает в никуда», а именно неопределённым: читать его — уже UB, даже без разыменования. Глубже — в статье про неопределённое поведение.

Практический вывод: эксперимент не доказывает корректность кода на C. Доказывают её правила языка плюс инструменты, которые проверяют программу, а не наблюдают за одним удачным запуском.

Инструменты: как ошибаться реже

AddressSanitizer — первый рубеж

ASan инструментирует программу при компиляции: подменяет аллокатор, окружает каждый блок «красными зонами» и держит освобождённые блоки в карантине. Замедление ~2x, память ~2–3x. Это цена, которую платят в CI и в отладочной сборке всегда.

gcc -std=c17 -O1 -g -fno-omit-frame-pointer \
    -fsanitize=address,undefined -fno-sanitize-recover=all uaf.c -o uaf
./uaf
==4155984==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010
READ of size 4 at 0x502000000010 thread T0
    #0 0x558dd4000216 in main uaf.c:8
0x502000000010 is located 0 bytes inside of 4-byte region [0x502000000010,0x502000000014)
freed by thread T0 here:            #1 0x558dd40001ef in main uaf.c:7
previously allocated by thread T0:   #1 0x558dd40001df in main uaf.c:4
SUMMARY: AddressSanitizer: heap-use-after-free uaf.c:8 in main

Три стека сразу: где обратились, где освободили, где выделили. Ровно это нужно для починки и ровно этого никогда не даст отладочная печать. Поведение донастраивается окружением: ASAN_OPTIONS=detect_leaks=1:abort_on_error=1:detect_stack_use_after_return=1, а известные утечки сторонних библиотек глушатся через LSAN_OPTIONS=suppressions=lsan.supp. LeakSanitizer входит в ASan и на Linux включён по умолчанию. Чего ASan не ловит: чтение неинициализированной памяти (это к MemorySanitizer) и гонки данных (ThreadSanitizer).

Valgrind Memcheck — когда пересобрать нельзя

Valgrind исполняет программу на синтетическом процессоре и следит за каждым доступом: пересборка не нужна, ловит неинициализированное чтение, но замедление 20–50x.

valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./leak
==4156337== Invalid read of size 4 at 0x1091BD: main (leak.c:8)
==4156337==  Address 0x4a8b040 is 0 bytes inside a block of size 4 free'd
==4156337==    by 0x1091B8: main (leak.c:7);  alloc'd at main (leak.c:5)
==4156337== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
==4156337==    by 0x1091CB: main (leak.c:9)
==4156337== LEAK SUMMARY:
==4156337==    definitely lost: 40 bytes in 1 blocks
==4156337==      possibly lost: 0 bytes in 0 blocks
==4156337==    still reachable: 0 bytes in 0 blocks

Различайте категории: definitely lost — настоящая утечка; still reachable — блок жив и достижим на момент выхода (часто это глобальные кэши, не всегда баг); possibly lost — указатель есть, но смотрит внутрь блока.

Что чем ловить

Инструмент Ловит Замедление Пересборка
-Wall -Wextra + -D_FORTIFY_SOURCE=3 часть переполнений, статически и в рантайме нет да
ASan + LeakSanitizer UAF, double free, overflow, утечки ~2x да
UndefinedBehaviorSanitizer переполнения, невыровненный доступ, UB-приведения ~1.2x да
MemorySanitizer чтение неинициализированной памяти ~3x да, всю программу с зависимостями
Valgrind Memcheck всё вышеперечисленное, кроме гонок 20–50x нет
MALLOC_CHECK_=3, MALLOC_PERTURB_ повреждения структур glibc ~1.1x нет
heaptrack, massif профиль потребления: кто выделил и сколько 1.5–3x нет

MALLOC_PERTURB_ — недооценённый трюк: glibc заполняет выдаваемую память заданным байтом, а освобождаемую — его дополнением.

$ ./demo                          # свежий malloc: 00 00 00 00 — повезло, страница от ядра
$ MALLOC_PERTURB_=42 ./demo       # свежий malloc: d5 d5 d5 d5 — это 0xff ^ 42

Код, молча полагавшийся на нули, ломается сразу. Поставьте MALLOC_PERTURB_=$((RANDOM % 255 + 1)) в тестовом окружении: это бесплатно и ловит целый класс «случайно работающего» кода. Отладка уже упавшего процесса — gdb, core dump, чтение стека — тема статьи про отладку и диагностику. Здесь важно правило: санитайзеры — часть сборочной конфигурации, а не разовая акция.

Фрагментация: утечки нет, а память растёт

Внешняя фрагментация кучи

Внутренняя фрагментация — просили 33 байта, чанк занял 48; предсказуема, лечится классами размеров и упаковкой структур. Внешняя — свободной памяти много, но она разбита на куски, ни один из которых не подходит. Именно она заставляет процесс расти. Воспроизводимый эксперимент: выделяем 200 000 блоков по 256 байт, освобождаем каждый второй, потом остальные.

enum { N = 200000, SZ = 256 };
for (int i = 0; i < N; i++) { v[i] = malloc(SZ); memset(v[i], 0xAB, SZ); }
for (int i = 0; i < N; i += 2) free(v[i]);   // освобождаем половину — около 25 МБ
malloc_trim(0);                              // явно просим вернуть память ядру
for (int i = 1; i < N; i += 2) free(v[i]);   // теперь остальные
malloc_trim(0);
старт                    VmRSS:  1424 kB
200k блоков по 256 Б     VmRSS: 56240 kB
free каждого второго     VmRSS: 56240 kB     <- освободили 25 МБ, RSS не изменился
после malloc_trim(0)     VmRSS: 56240 kB     <- и trim не помогает
free остальных           VmRSS:  3248 kB     <- вот теперь память вернулась
после malloc_trim(0)     VmRSS:  3120 kB

Утечки нет ни одной. Просто в каждой странице остался хотя бы один живой блок, а ядру память возвращается только целыми страницами. Так выглядит «сервис течёт» в большинстве случаев, когда valgrind ничего не находит. Что делают на практике:

  1. Группировать объекты по времени жизни, а не по типу. Всё, что живёт в рамках одного запроса, — в одну арену, которая сбрасывается целиком. Пул фиксированного размера не даёт внешней фрагментации внутри себя вообще.
  2. Сменить аллокатор без правки кода. jemalloc исторически создавался против фрагментации в долгоживущих серверах, mimalloc — про скорость и предсказуемость: LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 ./app.
  3. Ограничить арены glibc. В многопоточных программах glibc создаёт до 8 * nproc арен — хорошо для конкуренции, плохо для RSS: export GLIBC_TUNABLES=glibc.malloc.arena_max=2:glibc.malloc.trim_threshold=131072. Полный список — в документации glibc про tunables, а взаимодействие арен и потоков — в статье про потоки и синхронизацию.
  4. Мерить, а не гадать: heaptrack ./app даёт разбивку «кто выделил, сколько живёт, где пик».

Свои аллокаторы

Аллокатор общего назначения писать почти никогда не нужно — вы не обгоните jemalloc. А специализированный под конкретную схему времён жизни выигрывает в разы, потому что знает то, чего malloc знать не может. Классическая работа Berger, Zorn, McKinley «Reconsidering Custom Memory Allocation» (OOPSLA 2002) показывает: почти все самописные аллокаторы проигрывают системному — кроме региональных (арен), которые выигрывают стабильно.

Арена (bump allocator)

Идея на одну строчку: держим большой кусок памяти и указатель-курсор. Выделение — сдвиг курсора. Освобождение отдельного объекта не поддерживается; освобождается вся арена сразу.

#include <assert.h>
#include <stdint.h>
#include <stdlib.h>

static size_t align_up(size_t x, size_t align) {
    assert(align != 0 && (align & (align - 1)) == 0);   // только степени двойки
    return (x + align - 1) & ~(align - 1);
}

typedef struct { unsigned char *base; size_t cap; size_t off; } Arena;

static int arena_init(Arena *a, size_t cap) {
    a->base = malloc(cap);
    if (a->base == NULL) return -1;
    a->cap = cap; a->off = 0;
    return 0;
}
static void arena_reset(Arena *a)   { a->off = 0; }     // O(1): «освободили» всё
static void arena_destroy(Arena *a) { free(a->base); a->base = NULL; a->cap = a->off = 0; }

static void *arena_alloc(Arena *a, size_t size, size_t align) {
    size_t off = align_up(a->off, align);
    if (off > a->cap || size > a->cap - off) return NULL;   // так проверка не переполнится
    void *p = a->base + off;
    a->off = off + size;
    return p;
}
static void *arena_alloc_array(Arena *a, size_t n, size_t size, size_t align) {
    if (n != 0 && size > SIZE_MAX / n) return NULL;         // защита от переполнения размера
    return arena_alloc(a, n * size, align);
}

#define ARENA_NEW(a, T)     ((T *)arena_alloc((a), sizeof(T), _Alignof(T)))
#define ARENA_ARR(a, T, n)  ((T *)arena_alloc_array((a), (n), sizeof(T), _Alignof(T)))

/* Использование: */
Arena a;
if (arena_init(&a, 64 * 1024) != 0) return 1;
Item *it = ARENA_NEW(&a, Item);
int  *xs = ARENA_ARR(&a, int, 1000);
/* ... вся обработка одного запроса ... */
arena_reset(&a);          // одна инструкция вместо тысяч free

Характеристики: выделение — O(1) за несколько инструкций, ноль метаданных на объект, идеальная локальность (объекты лежат подряд, префетчер доволен), освобождение всей арены — O(1). Цена — нельзя освободить один объект, и арена держит пиковый объём до сброса. Где выигрывает: обработка одного HTTP-запроса, разбор одного файла компилятором, один кадр игрового цикла, один тик симуляции. Рецепты — у Chris Wellons, «Arena allocator tips and tricks» и Ryan Fleury, «Untangling Lifetimes: The Arena Allocator».

Выравнивание через _Alignof(T) здесь обязательно. Если раздавать base + off без него, вы получите невыровненный доступ: на x86-64 медленно и ловится UBSan, на ARM и во встраиваемых системах это аппаратный fault — см. C для встраиваемых систем.

Пул фиксированного размера

Когда все объекты одного размера (узлы списка, дескрипторы соединений, частицы), свободный список можно хранить прямо внутри свободных блоков — метаданные становятся бесплатными.

typedef struct FreeNode { struct FreeNode *next; } FreeNode;

typedef struct {
    unsigned char *base;
    size_t block_size;
    FreeNode *head;      // голова односвязного списка свободных блоков
} Pool;

static int pool_init(Pool *p, size_t block_size, size_t align, size_t count) {
    if (align < _Alignof(FreeNode))    align = _Alignof(FreeNode);
    if (block_size < sizeof(FreeNode)) block_size = sizeof(FreeNode);
    block_size = align_up(block_size, align);
    if (count != 0 && block_size > SIZE_MAX / count) return -1;
    unsigned char *mem = aligned_alloc(align, block_size * count);  // размер кратен align
    if (mem == NULL) return -1;
    p->base = mem; p->block_size = block_size; p->head = NULL;
    for (size_t i = count; i-- > 0; ) {          // прошиваем список от конца к началу
        FreeNode *node = (FreeNode *)(mem + i * block_size);
        node->next = p->head;
        p->head = node;
    }
    return 0;
}

static void *pool_alloc(Pool *p) {               // O(1)
    FreeNode *node = p->head;
    if (node == NULL) return NULL;               // пул исчерпан — честный NULL
    p->head = node->next;
    return node;
}

static void pool_free(Pool *p, void *ptr) {      // O(1)
    if (ptr == NULL) return;
    FreeNode *node = ptr;
    node->next = p->head;
    p->head = node;
}

На структуре { int id; double w; char tag[8]; } пул даёт block_size = 24 байта, пятая аллокация из пула на четыре блока честно возвращает NULL, а после pool_free следующий pool_alloc отдаёт тот же самый блок — LIFO держит горячие данные в кэше. Свойства: alloc/free — O(1), внешней фрагментации нет, накладные расходы — ноль байт на живой объект; ограничение — единственный размер блока. Это ровно та идея, что лежит в основе slab-аллокатора ядра Linux.

Собирайте такие аллокаторы под ASan: он не знает про вашу разметку и не поймает выход за границу блока внутри пула, если не добавить ручную разметку через __asan_poison_memory_region. Несколько строк кода возвращают вам полноценную проверку границ.

Когда свой аллокатор не нужен: профиль не показывает malloc в горячем пути (сначала измерьте — методика в профилировании CPU); времена жизни разнородные и непредсказуемые; нужна многопоточность — иначе вы перепишете jemalloc, только хуже.

Дисциплина владения

Технические приёмы бесполезны без ответа на вопрос «кто освобождает». В C нет типа, выражающего владение, поэтому его выражают конвенциями — и соблюдают жёстко.

Владение принадлежит одному, и оно явно в имени функции.

Conn *conn_create(const char *host);   // создаёт и передаёт владение вызывающему
void  conn_destroy(Conn *c);           // забирает владение; обязан принимать NULL

const char *conn_host(const Conn *c);  // одолжил: владелец по-прежнему Conn, free НЕЛЬЗЯ
char *conn_dup_host(const Conn *c);    // отдал владение: вызывающий обязан сделать free

Одна точка выхода для очистки. В C это делают через goto — редкий случай, когда goto не просто уместен, а является идиомой (так написано всё ядро Linux):

int doc_load(const char *path, Doc **out) {
    int rc = -1;
    FILE *f = NULL; char *buf = NULL; Doc *doc = NULL;

    f = fopen(path, "rb");
    if (f == NULL) goto done;
    buf = malloc(BUF_SZ);
    if (buf == NULL) goto done;
    doc = doc_parse(buf);
    if (doc == NULL) goto done;
    *out = doc;
    doc = NULL;              // владение ушло наружу — не освобождаем
    rc = 0;
done:
    doc_destroy(doc);        // корректно обрабатывает NULL
    free(buf);
    if (f != NULL) fclose(f);
    return rc;
}

Ключевой приём — обнулить переменную при успешной передаче владения. Тогда блок done: один и тот же для успеха и для всех ошибок, и его невозможно забыть в новой ветке.

cleanup, если позволяет проект. GCC и Clang поддерживают атрибут, вызывающий функцию при выходе переменной из области видимости, — фактически RAII в C. На нём построен весь systemd:

static void free_ptr(void *p) { free(*(void **)p); }
#define AUTOFREE __attribute__((cleanup(free_ptr)))

void handle(void) {
    AUTOFREE char *buf = malloc(1024);
    if (buf == NULL) return;      // free вызовется здесь
    /* ... */
}                                 // и здесь тоже

Это расширение компилятора, а не стандарт C17, — решение фиксируйте в стиле проекта осознанно. То же самое как часть языка вы получите в C++ через деструкторы и умные указатели (C++ после C), а как проверяемое компилятором свойство — в Rust (современные альтернативы).

Аллокатор — параметр, а не глобальность. Функция, жёстко зашившая внутрь malloc, не тестируется на исчерпание памяти и не переиспользуется с ареной. Явный аллокатор в сигнатуре — Node *tree_insert(Arena *a, Node *root, int key) — небольшое усилие с большой отдачей.

Чеклист и сборочные конфигурации

  • Результат каждого malloc/calloc/realloc/strdup проверен на NULL.
  • realloc пишется во временную переменную, а не в исходную.
  • Все вычисления размеров защищены от переполнения.
  • У каждого выделения виден парный free — по конвенции имён или по goto done.
  • Указатель обнуляется после free, если переменная переживает освобождение.
  • Нет указателей внутрь блока, переживших realloc.
  • Владение задокументировано в заголовке для каждой функции, возвращающей указатель.
  • В CI есть конфигурация с санитайзерами, и тесты гоняются на ней.
  • Для долгоживущего сервиса есть график RSS и хотя бы один прогон под heaptrack.
# Релиз: диагностика на этапе компиляции + рантайм-защиты
gcc -std=c17 -O2 -g -Wall -Wextra -Wpedantic -Wshadow -Wconversion \
    -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fstack-clash-protection \
    -Wl,-z,relro,-z,now app.c -o app

# Отладка и CI: санитайзеры, падаем на первой же ошибке
gcc -std=c17 -O1 -g -fno-omit-frame-pointer \
    -fsanitize=address,undefined -fno-sanitize-recover=all app.c -o app-dbg

_FORTIFY_SOURCE требует оптимизации хотя бы -O1, иначе тихо не работает — частая ошибка в сборочных скриптах.

Мини-итог

  • malloc — не системный вызов, а розничный склад поверх brk/mmap; метаданные лежат рядом с вашими данными, и это объясняет и накладные расходы, и механику heap-эксплойтов.
  • free возвращает память аллокатору, а не операционной системе. RSS падает, только когда освобождается целая страница или большой mmap-блок; растущий RSS без утечек — это фрагментация, и лечится она группировкой по времени жизни, а не поиском забытого free.
  • UB с кучей меняет поведение от компилятора к компилятору: GCC и Clang по-разному оптимизируют одну и ту же пару malloc/free. Один удачный запуск ничего не доказывает.
  • Санитайзеры и valgrind — обязательная часть сборочной конфигурации, а не инструмент «для сложных багов».
  • Арена знает, что «всё умрёт одновременно»; пул знает, что «все объекты одного размера». Оба дают O(1) и ноль внешней фрагментации в своей нише.

Источники

Что дальше

Мы разобрались, где программа берёт память в рантайме. Теперь посмотрим, как она вообще собирается в исполняемый файл: что делает препроцессор, что попадает в объектный файл, как компоновщик связывает символы и чем статическая библиотека отличается от динамической — включая то, почему подмена malloc через LD_PRELOAD вообще работает.

Сборка и компоновка: препроцессор, объектные файлы, статические и динамические библиотеки

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов