Массивы в структурах Go: слайсы, фиксированные массивы и производительность горячих путей | AdminWiki

Массивы в структурах Go: слайсы, фиксированные массивы и производительность горячих путей

28 сентября 2026 11 мин. чтения
Содержание статьи

Массив в 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 value16 нулевых байт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.
  • Массив в структуре увеличивает размер значения, поэтому крупные массивы в структурах передавайте по указателю.

Чек-лист: как выбрать между массивом и слайсом в структуре

  1. Размер данных известен на этапе компиляции и не меняется? Считайте в пользу [N]T.
  2. Структура создаётся на горячем пути миллионами вызовов? Массив убирает аллокацию на объект.
  3. Аллокации и паузы GC видны в профиле? Массив в поле снижает их число.
  4. Нужен append, рост размера или частичное заполнение? Берите слайс.
  5. Данные разделяются между несколькими владельцами через ссылки? Слайс подходит, массив вынудит копирование.
  6. Нужно сравнение значений через == без обхода элементов? Массив даёт это бесплатно.

Порядок действий: прогоните go test -bench=. -benchmem на своём горячем пути, посмотрите allocs/op, затем проверьте escape-анализ через go build -gcflags='-m -m'. Если счётчик аллокаций больше нуля, размер данных фиксирован, а структура не эскейпит, замена []T на [N]T в поле структуры даёт самый быстрый выигрыш с минимальным риском.

Поделиться: