Срезы и мапы в Go: что ревьюить и где течёт память
append на срезе-подрезке молча портит соседние данные, а один долгоживущий срез держит в памяти мегабайтный буфер. Разбираем, как это устроено внутри и что искать на ревью.

Сервис на Go 1.22 после релиза начал занимать вдвое больше памяти при той же нагрузке. Профилировщик показал живой массив на 40 МБ, хотя код возвращал из него срез в три элемента. Причина классическая: срез из трёх элементов держал ссылку на весь исходный массив, и сборщик мусора не мог его освободить. Ниже — как устроены срезы и мапы внутри, и какие конкретные формулировки замечаний писать на ревью, чтобы такие вещи ловить до прода.
Что такое срез на самом деле
Срез в Go — это не массив. Это структура из трёх полей: указатель на элемент базового массива, длина (len) и вместимость (cap). Сам массив живёт отдельно в куче или на стеке, а срез — лишь окно в него.
// Схематично: как выглядит заголовок среза во время выполнения
type sliceHeader struct {
Data uintptr // указатель на первый элемент базового массива
Len int // сколько элементов доступно
Cap int // сколько вмещается без реаллокации
}
Из этого вытекает всё остальное поведение. Когда вы копируете срез присваиванием, копируются три поля заголовка, а базовый массив остаётся общим. Два среза указывают на одну память.
Срез — это ссылка на память, оформленная как значение. Он копируется дёшево, но копия видит те же данные. Половина багов со срезами — это забытый факт, что базовый массив общий.
Почему append иногда портит соседей
Когда вы делаете append, Go смотрит на cap. Если места хватает — новый элемент пишется в существующий массив, и все срезы, смотрящие в этот массив, увидят изменение. Если места нет — выделяется новый массив, данные копируются, и связь со старым разрывается.
package main
import "fmt"
func main() {
исходный := []int{1, 2, 3, 4, 5}
// подрезка: len=2, но cap=5 — унаследован от исходного
первыеДва := исходный[:2]
fmt.Println(len(первыеДва), cap(первыеДва)) // 2 5
// места в cap хватает, поэтому append пишет в тот же массив
первыеДва = append(первыеДва, 99)
fmt.Println(исходный) // [1 2 99 4 5] — тройка затёрта
}
Это одна из самых частых ловушек. Функция получила срез, сделала на нём append в пределах cap — и молча испортила данные вызывающего кода. Ошибки нет, паники нет, просто где-то в другом месте неверное число.
Полное трёхиндексное выражение
Чтобы отрезать хвост cap и заставить append всегда копировать, используйте выражение с тремя индексами s[low:high:max] — оно ограничивает вместимость.
// cap теперь равен len: любой append выделит новый массив
безопасный := исходный[:2:2]
fmt.Println(len(безопасный), cap(безопасный)) // 2 2
безопасный = append(безопасный, 99) // исходный не тронут
Утечка через большой базовый массив
Вернёмся к инциденту из начала. Функция читала файл в срез на 40 МБ, находила в нём короткий заголовок и возвращала data[:16]. Заголовок жил в кэше приложения долго. Сборщик мусора не может освободить базовый массив, пока на него ссылается хоть один срез — а он ссылался.
// Утечка: результат держит весь 40 МБ массив ради 16 байт
func заголовок(данные []byte) []byte {
return данные[:16]
}
// Исправление: копируем нужные байты в новый маленький массив
func заголовокКопия(данные []byte) []byte {
результат := make([]byte, 16)
copy(результат, данные)
return результат
}
Правило для ревью: если из большого буфера возвращается маленький долгоживущий срез — требуйте copy в новый массив. Диагностировать постфактум помогает pprof с флагом просмотра кучи: в топе будет живой массив, размер которого не бьётся с логикой.
Как устроена мапа внутри
Мапа в Go — это хеш-таблица с бакетами. Каждый бакет хранит до 8 пар ключ-значение. При коллизии ключей сверх восьми на бакет цепляется overflow-бакет. Когда заполнение превышает порог, таблица растёт вдвое, и элементы постепенно перераспределяются между старыми и новыми бакетами (инкрементальный rehash).
Практические следствия, которые важнее внутренней механики:
- Порядок обхода не определён и намеренно рандомизирован. Полагаться на порядок ключей при итерации нельзя — рантайм стартует обход со случайного бакета.
- Нельзя брать адрес элемента мапы.
&m["ключ"]не компилируется: при росте таблицы элемент переезжает, адрес стал бы невалидным. - Изменение мапы во время итерации не запрещено, но опасно. Новые ключи могут появиться в обходе, а могут и нет — поведение не гарантировано.
- Мапа не потокобезопасна. Конкурентная запись из двух горутин без синхронизации даёт панику
fatal error: concurrent map writes, которую не поймать черезrecover.
Предвыделение через make с размером
Если вы заранее знаете примерное число ключей, передавайте его вторым аргументом make. Это уменьшает число операций роста и перераспределения бакетов.
// Без подсказки — несколько ростов по мере заполнения
безРазмера := make(map[string]int)
// С подсказкой — рантайм сразу выделит бакеты под ~10000 ключей
сРазмером := make(map[string]int, 10000)
То же касается срезов: make([]T, 0, n) убирает серию реаллокаций при известном заранее объёме.
Мапа не отдаёт память обратно
Ещё одно поведение, которое кусается под нагрузкой: удаление ключей через delete не уменьшает число выделенных бакетов. Если мапа разрослась до миллиона ключей, а потом вы удалили почти все — память под бакеты остаётся занятой. Начиная с Go 1.21 механизм менялся, но общий принцип держится: чтобы реально освободить память, пересоздайте мапу.
// delete чистит содержимое, но не возвращает память под бакеты
for ключ := range большаяМапа {
delete(большаяМапа, ключ)
}
// Реальное освобождение — новая мапа, старая уйдёт в GC
большаяМапа = make(map[string]int)
Сравнение: где что течёт
| Ситуация | Что происходит | Что писать на ревью |
|---|---|---|
| append на подрезке в пределах cap | Затирает данные исходного среза | Требовать s[low:high:max] или copy перед append |
| Маленький срез из большого буфера живёт долго | Держит весь базовый массив в памяти | Копировать нужные байты через copy |
| Много delete на большой мапе | Память под бакеты не освобождается | Пересоздавать мапу через make |
| Конкурентная запись в мапу | fatal error, процесс падает | sync.Mutex или sync.Map, показать место синхронизации |
| Итерация с расчётом на порядок | Порядок случайный между запусками | Сортировать ключи явно, если порядок важен |
Граничные случаи, о которых забывают
- nil-срез и пустой срез — не одно и то же по происхождению, но ведут себя одинаково при append и range.
var s []intиs := []int{}оба безопасны для append. Разница вылезает при сериализации в JSON: nil даётnull, пустой —[]. - Чтение из nil-мапы не паникует, запись — паникует.
var m map[string]int; _ = m["x"]вернёт нулевое значение, аm["x"] = 1уронит сassignment to entry in nil map. Мапу нужно инициализировать через make. - copy копирует min(len назначения, len источника) элементов. Если целевой срез создан как
make([]T, 0, n), его len равен нулю, и copy не скопирует ничего. Нужен len, а не только cap.
Что делать дальше
Заведите в чек-лист ревью три вопроса: возвращается ли маленький срез из большого буфера, делается ли append на срезе-аргументе, и есть ли конкурентный доступ к мапе. Эти три пункта закрывают большинство инцидентов с памятью и гонками на срезах и мапах.
Для диагностики уже случившегося подключите net/http/pprof и снимите профиль кучи под нагрузкой — живой массив, размер которого не совпадает с логикой, выдаёт удержание базового массива. Гонки на мапах ловит запуск тестов и сервиса с флагом -race: он показывает точное место конкурентного доступа со стеками обеих горутин.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Go Slices: usage and internals — The Go Blog, Go maps in action — The Go Blog
Частые вопросы
У меня Go 1.20, поведение мап с памятью такое же?
append точно портит данные или только иногда?
Как понять, что срез держит лишнюю память, если явных симптомов нет?
sync.Map или мьютекс — что выбрать для конкурентной мапы?
А если я сделал copy, но результат всё равно пустой?
Итерация по мапе точно всегда в разном порядке?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.