habrahabr

Как malloc() и free() управляют памятью в C

  • вторник, 11 марта 2025 г. в 00:00:10
https://habr.com/ru/companies/otus/articles/889020/

Привет, Хабр!

Сегодня рассмотрим, почему free() не всегда освобождает память, как работает malloc(), когда glibc действительно возвращает память в ОС, и как избежать фрагментации хипа. А так же напишем кастомный аллокатор.

malloc

Вызываете malloc(42), думаете, что получили 42 байта и живёте счастливо. Но всё гораздо сложнее. malloc() — это не просто «дай мне N байтов», а целая система управления памятью, которая старается минимизировать фрагментацию, уменьшить системные вызовы и ускорить выделение памяти.

malloc(size_t size) — это стандартная функция из stdlib.h, которая выделяет size байтов в хипе и возвращает указатель на начало блока.

#include <stdlib.h>
void *ptr = malloc(42); // Запрашиваем 42 байта

Что будет происходить?

malloc(42) не выделит ровно 42 байта — система округлит размер до удобного для CPU значения (обычно 8 или 16 байт). Если в уже выделенной памяти есть подходящий свободный блок — он будет использован.

Если свободного блока нет malloc() запрашивает новый блок памяти у ОС через sbrk() (для маленьких аллокаций) или mmap() (для больших).

malloc() возвращает указатель на данные, но в памяти перед ним хранится метаинформация (размер блока, флаги и т. д.).

Рассмотрим упрощённый вариант стандартной реализации malloc() (из glibc):

void *malloc(size_t size) {
    if (size == 0) return NULL; // Нельзя выделять 0 байт
    size = align_size(size); // Выравнивание по 8 или 16 байтам
    chunk_t *chunk = find_free_chunk(size);
    if (chunk == NULL) {
        chunk = request_memory_from_os(size);
    }
    mark_chunk_used(chunk);
    return (void *)(chunk + 1);
}

align_size(size) округляет запрошенный размер до 8 или 16 байт (для скорости работы с памятью). Например, malloc(42) → выделит 48 байт, а malloc(25)32 байта.

Если есть освобождённые ранее блоки, malloc() попробует переиспользовать их, чтобы не делать лишний системный вызов.

Если нет свободного блока — запрос памяти у ОС (request_memory_from_os()):

  1. Для маленьких аллокаций используется sbrk() (растягивает хип).

  2. Для больших аллокаций используется mmap() (выделяет страницы памяти напрямую).

Отмечаем блок как занятый (mark_chunk_used()) и возвращаем указатель на данные, но перед ним есть заголовок с метаинформацией.

Как malloc() запрашивает память у ОС?

Когда свободной памяти в хипе нет, malloc() вызывает один из двух системных вызовов:

sbrk(): двигает границу хипа (маленькие аллокации)

Если запрошенный размер маленький (например, malloc(16)), malloc() растягивает границу хипа через sbrk():

#include <stdio.h>
#include <stdlib.h>

int main() {
    void *ptr = malloc(64);  // Выделяем 64 байта
    printf("malloc(64) = %p\n", ptr);
    free(ptr);
    return 0;
}

Посмотрим системные вызовы через strace:

strace ./a.out

Вывод:

brk(0x561a4b5e2000) = 0x561a4b5e2000

Здесь brk() сдвинул конец хипа, добавив новую память.

mmap(): выделяет страницы памяти (большие аллокации)

Если размер большой (>128KB), malloc() использует mmap(), чтобы выделить страницу памяти напрямую.

Проверим это на коде:

#include <stdio.h>
#include <stdlib.h>

int main() {
    void *ptr = malloc(2 * 1024 * 1024); // Выделяем 2MB
    printf("malloc(2MB) = %p\n", ptr);
    free(ptr);
    return 0;
}

Запускаем strace:

strace ./a.out

Вывод:

mmap(NULL, 2097152, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4e2c000000

mmap() выделяет большие блоки памяти напрямую из адресного пространства процесса, минуя хип.

Почему так?

Потому что mmap() выделяет страницы памяти (обычно 4KB+). Для больших объектов так проще избежать фрагментации хипа. ОС легче освободить такие блоки, чем sbrk()‑память.

Где хранится информация о выделенной памяти?

Когда вы вызываете malloc(), в памяти перед вашим указателем хранится метаинформация:

[ Метаинформация | Ваша память ]

Структура заголовка блока может выглядеть так:

typedef struct chunk {
    size_t size;    // Размер блока
    struct chunk *next;  // Следующий свободный блок
    int free;  // Флаг занятости
} chunk_t;

Когда вы вызываете malloc(64), реально выделяется больше памяти:

[ Заголовок (16 байт) | Ваши 64 байта ]

Когда вы вызываете free(), malloc() ищет заголовок перед указателем, чтобы понять, сколько памяти освобождать. Именно поэтому нельзя free()‑ить указатели, которые не были получены через malloc().

free(): почему память не всегда освобождается?

free(void *ptr) — стандартная функция из stdlib.h, предназначенная для освобождения памяти, выделенной malloc(), calloc() или realloc().

#include <stdlib.h>
int *ptr = malloc(128);
free(ptr); // Освобождаем память

Что здесь будет происходить?

free будет искать заголовок блока перед ptr, чтобы узнать размер выделенной памяти. Помечает блок как свободный. Объединяет его с соседними свободными блоками, чтобы уменьшить фрагментацию. Если освобождённый блок находится в конце хипа, то malloc() может сдвинуть границу (brk()) и вернуть память ОС.

Почему free() НЕ возвращает память ОС?

Когда вы вызываете free(), память не сразу возвращается в операционную систему. Она остаётся внутри кучи процесса, в пуле malloc(), чтобы ускорить последующие аллокации.

Создадим два блока памяти, освободим их и посмотрим, как ведёт себя процесс:

#include <stdio.h>
#include <stdlib.h>

int main() {
    void *p1 = malloc(1024);
    void *p2 = malloc(1024);
    free(p1);
    free(p2);
    getchar(); // Ждём, чтобы посмотреть в /proc
    return 0;
}

Теперь откроем в другом терминале:

cat /proc/$(pgrep a.out)/maps | grep heap

Вывод будет примерно таким:

55a4c2d4e000-55a4c2d6f000 rw-p 00000000 00:00 0     [heap]

Хотя мы освободили p1 и p2, размер хипа не изменился.

Почему?

  1. free() не вызывает brk() сразу.

  2. Память остаётся внутри glibc malloc(), чтобы ускорить последующие аллокации.

  3. ОС не видит, что память «свободна», пока malloc() явно не решит вернуть её через sbrk(-size) или munmap().

Если блок маленький (<128KB), он не возвращается в ОС, а попадает в fastbins (специальный список для быстрого переиспользования).

Когда free() освобождает память?

  1. Если освобождаемый блок находится в конце хипа и malloc() решает сдвинуть brk().

  2. Если это большая аллокация (>128KB), тогда glibc использует mmap() и освобождает её через munmap().

  3. Если вызвать malloc_trim(0) — он заставит glibc попытаться вернуть неиспользуемую память.

Пример того, как free() не освобождает память:

#include <stdio.h>
#include <stdlib.h>

int main() {
    void *p1 = malloc(100000);
    void *p2 = malloc(100000);
    free(p1);
    free(p2);
    getchar();
    return 0;
}

Запустим htop и увидим, что размер процесса не изменился.

Пример того, как malloc_trim() помогает:

#include <stdio.h>
#include <stdlib.h>
#include <malloc.h>

int main() {
    void *p1 = malloc(100000);
    void *p2 = malloc(100000);
    free(p1);
    free(p2);
    malloc_trim(0); // Принудительно возвращаем память ОС
    getchar();
    return 0;
}

Теперь после вызова malloc_trim(0) процесс реально освободит память.

Как free() управляет фрагментацией?

Если вы вызываете free() хаотично, память фрагментируется, и куча превращается в мусорку:

[ 128B used ] [ 256B free ] [ 512B used ] [ 128B free ]

Такой хип плохо переиспользуется, поэтому glibc malloc() использует алгоритм слияния блоков.

Слияние блоков

Если два соседних блока свободны, free() их объединяет:

До:
[ 128B used ] [ 256B free ] [ 512B free ]

После:
[ 128B used ] [ 768B free ]

Освобождаются ли mmap()-аллокации?

Если malloc() использует mmap(), то при вызове free() оно реально освобождает память через munmap().

Пример того, как mmap() реально освобождает память:

#include <stdio.h>
#include <stdlib.h>

int main() {
    void *ptr = malloc(2 * 1024 * 1024); // 2MB (больше 128KB)
    free(ptr);
    getchar();
    return 0;
}

Запустим:

cat /proc/$(pgrep a.out)/maps | grep heap

После free(ptr) блок исчезнет. Большие блоки памяти (>128KB) действительно освобождаются в ОС.

Пишем кастомный аллокатор

Стандартные аллокаторы (например, glibc malloc) работают с фри‑листами — специальной структурой, которая хранит освобождённые блоки памяти. Cделаем упрощённую версию, где каждый блок будет содержать небольшой заголовок с метаинформацией (размер и флаг занятости).

Код:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdint.h>

#define POOL_SIZE 1024 * 1024  // 1MB

typedef struct Block {
    size_t size;
    int free;
    struct Block *next;
} Block;

static char memory_pool[POOL_SIZE]; // Наша память
static Block *free_list = (Block *)memory_pool; // Указатель на первый свободный блок

// Инициализация памяти
void my_init() {
    free_list->size = POOL_SIZE - sizeof(Block);
    free_list->free = 1;
    free_list->next = NULL;
}

// Функция поиска подходящего блока
Block *find_free_block(size_t size) {
    Block *current = free_list;
    while (current) {
        if (current->free && current->size >= size) {
            return current;
        }
        current = current->next;
    }
    return NULL;
}

// Разделение блока, если запрашиваемый размер меньше доступного
void split_block(Block *block, size_t size) {
    if (block->size >= size + sizeof(Block) + 8) { // Минимальный размер для нового блока
        Block *new_block = (Block *)((char *)block + sizeof(Block) + size);
        new_block->size = block->size - size - sizeof(Block);
        new_block->free = 1;
        new_block->next = block->next;
        block->size = size;
        block->next = new_block;
    }
}

// Аллоцируем память
void *my_malloc(size_t size) {
    if (size <= 0) return NULL;
    
    Block *block = find_free_block(size);
    if (!block) return NULL; // Нет памяти
    
    block->free = 0;
    split_block(block, size);
    return (void *)(block + 1); // Пропускаем заголовок
}

// Освобождаем память
void my_free(void *ptr) {
    if (!ptr) return;
    
    Block *block = (Block *)ptr - 1;
    block->free = 1;

    // Попытка слить с последующим блоком
    if (block->next && block->next->free) {
        block->size += block->next->size + sizeof(Block);
        block->next = block->next->next;
    }
}

// Отладочный вывод состояния памяти
void my_dump() {
    Block *current = free_list;
    printf("Состояние памяти:\n");
    while (current) {
        printf("[Адрес: %p | Размер: %zu | %s]\n", (void *)current, current->size, current->free ? "Свободно" : "Занято");
        current = current->next;
    }
}

int main() {
    my_init();
    
    void *p1 = my_malloc(128);
    void *p2 = my_malloc(256);
    my_dump();

    my_free(p1);
    my_free(p2);
    my_dump();

    return 0;
}

Создаём структуру Block, которая содержит:

  • size — размер блока

  • free — флаг занятости

  • next — указатель на следующий блок

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

Метод find_free_block(size_t size) просто пробегает список и ищет первый свободный блок подходящего размера.

Но что делать, если блок слишком большой?

Допустим, есть 512B свободной памяти, а пользователь запросил 128B. Чтобы не тратить впустую 512B, разделяем блок:

  1. Оставляем 128B занятыми.

  2. Остаток (512B — 128B — sizeof(Block)) делаем новым свободным блоком.

Этим занимается функция split_block().

Как освобождаем память?

Когда пользователь вызывает my_free(), просто помечаем блок как свободный. Но если следующий блок тоже свободен — сливаем их, чтобы уменьшить фрагментацию.

Как видим состояние памяти?

Функция my_dump() проходит по всей памяти и выводит блоки:

Состояние памяти:
[Адрес: 0x55d8af345000 | Размер: 128 | Занято]
[Адрес: 0x55d8af3450a0 | Размер: 256 | Занято]

После освобождения:
[Адрес: 0x55d8af345000 | Размер: 384 | Свободно]

Фрагментация минимальна, а свободные блоки сразу сливаются.


Итоги

malloc не просто «выделяет память», а управляет целым пулом страниц. free не всегда освобождает память сразу, а оставляет её для будущих вызовов. mmap() используется для больших блоков, sbrk() — для маленьких.

Можно писать свои аллокаторы, но делать это нужно правильно.

Научиться применять шаблоны проектирования и SOLID в разработке можно под руководством экспертов на онлайн-курсе в Otus. Переходите на страницу курса, чтобы записаться на открытые уроки.