Слайсы и карты в Go: внутренности и подводные камни

Слайс — это заголовок (указатель, длина, ёмкость) над общим backing-массивом, и отсюда растут коварные баги: append молча перезаписывает «соседа», nil и пустой слайс не одно и то же. Карты рандомизируют порядок обхода и роняют процесс при конкурентной записи. Разбираем внутренности и подводные камни на живых примерах

Слайс в Go выглядит как «динамический массив», а карта — как «словарь», и на первом уровне понимания этого достаточно. Но обе структуры прячут за удобным синтаксисом устройство, о которое регулярно спотыкаются даже опытные разработчики: слайс — это всего лишь заголовок над общим массивом, поэтому append умеет молча перезаписать данные, на которые смотрит совсем другой слайс; а карта намеренно рандомизирует порядок обхода и роняет весь процесс при конкурентной записи, причём так, что recover не помогает. Разберём внутренности и подводные камни — с опорой на живой стенд, где каждый эффект воспроизведён и сверен.

Это третья статья мини-серии «Основы Go вглубь». Она продолжает разговор об интерфейсах и обработке ошибок: там речь шла о том, как значения представлены в памяти и как ведут себя при копировании; здесь — про две структуры, где «копирование значения» и «общая память» расходятся сильнее всего. Тема конкурентного доступа к картам смыкается с примитивами синхронизации и моделью памяти — на них и будем ссылаться, где это уместно.

Схема слайсов и карт: слева — заголовок слайса (POINTER/LEN/CAP) над общим backing-массивом [10 20 30 40], view = full[:2] и append(view, 99) молча меняют full[2] с 30 на 99 (безопасно — трёхиндексный full[:2:2]); справа — карта: случайный порядок обхода по бакетам и одновременная запись двух горутин в bucket k → fatal error: concurrent map writes (не паника — recover не ловит)

В статье

Слайс — это заголовок

Ключ к пониманию слайсов: слайс — это не массив. Слайс — это небольшая структура из трёх полей (в рантайме — reflect.SliceHeader по смыслу):

  • указатель на первый доступный элемент backing-массива;
  • длина (len) — сколько элементов сейчас в слайсе;
  • ёмкость (cap) — сколько элементов помещается в backing-массиве, начиная с указателя, до его конца.

Сам массив с данными живёт отдельно, в куче или на стеке. Заголовок на него только ссылается. Отсюда — главное следствие: когда слайс присваивают, передают в функцию или режут (s[a:b]), копируется заголовок (три машинных слова), но backing-массив остаётся общим. Два слайса-заголовка могут указывать на один и тот же массив и видеть правки друг друга.

a := []int{10, 20, 30, 40}
b := a       // копируется заголовок: тот же указатель, len=4, cap=4
b[0] = 99    // пишем через b ...
// теперь a[0] == 99 — b и a смотрят в ОДИН массив

c := a[1:3]  // новый заголовок: указатель сдвинут на a[1], len=2, cap=3
c[0] = 7     // это a[1]
// теперь a == [99 7 30 40]

Мысленная модель: слайс — это «окно» (offset + длина) поверх массива, а cap показывает, сколько ещё места есть справа от окна до конца массива. Срез a[1:3] сдвигает начало окна на элемент a[1], поэтому его cap становится равным 3 (a[1], a[2], a[3]), а не 4. Пока разные слайсы делят один массив, запись по индексу в одном видна в другом — это не баг, а прямое следствие того, что данные лежат в общем массиве, а заголовки лишь ссылаются на него.

Рост append

append добавляет элементы в конец слайса. Его поведение определяется тем, есть ли в backing-массиве запас ёмкости:

  • len < cap — место есть. append записывает новый элемент прямо в существующий массив (в ячейку с индексом len), увеличивает len в возвращённом заголовке и не выделяет новую память. Backing-массив тот же — а значит, все слайсы, делящие этот массив, могут «увидеть» запись (об этом — в следующем разделе).
  • len == cap — место кончилось. append выделяет новый, обычно кратно больший массив, копирует туда старые элементы, дописывает новый и возвращает заголовок, указывающий уже на новый массив. С этого момента слайс отвязан от старого backing-массива: правки в него больше не видны через старые слайсы.

Именно поэтому канонический способ вызова — s = append(s, x): возвращённый заголовок может отличаться от исходного (другой указатель, другой cap), и присваивание обязательно. Конкретный множитель роста — деталь реализации рантайма (исторически «удвоение» для маленьких слайсов и коэффициент около 1.25 для больших), и полагаться на точные числа нельзя. Гарантируется лишь амортизированная стоимость O(1) на элемент: редкие дорогие перевыделения размазываются по множеству дешёвых вставок.

На стенде это показывает функция AppendGrowth из slices.go — она добавляет элементы по одному, начиная с nil-слайса, и снимает (len, cap) после каждого шага:

func AppendGrowth(n int) []GrowthStep {
	steps := make([]GrowthStep, 0, n)
	var s []int // nil-слайс: len==0, cap==0
	for i := 0; i < n; i++ {
		s = append(s, i)
		steps = append(steps, GrowthStep{Len: len(s), Cap: cap(s)})
	}
	return steps
}

Тест стенда сознательно проверяет инварианты, а не жёсткие значения cap: len растёт на единицу за шаг, cap >= len на каждом шаге и cap никогда не убывает. Это правильная дисциплина при работе с деталями рантайма — фиксировать поведение (амортизированный рост), а не магические числа, которые вправе меняться между версиями Go.

Практический вывод: если итоговый размер известен заранее, стоит выделить ёмкость сразу — make([]T, 0, n) — и append ни разу не будет перевыделять массив. Это тот же приём, что использован внутри AppendGrowth для аккумулятора steps.

Ловушка алиасинга append

Теперь соединим два факта из предыдущих разделов — «слайсы делят backing-массив» и «append при наличии запаса cap пишет в этот массив на месте» — и получим самый коварный баг вокруг слайсов. Это демо cmd/append-aliasing, вывод сверен на Go 1.26.3.

full := []int{10, 20, 30, 40}
// full: [10 20 30 40]

// view делит backing-массив с full и имеет ЗАПАС ёмкости.
view := full[:2] // len=2, cap=4 -> [10 20]

// append влезает в имеющуюся ёмкость (len 2 < cap 4) — новый массив
// НЕ выделяется, запись идёт в общий массив, в ячейку, которую видит full[2].
view = append(view, 99)

// view: [10 20 99]
// full: [10 20 99 40]   <- элемент full[2] МОЛЧА изменился с 30 на 99

Разберём по шагам, что произошло:

  1. view := full[:2] даёт заголовок с len=2, но cap=4 — окно сузилось до двух элементов, однако backing-массив тот же, и ёмкость видна до самого его конца.
  2. append(view, 99) видит len (2) < cap (4) — место есть. Значит, новый массив не выделяется: 99 записывается в ячейку с индексом 2 общего массива и len увеличивается до 3.
  3. Но эта ячейка с индексом 2 — та самая, на которую смотрит full[2]. Через full там был 30. Теперь там 99. Значение full изменилось молча, без единой строчки, где мы явно трогали бы full.

Ни компилятор, ни рантайм не выдают предупреждения: с точки зрения языка всё легально. Баг проявляется, когда «урезанный» слайс view живёт своей жизнью (например, возвращён из функции-парсера), а исходный full где-то ещё считается неизменным.

Защищаться можно двумя способами.

Трёхиндексный срез full[:2:2] — форма s[low:high:max] — задаёт не только длину (high-low), но и ёмкость (max-low). Ограничив cap длиной, мы лишаем слайс запаса: следующий append будет вынужден выделить новый массив, и сосед не пострадает.

base := []int{1, 2, 3, 4}
safe := base[:2:2] // len=2, cap=2 — запаса НЕТ
safe = append(safe, 777)
// safe: [1 2 777]     — новый массив
// base: [1 2 3 4]     — НЕ тронут

Явная копияslices.Clone(s) (Go 1.21+) или классический append([]T(nil), s...). Копия не делит backing-массив с оригиналом вообще, поэтому любые append и записи по индексу безопасны. На стенде оба приёма — CloneViaStdlib и CloneViaAppend — покрыты тестом TestCloneIsIndependent, который правит копию и проверяет, что правка не протекла в исходник.

Правило по итогу: если урезанный слайс отдаётся наружу и должен быть независим — либо обрежьте его ёмкость трёхиндексным срезом, либо клонируйте. Полагаться на то, что «append наверняка выделит новый массив», нельзя — при наличии запаса cap он этого не сделает.

nil против пустого слайса

nil-слайс — это заголовок с нулевым указателем, len==0 и cap==0. Пустой слайс []T{} — это заголовок с ненулевым указателем (на массив нулевой длины), тоже len==0. По поведению они почти неотличимы, и это сделано намеренно:

  • append к nil-слайсу работает — рантайм сам выделит массив: var s []int; s = append(s, 1, 2, 3) даёт [1 2 3];
  • len(nil) и cap(nil) равны 0;
  • range по nil-слайсу просто не делает ни одной итерации.

Поэтому проверять «пусто ли» надо через len(s) == 0, а не через s == nil: пустой не-nil слайс тоже пуст, но s == nil для него ложно. На стенде это фиксирует TestNilVsEmpty.

var nilSlice []int      // nil:   len==0, cap==0, ==nil истинно
emptySlice := []int{}   // пустой: len==0,        ==nil ложно

_ = append(nilSlice, 1) // работает: append к nil легален
// len обоих == 0; проверять пустоту надо через len(), не через == nil

Где различие всё же важно — сериализация. Стандартный encoding/json кодирует nil-слайс как null, а пустой []T{} — как []:

type Resp struct {
	Items []string `json:"items"`
}

// Items == nil        -> {"items":null}
// Items == []string{} -> {"items":[]}

Для многих клиентов (особенно фронтенда и строго типизированных API) null и [] — разные вещи: первое ломается на .map/.length, второе штатно означает «пустой список». Если контракт требует именно пустой массив, инициализируйте поле явным []string{} перед маршалингом (или через make([]string, 0)), а не оставляйте nil. В обратную сторону симметрично: при десериализации null в слайс даёт nil, отсутствующий ключ — тоже nil, а [] — пустой не-nil слайс.

Карты: устройство и порядок обхода

Карта в Go — это хеш-таблица. Ключ хешируется; часть битов хеша выбирает группу слотов, другая часть (top hash) хранится рядом со слотами и служит для быстрой отбраковки: сравнивая эти биты, рантайм пропускает заведомо чужие слоты, не трогая сами ключи, — это ускоряет поиск, но не задаёт «позицию» как прямой индекс. По мере наполнения таблица инкрементально перехешируется — обычно в больший набор слотов, но не всегда «ровно вдвое»: при разросшихся цепочках/перегрузке overflow возможен same-size grow — перепаковка в тот же размер, чтобы убрать фрагментацию. Точная внутренняя раскладка — деталь реализации и менялась между версиями (в Go 1.24 встроенная map переехала на Swiss Tables), поэтому опираться стоит не на неё, а на наблюдаемые свойства: неопределённый порядок обхода и фатальное падение при конкурентной записи (оба разбираем ниже). Заголовок карты хранится по указателю, поэтому карта, в отличие от массива, не копируется при присваивании — копируется ссылка, и два имени указывают на одну таблицу.

Важнейшая практическая деталь: по спецификации языка порядок обхода карты не определён — полагаться на него нельзя. Текущая реализация рантайма идёт дальше и намеренно его рандомизирует: каждый for k := range m стартует со случайного бакета и смещения. Это уже деталь реализации, а не гарантия спецификации — гарантия ровно одна: порядок непредсказуем. Демонстрирует cmd/map-order:

m := map[string]int{
	"alpha": 1, "bravo": 2, "charlie": 3,
	"delta": 4, "echo": 5, "foxtrot": 6,
}

for pass := 1; pass <= 3; pass++ {
	keys := make([]string, 0, len(m))
	for k := range m { // порядок range по map НЕ определён
		keys = append(keys, k)
	}
	fmt.Printf("проход %d: %v\n", pass, keys)
}

Проходы по одной и той же неизменной карте могут давать разные порядки ключей — эффект вероятностный: отдельные два прохода иногда совпадают, но полагаться на порядок нельзя (конкретные перестановки зависят от запуска — приводить их бессмысленно, они меняются). Чем больше проходов, тем очевиднее разброс. Это не побочный эффект реализации, а сознательное решение авторов языка: если бы порядок случайно оказывался стабильным, разработчики начали бы неявно на него полагаться, и код ломался бы при смене версии Go или размера карты. Рандомизация превращает скрытую зависимость в явную ошибку, заметную сразу.

Когда нужен детерминированный обход — соберите ключи в слайс и отсортируйте:

sorted := make([]string, 0, len(m))
for k := range m {
	sorted = append(sorted, k)
}
sort.Strings(sorted) // или slices.Sort(sorted) (Go 1.21+)
for _, k := range sorted {
	// обход в предсказуемом порядке
	_ = m[k]
}

Это единственный корректный способ получить воспроизводимый порядок: сам range его не гарантирует ни при каких условиях.

Частый смежный вопрос — можно ли менять карту прямо во время range. Спецификация отвечает раздельно для двух случаев. Удаление ключа во время обхода разрешено явно: удалённый до его посещения ключ дальше не будет отдан, а удаление текущего или уже пройденного — безопасно.

m := map[int]int{1: 1, 2: 2, 3: 3, 4: 4}
for k := range m {
    if k%2 == 0 {
        delete(m, k) // удалять в range можно
    }
}
// len(m) == 2 — остались нечётные ключи

А вот добавление ключей во время range спецификация оставляет неопределённым: новый ключ может быть отдан в этой же итерации, а может и нет — полагаться на исход нельзя. Практическое правило простое: удалять по ходу обхода — можно; добавлять — нет, для этого копят новые ключи в отдельный слайс/карту и вносят после цикла. (Это про однопоточную мутацию из той же горутины; параллельная запись из другой горутины — уже не «неопределённый порядок», а fatal error, см. ниже.)

nil-карта: чтение и запись

С nil-картой (объявлена, но не инициализирована через make или литерал) поведение асимметрично — и это регулярный источник паник.

  • Чтение из nil-карты легально. m["absent"] возвращает нулевое значение типа (0 для int, "" для string), comma-ok даёт v, false, а len(m) равен 0. Ничего не паникует.
  • Запись в nil-карту паникуетpanic: assignment to entry in nil map.
var m map[string]int // nil-карта

_ = m["absent"] // ок: вернёт 0
_, ok := m["x"] // ок: ok == false
_ = len(m)      // ок: 0

m["key"] = 1 // panic: assignment to entry in nil map

На стенде это проверено безопасно: TestReadFromNilMap убеждается, что чтение из nil-карты возвращает нули, а TestWriteToNilMapPanics через хелпер didPanicrecover) фиксирует факт паники — тест ловит её в изоляции и потому проходит. Ключевой момент для следующего раздела: паника при записи в nil-карту — это обычная паника, её можно перехватить recover. Лечится проблема одной строкой инициализации: m := make(map[string]int) (или литерал map[string]int{}) перед первой записью.

Concurrent map writes

Встроенная карта не безопасна для конкурентного использования. Одновременное чтение из нескольких горутин допустимо, но параллельная запись из нескольких горутин (или запись одновременно с чтением) — это data race, и её нельзя «сделать безопаснее», не убрав саму гонку. У рантайма есть встроенная защита: если он застаёт такое пересечение, он аварийно завершает весь процесс. Важно не переоценить: это не гарантия поймать любой некорректный доступ и не замена детектору -race — рантайм ловит лишь часть реально пересёкшихся операций, когда они фактически столкнулись на исполненном пути (подробнее об этом ниже). Демо cmd/concurrent-map:

m := make(map[int]int)
var wg sync.WaitGroup

for g := 0; g < 8; g++ {
	wg.Add(1)
	go func(base int) {
		defer wg.Done()
		for i := 0; i < 100_000; i++ {
			m[base] = i // незащищённая конкурентная запись
		}
	}(g)
}
wg.Wait() // до сюда обычно не доходим — процесс уже упал

Запуск роняет программу с сообщением:

fatal error: concurrent map writes

goroutine ... [running]:
... main.main.func1 ...
exit status 2

Критически важное различие: concurrent map writes — это fatal error рантайма, а не паника. И recover её не ловит. Здесь принципиальный контраст с предыдущим разделом: запись в nil-карту — обычная паника, перехватываемая recover; конкурентная запись — фатальная ошибка, которая убивает процесс безусловно, никакой defer/recover её не остановит. Так сделано намеренно: обнаружив повреждение внутренней структуры карты гонкой, рантайм не пытается «продолжить как-нибудь» — состояние уже неопределённо, и единственный безопасный исход — немедленно упасть.

Обратите внимание: детектор конкурентного доступа к картам встроен в рантайм и работает всегда, без флага -race (это дешёвая проверка на уровне самих операций с картой, а не полноценный детектор гонок). Но, как любая динамическая проверка, он замечает нарушение только когда оно фактически произошло на исполненном пути.

Чинить — стандартными средствами синхронизации: sync.Mutex/sync.RWMutex вокруг чтения и записи, либо sync.Map (оптимизирована под сценарии «много читателей» и непересекающиеся ключи). Подробно об этих примитивах и о том, какие гарантии видимости они дают, — в статье про пакет sync и атомики.

type SafeMap struct {
	mu sync.Mutex
	m  map[int]int
}

func (s *SafeMap) Set(k, v int) {
	s.mu.Lock()
	s.m[k] = v
	s.mu.Unlock()
}

func (s *SafeMap) Get(k int) (int, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	v, ok := s.m[k]
	return v, ok
}

Практические правила

  • Копируйте перед мутацией разделяемого слайса. Если слайс пришёл извне (аргумент, поле структуры) и вы собираетесь его менять append-ом или по индексу, а оригинал должен остаться нетронутым — сделайте slices.Clone (или append([]T(nil), s...)).
  • Обрезайте ёмкость на границах. Возвращаете «урезанный» срез наружу как самостоятельное значение — используйте трёхиндексный срез s[low:high:high], чтобы append получателя не переписал соседние элементы общего массива.
  • Не полагайтесь на append, что он выделит новый массив. При len < cap он пишет в общий массив на месте. Хотите гарантированной независимости — клон или обрезанная ёмкость.
  • Проверяйте пустоту через len(s) == 0, а не s == nil. Учитывайте разницу nil (null) и []T{} ([]) при JSON-сериализации, если контракт API это различает.
  • Никогда не полагайтесь на порядок обхода карты. Нужен стабильный порядок — соберите ключи в слайс и отсортируйте.
  • Инициализируйте карту перед записью (make/литерал): запись в nil-карту паникует.
  • Преаллоцируйте карту под известный размерmake(map[K]V, n). Как make([]T, 0, n) для слайса, подсказка ёмкости сокращает число перестроек хеш-таблицы (rehash) при наполнении: если знаете, что вложите ~N пар, скажите это сразу.
  • Мутируйте карту в range осознанно: delete по ходу обхода легален, добавление ключей — поведение не определено; новые ключи копите и вносите после цикла.
  • Защищайте карты под конкуренцией. Любой параллельный доступ, где есть хотя бы одна запись, — под Mutex/RWMutex или через sync.Map. Помните: concurrent map writes — фатальная ошибка, recover её не перехватит.

Демо и версии

  • Код: живой стенд digital-cookbook/go-fundamentals/slices-maps/. В корне — slices.go (рост append, nil/пустой, независимое клонирование) и тесты slices_test.go/maps_test.go (инварианты роста, паника записи в nil-карту через recover, независимость копий) — проходят под go test ./.... Демонстрации спецэффектов — в cmd/: append-aliasing (тихая перезапись соседа, вывод сверен на Go 1.26.3), map-order (рандомизация порядка обхода), concurrent-map (намеренно падает с fatal error: concurrent map writes — эту программу не «чинить», она показывает эффект). Код в тексте — выжимки. Запуск из папки slices-maps/: go run ./cmd/append-aliasing (и аналогично ./cmd/map-order, ./cmd/concurrent-map); из корня модуля go-fundamentals — с префиксом пути: go run ./slices-maps/cmd/append-aliasing.
  • Версии: slices.Clone, slices.Sort, maps и другие обобщённые пакеты доступны с Go 1.21; трёхиндексный срез и всё остальное про слайсы/карты — с ранних версий языка. Собирайте на текущем стабильном релизе.
  • Смежные статьи мини-серии: интерфейсы, обработка ошибок. По конкурентному доступу — пакет sync и атомики и модель памяти Go.

Документация и первоисточники

  • Go Slices: usage and internals — блог go.dev: заголовок слайса (указатель/len/cap), устройство append и рост, алиасинг backing-массива.
  • Arrays, slices (and strings): The mechanics of ‘append’ — как именно append копирует и когда перевыделяет массив.
  • The Go Programming Language Specification — разделы Slice types, Slice expressions (в т.ч. трёхиндексная форма a[low:high:max]), Map types, Appending to and copying slices: формальная семантика.
  • Go maps in action — блог go.dev: карта как хеш-таблица, отсутствие гарантий порядка, nil-карты и потокобезопасность.
  • Go 1.21 Release Notes — появление стандартных пакетов slices и maps (slices.Clone, slices.Sort и др.).

Обсуждение в Telegram

Присоединиться →

Комментарии