Мапы в Go: создание, обход, удаление без боли
Map в Go — одна из самых используемых структур данных, но с несколькими неочевидными ловушками: паника при записи в nil-мапу, случайный порядок обхода и гонки при конкурентном доступе. Разбираем три способа создания, безопасное чтение, удаление ключей прямо во время итерации и то, за что компилятор не предупредит, но рантайм накажет.

Три способа создать мапу — и когда какой
В Go (речь о версии 1.22+) мапа — это ссылочный тип. Переменная var m map[string]int после объявления равна nil. Читать из неё можно — вернётся нулевое значение типа. Записать — паника: assignment to entry in nil map. Это первая ловушка, на которую наступает каждый новичок.
Создать рабочую мапу можно тремя путями:
- Литерал — когда данные известны заранее.
makeбез размера — универсальный вариант.makeс подсказкой ёмкости — когда примерный объём данных предсказуем и хочется избежать лишних аллокаций при росте.
// 1. Литерал
statusCodes := map[int]string{
200: "OK",
404: "Not Found",
500: "Internal Server Error",
}
// 2. make без размера
counters := make(map[string]int)
// 3. make с подсказкой ёмкости
// Если ожидаем ~10 000 ключей, рантайму проще выделить память сразу
largeIndex := make(map[string]int, 10_000)
Подсказка ёмкости — это именно подсказка, а не жёсткий лимит. Мапа вырастет, если данных окажется больше. Но без подсказки при вставке десятков тысяч ключей рантайм будет несколько раз перевыделять внутренние бакеты, и каждое перевыделение — это копирование.
| Способ | Когда использовать | Подвох |
|---|---|---|
| Литерал | Конфигурации, словари, маленькие справочники | Неудобно, если данные приходят динамически |
make(map[K]V) | Обычная работа, объём неизвестен | Нет; универсальный вариант |
make(map[K]V, n) | Известен примерный объём (тысячи ключей и больше) | Память выделяется сразу, даже если ключей будет мало |
Чтение: comma ok и нулевое значение
Чтение из мапы в Go никогда не паникует — даже из nil-мапы. Но оно возвращает нулевое значение типа, если ключа нет. Для int это 0, для string — пустая строка. Если нулевое значение — допустимый результат, без второй переменной не обойтись:
price, exists := catalog["widget"]
if !exists {
// ключа нет, price == 0 — это не "цена равна нулю"
log.Println("товар не найден")
}
Игнорировать exists — частый источник логических ошибок. Компилятор промолчит: формально код корректен. Но баг в бизнес-логике уже заложен.
Нулевое значение мапы — не пустая мапа, а отсутствие структуры данных. Пустая мапа занимает память и принимает записи. Nil-мапа — нет.
Обход: порядок случайный, и это не баг
Спецификация Go прямо говорит: порядок итерации по range не определён и может отличаться между запусками. Рантайм специально рандомизирует стартовую позицию, чтобы разработчики не полагались на порядок.
colors := map[string]string{
"red": "#FF0000",
"green": "#00FF00",
"blue": "#0000FF",
}
for name, hex := range colors {
fmt.Printf("%s → %s\n", name, hex)
}
// Порядок вывода непредсказуем
Если нужен стабильный порядок — собирайте ключи в слайс, сортируйте и обходите по нему:
import "sort"
keys := make([]string, 0, len(colors))
for k := range colors {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s → %s\n", k, colors[k])
}
Начиная с Go 1.23 в стандартной библиотеке появились итераторы в пакетах maps и slices, и для сортированного обхода можно использовать slices.Sorted(maps.Keys(m)). Если вы на более ранней версии, ручной способ выше остаётся единственным вариантом.
Удаление во время обхода — можно
Спецификация разрешает удалять ключи из мапы прямо внутри range. Удалённый ключ больше не появится при дальнейшей итерации (если он ещё не был посещён). Добавленный во время range ключ может появиться, а может нет — поведение не гарантировано.
sessions := map[string]int64{
"abc": 1710000000,
"def": 1710003600,
"ghi": 1700000000, // устарела
}
cutoff := int64(1709000000)
for id, ts := range sessions {
if ts < cutoff {
delete(sessions, id) // безопасно
}
}
Это работает, потому что delete не инвалидирует внутренний итератор. Но добавлять ключи во время обхода — плохая идея: результат неопределён.
Конкурентный доступ: где рантайм убьёт процесс
Обычная map в Go не потокобезопасна. Если две горутины одновременно пишут в одну и ту же мапу, рантайм детектирует гонку и вызывает fatal error: concurrent map writes. Это не паника — её нельзя перехватить через recover. Процесс падает.
Два стандартных решения:
sync.Mutex/sync.RWMutex— оборачиваете доступ к обычной мапе в лок. Просто, предсказуемо, полный контроль.sync.Map— встроенная потокобезопасная структура. Оптимизирована под два сценария: ключи пишутся один раз и читаются много раз, или каждая горутина работает со своим подмножеством ключей.
Мьютекс — предсказуемый вариант
import "sync"
type SafeCounter struct {
mu sync.RWMutex
m map[string]int
}
func NewSafeCounter() *SafeCounter {
return &SafeCounter{m: make(map[string]int)}
}
func (c *SafeCounter) Inc(key string) {
c.mu.Lock()
c.m[key]++
c.mu.Unlock()
}
func (c *SafeCounter) Get(key string) int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.m[key]
}
sync.Map — когда она уместна
sync.Map не требует явного лока, но у неё нет типизации ключей и значений (до дженериков — any везде), нет len() без полного обхода и нет подсказки ёмкости. В сценарии частых записей по общим ключам она медленнее мьютекса.
Правило простое: если у вас нет измеренной проблемы с производительностью RWMutex, начинайте с мьютекса.
Сноска: бакет (bucket) — внутренняя единица хранения хеш-таблицы в Go. Каждый бакет вмещает до 8 пар ключ-значение. Когда бакеты заполняются, рантайм запускает процесс «эвакуации» — перераспределения данных по новому, большему массиву бакетов. Подробности реализации описаны в исходниках рантайма (файл runtime/map.go), но для повседневной работы достаточно помнить про подсказку ёмкости в make.
Если не получилось: типичные ошибки
assignment to entry in nil map— пишете в неинициализированную мапу. Инициализируйте черезmakeили литерал.fatal error: concurrent map writes— две горутины пишут одновременно. Добавьте мьютекс или используйтеsync.Map.fatal error: concurrent map read and map write— одна горутина читает, другая пишет. Решение то же.- Неожиданный порядок обхода — тест проходит локально, падает на CI. Не полагайтесь на порядок
range; сортируйте ключи, если порядок важен. - Мапа не уменьшается — после удаления тысяч ключей память не возвращается ОС. Рантайм Go не сжимает бакеты. Если это критично, пересоздайте мапу, скопировав оставшиеся данные в новую.
Что делать дальше
Основы мап покрыты. Следующие шаги, на которые стоит потратить время:
- Пакет
mapsстандартной библиотеки (Go 1.21+): функцииClone,Copy,Equal,DeleteFunc— они убирают ручные циклы там, где раньше без них не обходилось. - Профилирование аллокаций: если вы заполняете большие мапы в горячем пути, запустите
go test -bench -memprofileи посмотрите, сколько аллокаций уходит на рост. Подсказка ёмкости вmakeчасто решает вопрос без смены структуры данных. - Альтернативы: для кеша с TTL стандартная мапа не подходит — посмотрите сторонние библиотеки вроде
ristrettoилиbigcache. Для упорядоченных данных —btree-реализации.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: The Go Programming Language Specification — Map types, Go Blog — Go maps in action
Частые вопросы
Можно ли использовать слайс или мапу как ключ мапы?
Правда ли, что после delete память не освобождается?
А если у меня другая версия Go — код заработает?
Как понять, что в проекте есть гонка по мапе, если она не падает?
sync.Map быстрее обычной мапы с мьютексом?
Безопасно ли удалять элементы из мапы во время range?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.