Срез в Go: три слова, общий массив и рост запаса не по тому правилу, которое все помнят
Срез — это заголовок из трёх машинных слов над чужим массивом. Отсюда следует и то, что append иногда меняет соседний срез, и то, что кусок в десять байт держит в памяти пятьдесят мегабайт. А правило «×2 до 1024, дальше ×1,25» описывает рантайм до Go 1.18 — и даже тогда было неточным.
Полное техническое изложение
TL;DR
- Срез — три машинных слова: указатель, длина, запас. 24 байта, сколько бы элементов ни было.
- Заголовок копируется, массив — нет. Отсюда всё остальное.
appendв подсрез может затереть соседа: уa[:2]запас считается до конца массива. Лечится третьим индексомa[:2:2].- Рост запаса не «×2 до 1024, дальше ×1,25»: удвоение кончается на 256, дальше отношение не постоянно — 1,656 → 1,509 → 1,400 → 1,429 → 1,331 → 1,502.
- Без запаса дорого: 10 000 элементов — 19 выделений против 1.
- Кусок в 10 байт держит массив в 50 МБ.
Три слова
unsafe.Sizeof([]int{}) // 24 — указатель, длина, запас
unsafe.Sizeof([8]int{}) // 64 — а это массив, он хранит элементыМассив — значение и копируется целиком. Срез — ссылка: 24 байта на любую длину.
Заголовок копируется, массив — нет
Первое следствие из трёх слов.
func grow(s []int) { s = append(s, 42) } // снаружи не видно
func set(s []int) { s[0] = 7 } // видноФункция получила копию трёх слов. Указатель тот же — запись проходит. Новая
длина осталась в копии. Отсюда правило s = append(s, x).
Общий массив
Массив общий — осталось выяснить, докуда.
a := []int{1, 2, 3, 4, 5}
b := append(a[:2], 9)
// b = [1 2 9]
// a = [1 2 9 4 5] <- затёртоУ a[:2] длина 2, а запас 5 — он считается до конца массива, а не до
конца среза. append увидел свободную ячейку и записал в неё. Ошибки нет:
он сделал ровно то, что обещано.
Тот же вызов при исчерпанном запасе выделит новый массив и ничего не тронет. По коду в точке вызова это не видно.
Как это чинить
b := a[:2:2] // третий индекс ограничивает ЗАПАС: длина 2, запас 2
b = append(b, 9)
// a = [1 2 3 4 5] — целЗапаса нет — append обязан выделить свой массив. Так отдают наружу кусок
своего буфера.
Рост запаса
Расхожее «вдвое до 1024, дальше в 1,25 раза» описывает рантайм до Go 1.18.
Сейчас в runtime/slice.go стоит const threshold = 256, а формула после
порога — newcap += (newcap + 3*threshold) >> 2.
Замеренные отношения для []int: 1,656 → 1,509 → 1,400 → 1,429 → 1,331 → 1,502.
Не 1,25 и даже не монотонно — потому что результат формулы округляется вверх
до ближайшего класса размера: аллокатор Go выдаёт блоки не любого размера,
а из фиксированного набора (8, 16, 24, 32, 48, 64, … 2688), и набор задан
в байтах.
Нагляднее всего на срезе структур по 40 байт: cap идёт 32 → 67 → 134 →
272, а дальше 544 → 1024 → 1638. Число 67 никто не заказывал: формула
попросила 64 элемента, то есть 2560 байт, ближайший класс — 2688, в него
влезает 67 штук по 40.
Что это стоит
Собрать []int из 10 000:
- без запаса — 19 выделений, 357 624 байта;
make([]int, 0, N)— 1 выделение, 81 920 байт.
Полезных данных 80 000 байт. Остальное — промежуточные массивы.
Три способа заказать запас (make(…, 0, N), make(…, N), slices.Grow)
между собой неразличимы. Важно, что запас заказан вообще.
Ещё две строки оттуда же:
copy— 1 668 нс, ручной цикл — 6 163 на тех же данных;rangeпо значению — 32 028 нс, по индексу — 6 206 (срез структур по 136 байт; на[]intразницы нет).
Утечка через подсрез
срез из 10 байт от массива в 50 МБ: в куче 50,1 МБ
после copy в свой массив и отказа от подсреза: в куче 0,1 МБ
Сборщик работает с массивом целиком. Возвращаете маленький кусок большого
буфера — возвращайте копию (slices.Clone).
nil и пустой
var n []int // n == nil, json -> null
e := []int{} // e != nil, json -> []Для len, range, append они неразличимы, и if s == nil почти всегда
лишняя проверка — нужна if len(s) == 0. Разница вылезает в сериализации:
клиент, ждущий массив, получит null.
TL;DR
Срез — это три машинных слова: указатель на массив, длина и запас. Всё остальное следует отсюда.
- Заголовок копируется, массив — нет. Поэтому
appendвнутри функции не виден снаружи, а запись в элемент — видна. appendв подсрез может затереть соседа. Уa[:2]длина 2, а запас — до конца массива;appendувидит свободную ячейку и запишет в неё. Тот же вызов при исчерпанном запасе выделит новый массив — и по коду это не видно.- Лечится третьим индексом:
a[:2:2]отбирает запас, иappendобязан выделить своё. - Рост запаса устроен не так, как принято думать. Удвоение кончается на
256, а не на 1024, и дальше отношение не 1,25 и вообще не постоянно:
1,656 → 1,509 → 1,400 → 1,429 → 1,331 → 1,502. Причина — округление
до класса размера аллокатора, из-за которого
capбывает 67 или 848. - Цена этого: собрать 10 000 элементов без запаса — 19 выделений и 357 624 байта против 1 выделения и 81 920 байт с запасом.
- Маленький срез держит большой массив. Кусок в 10 байт от массива в 50 МБ оставляет в куче все 50 МБ.
Три слова
A slice is a descriptor for a contiguous segment of an underlying array and
provides access to a numbered sequence of elements from that array.
Срез — это описатель непрерывного участка нижележащего массива, дающий доступ к пронумерованной последовательности его элементов.
Описатель — это буквально структура из трёх полей, и её размер можно спросить у компилятора:
unsafe.Sizeof([]int{}) // 24 — указатель, длина, запас
unsafe.Sizeof("") // 16 — у строки полей два
unsafe.Sizeof([8]int{}) // 64 — а это массив, он хранит элементыМассив в Go — значение: [8]int занимает 64 байта и копируется целиком.
Срез — ссылка: 24 байта, сколько бы элементов ни было за ними. Отсюда и
следствие, которое спецификация тоже называет прямо:
A slice, once initialized, is always associated with an underlying array that
holds its elements. A slice therefore shares storage with its array and with
other slices of the same array.
Срез после инициализации всегда связан с нижележащим массивом, который хранит его элементы. Поэтому срез разделяет память со своим массивом и с другими срезами того же массива.
Заголовок копируется, массив — нет
Первое следствие из трёх слов — и самое частое место, где на нём спотыкаются. Копируются именно три слова, а не массив за ними.
func grow(s []int) { s = append(s, 42) } // снаружи не видно
func set(s []int) { s[0] = 7 } // снаружи видноФункция получила копию из трёх слов. Указатель в копии тот же, поэтому запись в существующий элемент проходит в общий массив. А новая длина осталась в копии и умерла вместе с ней.
Причём элемент записан: если у среза был запас, 42 лежит в массиве, просто
за пределами длины. Вызовите grow для s := make([]int, 3, 8) — и
s[:cap(s)][3] покажет там 42, хотя len(s) по-прежнему 3. Именно поэтому правило
звучит как s = append(s, x), а не «не забудьте указатель»: указатель тут ни
при чём, дело в длине.
Запас считается до конца массива
Итак, массив общий. Осталось выяснить, докуда именно он общий, — и вот здесь находится место, о которое спотыкаются чаще всего.
b := a[:2] даёт длину 2 — это очевидно. Но запас у b равен 5, а не 2:
the capacity is cap(a) - low
запас равен cap(a) − low
То есть запас считается до конца массива, а не до конца среза: cap(a)
равен 5, low равен нулю. Между длиной 2 и запасом 5 остаются три ячейки,
которые для b свободны, а для a — заняты. Дальше остаётся прочитать, что делает append:
If the capacity of s is not large enough to fit the additional values,
append allocates a new, sufficiently large underlying array that fits both
the existing slice elements and the additional values. Otherwise, append
re-uses the underlying array.
Если запаса s не хватает для дополнительных значений, append выделяет новый, достаточно большой нижележащий массив, вмещающий и существующие элементы среза, и дополнительные. Иначе append переиспользует нижележащий массив.
Слово «иначе» здесь и есть вся история:
a := []int{1, 2, 3, 4, 5}
b := append(a[:2], 9)
// b = [1 2 9]
// a = [1 2 9 4 5] <- третий элемент затёртОшибки не произошло. append сделал ровно то, что обещано: увидел свободную
ячейку и записал в неё.
Самое неприятное — что тот же вызов ведёт себя иначе, когда запаса нет.
c := []int{1, 2}
d := append(c, 9) // запаса нет -> новый массив, c не тронутОдин и тот же append: в первом случае два среза делят массив, во втором —
нет. По коду в точке вызова это не видно, потому что зависит не от кода, а от
того, каким срез был получен.
Как отдать кусок и не бояться
Для этого есть трёхиндексная форма. Спецификация про неё говорит коротко:
sets the capacity to max - low
задаёт запас равным max − low
b := a[:2:2] // длина 2, запас 2
b = append(b, 9)
// a = [1 2 3 4 5] — не тронутТретий индекс ограничивает запас, а не длину. Запаса нет — append обязан
выделить новый массив.
Это единственный способ отдать наружу кусок своего буфера, ничего не копируя: не попросить не дописывать, а сделать дописывание в ваш массив физически невозможным. Копия — второй способ, и она защищает от большего; чем именно эти два отличаются, разобрано в следующем разделе.
Clip и Clone: что из них от чего защищает
Трёхиндексную форму не обязательно писать руками: в стандартной библиотеке она
лежит под именем slices.Clip, а рядом с ней — slices.Clone. Обе функции коротки настолько, что их тела и есть ответ:
// Clip removes unused capacity from the slice, returning s[:len(s):len(s)].
func Clip[S ~[]E, E any](s S) S {
return s[:len(s):len(s)]
}
// Clone returns a copy of the slice.
func Clone[S ~[]E, E any](s S) S {
if s == nil {
return nil
}
return append(S{}, s...)
}Clip — это ровно выражение из предыдущего раздела и ни одного копирования.
Clone — это append в пустой срез, то есть копирование всегда. Отсюда
различие, которое путают чаще всего (bench/goslice/clip.go):
Clip(head) и buf делят: ТОТ ЖЕ массив <- Clip НЕ копирует
Clone(head) и buf делят: другой массив <- Clone копирует сразу
Clip защищает от одного и только одного: append больше не залезет в чужой
хвост.
buf ПОСЛЕ append(head,99) len=5 cap=5 [1 2 3 99 5]
buf ПОСЛЕ append(Clip(head),99) len=5 cap=5 [1 2 3 4 5]
В первой строке append затёр чужой элемент, во второй массив цел: Clip
заставил append выделить свой.
От записи по индексу он не защищает вовсе — массив-то тот же:
buf после clipped[0]=777 len=5 cap=5 [777 2 3 4 5]
buf после cloned[0]=777 len=5 cap=5 [1 2 3 4 5]
И — это прямо про раздел ниже, где маленький срез держит большой массив, —
Clip не отпускает массив. Срез длины 3 от массива в 1 048 576 элементов
после Clip имеет запас 3, а массив держит целиком: указатель остался тем же.
Против удержания памяти работает Clone, и только он.
Правило, которое стоит унести: Clip — про append, Clone — про
собственность. nil при этом переживает обе: у Clone под это отведена
отдельная ветка с пометкой авторов
Preserve nilness in case it matters
(сохранить nil-ность на случай, если это важно).
Общий массив — это общая память
До сих пор общий массив разбирался в одной горутине. Стоит добавить вторую — и тот же самый факт перестаёт быть неожиданным результатом и становится гонкой данных.
Всё, что выше сказано про общий массив, в однопоточной программе даёт
неожиданный результат. В многопоточной — гонку данных, причём ни один индекс
при этом не выглядит подозрительно. Два случая, и оба ломаются на пустом месте
(bench/goslice/race_shared.go):
buf[0:4]иbuf[2:6]— диапазоны перехлёстываются,a[2]иb[0]— это одна и та же ячейка;- два среза
len=1 cap=8от одного массива — обаappendпишут вbuf[1], то есть в общий запас.
Второй злее: общего индекса в коде нет вовсе, общее — ровно то, чего в исходнике не видно.
Детектор находит оба и называет обе стороны. Адреса и номера горутин от запуска к запуску меняются, порядок двух строф — тоже; неизменны строки исходника:
WARNING: DATA RACE
Write at 0x00c0000a6010 by goroutine 8:
main.overlap.func2()
.../racesrc/main.go:73
Previous write at 0x00c0000a6010 by goroutine 7:
main.overlap.func1()
.../racesrc/main.go:67
Код возврата при этом 66, а не 1 или 2 — отдельное значение, заданное именно под гонку:
exitcode (default 66): The exit status to use when exiting after a detected
race.
exitcode (по умолчанию 66): код выхода, используемый при завершении после обнаруженной гонки.
Мьютекс здесь — не лечение, а полумера. В первом случае он гонку уберёт. Во
втором под замком останется потерянная запись: два append по очереди положат
своё в один и тот же buf[1], и второй затрёт первый — детектор замолчит, а
данные пропадут. Лечит нарезка: развести диапазоны (buf[0:4] и buf[4:8])
или отобрать запас через slices.Clip, чтобы каждый append выделил своё. На
обоих починенных вариантах детектор молчит.
Молчание, впрочем, не доказательство:
The race detector only finds races that happen at runtime, so it can't find
races in code paths that are not executed.
Детектор гонок находит только те гонки, которые случились во время выполнения, поэтому он не может найти гонки в путях кода, которые не выполнялись.
Рост запаса
Про запас до сих пор говорилось только то, что он есть и докуда тянется. Теперь — откуда он берётся.
Расхожее правило здесь неверно дважды — и в пороге, и в множителе.
Расхожая формулировка — «вдвое до 1024 элементов, дальше в 1,25 раза». Она
описывает рантайм до Go 1.18. Сейчас в runtime/slice.go стоит:
if newLen > doublecap {
return newLen
}
const threshold = 256
if oldCap < threshold {
return doublecap
}
for {
newcap += (newcap + 3*threshold) >> 2
...
}Первая строка отвечает на вопрос, который обычно даже не задают: если за один
append добавляют больше, чем даёт удвоение, никакого множителя нет вовсе —
запас берётся ровно по запрошенной длине.
Порог — 256, и сравнивается он с запасом, а не с длиной. Так его и меняли:
Instead of growing 2x for < 1024 elements and 1.25x for >= 1024 elements, use a
somewhat smoother formula for the growth factor. Start reducing the growth
factor after 256 elements, but slowly.
Вместо роста вдвое до 1024 элементов и в 1,25 раза от 1024 использовать более плавную формулу множителя роста. Начинать снижать множитель после 256 элементов, но медленно.
Но и «1,25» неверно — даже как описание нынешней формулы. Замеренные отношения
для []int идут 1,656 → 1,509 → 1,400 → 1,429 → 1,331 → 1,502: не 1,25 и
даже не монотонно. Причин две, и обе видны в исходнике.
Первая — сама формула. newcap += (newcap + 3*256) >> 2 — это не
умножение на 1,25, а прибавление четверти плюс 192. К 1,25 отношение
приближается только на больших значениях; на 512 оно даёт 1,625.
Вторая — округление. Аллокатор Go не выдаёт блоки произвольного размера:
у него фиксированный набор — 8, 16, 24, 32, 48, 64, … 2048, 2304, 2688, — и
запрос округляется вверх до ближайшего из них. Такой размер и называют
классом размера. Результат формулы идёт в roundupsize, который
округляет объём в байтах до ближайшего класса. Автор изменения
предупредил об этом прямо в том же сообщении коммита:
(Note that the real growth factor, both before and now, is somewhat larger
because we round up to the next size class.)
(Заметьте, что настоящий множитель роста, и до, и теперь, несколько больше, потому что мы округляем вверх до следующего класса размера.)
То есть правило, сформулированное в элементах, не может быть точным в
принципе: рантайм считает в байтах. Лучше всего это видно на срезе
структур по 40 байт: cap идёт 32 → 67 → 134 → 272 → 544 → 1024 →
1638. Число 67 никто не заказывал: формула запросила 64 элемента, то есть
2560 байт; ближайший класс — 2688; в него влезает 67 элементов по 40 байт.
А у []int8 запас начинается не с 1, а с 8: меньше восьми байт аллокатор
просто не выдаёт.
Что этот рост стоит
Собрать []int из 10 000 элементов:
- без запаса — 159 964 нс, 357 624 байта, 19 выделений;
make([]int, 0, N)— 40 542 нс, 81 920 байт, 1 выделение.
Полезных данных здесь 80 000 байт. Всё, что сверх, — промежуточные массивы, которые пришлось выделить, скопировать и выбросить.
Обратите внимание, чего в этих числах нет: разницы между тремя способами
заказать запас. make([]T, 0, N) с append, make([]T, N) с записью по
индексу и slices.Grow дают одно и то же с точностью до разброса прогонов.
Выбирать между ними по скорости не из чего — важно, что запас заказан вообще.
Ещё две строки из тех же замеров. Выделений в них нет ни одного — а разница есть:
copyпротив ручного цикла — 1 668 нс против 6 163 на тех же 10 000 элементах.copyиappend(dst[:0], src...)сводятся к одному и тому жеmemmove, а цикл копирует по восемь байт с проверкой границ.rangeпо значению противrangeпо индексу — 32 028 нс против 6 206 на срезе структур по 136 байт. Выделений нет ни там, ни там: вся пятикратная разница — копирование 136 байт на каждой итерации ради восьми нужных. На срезе[]intэтой разницы не существует.
Маленький срез держит большой массив
Сборщик мусора работает с массивом целиком. Пока жив хоть один срез, жив весь массив под ним:
срез из 10 байт от массива в 50 МБ: в куче 50,1 МБ
после copy в свой массив и отказа от подсреза: в куче 0,1 МБ
Вторая строка требует обоих действий: копия сама по себе ничего не освобождает, пока жив подсрез, — а он и держит массив.
Это не оптимизация, а разница между работающим сервисом и утечкой. Функция вида «найти нужный кусок в прочитанном файле» возвращает подсрез — и держит весь файл, пока результат жив.
Официальное введение называет это прямо и предлагает то же лечение: вернуть
копию, а не подсрез. slices.Clone делает это одной строкой.
nil и пустой
var n []int // len 0, cap 0, n == nil, указатель 0x0
e := []int{} // len 0, cap 0, e != nil, указатель ненулевойУ пустого среза указатель ненулевой, но памяти под него не выделено: он показывает на общий для всей программы нулевой объект.
Для len, range, append и copy они неразличимы — append к nil-срезу
работает, диапазон по нему делает ноль итераций. Поэтому проверка
if s == nil почти всегда лишняя, нужна if len(s) == 0.
Разница вылезает ровно в одном месте — в сериализации:
json.Marshal(n) // null
json.Marshal(e) // []Клиент, который ждёт массив, получает null. Это единственная практическая
причина различать их — и она про API, а не про Go.
Что стоит проверять в своём коде
- Возвращаете подсрез чужого буфера — ограничьте запас третьим индексом или верните копию.
- Знаете размер заранее — закажите запас. Не ради наносекунд, а ради выделений: их станет одно вместо девятнадцати.
- Держите маленький кусок большого массива — скопируйте его, иначе большой массив жив.
- Проходите диапазоном по срезу крупных структур — берите индекс, а не значение.
- Отдаёте срез в JSON — решите,
nullтам уместен или[]. - Раздаёте куски одного массива в горутины — разведите диапазоны или
отберите запас: общий массив под
-raceнаходится, а глазами нет.
Как воспроизвести числа
Времени не меряют вовсе три скрипта. bench/goslice/grow.go печатает адреса,
длины, запас и размеры кучи. bench/goslice/clip.go сравнивает Clip и
Clone по адресам массивов. bench/goslice/race_shared.go собирает под
-race программу-спутник bench/goslice/racesrc/main.go — она и есть
подопытный код — и печатает дословный ответ детектора. Все три запускаются из
корня репозитория.
Замер цены — bench/goslice/cost_test.go, четыре блока. Это отдельный модуль
Go, поэтому go test запускается из него:
go run bench/goslice/grow.go
go run bench/goslice/clip.go
go run bench/goslice/race_shared.go
cd bench/goslice
go test -run '^$' -bench . -benchmem .
Запускать с -benchmem, иначе не будет видно главного столбца.
Опубликованный прогон: go1.24.7 linux/amd64, Intel Xeon 2,80 ГГц, август
2026 года. Времена зависят от машины, а столбцы B/op и allocs/op — нет:
они от прогона к прогону не менялись вовсе. Числа роста в grow.go считаются
по шагам роста, а не по счётчику кучи, и потому воспроизводятся до байта;
сверочный счёт по runtime.MemStats печатается рядом и как раз плавает —
на то он и сверочный.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Срез — это три машинных слова: указатель на массив, длина и запас. Всё остальное следует отсюда.
- Заголовок копируется, массив — нет. Поэтому
appendвнутри функции не виден снаружи, а запись в элемент — видна. appendв подсрез может затереть соседа. Уa[:2]длина 2, а запас — до конца массива;appendувидит свободную ячейку и запишет в неё. Тот же вызов при исчерпанном запасе выделит новый массив — и по коду это не видно.- Лечится третьим индексом:
a[:2:2]отбирает запас, иappendобязан выделить своё. - Рост запаса устроен не так, как принято думать. Удвоение кончается на 256, а не на 1024, и дальше отношение не 1,25 и вообще не постоянно: 1,656 → 1,509 → 1,400 → 1,429 → 1,331 → 1,502. Причина — округление до класса размера аллокатора, из-за которого
capбывает 67 или 848. - Цена этого: собрать 10 000 элементов без запаса — 19 выделений и 357 624 байта против 1 выделения и 81 920 байт с запасом.
- Маленький срез держит большой массив. Кусок в 10 байт от массива в 50 МБ оставляет в куче все 50 МБ.
На самом деле
- Порог — 256, и это
const threshold = 256вruntime/slice.go. Версия с 1024 описывает рантайм до Go 1.18, и заметки к тому выпуску это подтверждают: The built-in function append now uses a slightly different formula when deciding how much to grow a slice. Но и «1,25» неточно: формулаnewcap += (newcap + 3*threshold) >> 2— это четверть плюс 192, и к 1,25 отношение лишь приближается. Замерено для[]int: 1,656 → 1,509 → 1,400 → 1,429 → 1,331 → 1,502. Ни постоянного множителя, ни монотонности. - Спецификация считает иначе: the capacity is cap(a) - low. То есть до конца МАССИВА, а не до конца среза: у
a[1:3]от[]int{1,2,3,4,5}запас равен 4. Между длиной и запасом остаются ячейки, которые этот срез считает свободными, а соседний — своими; на этом и построена вся история с затиранием. - Ровно наоборот: Otherwise, append re-uses the underlying array. Воспроизведено:
b := append(a[:2], 9)приa = [1 2 3 4 5]оставляетa = [1 2 9 4 5], и адрес первого элемента у них общий. Хуже того, один и тот же вызов ведёт себя по-разному: при исчерпанном запасе он выделит новый массив и не тронет ничего. По коду в точке вызова это не различить. - Она ограничивает ЗАПАС: sets the capacity to max - low. Длину задаёт второй индекс, и он в этой записи тоже есть. Смысл третьего именно в запасе: без запаса
appendобязан выделить новый массив, и это единственный способ отдать наружу кусок своего буфера, не полагаясь на дисциплину вызывающего. - Замерено на 10 000 элементов: 39 154 нс против 40 542 при одинаковых 81 920 B/op и одном выделении — разница внутри разброса прогонов, где отдельные значения идут от 33 779 до 43 558. Различать эти способы по скорости не из чего. Различать их стоит по другому:
make([]T, N)даёт срез, уже заполненный нулями, иappendк нему допишет ПОСЛЕ них. - Занимает столько, сколько занимает массив под ним, — а он может быть любым. Замерено: срез из 10 байт от массива в 50 МБ оставляет в куче 50,1 МБ; после
copyв свой массив И отказа от подсреза — 0,1 МБ: одна копия ничего не освобождает, пока жив подсрез. Сборщик мусора работает с массивом целиком: пока жив хоть один срез, жив весь массив. Поэтому функция, возвращающая найденный кусок большого буфера, обязана возвращать копию. - Для
len,cap,range,appendиcopy— да, неразличимы, и поэтомуif s == nilпочти всегда лишняя проверка. Но указатель у них разный: у nil он нулевой, у[]int{}— нет (общий нулевой объект, памяти под него не выделено). Наружу разница выходит в сериализации:json.Marshalдаётnullпротив[], и клиент, ждущий массив, получает не то. - Копирует, если брать значение. Замерено на срезе структур по 136 байт:
for _, r := range— 32 028 нс,for j := range— 6 206 нс, впятеро. Выделений нет ни там, ни там — копия попадает в стек, — но 136 байт на итерацию копируются ради восьми нужных. На срезе[]intразницы не существует: там элемент и так помещается в регистр.
Что разобрано
- Три слова
- Заголовок копируется, массив — нет
- Запас считается до конца массива
- Как отдать кусок и не бояться
- `Clip` и `Clone`: что из них от чего защищает
- Общий массив — это общая память
- Рост запаса
- Что этот рост стоит
- Маленький срез держит большой массив
- nil и пустой
- Что стоит проверять в своём коде
- Как воспроизвести числа
Расхожие заблуждения
Срез растёт вдвое до 1024 элементов, дальше в 1,25 раза
Порог — 256, и это const threshold = 256 в runtime/slice.go. Версия с 1024 описывает рантайм до Go 1.18, и заметки к тому выпуску это подтверждают: The built-in function append now uses a slightly different formula when deciding how much to grow a slice
(Встроенная функция append теперь использует несколько иную формулу, решая, насколько нарастить срез). Но и «1,25» неточно: формула newcap += (newcap + 3*threshold) >> 2 — это четверть плюс 192, и к 1,25 отношение лишь приближается. Замерено для []int: 1,656 → 1,509 → 1,400 → 1,429 → 1,331 → 1,502. Ни постоянного множителя, ни монотонности.
Запас у a[1:3] равен 2 — по числу элементов среза
Спецификация считает иначе: the capacity is cap(a) - low
(запас равен cap(a) − low). То есть до конца МАССИВА, а не до конца среза: у a[1:3] от []int{1,2,3,4,5} запас равен 4. Между длиной и запасом остаются ячейки, которые этот срез считает свободными, а соседний — своими; на этом и построена вся история с затиранием.
append всегда возвращает новый массив, поэтому исходный срез в безопасности
Ровно наоборот: Otherwise, append re-uses the underlying array
(Иначе append переиспользует нижележащий массив). Воспроизведено: b := append(a[:2], 9) при a = [1 2 3 4 5] оставляет a = [1 2 9 4 5], и адрес первого элемента у них общий. Хуже того, один и тот же вызов ведёт себя по-разному: при исчерпанном запасе он выделит новый массив и не тронет ничего. По коду в точке вызова это не различить.
Трёхиндексная форма a[:2:2] ограничивает длину
Она ограничивает ЗАПАС: sets the capacity to max - low
(задаёт запас равным max − low). Длину задаёт второй индекс, и он в этой записи тоже есть. Смысл третьего именно в запасе: без запаса append обязан выделить новый массив, и это единственный способ отдать наружу кусок своего буфера, не полагаясь на дисциплину вызывающего.
make([]T, N) с записью по индексу быстрее, чем make([]T, 0, N) с append
Замерено на 10 000 элементов: 39 154 нс против 40 542 при одинаковых 81 920 B/op и одном выделении — разница внутри разброса прогонов, где отдельные значения идут от 33 779 до 43 558. Различать эти способы по скорости не из чего. Различать их стоит по другому: make([]T, N) даёт срез, уже заполненный нулями, и append к нему допишет ПОСЛЕ них.
Срез из десяти элементов занимает память под десять элементов
Занимает столько, сколько занимает массив под ним, — а он может быть любым. Замерено: срез из 10 байт от массива в 50 МБ оставляет в куче 50,1 МБ; после copy в свой массив И отказа от подсреза — 0,1 МБ: одна копия ничего не освобождает, пока жив подсрез. Сборщик мусора работает с массивом целиком: пока жив хоть один срез, жив весь массив. Поэтому функция, возвращающая найденный кусок большого буфера, обязана возвращать копию.
nil-срез и пустой срез — одно и то же
Для len, cap, range, append и copy — да, неразличимы, и поэтому if s == nil почти всегда лишняя проверка. Но указатель у них разный: у nil он нулевой, у []int{} — нет (общий нулевой объект, памяти под него не выделено). Наружу разница выходит в сериализации: json.Marshal даёт null против [], и клиент, ждущий массив, получает не то.
Диапазон по срезу не копирует элементы
Копирует, если брать значение. Замерено на срезе структур по 136 байт: for _, r := range — 32 028 нс, for j := range — 6 206 нс, впятеро. Выделений нет ни там, ни там — копия попадает в стек, — но 136 байт на итерацию копируются ради восьми нужных. На срезе []int разницы не существует: там элемент и так помещается в регистр.
Проверьте себя
a := []int{1,2,3,4,5}. Чему равен cap(a[1:3])?
Источники и что читать дальше
8 ИСТОЧНИКОВ
- Спецификация Go — Slice types, Slice expressions, Appending to and copying slicesОфициальная документация. Определение: «A slice is a descriptor for a contiguous segment of an underlying array and provides access to a numbered sequence of elements from that array» (Срез — это описатель непрерывного участка нижележащего массива, дающий доступ к пронумерованной последовательности его элементов). Совместное владение названо прямо: «A slice, once initialized, is always associated with an underlying array that holds its elements. A slice therefore shares storage with its array and with other slices of the same array» (Срез после инициализации всегда связан с нижележащим массивом, который хранит его элементы. Поэтому срез разделяет память со своим массивом и с другими срезами того же массива). Оттуда же правило запаса для `a[low:high]` — «the capacity is cap(a) - low» (запас равен cap(a) − low) — и для трёхиндексной формы `a[low:high:max]`: она «sets the capacity to max - low» (задаёт запас равным max − low). И поведение append: «If the capacity of s is not large enough to fit the additional values, append allocates a new, sufficiently large underlying array that fits both the existing slice elements and the additional values. Otherwise, append re-uses the underlying array» (Если запаса s не хватает для дополнительных значений, append выделяет новый, достаточно большой нижележащий массив, вмещающий и существующие элементы среза, и дополнительные. Иначе append переиспользует нижележащий массив).https://go.dev/ref/spec
- runtime/slice.go — growslice и nextslicecapИсходный код Go. Порог, вокруг которого весь спор о росте: `const threshold = 256`. Формула после порога — `newcap += (newcap + 3*threshold) >> 2`, и рядом с ней комментарий авторов: «Transition from growing 2x for small slices to growing 1.25x for large slices. This formula gives a smooth-ish transition between the two» (Переход от роста вдвое для маленьких срезов к росту в 1,25 раза для больших. Эта формула даёт более или менее плавный переход между ними). Результат формулы дальше округляется вызовом `roundupsize` до класса размера аллокатора, и именно поэтому наблюдаемые отношения не совпадают ни с 2, ни с 1,25.https://go.dev/src/runtime/slice.go
- Коммит runtime: make slice growth formula a bit smoother, Кит РэндаллИсточник. Изменение, после которого расхожее правило устарело: «Instead of growing 2x for < 1024 elements and 1.25x for >= 1024 elements, use a somewhat smoother formula for the growth factor. Start reducing the growth factor after 256 elements, but slowly» (Вместо роста вдвое до 1024 элементов и в 1,25 раза от 1024 использовать более плавную формулу множителя роста. Начинать снижать множитель после 256 элементов, но медленно). Там же таблица ожидаемых множителей: 256 → 2.0, 512 → 1.63, 1024 → 1.44, 2048 → 1.35, 4096 → 1.30. И оговорка, снимающая вопрос о точности любых таких таблиц: «(Note that the real growth factor, both before and now, is somewhat larger because we round up to the next size class.)» ((Заметьте, что настоящий множитель роста, и до, и теперь, несколько больше, потому что мы округляем вверх до следующего класса размера.)).https://github.com/golang/go/commit/2dda92ff6f9f07eeb110ecbf0fc2d7a0ddd27f9d
- Заметки к выпуску Go 1.18, раздел RuntimeОфициальная документация. Единственное место, где смена формулы привязана к номеру выпуска: «The built-in function append now uses a slightly different formula when deciding how much to grow a slice when it must allocate a new underlying array. The new formula is less prone to sudden transitions in allocation behavior» (Встроенная функция append теперь использует несколько иную формулу, решая, насколько нарастить срез, когда приходится выделять новый нижележащий массив. Новая формула меньше подвержена резким переходам в поведении при выделении памяти). Что это за формула, заметки не говорят — за этим приходится идти в исходник.https://go.dev/doc/go1.18
- runtime/sizeclasses.go — таблица class_to_sizeИсходный код Go. Набор размеров блоков, которые аллокатор вообще умеет выдавать: 8, 16, 24, 32, 48, 64, 80, … 2048, 2304, 2688, 3072. Именно к ним `roundupsize` округляет запрошенный объём, и именно поэтому у среза 40-байтных структур запас выходит 67: запрошенные 64 × 40 = 2560 байт попадают в класс 2688, а в него влезает 67 элементов.https://go.dev/src/runtime/sizeclasses.go
- Go Slices: usage and internalsОфициальная документация. Официальное введение, где срез описан как описатель участка массива из трёх полей — указателя, длины и запаса, — и где названа ловушка с памятью: пока жив срез, сборщик мусора не может освободить весь массив под ним, поэтому функция, возвращающая маленький кусок большого буфера, должна возвращать копию.https://go.dev/blog/slices-intro
- slices/slices.go — Clip и CloneИсходный код Go. Тела обеих функций короче их документирующих комментариев, и в них весь ответ: `func Clip[S ~[]E, E any](s S) S { return s[:len(s):len(s)] }` — то есть трёхиндексное выражение и ноль копирований; `Clone` же кончается на `return append(S{}, s...)`, то есть копирует всегда. Отдельная ветка на nil снабжена пометкой авторов «Preserve nilness in case it matters» (сохранить nil-ность на случай, если это важно), а отказ от `s[:0:0]` — ссылкой на go.dev/issue/68488: такое выражение оставляет живым большой массив.https://go.dev/src/slices/slices.go
- Data Race DetectorОфициальная документация. Откуда взят код возврата 66 в разделе про общий массив: «`exitcode` (default `66`): The exit status to use when exiting after a detected race» (exitcode (по умолчанию 66): код выхода, используемый при завершении после обнаруженной гонки). И граница, за которую зелёный прогон не переносится: «The race detector only finds races that happen at runtime, so it can't find races in code paths that are not executed» (Детектор гонок находит только те гонки, которые случились во время выполнения, поэтому он не может найти гонки в путях кода, которые не выполнялись).https://go.dev/doc/articles/race_detector