
«Преждевременная оптимизация — корень всех зол, но преждевременная пессимизация является им не в меньшей степени». — Андрей Александреску
Проблема перераспределения
Динамические массивы (векторы C++, ArrayList в Java) — одна из самых полезных структур данных. Они сочетают в себе удобство для кэша, присущее массивам, с гибкостью динамического изменения размера.
Однако у них есть скрытые затраты, связанные с перераспределением.
Однажды я работал над агрегатором логов встраиваемой системы. Система накапливала сообщения логов в динамическом массиве и периодически скидывала их на флэш-накопитель. Кажется, всё просто, не так ли?
Но производительность была ужасной. Система тратила 60% времени на realloc().
Проблема заключалась в том, что массив разрастался по одному элементу за раз:
typedef struct { char **messages; int size; int capacity; } log_buffer_t; void add_message(log_buffer_t *buf, const char *msg) { if (buf->size >= buf->capacity) { buf->capacity++; // Увеличение на 1! buf->messages = realloc(buf->messages, buf->capacity * sizeof(char*)); } buf->messages[buf->size++] = strdup(msg); }
Для 1000 сообщений: 1000 перераспределений, каждое из которых копировало весь массив.
Общее количество копирований: 1 + 2 + 3 + ... + 1000 = всего копировалось 500500 элементов!
Решение проблемы было простым: выполнять увеличение экспоненциально, а не линейно.
void add_message(log_buffer_t *buf, const char *msg) { if (buf->size >= buf->capacity) { buf->capacity = buf->capacity ? buf->capacity * 2 : 16; buf->messages = realloc(buf->messages, buf->capacity * sizeof(char*)); } buf->messages[buf->size++] = strdup(msg); }
Для 1000 сообщений: 7 перераспределений (16, 32, 64, 128, 256, 512, 1024).
Общее количество копирований: ~2000 элементов (а не 500500).
Результат: в 250 меньше копий, в 60 раз быстрее.
Визуализация экспоненциального роста:
Исходное состояние: ┌────────────────────────┐ │ Размер: 0, Ёмкость: 0 │ └────────────────────────┘ Добавляем элемент 1 → Распределяем исходную ёмкость ┌────────────────────────┐ │ Размер: 1, Ёмкость: 16 │ ✅ Распределено └────────────────────────┘ Добавляем элементы 2-16 → Перераспределение не выполняется ┌─────────────────────────┐ │ Размер: 16, Ёмкость: 16 │ ⚠️ Заполнено └─────────────────────────┘ Добавляем элемент 17 → Перераспределение (16 → 32) ┌─────────────────────────┐ │ Размер: 17, Ёмкость: 32 │ ✅ Перераспределено (копирование 16 элементов) └─────────────────────────┘ Добавляем элементы 18-32 → Перераспределение не выполняется ┌─────────────────────────┐ │ Размер: 32, Ёмкость: 32 │ ⚠️ Заполнено └─────────────────────────┘ Добавляем элемент 33 → Перераспределение (32 → 64) ┌─────────────────────────┐ │ Размер: 33, Ёмкость: 64 │ ✅ Перераспределено (копирование 32 элементов) └─────────────────────────┘ Продолжение... ┌────────────────────────────┐ │ Размер: 64, Ёмкость: 64 │ → Перераспределено до 128 │ Размер: 128, Ёмкость: 128 │ → Перераспределено до 256 │ Размер: 256, Ёмкость: 256 │ → Перераспределено до 512 │ Размер: 512, Ёмкость: 512 │ → Перераспределено до 1024 └────────────────────────────┘ Окончательное состояние (1000 элементов): ┌──────────────────────────────┐ │ Размер: 1000, Ёмкость: 1024 │ ✅ Всего примерно 10 перераспределений └──────────────────────────────┘ Общее количество перераспределений: 10 (против 1000 при увеличении массива по 1 элементу) Общее количество копирований: около 2000 элементов (против 500500)
Применение этой стратегии в реальном мире:
Паттерн «распределение лишнего места, чтобы избежать частых затратных операций» встречается во многих системах:
Построители строк (Java
StringBuilder, C#StringBuilder): экспоненциальный рост, чтобы избежать конкатенации строк за O(n²).Сетевые буферы (буферы приёма TCP): предварительное распределение больших буферов для снижения количества системных вызовов.
Распределители памяти (реализации malloc): используют классы размеров (16, 32, 64, 128...) для снижения фрагментации.
Логи транзакций баз данных: выполняют предварительное распределение пространства логов блоками, чтобы избежать частого дискового ввода-вывода.
Разреженные матрицы (научные вычисления): распределяют дополнительную ёмкость в хранилище сжатых строк, чтобы обеспечить возможность эффективных вставок.
Файловые системы (ext4, XFS): выполняют предварительное распределение блоков увеличивающихся файлов с целью снижения фрагментации.
Главный вывод: увеличение пространства для снижения времени избыточным распределением снижает амортизированные затраты на рост с O(n) до O(1).
Реализация динамических массивов
Вот, как выглядит реализация динамического мас��ива:
typedef struct { int *data; size_t size; // Количество элементов size_t capacity; // Распределённое пространство } vector_t; void vector_init(vector_t *v) { v->data = NULL; v->size = 0; v->capacity = 0; } void vector_free(vector_t *v) { free(v->data); v->data = NULL; v->size = 0; v->capacity = 0; } void vector_push(vector_t *v, int value) { if (v->size >= v->capacity) { size_t new_capacity = v->capacity ? v->capacity * 2 : 16; int *new_data = realloc(v->data, new_capacity * sizeof(int)); if (!new_data) { // Обработка сбоя распределения return; } v->data = new_data; v->capacity = new_capacity; } v->data[v->size++] = value; } int vector_pop(vector_t *v) { if (v->size > 0) { return v->data[--v->size]; } return -1; // Ошибка } int vector_get(vector_t *v, size_t index) { if (index < v->size) { return v->data[index]; } return -1; // Ошибка }
Главные архитектурные решения:
Исходная ёмкость: 16 (избегаем малых распределений).
Коэффициент роста: 2× (экспоненциальный рост).
realloc(): может избежать копирования, если есть место.
Анализ коэффициента роста
Коэффициент роста влияет и на объём используемой памяти, и на производительность.
Часто используемые коэффициенты роста:
1,5×: применяется в некоторых реализациях (например, в библиотеке Facebook folly).
2×: самый распространённый (std::vector языка C++, list языка Python).
φ (1,618): золотое сечение, теоретический оптимум.
Компромиссы:
Рост 2×:
Плюсы: простой, быстрый (битовый сдвиг), хорошая амортизированная производительность.
Минусы: может впустую тратить до 50% памяти.
Рост 1,5×:
Плюсы: меньше пустой траты памяти (примерно 33%), более оптимальное повторное использование памяти.
Минусы: больше перераспределений, чуть медленнее.
Бенчмарк (рост до 1 миллиона элементов):
Коэффициент роста 1,5×: Перераспределения: 34 Пиковый объём памяти: 1,5 МБ Время: 12 мс Коэффициент роста 2×: Перераспределения: 20 Пиковый объём памяти: 2 МБ Время: 8 мс
В 2 раза быстрее (меньше перераспределений), но требует больше памяти.
Рекомендация: если память не в дефиците, использовать 2×.
Уменьшение размеров: когда освобождать память
Следует ли уменьшать размер массива при удалении элементов?
Наивный подход: уменьшение при каждом удалении
void vector_pop(vector_t *v) { if (v->size > 0) { v->size--; if (v->size < v->capacity / 2) { v->capacity /= 2; v->data = realloc(v->data, v->capacity * sizeof(int)); } } }
Проблема: скачки при удалении/добавлении рядом с границей ёмкости
Добавление до 1024 → ёмкость 1024 Удаление до 512 → уменьшение до 512 Добавление до 513 → рост до 1024 Удаление до 512 → уменьшение до 512 ...
Более оптимальное решение: гистерезис (уменьшение на 1/4, а не на 1/2)
void vector_pop(vector_t *v) { if (v->size > 0) { v->size--; if (v->size < v->capacity / 4 && v->capacity > 16) { v->capacity /= 2; v->data = realloc(v->data, v->capacity * sizeof(int)); } } }
В этом случае: необходимо выполнить удаление до 256 элементов, прежде чем произойдёт уменьшение массива с 1024.
Ещё лучше: не выполнять уменьшение массива автоматически, создать явную функцию vector_shrink_to_fit().
void vector_shrink_to_fit(vector_t *v) { if (v->size < v->capacity) { v->data = realloc(v->data, v->size * sizeof(int)); v->capacity = v->size; } }
Рекомендация: если память не критична, не выполнять автоматическое уменьшение массива.
Резервирование и ёмкость
Если окончательный размер известен заранее, то заблаговременно резервируйте пространство:
void vector_reserve(vector_t *v, size_t capacity) { if (capacity > v->capacity) { int *new_data = realloc(v->data, capacity * sizeof(int)); if (new_data) { v->data = new_data; v->capacity = capacity; } } } // Применение vector_t v; vector_init(&v); vector_reserve(&v, 1000); // Распределение выполняется один раз for (int i = 0; i < 1000; i++) { vector_push(&v, i); // Перераспределения нет! }
Бенчмарк (1000 элементов):
Без резервирования: Перераспределения: 7 Время: 45 мкс С резервированием: Перераспределения: 1 Время: 12 мкс
В 3,75 раза быстрее благодаря отсутствию перераспределений.
Рекомендация: если размер известен, всегда выполняйте резервирование.
Оптимизация малых векторов
В случае малых векторов преобладающим оказывается оверхед распределения кучи.
Оптимизация малых векторов: малые массивы следует хранить встраиваемым образом и выполнять распределение только для больших массивов.
#define SMALL_SIZE 16 typedef struct { int small_data[SMALL_SIZE]; // Встроенное хранилище int *data; // Хранилище в куче (если необходимо) size_t size; size_t capacity; } small_vector_t; void small_vector_init(small_vector_t *v) { v->data = v->small_data; // Начинаем со встроенного хранилища v->size = 0; v->capacity = SMALL_SIZE; } void small_vector_push(small_vector_t *v, int value) { if (v->size >= v->capacity) { size_t new_capacity = v->capacity * 2; int *new_data = malloc(new_capacity * sizeof(int)); // Копирование из встроенного хранилища или кучи memcpy(new_data, v->data, v->size * sizeof(int)); // Освобождение старого хранилища в куче (если есть) if (v->data != v->small_data) { free(v->data); } v->data = new_data; v->capacity = new_capacity; } v->data[v->size++] = value; } void small_vector_free(small_vector_t *v) { if (v->data != v->small_data) { free(v->data); } }
Преимущества:
Отсутствие распределений в случае малых векторов (≤ 16 элементов).
Повышенная локальность кэша (встраивание данных с помощью struct).
Высокая скорость в общем случае.
Затраты:
Увеличенный размер struct (64 байт против 16 байт).
Одно лишнее копирование при переходе к куче.
Бенчмарк (средний размер: 8 элементов):
Обычный вектор: Распределения: 1 на вектор Время: 850 нс на вектор Малый вектор: Распределения: 0 (встроенное хранение) Время: 120 нс на вектор
В 7 раз быстрее для малых векторов.
Рекомендация: используйте оптимизацию малых векторов для векторов, которые обычно имеют небольшой размер (< 16-32 элементов).
Распределитель памяти
Производительность realloc() зависит от распределителя.
Наилучший случай: распределитель может расширять объём на месте (без копирования)
// Распределение 1 КБ void *ptr = malloc(1024); // Расширение до 2 КБ (может расширяться на месте) ptr = realloc(ptr, 2048); // Если пространство есть, копирование отсутствует
Наихудший случай: необходимо распределить новый блок и выполнить копирование
// Распределение 1 КБ void *ptr = malloc(1024); // Другое распределение использует соседнее пространство void *other = malloc(1024); // Теперь realloc должна выполнить копирование ptr = realloc(ptr, 2048); // Необходимо распределить новый блок и выполнить копирование
Встраиваемые системы: часто используют простые распределители, которые не могут расширяться на месте.
Решение: использовать специализированный распределитель или пул памяти
typedef struct { char pool[64 * 1024]; // Пул 64 КБ size_t used; } memory_pool_t; memory_pool_t global_pool = {0}; void *pool_alloc(size_t size) { if (global_pool.used + size > sizeof(global_pool.pool)) { return NULL; // Память закончилась } void *ptr = &global_pool.pool[global_pool.used]; global_pool.used += size; return ptr; } // Невозможно освободить отдельные распределения, но можно сбросить весь пул void pool_reset(void) { global_pool.used = 0; }
Сценарий использования: временные векторы, которые можно освобождать вместе.
Вставка и удаление
Для вставки и удаления в середине требуется сдвиг элементов.
Вставка по индексу:
void vector_insert(vector_t *v, size_t index, int value) { if (index > v->size) return; // Обеспечение достаточной ёмкости if (v->size >= v->capacity) { size_t new_capacity = v->capacity ? v->capacity * 2 : 16; v->data = realloc(v->data, new_capacity * sizeof(int)); v->capacity = new_capacity; } // Сдвиг элементов вправо memmove(&v->data[index + 1], &v->data[index], (v->size - index) * sizeof(int)); v->data[index] = value; v->size++; }
Удаление по индексу:
void vector_delete(vector_t *v, size_t index) { if (index >= v->size) return; // Сдвиг элементов влево memmove(&v->data[index], &v->data[index + 1], (v->size - index - 1) * sizeof(int)); v->size--; }
Производительность:
Вставка/удаление в конце: O(1)
Вставка/удаление в начале: O(n) (необходимо сдвинуть все элементы)
Вставка/удаление в середине: O(n)
Бенчмарк (1000 элементов):
Вставка в конце: 50 нс Вставка в начале: 12000 нс (в 240 раз медленнее) Вставка в середине: 6000 нс (в 120 раз медленнее)
Рекомендация: если вам нужны частые вставки/удаления в середине, то задумайтесь о применении другой структуры данных (например, связанного списка или буферного окна).
Встраиваемые системы: векторы с фиксированной ёмкостью
Во встраиваемых системах использование динамического распределения часто недопустимо.
Вектор с фиксированной ёмкостью:
#define MAX_CAPACITY 256 typedef struct { int data[MAX_CAPACITY]; size_t size; } fixed_vector_t; void fixed_vector_init(fixed_vector_t *v) { v->size = 0; } int fixed_vector_push(fixed_vector_t *v, int value) { if (v->size >= MAX_CAPACITY) { return -1; // Заполнен } v->data[v->size++] = value; return 0; }
Преимущества:
Отсутствие распределения.
Предсказуемый объём занимаемой памяти.
Быстрый (нет перераспределения).
Простой.
Затраты:
Фиксированный максимальный размер.
Может тратить память впустую, если не заполнен.
Рекомендация: если вам не требуется динамическое изменение размеров, во встраиваемых системах используйте векторы с фиксированной ёмкостью.
Пример из реального мира: оптимизация буфера логов
Вернёмся к моему агрегатору логов. Вот, как выглядит оптимизация целиком:
До (увеличение размера на 1):
typedef struct { char **messages; int size; int capacity; } log_buffer_t; void add_message(log_buffer_t *buf, const char *msg) { if (buf->size >= buf->capacity) { buf->capacity++; buf->messages = realloc(buf->messages, buf->capacity * sizeof(char*)); } buf->messages[buf->size++] = strdup(msg); }
Проблемы:
1000 перераспределений для 1000 сообщений.
Копирование 500500 элементов.
Ужасная производительность.
После (экспоненциальный рост + резервирование):
typedef struct { char **messages; int size; int capacity; } log_buffer_t; void log_buffer_init(log_buffer_t *buf, int expected_size) { buf->messages = malloc(expected_size * sizeof(char*)); buf->size = 0; buf->capacity = expected_size; } void add_message(log_buffer_t *buf, const char *msg) { if (buf->size >= buf->capacity) { buf->capacity *= 2; buf->messages = realloc(buf->messages, buf->capacity * sizeof(char*)); } buf->messages[buf->size++] = strdup(msg); }
Улучшения:
Предварительное резервирование ожидаемого размера.
Экспоненциальный рост при превышении ёмкости.
7 перераспределений (против 1000).
Копирование 2000 элементов (против 500500).
Результат: в 60 раз быстрее, с 60% CPU до < 1% CPU.
Буферное окно (gap buffer): эффективное редактирование текста
Текстовым редакторам нужны эффективные вставки/удаления в позиции курсора.
Проблемы динамического массива: вставки в позиции курсора требуют сдвига всего текста после курсора.
Решение: буферное окно (используется в Emacs)
typedef struct { char *buffer; size_t gap_start; // Начало окна size_t gap_end; // Конец окна size_t capacity; } gap_buffer_t; void gap_buffer_init(gap_buffer_t *gb, size_t capacity) { gb->buffer = malloc(capacity); gb->gap_start = 0; gb->gap_end = capacity; gb->capacity = capacity; } void gap_buffer_insert(gap_buffer_t *gb, char c) { if (gb->gap_start >= gb->gap_end) { // Увеличение буфера (удвоение размера) size_t new_capacity = gb->capacity * 2; char *new_buffer = malloc(new_capacity); // Копирование перед окном memcpy(new_buffer, gb->buffer, gb->gap_start); // Копирование после окна size_t after_gap = gb->capacity - gb->gap_end; memcpy(new_buffer + new_capacity - after_gap, gb->buffer + gb->gap_end, after_gap); free(gb->buffer); gb->buffer = new_buffer; gb->gap_end = new_capacity - after_gap; gb->capacity = new_capacity; } gb->buffer[gb->gap_start++] = c; } void gap_buffer_move_cursor(gap_buffer_t *gb, int new_pos) { if (new_pos < gb->gap_start) { // Сдвиг окна влево size_t move = gb->gap_start - new_pos; memmove(&gb->buffer[gb->gap_end - move], &gb->buffer[new_pos], move); gb->gap_start = new_pos; gb->gap_end -= move; } else if (new_pos > gb->gap_start) { // Сдвиг окна вправо size_t move = new_pos - gb->gap_start; memmove(&gb->buffer[gb->gap_start], &gb->buffer[gb->gap_end], move); gb->gap_start += move; gb->gap_end += move; } }
Как это работает:
Исходное состояние (ёмкость 10): [_, _, _, _, _, _, _, _, _, _] ^gap_start ^gap_end Вставка "abc": [a, b, c, _, _, _, _, _, _, _] ^gap_start ^gap_end Перемещение курсора в 1: [a, _, _, _, _, _, _, b, c, _] ^gap_start ^gap_end Вставка "x": [a, x, _, _, _, _, _, b, c, _] ^gap_start ^gap_end
Преимущества:
Вставка в позиции курсора за O(1) (достаточно переместить gap_start).
Удаление в позиции курсора за O(1).
Затраты только на перемещение курсора (амортизированное O(1) для последовательного редактирования).
Бенчмарк (1000 вставок в позиции курсора):
Динамический массив: 12000 мкс (сдвиг при каждой вставке) Буферное окно: 120 мкс (в 100 раз быстрее)
Подведём итог
Проблема перераспределения была решена благодаря пониманию стратегий роста. Агрегатор логов, тративший 60% своего времени на realloc(), разрастался по одному элементу за раз, из-за чего при всего 1000 элементов копировалось 500500 элементов. Переход на экспоненциальный рост (удвоение ёмкости) снизил количество перераспределений с 1000 до всего 10, что сильно повысило скорость системы.
Ключевые выводы:
Экспоненциальный рост (2×) для амортизированного добавления за O(1).
Резервирование пространства, если известен размер.
Не нужно выполнять автоматическое уменьшение размера (используйте shrink_to_fit).
Оптимизация малых векторов в общем случае.
Фиксированная ёмкость для встраиваемых систем.
Стратегии роста:
Рост 2×: меньше перераспределений, больше памяти.
Рост 1,5×: больше перераспределений, меньше памяти.
Рекомендация: если память не критична, используйте 2×.
Оптимизации:
Резервирование: если размер известен, избегаем перераспределений.
Оптимизация малых векторов: встроенное хранилище для малых массивов.
Пулы памяти: избегаем оверхеда распределителя.
Буферное окно: эффективное редактирование текста.
Встраиваемые системы:
Векторы с фиксированной ёмкостью (без распределения).
Предсказуемый объём памяти.
Простые распределители не могут выполнять расширение на месте.
Стоит рассмотреть возможность использования пулов памяти.
Когда использовать динамические массивы:
Необходимость переменного размера.
В основном применяются операции добавления.
Требуется произвольный доступ.
Удобный для кэша последовательный доступ.
Когда их НЕ использовать:
Частые вставки/удаления в середине → буферное окно или rope.
Известен фиксированный размер → статический массив.
Встраиваемая система с дефицитом памяти → вектор с фиксированной ёмкостью.
Дальнейшие шаги: мы рассмотрели фундаментальные структуры данных (массивы, списки, стеки, очереди, хэш-таблицы динамические массивы). В следующей части мы исследуем деревья и иерархические структуры, в которых поведение кэша становится ещё более критичным.