17. Slice под капотом. len, cap, append и realloc
В предыдущей статье мы выяснили, что срез в Go — это всего лишь компактный 24-байтный дескриптор (SliceHeader), открывающий смотровое окно в непрерывный базовый массив. Но за этой обманчиво простой абстракцией скрывается один из самых сложных и тонко настроенных механизмов рантайма — алгоритм динамического роста срезов.
Когда Брайан Керниган и Деннис Ритчи создавали язык C, управление памятью было предельно осязаемым: программист вручную вызывал realloc() и физически ощущал перемещение каждого байта. В Go встроенная функция append настолько лаконична и удобна, что создает опасную иллюзию бесплатности.
Именно из-за непонимания того, что происходит в недрах runtime/slice.go при переаллокации (realloc) и как сборщик мусора (Garbage Collector) отслеживает ссылки базового массива, в продакшен-сервисах утекают гигабайты оперативной памяти.
В этой главе мы разберем физику расширения срезов, познакомимся с ассемблерными оптимизациями memmove и изучим два главных паттерна утечек памяти при работе со срезами.
Анатомия функции append
Встроенная функция append(slice, elements...) на уровне скомпилированного машинного кода разделяется на два принципиально разных сценария: быстрый путь (Fast Path) и медленный путь (Slow Path).
Fast Path: Места хватает (Len < Cap)
Если в скрытом базовом массиве еще остается свободная емкость (Cap - Len >= количество добавляемых элементов), выполнение идет по «зеленому коридору»:
- Процессор вычисляет адрес следующей свободной ячейки памяти базового массива простым смещением:
Data + Len * sizeof(Element). - Записывает новые данные напрямую в память.
- Инкрементирует поле логической длины (
Len) в 24-байтном дескрипторе. - Возвращает обновленный
SliceHeader.
Это чистая операция со сложностью $O(1)$. Она укладывается в несколько тактов CPU, идеально ложится в кэш-линию L1 и не требует ни системных вызовов ядра, ни взаимодействия с аллокатором кучи.
Slow Path: Массив заполнен (Len == Cap)
Когда физическое пространство в базовом массиве исчерпано, простая запись невозможна: она перезаписала бы чужие данные в соседней памяти (buffer overflow).
В этот момент рантайм Go переключается на «медленный путь» и вызывает системную функцию runtime.growslice.
runtime.growslice: Физика переаллокации
Функция growslice выполняет всю тяжелую работу по миграции данных. Процесс переаллокации (reallocation) включает четыре последовательных этапа:
- Расчет новой емкости (Cap) по алгоритму плавного масштабирования.
- Аллокация: Запрос у менеджера памяти нового, существенно большего непрерывного блока в куче.
- Копирование (memmove): Побитовое копирование всех существующих данных из старого массива в новый.
- Утилизация: Старый массив остается в памяти без живых дескрипторов (сиротой). На следующем цикле GC фаза разметки (mark) не найдёт на него ссылок, и память будет освобождена.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph Old["Старая память"]
A["Базовый массив<br>Len 4, Cap 4"]:::process
end
subgraph New["Новая память в Куче"]
B["Новый массив<br>Cap 8"]:::process
end
Old -->|"1. growslice понимает<br>что места нет"| New
A -.->|"2. memmove<br>побитовое копирование"| B
A -->|"3. GC: нет ссылок<br>Старый массив освобождается"| GC["Сборщик мусора"]
Алгоритм роста (Как меняется Cap?)
На собеседованиях кандидаты нередко отвечают: «Вместимость среза удваивается при каждом переполнении». Этот ответ устарел.
Начиная с Go 1.18, алгоритм роста был переработан, чтобы исключить скачкообразный перерасход памяти на больших объемах данных. Критический порог (threshold) теперь составляет 256 элементов (вместо старого лимита в 1024):
- Если старая вместимость
Cap < 256, новый размер строго удваивается (x2). -
Если старая вместимость
Cap >= 256, применяется формула плавного демпфирования:Text newcap += (newcap + 3*256) / 4Эта формула обеспечивает плавное снижение коэффициента роста: от $2.0\times$ при 256 элементах до примерно $\sim 1.25\times$ для гигантских массивов.
-
Если добавляется сразу много элементов и новая длина больше удвоенной емкости, берётся сама новая длина (
append([]int(nil), 1, 2, 3, 4, 5)начинает с 5). - В конце емкость округляется вверх до класса размера аллокатора, поэтому итоговый
capзависит от размера элемента (в примере выше для[]intполучится 6: 48 байт — ближайший класс размера).
Утечка памяти №1: Теневой массив (Capacity Leak)
Это классический подводный камень при обработке сетевых пакетов, парсинге логов и чтении файлов.
Представьте типичный микросервис: вы считываете из сокета сетевой пакет размером 1 Мегабайт в срез []byte. Затем вы находите полезный заголовок (всего 10 байт) и передаете его дальше по бизнес-логике:
package main
var cache [][]byte // Глобальный кэш полезных нагрузок
func getPayload(packet []byte) []byte {
// Допустим, мы нашли полезные данные в самом конце
return packet[len(packet)-10:]
}
func main() {
packet := make([]byte, 1024*1024) // Аллоцируем 1 МБ
// ... читаем данные из сети ...
payload := getPayload(packet) // Берем 10 байт
cache = append(cache, payload) // Сохраняем в глобальный кэш
// Переменная packet больше не используется.
// GC должен бы удалить 1 МБ. Но пока payload хранится в кэше,
// он этого НЕ СДЕЛАЕТ!
}
Mechanical Sympathy: Почему GC бессилен?
Срез payload — это окно. Поле Data этого 24-байтового дескриптора всё еще указывает на оригинальный мегабайтный массив в куче (просто с большим начальным смещением).
Сборщик мусора Go использует алгоритм трехцветной маркировки (Mark and Sweep). Он начинает сканирование от корней (стеков и глобальных переменных) и видит: глобальный cache содержит payload — живой указатель в кучу! (Сборщику важна не лексическая область видимости, а живость: если бы payload после использования нигде не хранился, массив освободился бы.) GC не имеет права частично удалить массив. Пока живы несчастные 10 байт полезной нагрузки, весь 1 Мегабайт памяти будет удерживаться в куче «мертвым грузом». Тысячи таких запросов быстро приведут к OOM-киллеру в Linux.
Как лечить?
Необходимо физически разорвать связь с оригинальным базовым массивом, скопировав 10 байт в независимый блок памяти:
Способ 1: Идиоматичный (через bytes / slices в Go 1.21+)
func getPayload(packet []byte) []byte {
return slices.Clone(packet[len(packet)-10:])
}
Способ 2: Через трюк append в nil
func getPayload(packet []byte) []byte {
// append в nil принудительно создаст новый массив и скопирует элементы
return append([]byte(nil), packet[len(packet)-10:]...)
}
Утечка памяти №2: Заражение через append
Вторая критическая ловушка возникает, когда два среза ссылаются на общий базовый массив, и к одному из них применяется операция append. Строго говоря, это не утечка памяти, а алиасинг — неожиданная порча общих данных:
package main
import "fmt"
func main() {
// Представим это как кэш или общий буфер
origin := []int{1, 2, 3, 4, 5} // Len=5, Cap=5
// Делаем срез
view := origin[1:3] // [2, 3]. Len=2, Cap=4
// А теперь добавляем данные в "вид"
view = append(view, 999)
fmt.Println("view:", view) // [2, 3, 999]
fmt.Println("origin:", origin) // [1, 2, 3, 999, 5] - ОРИГИНАЛ ИСПОРЧЕН!
}
Срез view обладал свободной емкостью (Cap = 4, при Len = 2). Функция append пошла по «быстрому пути» (Fast Path) и без зазрения совести перезаписала 4-й элемент общего базового массива. В итоге число 4 в срезе origin оказалось уничтожено.
Как лечить? (Трех-индексный срез)
Для защиты от взаимного повреждения данных в Go существует синтаксис полного выражения среза: slice[low:high:max].
Третий индекс (max) жестко задает границу емкости (Cap = max - low) для нового среза:
func main() {
origin := []int{1, 2, 3, 4, 5}
// Создаем срез, но жестко ограничиваем Cap до длины среза (3)
view := origin[1:3:3]
// Теперь у view Len=2, Cap=2.
// При вызове append свободного места НЕТ.
// Сработает slow path (runtime.growslice), будет выделен НОВЫЙ массив.
view = append(view, 999)
fmt.Println("origin:", origin) // [1, 2, 3, 4, 5] - ОРИГИНАЛ ЦЕЛ!
}
Ограничение емкости конструкцией [1:3:3] возводит надежный изоляционный барьер: любая последующая попытка добавления элементов принудительно уведет view на собственный независимый массив, сохранив origin в неприкосновенности.
Итог
- Fast vs Slow Path: При наличии свободной емкости
appendработает за мгновенные $O(1)$. При переполнении вызываетсяruntime.growsliceсо сложностью $O(N)$ и аллокацией новой памяти. - Плавный рост: Начиная с Go 1.18, удвоение емкости происходит только до 256 элементов. Далее работает сглаживающая формула
newcap += (newcap + 3*256) / 4. - Размерные классы памяти: Из-за выравнивания памяти под классы размеров
mspanитоговыйCapможет быть больше расчетного. - Capacity Leak: Срез маленького фрагмента удерживает от сборки мусора весь гигантский исходный массив. Используйте
slices.Clone()для копирования данных илиappend([]byte(nil), ...)для разрыва связей. - Изоляция через a[low:high:max]: Полный трех-индексный срез
slice[low:high:max](илиa[low:high:max]) жестко ограничиваетCapи форсирует переаллокацию, исключая порчу общего массива при вызовахappend.
Мы всесторонне исследовали массивы и динамические срезы. Однако в реальных веб-серверах и базах данных критически важно находить значения не по числовому смещению, а по произвольному ключу за константное время $O(1)$.
Для этого в арсенале Go есть великолепная хэш-таблица. В следующей лекции — 18. Map. Хеш-таблица в Go — мы исследуем структуру map, узнаем, почему ее итерация намеренно рандомизирована и как устроена адресация ключей в памяти.