Динамическая память: 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 — 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: компилятор и санитайзеры знают только про запрошенный размер.
(128 КБ)?"} B -- да --> M["mmap(): отдельный регион,
free() вернёт его ядру немедленно"] B -- нет --> C["chunk = align16(n + 8),
минимум 32 байта"] C --> D{"есть блок в tcache или
fastbin этого класса?"} D -- да --> H["снять с головы списка,
O(1), без блокировок"] D -- нет --> F["разобрать unsorted bin: слить соседей,
разложить по small/large bins"] F --> G{"нашёлся подходящий
или можно отрезать кусок?"} G -- да --> H G -- нет --> I{"хватает места
в top chunk?"} I -- да --> J["отрезать от top chunk"] I -- нет --> K["sysmalloc: brk() или mmap() — идём к ядру"] K --> J J --> H H --> Z["вернуть указатель на тело чанка"] M --> Z
Стоимость: попадание в tcache — несколько инструкций, O(1); промах с разбором unsorted bin — десятки-сотни наносекунд; поход к ядру — микросекунды плюс page fault на каждой странице при первом касании. Детали — в официальной wiki glibc: Malloc Internals, а канонический разбор алгоритма — статья Дуга Ли про dlmalloc, предка glibc-аллокатора.
Откуда память приходит от ядра
RSS не уменьшился ни на байт
Механизма получения памяти два. 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 ничего не находит. Что делают на практике:
- Группировать объекты по времени жизни, а не по типу. Всё, что живёт в рамках одного запроса, — в одну арену, которая сбрасывается целиком. Пул фиксированного размера не даёт внешней фрагментации внутри себя вообще.
- Сменить аллокатор без правки кода. jemalloc исторически создавался против фрагментации в долгоживущих серверах, mimalloc — про скорость и предсказуемость:
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 ./app. - Ограничить арены glibc. В многопоточных программах glibc создаёт до
8 * nprocарен — хорошо для конкуренции, плохо для RSS:export GLIBC_TUNABLES=glibc.malloc.arena_max=2:glibc.malloc.trim_threshold=131072. Полный список — в документации glibc про tunables, а взаимодействие арен и потоков — в статье про потоки и синхронизацию. - Мерить, а не гадать:
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) и ноль внешней фрагментации в своей нише.
Источники
- ISO/IEC 9899:2018 (C17), раздел 7.22.3 — черновик N2310
- man 3 malloc, man 3 mallopt, man 2 brk
- glibc wiki: Malloc Internals и Memory Allocation Tunables
- Doug Lea, A Memory Allocator — предок glibc malloc; Wilson и др., Dynamic Storage Allocation: A Survey and Critical Review (1995)
- Berger, Zorn, McKinley, Reconsidering Custom Memory Allocation (OOPSLA 2002)
- AddressSanitizer wiki, Valgrind Memcheck manual, heaptrack, jemalloc, mimalloc
- CWE: 416 Use After Free, 415 Double Free, 401 Memory Leak
Что дальше
Мы разобрались, где программа берёт память в рантайме. Теперь посмотрим, как она вообще собирается в исполняемый файл: что делает препроцессор, что попадает в объектный файл, как компоновщик связывает символы и чем статическая библиотека отличается от динамической — включая то, почему подмена malloc через LD_PRELOAD вообще работает.
Сборка и компоновка: препроцессор, объектные файлы, статические и динамические библиотеки