Map — одна из самых часто используемых структур данных в Go, и одна из самых опасных с точки зрения скрытых граблей. Чтобы уверенно отвечать на вопросы о ней, нужно понимать не только API, но и то, что происходит внутри — причём это "внутри" кардинально изменилось в Go 1.24.


Базовый синтаксис

go
// Объявление — nil map, читать можно, писать нельзя var m map[string]int // Создание через литерал m := map[string]int{ "alice": 42, "bob": 17, } // Создание через make — предпочтительно, если размер примерно известен m := make(map[string]int) m := make(map[string]int, 100) // hint — начальная ёмкость, не жёсткое ограничение

Чтение и безопасная проверка существования

go
v := m["alice"] // 42 v := m["unknown"] // 0 — zero value, не паника // Двухзначная форма — единственный способ отличить "нет ключа" от "значение = 0" v, ok := m["alice"] if ok { fmt.Println(v) } // Классическая ошибка: проверять на zero value вместо ok count := m["visits"] // 0 — но существует ли ключ? count, exists := m["visits"] // правильно

Удаление и итерация

go
delete(m, "alice") // безопасно даже если ключа нет for k, v := range m { fmt.Println(k, v) }

Порядок итерации не гарантирован и намеренно рандомизирован с Go 1.0. Рандомизация введена специально, чтобы разработчики не полагались на случайно воспроизводимый порядок.


Что может быть ключом

Ключом может быть любой comparable тип — тот, для которого определён оператор ==:

go
// Можно использовать как ключ map[string]int map[int]string map[[3]int]string // массив — comparable map[struct{ X, Y int }]string // struct из comparable полей // Нельзя — не comparable // map[[]int]string // слайс // map[map[string]int]string // map // map[func()]string // функция

Интерфейсы как ключи допустимы синтаксически, но опасны: если в рантайме интерфейс содержит несравниваемый тип — паника:

go
var m = map[interface{}]int{} m[1] = 1 // ок m[[]int{1}] = 1 // panic: runtime error: hash of unhashable type []int

Map не потокобезопасен

Это, пожалуй, самая популярная ловушка. Конкурентное чтение безопасно, но конкурентная запись (или запись + чтение одновременно) — это гонка данных и паника в рантайме:

go
m := make(map[string]int) // Паника: concurrent map writes go func() { m["a"] = 1 }() go func() { m["b"] = 2 }()

Go 1.6 добавил детектор гонок в рантайм — при конкурентной записи программа упадёт с fatal error: concurrent map writes. Это намеренное поведение: лучше явная паника, чем тихая порча данных.

Три способа сделать map потокобезопасным:

go
// 1. sync.Mutex — универсально type SafeMap struct { mu sync.Mutex m map[string]int } func (s *SafeMap) Set(k string, v int) { s.mu.Lock() defer s.mu.Unlock() s.m[k] = v } // 2. sync.RWMutex — если чтений много больше записей type SafeMap struct { mu sync.RWMutex m map[string]int } func (s *SafeMap) Get(k string) (int, bool) { s.mu.RLock() defer s.mu.RUnlock() v, ok := s.m[k] return v, ok } // 3. sync.Map — специализирован для случая "много читателей, // редкие записи, стабильный набор ключей" var sm sync.Map sm.Store("alice", 42) v, ok := sm.Load("alice") sm.Delete("alice") sm.Range(func(k, v interface{}) bool { fmt.Println(k, v) return true // false — прервать итерацию })

sync.Map не является заменой обычного map с мьютексом по умолчанию. Он быстрее в конкретном сценарии: ключи добавляются один раз и потом только читаются. При частых записях разных ключей он медленнее из-за внутренних двух уровней хранения.


Старая реализация map (до Go 1.24)

Чтобы понять, что изменилось, нужно сначала понять, как было.

До Go 1.24 map реализовывался как хэш-таблица с цепочками через бакеты:

  • Данные хранятся в массиве бакетов, каждый бакет вмещает ровно 8 пар ключ-значение
  • У каждого бакета есть tophash — массив из 8 байт, где каждый байт хранит старшие 8 бит хэша ключа
  • При коллизии (бакет переполнен) создаётся overflow bucket — связный список дополнительных бакетов
  • Максимальный load factor: 6.5 элементов на бакет
text
Бакет (старый): ┌─────────────────────────────────────────────┐ │ tophash: [h1, h2, h3, h4, h5, h6, h7, h8] │ 8 байт │ keys: [k1, k2, k3, k4, k5, k6, k7, k8] │ 8 × sizeof(K) │ values: [v1, v2, v3, v4, v5, v6, v7, v8] │ 8 × sizeof(V) │ overflow: *bucket │ указатель на след. бакет └─────────────────────────────────────────────┘

Проблемы старой реализации:

Поиск ключа требовал пройти по цепочке overflow buckets, каждый из которых находится в другом месте памяти. Это pointer chasing — прыжки по куче, убивающие CPU cache. При большом количестве коллизий производительность деградировала, а tail latency при росте таблицы (когда она удваивается и перехеширует все элементы разом) могла быть заметна в latency-sensitive сервисах.


Swiss Map — новая реализация в Go 1.24

В Go 1.24 map полностью переписан на основе Swiss Tables — дизайна хэш-таблицы, разработанного в Google в 2017 году для C++ библиотеки Abseil и доказавшего свою эффективность. Название "Swiss" — не случайное: аббревиатура Closed Hashing — CH, что совпадает с кодом Швейцарии.

Структура: группы и control word

Вместо бакетов с overflow — группы (groups), каждая из которых содержит ровно 8 слотов и 64-битное control word:

text
Группа в Swiss Map: ┌────────────────────────────────────────────────────────────────┐ │ control word: [b0, b1, b2, b3, b4, b5, b6, b7] ← 8 байт │ │ │ │ slots: [kv0, kv1, kv2, kv3, kv4, kv5, kv6, kv7] │ │ key+value хранятся вместе → cache locality │ └────────────────────────────────────────────────────────────────┘

Каждый байт control word соответствует одному слоту и кодирует его состояние:

  • Старший бит (1) — слот пуст или удалён
  • Если старший бит 0 — слот занят, и оставшиеся 7 бит хранят младшие биты хэша ключа (h2)

Хэш ключа делится на две части:

  • h1 (старшие 57 бит) — определяет, в какую группу попадёт элемент
  • h2 (младшие 7 бит) — хранится в control word для быстрого отсева

Поиск через SIMD

Именно здесь Swiss Map становится принципиально быстрее. При поиске ключа происходит следующее:

  1. Вычисляем хэш ключа, делим на h1 и h2
  2. По h1 находим нужную группу
  3. Берём control word группы (8 байт) и сравниваем h2 сразу со всеми 8 байтами одновременно

Шаг 3 — это SIMD (Single Instruction, Multiple Data). На процессорах amd64 это делается одной инструкцией SSE3, которая за один такт сравнивает 8 байт. В результате мы получаем битовую маску: какие слоты потенциально содержат наш ключ.

text
h2 нашего ключа: [42, 42, 42, 42, 42, 42, 42, 42] control word: [42, 7, 99, 42, 0, 13, 42, 1] ↑ ↑ ↑ совпадение совпадение совпадение → проверяем только эти слоты

Только для слотов с совпадением h2 мы сравниваем полный ключ. Вероятность ложного срабатывания — 1/128 (7 бит хэша), так что в большинстве случаев проверяется 0-1 слот.

На платформах без аппаратного SIMD Go применяет SWAR (SIMD Within A Register) — математически эквивалентный трюк с XOR и битовыми операциями, который обрабатывает все 8 байт за одну арифметическую операцию. Аппаратный SIMD на arm64 пока не реализован, но это уже запланировано.

Extendible hashing вместо глобального удвоения

Старый map при росте удваивал всю таблицу разом — все элементы перехешировались в одном вызове. Для большой таблицы это могло занять десятки миллисекунд.

Swiss Map ограничивает одну таблицу 128 группами (1024 слота). При переполнении таблица делится (splitting), а не удваивается глобально — через механизм extendible hashing: директория указателей на независимые таблицы, которые растут поодиночке. Пиковая задержка на вставку становится предсказуемой и ограниченной.

Итог: что изменилось в числах

Go ≤ 1.23Go 1.24
Структурабакеты + overflow (chaining)группы + control word (open addressing)
Поиск h2последовательный, по одномуSIMD: 8 сравнений за 1 инструкцию
Max load factor6.5 / 8 ≈ 81%7 / 8 = 87.5%
Рост таблицыглобальное удвоениелокальное splitting
Cache localitypointer chasing в overflowключ + значение рядом в памяти
Скорость операцийbaselineдо +60% в микробенчмарках
Потребление памятиbaseline−30–70% на больших map

Нюансы поведения map

Модификация во время итерации

Спецификация Go явно разрешает изменять map во время range:

go
m := map[string]int{"a": 1, "b": 2, "c": 3} for k := range m { if k == "b" { delete(m, "b") // безопасно — удалённый ключ не появится m["d"] = 4 // новый ключ может появиться или нет в этом range } }

Правила: удалённые в ходе итерации ключи точно не появятся, обновлённые значения будут актуальны, новые ключи — могут встретиться или нет (не гарантировано). Swiss Map сохраняет эту семантику, хотя реализовать её поверх open-addressed таблицы было нетривиально.

Запись в nil map — паника

go
var m map[string]int v := m["key"] // 0 — безопасно m["key"] = 1 // panic: assignment to entry in nil map

Элементы map — non-addressable

go
type Point struct{ X, Y int } m := map[string]Point{"a": {1, 2}} // m["a"].X = 10 // Ошибка компиляции: cannot assign to struct field in map // Правильно p := m["a"] p.X = 10 m["a"] = p

Это следствие того, что map может перераспределить память при росте, и адрес элемента может измениться — Go не позволяет хранить указатель на нестабильный адрес.


Производительность: практические советы

Задайте начальную ёмкость, если размер известен. make(map[K]V, n) — это hint, позволяющий избежать реаллокаций при росте:

go
// Плохо — много реаллокаций при росте m := make(map[string]int) for _, item := range items { m[item.Key] = item.Value } // Хорошо — одна аллокация m := make(map[string]int, len(items)) for _, item := range items { m[item.Key] = item.Value }

Используйте struct{} для set-семантики. Если нужен только факт присутствия ключа:

go
seen := make(map[string]struct{}) seen["alice"] = struct{}{} _, exists := seen["alice"]

struct{} не занимает памяти (zero-size type) — экономия по сравнению с map[string]bool.


Вопросы на собеседовании

Q: Что такое map в Go внутри? Опишите структуру.
A: До Go 1.24 — хэш-таблица с бакетами по 8 элементов и overflow buckets в виде связного списка при коллизиях. С Go 1.24 — Swiss Table: группы из 8 слотов с 64-битным control word, open addressing с линейным пробированием, extendible hashing для роста.

Q: Почему порядок итерации по map не гарантирован?
A: Намеренная рандомизация, введённая в Go 1.0, чтобы разработчики не полагались на случайно воспроизводимый порядок. При каждом range начальная позиция итерации выбирается случайно.

Q: Почему map не потокобезопасен? Что будет при конкурентной записи?
A: Map не защищён мьютексом для производительности. Конкурентная запись (или запись + чтение) — это data race, которая в Go 1.6+ детектируется в рантайме и вызывает fatal error: concurrent map writes. Решения: sync.Mutex, sync.RWMutex или sync.Map.

Q: Чем sync.Map отличается от map с мьютексом? Когда что использовать?
A: sync.Map оптимизирован для сценария "ключи записываются редко, читаются часто". Внутри у него два уровня хранения: read-only (без блокировки) и dirty (с блокировкой). При частых записях разных ключей он медленнее обычного map с мьютексом из-за накладных расходов на синхронизацию двух уровней.

Q: Какие типы могут быть ключами map?
A: Только comparable типы — те, для которых определён ==. Базовые типы, массивы, структуры из comparable полей. Нельзя: слайсы, map, функции. Интерфейсы можно, но паника в рантайме, если реальный тип не comparable.

Q: Что такое Swiss Table и чем она лучше старой реализации?
A: Swiss Table — это open-addressed хэш-таблица с control word на группу. Ключевое улучшение — поиск через SIMD: 7 бит хэша ключа (h2) сравниваются со всеми 8 слотами группы одной процессорной инструкцией. Это устраняет pointer chasing от overflow buckets, улучшает cache locality и даёт до 60% ускорения операций.

Q: Что такое SIMD в контексте Swiss Map?
A: Single Instruction, Multiple Data — одна CPU инструкция, обрабатывающая несколько данных одновременно. Swiss Map использует SSE3 на amd64: control word группы (8 байт) сравнивается с h2 ключа за один такт, возвращая битовую маску совпавших слотов. На платформах без аппаратного SIMD используется SWAR — тот же эффект через XOR и битовую арифметику.

Q: Можно ли взять адрес элемента map? Почему?
A: Нет. Элементы map non-addressable — при росте таблицы Go может переместить данные в памяти, и старый указатель стал бы невалидным. Чтобы изменить поле в структуре внутри map, нужно достать значение, изменить копию, положить обратно.

Q: Чем nil-map отличается от пустой map?
A: nil-map (var m map[string]int) — чтение возвращает zero value, запись вызывает панику. Пустая map (make(map[string]int)) — готова к использованию. Длина обоих — 0.

Q: Как сделать map потокобезопасным для сценария "много читателей, редкие записи"?
A: Через sync.RWMutex: читатели берут RLock() (не блокируют друг друга), писатель берёт Lock() (эксклюзивный доступ). Как альтернатива — sync.Map, который внутренне реализует схожую стратегию с atomic-операциями на read-path.

Q: Что произойдёт, если удалить ключ во время range по map?
A: Безопасно — удалённый ключ гарантированно не появится в текущей итерации. Добавление новых ключей во время range допустимо, но они могут появиться или не появиться — это неопределённое поведение (в рамках спецификации).


Практика

Quiz+10 XP

Что выведет код?

go
m := map[string]int{"a": 1, "b": 2} v := m["c"] fmt.Println(v)
  • panic: key not found
  • 0
  • nil
  • ошибка компиляции
Quiz+10 XP

Как правильно проверить, существует ли ключ "player" в map?

  • if count, ok := m["player"]; ok { ... }
  • if m["player"] != 0 { ... }
  • if m["player"] != nil { ... }
  • if len(m["player"]) > 0 { ... }
Predict+15 XP

Что выведет этот код?

go
package main import "fmt" func main() { votes := []string{"alice", "bob", "alice", "eve", "alice", "eve"} counter := make(map[string]int) for _, name := range votes { counter[name]++ } fmt.Println(counter["alice"])\n fmt.Println(counter["bob"]) fmt.Println(counter["eve"]) }
Задача+20 XP

Напиши функцию firstDuplicate, которая принимает []string и возвращает первый элемент, встретившийся дважды. Если дублей нет — верни пустую строку. Используй map для отслеживания встреченных элементов.


Задачи: Map


Задача 1: Частотный анализ

Уровень: Лёгкая

Что проверяет: базовая работа с map, итерация

Условие: Напиши функцию wordCount(s string) map[string]int которая возвращает map где ключ — слово, значение — количество его вхождений в строку. Слова разделены пробелами, регистр не важен.

Примеры:

text
wordCount("the cat sat on the mat") → map[cat:1 mat:1 on:1 sat:1 the:2] wordCount("Go go GO") → map[go:3]

Решение:

go
package main import ( "fmt" "strings" ) func wordCount(s string) map[string]int { result := make(map[string]int) words := strings.Fields(s) // разбивает по пробелам, убирает лишние for _, word := range words { result[strings.ToLower(word)]++ } return result } func main() { fmt.Println(wordCount("the cat sat on the mat"))\n fmt.Println(wordCount("Go go GO")) }

Задача 2: Группировка элементов

Уровень: Средняя

Что проверяет: map со слайсом как значением, проектирование структуры данных

Условие: Напиши функцию groupBy(words []string) map[int][]string которая группирует слова по длине. Слова в каждой группе должны быть в том же порядке что и в исходном слайсе.

Примеры:

text
groupBy([]string{"go", "is", "fun", "and", "fast"}) → map[2:[go is] 3:[fun and] 4:[fast]] groupBy([]string{"a", "bb", "ccc", "dd", "e"}) → map[1:[a e] 2:[bb dd] 3:[ccc]]

Решение:

go
package main import "fmt" func groupBy(words []string) map[int][]string { result := make(map[int][]string) for _, word := range words { length := len([]rune(word)) // корректно для Unicode result[length] = append(result[length], word) } return result } func main() { fmt.Println(groupBy([]string{"go", "is", "fun", "and", "fast"}))\n fmt.Println(groupBy([]string{"a", "bb", "ccc", "dd", "e"})) } // append к nil-слайсу работает корректно — // не нужно инициализировать result[length] отдельно.

Задача 3: LRU-подобный кеш

Уровень: Сложная

Что проверяет: комбинирование map со структурами, проектирование API

Условие: Реализуй простой кеш с ограничением размера. Когда кеш заполнен и добавляется новый элемент — удаляется самый старый. Реализуй методы Set(key string, value int) и Get(key string) (int, bool).

Примеры:

text
cache := NewCache(3) cache.Set("a", 1) cache.Set("b", 2) cache.Set("c", 3) cache.Get("a") → (1, true) cache.Set("d", 4) // кеш полон — удаляем "b" (самый старый) cache.Get("b") → (0, false) cache.Get("d") → (4, true)

Подсказка: Храни порядок добавления через отдельный слайс ключей. При удалении убирай первый элемент слайса и соответствующий ключ из map.

Решение:

go
package main import "fmt" type Cache struct { capacity int data map[string]int order []string // порядок добавления ключей } func NewCache(capacity int) *Cache { return &Cache{ capacity: capacity, data: make(map[string]int), order: make([]string, 0, capacity), } } func (c *Cache) Set(key string, value int) { // Если ключ уже есть — просто обновляем значение if _, exists := c.data[key]; exists { c.data[key] = value return } // Кеш полон — удаляем самый старый if len(c.data) >= c.capacity { oldest := c.order[0] c.order = c.order[1:] delete(c.data, oldest) } c.data[key] = value c.order = append(c.order, key) } func (c *Cache) Get(key string) (int, bool) { v, ok := c.data[key] return v, ok } func main() { cache := NewCache(3) cache.Set("a", 1) cache.Set("b", 2) cache.Set("c", 3) fmt.Println(cache.Get("a")) // 1 true\n cache.Set("d", 4) fmt.Println(cache.Get("b")) // 0 false — удалён\n fmt.Println(cache.Get("d")) // 4 true }