Массив в Go хранит данные по месту: значение [32]byte занимает ровно 32 байта и копируется целиком при присваивании или передаче в функцию. Слайс []byte описывает окно в базовый массив и передаётся как небольшой дескриптор, а сами элементы лежат в отдельно выделенном буфере.
Отсюда два практических следствия. Массив в структуре избавляет от отдельной аллокации под данные, но каждая передача по значению копирует весь его объём. Слайс копирует только дескриптор, зато базовый массив живёт в куче и отслеживается сборщиком мусора.
Ниже разбираем устройство слайса, три способа передать данные в функцию, размещение [N]T в поле структуры, чтение escape-анализа и метрики бенчмарков. В конце - чек-лист выбора. Все команды рассчитаны на самостоятельный прогон: решения компилятора и цифры зависят от версии тулчейна и платформы.
Массивы и слайсы в Go: в чём разница и почему это важно для производительности
Массив - значение фиксированной длины, известной на этапе компиляции. Его размер входит в тип: [4]int и [8]int - разные типы. Слайс описывает окно в базовый массив и меняет длину во время работы программы. len возвращает число доступных элементов, cap - сколько элементов поместится до перевыделения.
Семантика копирования задаёт цену операций на горячем пути: присваивание массива копирует все байты, присваивание слайса копирует дескриптор и делит данные между владельцами.
Как устроен слайс внутри: указатель, длина, ёмкость
По блогу Go слайс - это представление (view) части массива, содержащее указатель, длину и ёмкость. Элементы лежат в базовом массиве, который выделяется отдельно от дескриптора. Документация unsafe.Sizeof уточняет: для слайса Sizeof возвращает размер дескриптора слайса, а не размер памяти, на которую ссылается слайс. Там же отмечено, что reflect.SliceHeader и reflect.StringHeader следует использовать только как указатели на реальные слайсы или строки и никогда как обычные структуры.
s := make([]byte, 4, 16) // len=4, cap=16, под данные выделено 16 байт t := s[:2] // len=2, cap=16, тот же базовый массив
Передача слайса в функцию копирует дескриптор, базовый массив остаётся общим. Изменение s[0] внутри функции видно вызывающему коду. Если append выходит за границу cap, рантайм выделяет новый базовый массив и переносит туда элементы: правки через полученный слайс исходных данных уже не касаются.
nil-слайс (var s []byte) имеет len=0 и cap=0, память под данные не выделяется. Про общий буфер, алиасинг и время жизни ссылок в разных языках мы разбирали в материале срезы и представления массивов: общий буфер без копирования данных.
Массив как значение: копирование при присваивании и передаче
Присваивание массива создаёт полную копию.
var a [4]int b := a // копия 4 * 8 = 32 байта b[0] = 99 // a[0] остаётся 0
Вызов функции с массивом без указателя копирует все байты: f([1024]byte) переносит 1 КиБ на каждый вызов. Массивы сравнимы оператором ==, если сравнимы их элементы. Слайсы сравнивать через == нельзя, допустимо только сравнение с nil; для поэлементной проверки используйте slices.Equal из пакета slices. По документации пакета slices функция сравнивает элементы по возрастанию индекса и останавливается на первой несовпадающей паре; пустые и nil-слайсы считаются равными, а NaN не считаются равными. Точный номер версии, в которой появился slices.Equal, в доступных источниках не зафиксирован - проверяйте по release notes вашего тулчейна.
Передача массива по значению и слайса в функцию: что происходит на самом деле
Три варианта передачи дают разное поведение:
- Массив по значению: полная копия, изменения внутри функции наружу не видны.
- Указатель на массив: копии нет, изменения видны, но структура может эскейпить в кучу.
- Слайс: копируется дескриптор, элементы общие, append может отвязать слайс от исходного массива.
Когда копирование массива дороже аллокации слайса
Ориентиры по объёму копии за один вызов: [64]byte - 64 байта, [1024]byte - 1 КиБ, [4096]byte - 4 КиБ. При миллионах вызовов в секунду копия на 4 КиБ превращается в гигабайты перемещаемых данных, и одна аллокация слайса в куче оказывается дешевле. На малых размерах расклад обратный: копия 64 байт дешевле аллокации.
Компилятор оставляет массив на стеке, если тот не эскейпит. Проверьте на своём коде:
go build -gcflags=-m ./... go build -gcflags='-m -m' ./...
Критерии выбора между стеком и кучей, причины переполнения стека и работу аллокаторов мы разбирали отдельно: размещение массивов на стеке и в куче.
Практический пример: функция обработки пакета
type Packet struct {
payload [1500]byte
n int
}
func (p *Packet) readFrom(r io.Reader) error {
n, err := r.Read(p.payload[:])
p.n = n
return err
}
func handleValue(p [1500]byte) {} // копия 1500 байт на каждый вызов
func handlePtr(p *[1500]byte) {} // копии нет
func handleSlice(p []byte) {} // копия дескриптора
Для сетевых пакетов с известным MTU 1500 фиксированный массив в структуре убирает аллокацию под каждый пакет: буфер живёт вместе со значением. Слайс добавляет дескриптор и потенциальную аллокацию базового массива, если буфер не переиспользуется. Структура Packet весит около 1.5 КиБ, поэтому передавайте её по указателю.
Массив как поле структуры: размещение, копирование и escape-анализ
Поле [N]T размещается внутри значения структуры: данные лежат по месту, без отдельного выделения. Поле []T хранит дескриптор и указывает на буфер в куче. Структура с массивом крупнее по размеру, но создаётся одним куском памяти, а её данные сборщик мусора не сканирует как указатели.
type Digest struct {
sum [32]byte // 32 байта внутри значения
ok bool
}
type Dyn struct {
sum []byte // дескриптор плюс отдельный буфер
ok bool
}
Как читать вывод escape-анализа для массивов и слайсов
Флаг -gcflags='-m -m' печатает решения компилятора построчно. По материалам о директивах компилятора в примерах вывода escape-анализа встречаются строки вида "moved to heap: i" и "leaking param: p". "moved to heap" означает, что значение размещено в куче; "leaking param" говорит о том, что параметр функции может сохраниться за пределами вызова. Точные формулировки для "escapes to heap" и "does not escape" в доступных источниках не зафиксированы, поэтому ориентируйтесь на фактический вывод вашего тулчейна, а не на заученную фразу.
Массив в структуре часто остаётся на стеке, если структура не передаётся в интерфейс, не возвращается по указателю и не захвачена горутиной. Слайс с локальным make() эскейпит чаще: компилятору сложнее доказать время жизни базового массива. Отдельно учитывайте, что компилятор Go предполагает, что указатели и другие reference-типы убегают в кучу, если передаются в функцию, написанную не на Go. Для низкоуровневых функций на ассемблере есть директива //go:noescape, которая сообщает компилятору, что параметры не убегают и не нуждаются в размещении в куче.
Escape-анализ зависит от версии компилятора, поэтому вывод, полученный на одном тулчейне, стоит перепроверять после обновления Go.
Сравнение размещения: структура с [16]byte против структуры с []byte
| Критерий | Структура с [16]byte | Структура с []byte |
|---|---|---|
| Размер значения | 16 байт плюс байты выравнивания | дескриптор слайса |
| Аллокации при создании | 0, если структура остаётся на стеке | 1 на базовый массив при выделении в куче |
| Работа сборщика мусора | Байты данных не сканируются как указатели | Буфер отслеживается и участвует в циклах GC |
| Передача по значению | Копируется всё поле целиком | Копируется только дескриптор |
| Смена размера | Невозможна | append и перевыделение |
| Zero value | 16 нулевых байт | nil, len=0, cap=0 |
Для буфера SHA-256 разумно взять [32]byte: по документации crypto/sha256 константа Size = 32 - размер контрольной суммы SHA256 в байтах, размер хеша известен заранее, значение можно копировать и сравнивать оператором ==. Слайс []byte дал бы дескриптор и отдельную аллокацию под каждый хеш. В слайсе хешей []SHA256Sum элементы лежат подряд по 32 байта без указателей, что улучшает локальность кэша при обходе.
Бенчмарки: как измерить разницу между массивом и слайсом
Проверяйте решения замерами. Базовый набор команд для тестового пакета:
go test -bench=. -benchmem go test -bench=. -benchmem -count=10 benchstat old.txt new.txt
Шаблон бенчмарка для массива и слайса
var sinkByte byte
func BenchmarkArrayByValue(b *testing.B) {
var a [64]byte
b.ReportAllocs()
for i := 0; i < b.N; i++ {
sinkByte = sumValue(a)
}
}
func BenchmarkArrayByPointer(b *testing.B) {
var a [64]byte
b.ReportAllocs()
for i := 0; i < b.N; i++ {
sinkByte = sumPtr(&a)
}
}
func BenchmarkSlice(b *testing.B) {
s := make([]byte, 64)
b.ReportAllocs()
for i := 0; i < b.N; i++ {
sinkByte = sumSlice(s)
}
}
Записывайте результат в переменную уровня пакета (sinkByte), иначе компилятор выбросит вызов как ненужный. Флаг -count=10 даёт десять прогонов и позволяет оценить разброс; benchstat сравнивает две серии и показывает, выходит ли разница за пределы шума.
Как интерпретировать allocs/op и B/op
ns/op - среднее время операции. По документации пакета testing AllocedBytesPerOp возвращает метрику "B/op", вычисляемую как r.MemBytes / r.N, а AllocsPerOp возвращает метрику "allocs/op". Флаг -benchmem сообщает B/op и allocs/op; сырой вывод бенчмарка выглядит примерно так: BenchmarkNATApply_SingleRule-11 517938 2318 ns/op 2624 B/op 38 allocs/op. Метод ReportAllocs включает статистику malloc для бенчмарка: это эквивалентно -test.benchmem, но влияет только на ту функцию бенчмарка, которая его вызывает.
Конкретные значения вроде 1 alloc/op и ~64 B/op для make([]byte, 64) или 0 allocs/op и 0 B/op для массива на стеке - это ожидаемый результат вашего собственного замера, а не универсальная константа: цифры зависят от тулчейна, платформы и того, эскейпит ли значение. Ноль аллокаций на горячем пути снижает работу сборщика мусора и стабилизирует хвостовые задержки p99.
Если счётчик аллокаций нужно разложить по строкам кода, подключите профилирование: методика расчёта и сравнения фактического объёма описана в материале измерение памяти под массивы: профилирование и оценка объёма.
Когда фиксированный массив в структуре предпочтительнее слайса
Массив выигрывает там, где размер известен заранее и данные не меняют длину: буферы под MTU 1500, хеши и контрольные суммы (для SHA-256 документация фиксирует Size = 32 байта), заголовки протоколов, кэши с жёстким лимитом. Общий эффект: одна аллокация на объект вместо двух и меньше объектов под управлением GC.
Когда вопрос в выборе структуры хранения под нагрузкой с частыми вставками и удалениями, смотрите отдельное сравнение: связные списки или массивы: что выбрать для хранения данных.
Сценарий: буфер для чтения из сети
type Reader struct {
buf [1500]byte
n int
}
func (r *Reader) readFrom(c net.Conn) error {
n, err := c.Read(r.buf[:])
r.n = n
return err
}
Аллокаций на пакет нет, пул не нужен. Передавайте Reader по указателю: копия структуры на 1.5 КиБ дороже, чем сам разбор пакета. Со слайсом буфер обычно переиспользуют через sync.Pool, но пул добавляет свой оверхед, требует возвращать буферы и усложняет время жизни данных.
Сценарий: хеш и контрольная сумма
type SHA256Sum [32]byte
func (s SHA256Sum) String() string {
return hex.EncodeToString(s[:])
}
func (s SHA256Sum) Equal(o SHA256Sum) bool {
return subtle.ConstantTimeCompare(s[:], o[:]) == 1
}
Размер хеша фиксирован, размещение предсказуемо, сравнение через == доступно без обхода элементов. Для проверки подлинности используйте crypto/subtle: обычное == на хешах даёт утечку по времени. Размеры SHA-1 (20 байт) и MD5 (16 байт) в доступных источниках не подтверждены - сверяйтесь с документацией соответствующих пакетов.
Типичные ошибки и подводные камни при работе с массивами и слайсами
Aliasing слайсов: как неожиданно изменить данные
s1 := []int{1, 2, 3}
s2 := s1[:2]
s2[0] = 99 // s1[0] тоже станет 99
s3 := make([]int, len(s1))
copy(s3, s1) // независимая копия
s4 := append([]int(nil), s1...)
Нарезка не копирует данные: s2 и s1 смотрят в один базовый массив. Если подмассив нужен надолго, скопируйте его: s := big[:2] удерживает в памяти весь базовый массив, даже когда нужны два элемента. Это частая причина роста RSS при обработке крупных буферов.
nil-слайс против пустого слайса
var a []int даёт nil-слайс, []int{} даёт пустой с len=0. В JSON первое сериализуется как null, второе как []. Проверка len(s) == 0 покрывает оба случая и читается однозначно; сравнение s == nil осмысленно, только если различие между nil и пустым слайсом важно для логики или протокола.
Ещё несколько граблей
- Передача массива в интерфейс копирует всё значение: передавайте указатель, если массив крупный.
- append может перевыделить базовый массив при исчерпании cap, и ранее полученные слайсы перестанут видеть новые элементы.
- Сравнение слайсов через == не компилируется; используйте slices.Equal или bytes.Equal для []byte.
- Массив в структуре увеличивает размер значения, поэтому крупные массивы в структурах передавайте по указателю.
Чек-лист: как выбрать между массивом и слайсом в структуре
- Размер данных известен на этапе компиляции и не меняется? Считайте в пользу [N]T.
- Структура создаётся на горячем пути миллионами вызовов? Массив убирает аллокацию на объект.
- Аллокации и паузы GC видны в профиле? Массив в поле снижает их число.
- Нужен append, рост размера или частичное заполнение? Берите слайс.
- Данные разделяются между несколькими владельцами через ссылки? Слайс подходит, массив вынудит копирование.
- Нужно сравнение значений через == без обхода элементов? Массив даёт это бесплатно.
Порядок действий: прогоните go test -bench=. -benchmem на своём горячем пути, посмотрите allocs/op, затем проверьте escape-анализ через go build -gcflags='-m -m'. Если счётчик аллокаций больше нуля, размер данных фиксирован, а структура не эскейпит, замена []T на [N]T в поле структуры даёт самый быстрый выигрыш с минимальным риском.