Deep Engineering
Продвинутый·Опубликовано·30 МИН

Срез в Go: три слова, общий массив и рост запаса не по тому правилу, которое все помнят

Срез — это заголовок из трёх машинных слов над чужим массивом. Отсюда следует и то, что append иногда меняет соседний срез, и то, что кусок в десять байт держит в памяти пятьдесят мегабайт. А правило «×2 до 1024, дальше ×1,25» описывает рантайм до Go 1.18 — и даже тогда было неточным.

Полное техническое изложение

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.

перевод

Срез — это описатель непрерывного участка нижележащего массива, дающий доступ к пронумерованной последовательности его элементов.

Спецификация Go, Slice types

Описатель — это буквально структура из трёх полей, и её размер можно спросить у компилятора:

GO
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.

перевод

Срез после инициализации всегда связан с нижележащим массивом, который хранит его элементы. Поэтому срез разделяет память со своим массивом и с другими срезами того же массива.

Спецификация Go, Slice types

Заголовок копируется, массив — нет

Первое следствие из трёх слов — и самое частое место, где на нём спотыкаются. Копируются именно три слова, а не массив за ними.

GO
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

Спецификация Go, Slice expressions

То есть запас считается до конца массива, а не до конца среза: 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 переиспользует нижележащий массив.

Спецификация Go, Appending to and copying slices

Слово «иначе» здесь и есть вся история:

GO
a := []int{1, 2, 3, 4, 5}
b := append(a[:2], 9)
// b = [1 2 9]
// a = [1 2 9 4 5]   <- третий элемент затёрт

Ошибки не произошло. append сделал ровно то, что обещано: увидел свободную ячейку и записал в неё.

Самое неприятное — что тот же вызов ведёт себя иначе, когда запаса нет.

GO
c := []int{1, 2}
d := append(c, 9)   // запаса нет -> новый массив, c не тронут

Один и тот же append: в первом случае два среза делят массив, во втором — нет. По коду в точке вызова это не видно, потому что зависит не от кода, а от того, каким срез был получен.

Как отдать кусок и не бояться

Для этого есть трёхиндексная форма. Спецификация про неё говорит коротко:

sets the capacity to max - low

перевод

задаёт запас равным max − low

Спецификация Go, Full slice expressions
GO
b := a[:2:2]   // длина 2, запас 2
b = append(b, 9)
// a = [1 2 3 4 5]  — не тронут

Третий индекс ограничивает запас, а не длину. Запаса нет — append обязан выделить новый массив.

Это единственный способ отдать наружу кусок своего буфера, ничего не копируя: не попросить не дописывать, а сделать дописывание в ваш массив физически невозможным. Копия — второй способ, и она защищает от большего; чем именно эти два отличаются, разобрано в следующем разделе.

Clip и Clone: что из них от чего защищает

Трёхиндексную форму не обязательно писать руками: в стандартной библиотеке она лежит под именем slices.Clip, а рядом с ней — slices.Clone. Обе функции коротки настолько, что их тела и есть ответ:

GO
// 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): код выхода, используемый при завершении после обнаруженной гонки.

Data Race Detector

Мьютекс здесь — не лечение, а полумера. В первом случае он гонку уберёт. Во втором под замком останется потерянная запись: два 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 стоит:

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 элементов, но медленно.

Кит Рэндалл, коммит runtime: make slice growth formula a bit smoother

Но и «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 и пустой

GO
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.

Разница вылезает ровно в одном месте — в сериализации:

GO
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 печатается рядом и как раз плавает — на то он и сверочный.

Расхожие заблуждения

Утверждение

Срез растёт вдвое до 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 разницы не существует: там элемент и так помещается в регистр.

Проверьте себя

Вопрос 1 из 5

a := []int{1,2,3,4,5}. Чему равен cap(a[1:3])?

Источники и что читать дальше

8 ИСТОЧНИКОВ

  1. Спецификация 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
  2. 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
  3. Коммит 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
  4. Заметки к выпуску 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
  5. 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
  6. Go Slices: usage and internalsОфициальная документация. Официальное введение, где срез описан как описатель участка массива из трёх полей — указателя, длины и запаса, — и где названа ловушка с памятью: пока жив срез, сборщик мусора не может освободить весь массив под ним, поэтому функция, возвращающая маленький кусок большого буфера, должна возвращать копию.https://go.dev/blog/slices-intro
  7. 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
  8. 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