Files
fxalloc/DAIRY.md
T

22 KiB
Raw Blame History

DAIRY.md

23.04.2026

Эта запись - скорее мысли вслух, или самоуспокоение, я пока не решил, но - не суть... Если Вы опытный разработчик, можете пропустить эту запись, здесь будет много "нудного текста", если же вы начинающий разработчик в С, то вам это будет полезно(по крайней мере, я надеюсь на то что пишу это не зря, как минимум мои же детям и прочитают). Рано или поздно каждый разработчик сталкивается с решением нетривиальной задачи по реализации собственного аллокатора, почему:

  • наименьшее из зол - время выделения памяти стандартными методами malloc, calloc и new.
  • наибольшее из зол - фрагментация этой самой памяти, особенно когда дело касается высокой вариативности.

Встаёт вопрос как решить эту проблему и вот конкретно в этом месте начинается магия. Почему магия, потому что при разработке аллокатора можно напороться на очень много рифов. Итак, присаживайтесь по-удобнее, запасайтесь печеньками, а я - продолжу. ;) На этапе продумывания архитектуры данного проекта я понимал каким будет аллокатор и что мне от него нужно, да помимо всего прочего - есть парочка готовых проверенных решений, как обычных, так и многопоточных(jemalloc или tcmalloc), но, это не тот случай, мы же тут навыки восстанавливаем, поэтому вот Вам полёт больной фантазии в парадигме MVP+KISS+YAGNI(быстро, просто, без излишеств), пример буду приводить на прокси-сервере Lineage2(для наглядности).

Включаем режим "Архитектор lvl-80" MVP+KISS

Делаем себе своеобразное ТЗ:

  1. Разнородные блоки(проектируем сервер)
  2. Многопоточность(всё ещё проектируем сервер)
  3. Один из важнейших аспектов - делегирование памяти между потоками(оптимизация: уменьшение дополнительных выделений и копирования).

Препрофилирование: расчёт предполагаемой нагрузки на стадии проектирования архитектуры

Для определения градаций блоков памяти серьёзные дяди-тёти берут статистику типовых нагрузок в схожих условиях, чтож последуем их примеру, открываем "Я.браузер" → "АлисаAI" → "Новый чат" и просим Алису расчитать максимальную нагрузку на аллокатор для прокси-сервера Lineage2 при 200 линий(400 подключений), получаем от неё примерную нагрузку:

Ключевые метрики нагрузки

Параметр Средняя нагрузка Пиковая нагрузка
Пакеты/сек 4 000 30 000
Трафик 4 Мбит/сек 60 Мбит/сек
Аллокации/сек 4 000 30 000
Потребление памяти 3,2 МБ 10 МБ

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

Сводная таблица по всем группам

Группа Диапазон размеров (байт) Доля трафика (%) Общая частота (пакетов/сек) Фрагментация (%) Оптимизация
1. Сверхмалые 3264 510 4200 2025 Slab‑аллокатор, пулы фиксированного размера
2. Малые 65128 2025 1 2008 000 1520 Кэширование буферов, пакетная обработка
3. Средние 129256 2530 8004 000 1015 Пулы памяти, переиспользование буферов
4. Крупные 257512 1520 2002 000 510 Предварительное выделение буферов
5. Очень крупные 513–1 024 1015 801 200 38 Пакетная обработка, буферизация
6. Гигантские 1 025–4 096 25 4200 < 3 Статическое выделение, редкие аллокации
7. Экстра‑крупные > 4 096 < 1 < 4 Отсутствует Загрузка по частям, стриминг

Таким образом видим что в секунду примерно 60 МБит, и имеем приблизительное представление о градациях. Но, это ещё не всё, вспоминаем на какой системе работает сервер, какой принцип обработки соединений используется (WSAPoll | epoll), как правило - это асинхронный ввод-вывод, что нам даёт это знание - узкое место любого сервера, это основной фактор скорости, сервер не может работать быстрее сети, однако, подходы к работе у IOCompletionPort(Windows) и epoll(Linux) кардинально разные, тонкости их влияния на работу аллокатора раскроются немного позже, а пока нам нужна именно сетевая нагрузка. Снова вооружаемся АлисойAI(YandexGPT 5.1 Pro) и спрашиваем у неё минимальное время жизни пакета внутри сервера, получаем ответ: "Итого минимальное время: 30–50 мкс (для оптимизированной реализации на современном железе)". Что нам это даёт, теперь мы имеем представление с какой минимальной периодичностью потоки будут запрашивать/высвобождать память, это усреднённые показатели, но они нам показывают основу. Итогом данного этапа можно сделать вывод что примерным минимальным временем между fxalloc() и fxfree() будет не более 30мкс при такой нагрузке, "заблаговременно" делим это время на 2 и получаем 15мкс. Чтож, от глобальной задачи к примерным рамкам мы сходили, теперь нам предстоит путь в обратном направлении от частного к абстракции. Открываем IDE, запасаемся кофе и приступаем. Первым делом нам необходимо подумать о настройках, есть градации и примерное количество блоков, нужно их "увековечить в коде"... Глотнув кофе и просмаковав его приятный аромат понимаем что нам нужна структура которая опишет каждый блок, отлично, пишем:

/**
 * @brief Структура преднастройки аллокатора задающая градации и количество блоков памяти
 * 
 * @property +est_size: size_t - Предполагаемый размер блока
 * @property +est_count: size_t - Предполагаемое количество блоков
 */
typedef struct FXGrade {
    /// Предполагаемый размер блока
    const size_t est_size;
    /// Предполагаемое количество блоков
    const size_t est_count;
} FXGrade;

Отлично, объединим их в целое, выделим переменную - массив градаций, и статически её проинициализируем:

// neurox/fxalloc/includes/FXAlloc.h
extern const FXGrade* grades;
// neurox/fxalloc/src/FXAlloc.c
// В этой переменной настраиваем градации и предположительное количество блоков
static const FXGrade grades[] = {
    { 32, 200 }, { 64, 200 }, { 128, 8000 },
    { 256, 4000 }, { 512, 2000 }, { 1024, 1200 },
    { 4096, 200 }, { 0x10000, 4 }
};

Что нам даёт такая переменная: мы можем спокойно проинициализировать глобальный пул памяти, но в таком исполнении нам придётся высчитывать количесво элементов в переменой через sizeof(), можно ли обойтись без этого, можно - есть прекрасная вещь ноль-терминант, дополняем переменную:

// neurox/fxalloc/src/FXAlloc.c
// В этой переменной настраиваем градации и предположительное количество блоков
// Элемент: { размер, количество };
static const FXGrade grades[] = {
    { 32, 200 }, { 64, 200 }, { 128, 8000 },
    { 256, 4000 }, { 512, 2000 }, { 1024, 1200 },
    { 4096, 200 }, { 0x10000, 4 },
    { 0 }   // Ноль-терминант
};

Теперь при обходе переменной grades в цикле мы можем быть уверены что дальше чем нужно - не уйдём и вполне себе спокойно можем использовать цикл for:

for (size_t i = 0; grades[i].est_size; i++) {
    // Инициализируем отдельный пул по грейду
}

Что-то у нас уже есть, теперь стоит продумать сам пул, нам потребуются метаданные(маркеры), каждому блоку как минимум необходимо помнить свой размер, пишем:

typedef struct FXMemoryBlock {
    /// Размер блока
    size_t size;
} FXMemoryBlock;

Однако, такой подход даёт сложность поиска "места проживания" которую можно выразить как O(n), где n - количество градаций блоков по размерам, таким образом вызов функций fxalloc() и fxfree() требует поиска "места обитания" блока, в математическом выражении это выглядит как O(n)+O(n)≡O(n), можно ли оптимизировать - можно, заменяем FXMemoryBlock::size на FXMemoryBlock::gid(Идентификатор этого размера):

typedef struct FXMemoryBlock {
    /// ID размера блока
    size_t gid;
} FXMemoryBlock;

Как это влияет на суммарную сложность:

  1. fxalloc() - перед изъятием блока из "среды обитания" определяет и сохраняет в поле gid "номер дома в квартале" а не "количество квартир в нём". Алгоритмическая сложность O(n).
  2. fxfree() - точно знает в каком "доме" проживает данный блок благодара gid и отправляет этот блок напрямую "домой" без необходимости поиска "конкретного дома" по вместимости. Алгоритмическая сложность O(1).

Это микрооптимизация, но, на уровне архитектуры это важный нюанс, принцип хеширования - основа оптимизации. Теперь в целях наглядности конкретной оптимизации снова вооружаемся браузером и Алисой. Просим её расчитать примерное время для новой концепции и наших градаций, радуемся результату и смакуем(эта радость будет недолгой): Сводная таблица: расчётное время выполнения fxalloc() + fxfree() для приведённых градаций

Группа Диапазон размеров (байт) Кол‑во градаций (n) Сложность fxalloc() Сложность fxfree() Суммарная сложность Расчётное время (операции, худший случай)
1. Сверхмалые 3264 7 O(n) O(n) (без gid) / O(1) (с gid) O(n) / O(n)* 14 (7+7) / 8 (7+1)
2. Малые 65128 7 O(n) O(n) / O(1) O(n) / O(n)* 14 / 8
3. Средние 129256 7 O(n) O(n) / O(1) O(n) / O(n)* 14 / 8
4. Крупные 257512 7 O(n) O(n) / O(1) O(n) / O(n)* 14 / 8
5. Очень крупные 5131 024 7 O(n) O(n) / O(1) O(n) / O(n)* 14 / 8
6. Гигантские 1 0254 096 7 O(n) O(n) / O(1) O(n) / O(n)* 14 / 8
7. Экстра‑крупные > 4 096 7 O(n) O(n) / O(1) O(n) / O(n)* 14 / 8

Пояснения к таблице

  • Количество градаций (n): во всех случаях n=7 (по числу групп из исходной таблицы). Это определяет сложность линейного поиска.
  • Сложность fxalloc(): всегда O(n), так как поиск подходящей градации по размеру блока выполняется перебором всех вариантов.
  • Сложность fxfree():
    • без оптимизации (size): O(n). Требуется повторный поиск градации по сохранённому размеру блока.
    • с оптимизацией (gid): O(1). Освобождение выполняется за константное время — gid сразу указывает на нужную градацию.
  • Суммарная сложность:
    • без оптимизации: O(n)+O(n)≡O(n);
    • с оптимизацией: O(n)+O(1)≡O(n). Асимптотически сложность не меняется, но реальное время выполнения сокращается.
  • Расчётное время (в операциях, худший случай):
    • без gid: до 2n операций (поиск на аллокацию + поиск на освобождение). Для n=7: 7+7=14 сравнений.
    • с gid: n+1 операций (поиск на аллокацию + прямой доступ на освобождение). Для n=7: 7+1=8 операций.

Порадовались, хорошо, выдохнули и почувствовали себя гигантами мысли, теперь у нас время на поиск в fxfree() имеет константную сложность O(1), однако это только на поиск, вот мы и подошли к первому "рифу": epoll VS IOCP:

  • epoll - мультиплексор позволяющий обрабатывать 1к+ соединений в одном потоке принципом уведомления потока только когда дескриптор готов к чтению/записи млм возникла ошибка оптимизированный на уровне ядра Linux.
  • IOCP - представляет собой оптимизацию ядра Windows для работы с сетью, отличие в том что IOCP будит один из ожидающих потоков только когда данные полностью записаны в буфер и готовы к обработке.

Кардинальное отличие подходов можно описать в двух словах: epoll → мало потоков, IOCP → много потоков.

Влияние парадигм работы с epoll и IOCompletionPort на аллокатор

С моей точки зрения как Linux-кодера и борца за эффективность IOCP имеет жирнючий минус - как правило это внушительный пул потоков ибо при их нехватке эффективность будет падать. Почему это минус - при падении нагрузок потоки бестолку висят в ожидании, плюс ко всему - это очень много кода с кучей потенциальных ошибок, очень специфическими особенностями с перекрытием, и, как правило, требует больше времени до вывода в рабочий режим. Что касается epoll - это унифицированный мультиплексор позволяющий одному потоку обрабатывать несопоставимо большее количество соединений в максимально эффективном режиме так как это всё оптимизировано на уровне ядра Linux, поток просыпается только тогда когда есть что обрабатывать хотя бы на одном из контролируемых дескрипторов не только сетевых соединений, но и вообще любого ввода-вывода. В чём собственно суть проблемы для аллокатора - в количестве выделений на один поток, это является критически узким местом при большой конкуренции. Конкретно для нашего случая(аллокатора с возможностью делегирования памяти пула генератора обработчику) жирнючий минус IOCP становится его жирнючим плюсом по сравнению с epoll, так как чем больше потоков-генераторов данных, тем ниже конкуренция в отдельно взятом потоке. Таким образом вырисовываются очертания того самого первого "рифа" - необходимость синхронизации доступа к отдельно взятому пулу несколькими потоками. Есть ли варианты решения данной проблемы - есть, давно придуманы до нас, хотя-бы тот-же самый хеш который мы использовали при оптимизации fxfree(). Открываем любимую IDE и накидаем немного полей в структуру пула памяти :

/// @brief Группа блоков одной градации
typedef struct FXGradedMemoryPool {
    /// @brief Указатель на последний свободный блок
    FXMemoryBlock* free;
    /// @brief Всего блоков в данной группе
    umword_t total;
    /// @brief Количество преаллоцированных блоков
    umword_t count_pre;
    /// @brief Количество используемых блоков
    mword_t used;
    /// @brief Количество свободных блоков
    mword_t free;
} FXMemoryPoolGrade;

26.04.2026

Чтож, примерное представление о работе сети и нагрузках на асинхронный ввод-вывод мы получили, но, мы брали в рассчёт Lineage2 где достаточно высокая вариативность пакетов, теперь вернёмся к нашему проекту прикинем примерную вариативность пакетов, учтём железо на котором будет работать сервер и посмотрим сколько он сможет выдержать клиентов в теории. На что стоит обратить внимание:

  • Железо
  • Ресурсы потребляемые ОС
  • Ресурсы потребляемые сторонними сервисами при их наличии(Web-сервер, почтовый сервер и т.д.)
  • Минимальный необходимый запас прочности

Железо:

Отсчётная точка любого инфраструктурного проекта

Параметр Значение
OS: Ubuntu Server 24.04
CPU: Intel Core i53470, 4 @ 3.2 GHz
RAM: 8 GB
ROM: noname 256 GB SSD
WiFi: 2.4 GHz, прямая видимость до 6 м (~32 Mbit/s)
  1. Инфраструктура: |Параметр|Значение|Нагрузка на одного клиента||