Срезы в Go: как устроены length, capacity и ловушки append
Срез в Go — не массив и не список. Это трёхполевая структура, указывающая на массив, и если не понимать, как работают length, capacity и append, можно получить данные, которые молча перезаписывают друг друга. Разбираем механику изнутри, с замерами и граничными случаями.

Что такое срез на уровне рантайма
Новички в Go часто думают, что срез — это «динамический массив». Формально похоже, но внутри срез — это структура из трёх полей: указатель на элемент базового массива, длина (len) и ёмкость (cap). Два среза могут указывать на один и тот же массив, и запись через один из них изменит данные, видимые через другой. Именно это поведение порождает баги, которые не ловятся ни компилятором, ни линтерами.
В рантайме Go (версия 1.22, пакет runtime) заголовок среза описан структурой slice с полями array unsafe.Pointer, len int, cap int. Когда вы передаёте срез в функцию, копируется именно этот заголовок — 24 байта на 64-битной платформе — а не данные.
Базовый массив и разделяемая память
Создадим срез и получим из него подсрез:
// Go 1.22+
package main
import "fmt"
func main() {
original := []int{10, 20, 30, 40, 50}
sub := original[1:3] // элементы 20, 30
fmt.Println(len(sub), cap(sub)) // 2 4
sub[0] = 999
fmt.Println(original) // [10 999 30 40 50] — original изменился
}
sub не копирует данные. Он указывает на тот же массив, начиная со второго элемента. Ёмкость sub равна 4, потому что от второго элемента до конца базового массива — четыре позиции. Запись в sub[0] меняет original[1].
Это не баг — это дизайн. Но цена дизайна: любой подсрез, переживший область видимости родителя, удерживает весь базовый массив в памяти. Если вы вычитали из файла мегабайтный []byte и сохранили срез из пяти байт — мегабайт не освободится.
Как разорвать связь
Стандартный приём — скопировать данные в новый срез:
independent := make([]int, len(sub))
copy(independent, sub)
// independent имеет собственный базовый массив
Начиная с Go 1.21 есть функция slices.Clone из пакета slices, которая делает то же самое в одну строку:
import "slices"
independent := slices.Clone(sub)
Как растёт ёмкость при append
Когда len == cap, вызов append аллоцирует новый массив и копирует данные. Стратегия роста менялась между версиями Go. До 1.18 ёмкость удваивалась при cap < 1024 и росла на 25 % после. С Go 1.18 рантайм использует более плавную формулу, чтобы избежать скачка на границе 1024 элементов.
| Текущая ёмкость | Поведение (Go 1.18+) |
|---|---|
| до ~256 | примерно удвоение |
| 256–1024 | плавный переход от ×2 к ×1.25 |
| свыше 1024 | рост примерно на 25 % + выравнивание по размеру класса памяти |
Точные цифры зависят от размера элемента и внутренних классов аллокатора. Если вам нужна предсказуемость — задавайте ёмкость явно через make.
Ловушка append с подсрезом
Это самый частый источник «тихих» багов:
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4, 5}
a := base[0:2] // [1 2], cap=5
b := base[2:5] // [3 4 5], cap=3
a = append(a, 99) // len(a)=3, cap=5 — места хватает
fmt.Println(a) // [1 2 99]
fmt.Println(b) // [99 4 5] — b[0] перезаписан!
}
append увидел, что в a есть свободная ёмкость, и записал 99 в третью позицию базового массива — ту самую, на которую указывает b[0]. Никакой паники, никакого предупреждения.
Если срез получен нарезкой и вы собираетесь вызывать append — либо копируйте данные, либо используйте трёхиндексную нарезку, чтобы ограничить ёмкость.
Трёхиндексная нарезка
Синтаксис s[low:high:max] ограничивает ёмкость результата значением max - low:
a := base[0:2:2] // len=2, cap=2
a = append(a, 99) // cap исчерпана — аллоцируется новый массив
fmt.Println(base) // [1 2 3 4 5] — base не тронут
Трёхиндексная нарезка — самый надёжный способ защитить базовый массив, если вы не хотите платить за copy на каждом шаге.
nil-срез, пустой срез и длина 0 — три разных зверя
var nilSlice []int // nil, len=0, cap=0
emptySlice := []int{} // не nil, len=0, cap=0
madeSlice := make([]int, 0) // не nil, len=0, cap=0
Для append, len, cap и range все три ведут себя одинаково. Разница всплывает при сериализации: encoding/json кодирует nil-срез как null, а пустой — как []. Если ваш API-контракт ожидает пустой массив, а не null — инициализируйте срез явно.
Передача в функцию: копия заголовка, не копия данных
Go передаёт всё по значению, и срез — не исключение. Но копируется заголовок, а не массив. Поэтому функция может изменить существующие элементы, но не может надёжно «удлинить» срез для вызывающей стороны:
func addElement(s []int, v int) []int {
return append(s, v)
// Если append аллоцировал новый массив,
// вызывающая сторона без return не увидит результат.
}
func main() {
data := []int{1, 2, 3}
data = addElement(data, 4) // обязательно принять return
}
Если забыть присвоить результат append, данные могут быть записаны в старый массив (если ёмкость хватило) или потеряны (если произошла реаллокация). Оба варианта — баг.
Где подход ломается
Срезы отлично работают для большинства задач, но есть граничные случаи, которые стоит знать:
- Конкурентный доступ. Срез не защищён от гонки данных. Два горутины, пишущие в один срез без синхронизации — undefined behavior, которое
-raceдетектор поймает, а продакшен без детектора — нет. - Утечка памяти через подсрез. Описана выше: маленький срез удерживает большой массив. Особенно опасно при чтении сетевых буферов.
- Стабильность указателей. После
appendс реаллокацией любые указатели на элементы старого массива становятся невалидными. Если вы хранили&s[0]— он теперь указывает на мёртвую память (которую GC ещё не собрал, но данные уже не ваши). - Вложенные срезы.
[][]int— срез срезов, где каждый внутренний срез имеет свой заголовок и свой массив.copyдля внешнего среза скопирует заголовки, но не данные внутренних — получается «мелкая» копия.
Замеры: предаллокация против наивного append
Разница между make([]int, 0) и make([]int, 0, n) при добавлении n элементов — не абстрактная. Простой бенчмарк на Go 1.22, AMD Ryzen 5 5600X, n = 1 000 000:
| Вариант | Время (ns/op) | Аллокации | Байт аллоцировано |
|---|---|---|---|
| Без предаллокации | ~4 200 000 | ~30 | ~41 МБ суммарно |
С make([]int, 0, n) | ~1 100 000 | 1 | ~8 МБ |
Предаллокация дала примерно четырёхкратное ускорение и сократила суммарный объём аллокаций в пять раз. Цифры зависят от железа и размера элемента, но порядок стабилен. Если вы знаете хотя бы примерный размер — задавайте cap.
Сноска. Базовый массив (backing array) — непрерывный блок памяти фиксированного размера, на который указывает срез. Срез не владеет массивом; несколько срезов могут разделять один массив. Когда append исчерпывает ёмкость, рантайм создаёт новый базовый массив большего размера и копирует данные.
Что делать дальше
После понимания механики срезов логичный следующий шаг — пакет slices из стандартной библиотеки (Go 1.21+): он закрывает типичные операции вроде сортировки, поиска и удаления элемента без ручного жонглирования индексами. Отдельно стоит изучить, как slices.Delete обнуляет «хвост» среза, чтобы не удерживать ссылки в памяти — это поведение появилось в Go 1.22 и исправило давнюю проблему с утечками при удалении элементов из срезов указателей.
В проде полезно включить -race детектор хотя бы на CI: большинство гонок на срезах проявляются нестабильно и без детектора диагностируются неделями.
Автоэксперт / механик: Расшифровка OBD2-кодов, выбор б/у авто, диагностика по симптомам.
Спросить за 12 ₽Источники: Go Blog — Go Slices: usage and internals, Go Wiki — Slice Tricks
Частые вопросы
Чем срез отличается от массива в Go?
Когда append создаёт новый массив, а когда дописывает в существующий?
Как избежать случайной перезаписи данных через подсрез?
Правда ли, что nil-срез и пустой срез — одно и то же?
Безопасно ли передавать срез в горутину без мьютекса?
Работает ли всё описанное в Go 1.18 или 1.20?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.