1.3Мапы
Разберём контракт map и отделим гарантии языка от устройства конкретной версии рантайма.
Что такое мапа и какой у неё контракт
Под map[K]V лежит хеш-таблица. Контракт языка:
амортизированные O(1) на вставку, поиск и удаление; ключи должны
быть comparable; порядок обхода не определён; мапа
не потокобезопасна. Переменная типа map[K]V
хранит один указатель (8 байт) на структуру
runtime.hmap в куче. Копируешь мапу, копируешь только
этот указатель, а nil-мапа и есть нулевой указатель.
m := map[string]int{"a": 1}
m2 := m // скопировали 8 байт указателя
m2["b"] = 2
fmt.Println(m) // map[a:1 b:2] - одна и та же таблица
fmt.Println(unsafe.Sizeof(m)) // 8
Почти все «странности» мапы (запрет на &m[k],
случайный порядок обхода, падение всей программы при конкурентной
записи, память, которая не возвращается после удаления ключей)
растут из устройства хеш-таблицы:
это прямые следствия, а не капризы дизайнеров. Разберёшь устройство, и эти вопросы перестанут быть списком для
зубрёжки.
Классическое устройство: hmap и бакеты (Go до 1.24)
Исторически (а в первых версиях со Swiss-мапами ещё и с GOEXPERIMENT=noswissmap) мапа Go устроена как массив
бакетов с цепочками overflow-бакетов. Бакет вмещает ровно
8 пар ключ-значение.
tophash кэширует старший байт
хеша: неподходящие слоты отбрасываются, ключи никто не трогает,
в разбросанную по кэш-линиям память лезть не приходится.
Сравнивать длинные строки-ключи дорого. Поэтому рядом с восемью
слотами бакета Go держит массив из восьми байт:
tophash хранит старший байт хеша
каждого лежащего в слоте ключа, запомненный заранее. Поиск сначала
пробегает эти восемь байт (одна кэш-линия, никаких разыменований)
и сравнивает ключ целиком только там, где байт совпал. Значение
0 в tophash служебное, оно помечает
пустой слот.
Если бы бакет хранил [8]struct{K; V}, то между
K и V появился бы padding для
выравнивания. Для map[int64]int8 это дало бы 8 байт
padding на каждую пару, то есть бакет вырос бы почти вдвое.
Раздельные массивы [8]K и [8]V дают
padding максимум один раз на бакет. Мелочь, но именно такие
мелочи спрашивают, когда хотят проверить, читал ли ты рантайм.
Хеш-функция, seed и коллизии - подробности
Go выбирает хеш-функцию по типу ключа на этапе компиляции: для строк и байтовых срезов берётся memhash (на amd64 это вариант на инструкциях AES-NI, очень быстрый), числа прогоняются через простое смешивание, у структур комбинируются хеши полей.
Каждая мапа при создании получает свой случайный seed
(hash0). Это защита от hash flooding: без seed
злоумышленник, знающий алгоритм, подобрал бы ключи с одинаковыми
хешами и превратил все операции в O(n). Классическая DoS-атака на
веб-сервер, который кладёт заголовки запроса в мапу.
Коллизии Go разрешает методом цепочек (не по одному элементу, а бакетами по 8): сначала заполняются 8 слотов бакета, при переполнении выделяется overflow-бакет, дополнительная корзина на те же 8 слотов, она прицепляется к основной как вагон к вагону. Поиск идёт по цепочке, пока ключ не найдётся или вагоны не кончатся.
В Swiss Tables (Go 1.24+) подход другой, открытая адресация: лишних вагонов нет, при занятом месте элемент ложится в другую группу того же массива по заранее известному правилу («пробинг»). Разбросанных по куче overflow-бакетов и указателей между ними больше нет, значит и промахов кэша меньше.
Рост и эвакуация
Мапа растёт, когда срабатывает одно из двух условий:
-
Превышен load factor. Порог в Go:
count > 6.5 * 2^B, то есть в среднем больше 6.5 элементов на бакет при вместимости 8. ТогдаBрастёт на единицу, а бакетов становится вдвое больше. -
Слишком много overflow-бакетов при нормальной
заполненности. Так бывает после массовых удалений. Тогда
включается
sameSizeGrow: массив бакетов того же размера, но элементы перекладываются плотно, и мусорные overflow-цепочки исчезают.
Переселение не происходит целиком за одну операцию. Разом
оно убило бы время отклика на больших мапах: одна
невезучая вставка встала бы на сотни миллисекунд. Вместо этого
старый массив остаётся в oldbuckets, а каждая
вставка/удаление эвакуирует один-два бакета. Пока эвакуация идёт,
чтение проверяет оба массива.
i
расщепляется ровно на два новых, i и
i + 2^oldB: добавился ещё один бит хеша. Из-за
этого вставка в мапу и получается амортизированной O(1), а не
гарантированной.
Go 1.24: Swiss Tables
В Go 1.24 мапу переписали целиком: вместо бакетов с
overflow-цепочками пришёл Swiss Table, дизайн из
absl::flat_hash_map (Google, 2017). Идея одна:
отделить метаданные от данных, чтобы за одно обращение к
памяти проверять сразу восемь слотов.
| Старая мапа (≤ Go 1.23) | Swiss Tables (Go 1.24+) | |
|---|---|---|
| Разрешение коллизий | цепочки overflow-бакетов | открытая адресация, квадратичный пробинг по группам |
| Метаданные | tophash [8]uint8 внутри бакета |
control word: 8 байт отдельно от данных |
| Проверка группы | цикл по 8 слотам | одно слово / SIMD (одна инструкция процессора обрабатывает сразу несколько значений), битовая маска совпадений |
| Большие мапы | один массив бакетов + инкрементальная эвакуация |
разбита на независимые таблицы (directory),
растёт по одной
|
| Выигрыш | - | чтение больших мап до ~30–60 % быстрее, вставка ~30 %, память примерно та же |
Изменилась только реализация: язык, семантика и гарантии
те же. Порядок обхода по-прежнему случайный, мапа по-прежнему не
потокобезопасна, &m[k] по-прежнему запрещён.
Разница видна в производительности и в том, что конкретные
последовательности итерации поменялись (тесты, которые на них
завязались, сломались, хотя некорректны они были и раньше).
Откатиться можно было через GOEXPERIMENT=noswissmap, пока опция жила: в Go 1.27 её уже нет. Заодно в 1.24 переписали
sync.Map на HashTrieMap.
Почему нельзя взять адрес значения: &m[key]
Потому что адрес нестабилен. Мапа растёт, начинается
эвакуация, и пары ключ-значение физически переезжают в другой
массив бакетов. Разреши язык &m[k], и после
первой же вставки указатель смотрел бы в старую память: тихие баги
ровно того сорта, которого Go старается не иметь. У слайса такой
проблемы нет, там переезжает весь массив сразу, а старый остаётся
валидным до сборки.
m := map[string]int{"a": 1}
// p := &m["a"] // ошибка компиляции: invalid operation: cannot take address of m["a"]
// m["a"]++ // а это можно: компилятор разворачивает в чтение + запись
// m["a"] += 5 // тоже можно
type S struct {
N int
}
ms := map[string]S{"a": {1}}
// ms["a"].N = 2 // ошибка: cannot assign to struct field ms["a"].N in map
v := ms["a"]; v.N = 2; ms["a"] = v // читаем, меняем, кладём обратно
// Или, что обычно правильнее, хранить указатели:
mp := map[string]*S{"a": {1}}
mp["a"].N = 2 // работает: адресуем то, на что указывает значение
«Почему m[k].Field = v не компилируется, а
m[k]++ компилируется?» Потому что значение в мапе
не адресуемо: инкремент компилятор разворачивает в
«прочитать значение, прибавить, записать обратно», а
присваивание полю потребовало бы адреса. Лечится просто:
прочитал в переменную, поменял, записал назад. Либо держи
map[K]*V.
Почему порядок итерации случайный
Это намеренная защита от неявной зависимости. Порядок
обхода и сам по себе зависит от размера таблицы, порядка вставок и
seed; он менялся бы от версии к версии и от запуска к запуску. А
окажись он стабильным «на практике», разработчики стали бы на него
полагаться, и любое улучшение рантайма ломало бы прод. Поэтому
команда Go пошла дальше и сделала рандомизацию явной:
mapiterinit выбирает
случайный стартовый бакет и случайное смещение внутри него
на каждый range.
m := map[int]string{1: "a", 2: "b", 3: "c"}
for k := range m { fmt.Print(k, " ") } // разный порядок при каждом запуске
// восемь запусков подряд дали: "1 2 3", "3 1 2", "1 2 3", "1 2 3",
// "1 2 3", "1 2 3", "2 3 1", "2 3 1" - на трёх ключах совпадения частые,
// но опираться на порядок нельзя
// Детерминированный обход: сортируем ключи
keys := make([]int, 0, len(m))
for k := range m { keys = append(keys, k) }
slices.Sort(keys)
for _, k := range keys { fmt.Println(k, m[k]) }
// Go 1.23+: то же короче
for _, k := range slices.Sorted(maps.Keys(m)) { fmt.Println(k, m[k]) }
// оба цикла печатают одно и то же, всегда:
// 1 a
// 2 b
// 3 c
Компилятор оптимизирует конструкцию вида
for k, v := range m внутри
fmt.Println(m): fmt печатает мапу
отсортированной по ключам (с Go 1.12), чтобы вывод
воспроизводился в тестах и логах. Это поведение
fmt, а не мапы: сам range остаётся
случайным.
nil-мапа и мутации во время итерации
var m map[string]int // nil: указатель на hmap равен nil
fmt.Println(m == nil) // true
fmt.Println(len(m)) // 0
fmt.Println(m["x"]) // 0 - чтение из nil-мапы легально
v, ok := m["x"] // 0, false
for range m {} // ноль итераций, без паники
delete(m, "x") // no-op, без паники
m["x"] = 1 // panic: assignment to entry in nil map
Асимметрия объясняется просто: чтение честно вернёт нулевое
значение, не трогая память, а запись обязана эту память создать.
Указатель nil означает, что создавать негде.
Инициализировать мапу сам рантайм не может: переменная передана по
значению, и новый указатель некуда записать.
Мутация во время range легальна, но с оговорёнными в спецификации последствиями: удалённый и ещё не пройденный ключ гарантированно не будет посещён; добавленный во время обхода ключ может быть посещён, а может и нет, спецификация этого не определяет.
// Безопасно удалять во время обхода
for k, v := range m {
if v == 0 { delete(m, k) } // ок, спецификация это разрешает
}
// Опасно добавлять во время обхода: обход может зациклиться или выдать не всё
for k := range m {
m[k+1000] = 1 // попадёт ли новый ключ в обход, не определено
}
Конкурентный доступ: почему это fatal error, а не panic
Мапа не потокобезопасна, и рантайм за этим следит. В заголовке мапы есть флаг записи: с Go 1.24 это поле writing, в классической мапе был бит hashWriting в hmap.flags. Запись и удаление ставят его в начале работы и снимают в конце. Любая другая операция, увидев этот флаг, немедленно валит процесс:
fatal error: concurrent map writes
fatal error: concurrent map read and map write
Это не паника, а throw из рантайма. Разницу
спрашивают почти всегда: recover() такое
не ловит, defer не выполняются, процесс
умирает целиком с дампом всех горутин. Логика простая: структуру
данных уже могло порвать, дальше выполнять небезопасно, лучше
упасть громко и сразу.
fatal error, это ещё не доказательство, что
гонки нет.
-
«Это паника, поймаю recover'ом». Нет,
fatal errorиз рантайма не перехватывается ничем. - «Параллельное чтение тоже падает». Нет. Много читателей без единого писателя работают законно и безопасно.
- «Если не падает, значит, потокобезопасно». Нет. Детектор срабатывает только при пересечении окон; без него можно получить порванный бакет и тихо неверные данные.
Чем защищаться: RWMutex, sync.Map, шардирование
Шардирование: одну большую структуру режут на N независимых кусков, у каждого свой замок, а ключ по хешу попадает ровно в один кусок. Две горутины с разными ключами чаще всего берут разные замки и не мешают друг другу.
// Вариант 1 - мьютекс рядом с мапой. Дефолт в 90 % случаев.
type Cache struct {
mu sync.RWMutex
m map[string]int
}
func (c *Cache) Get(k string) (int, bool) {
c.mu.RLock()
v, ok := c.m[k] // много читателей одновременно - ок
c.mu.RUnlock()
return v, ok
}
func (c *Cache) Set(k string, v int) {
c.mu.Lock()
c.m[k] = v
c.mu.Unlock()
}
// Вариант 2 - sync.Map. Только под два паттерна.
var sm sync.Map
sm.Store("a", 1)
v, ok := sm.Load("a")
actual, loaded := sm.LoadOrStore("b", 2) // атомарно: получить-или-положить
sm.Range(func(k, v any) bool { return true })
// Вариант 3 - шардирование. Когда RWMutex стал узким местом.
type Shard struct {
mu sync.RWMutex
m map[string]int
}
type Sharded [256]*Shard
func (s *Sharded) shard(k string) *Shard {
return s[fnv32(k)%256] // разные ключи - разные мьютексы
}
RWMutex + map |
sync.Map |
Шардирование | |
|---|---|---|---|
| Типизация | полная, дженерик по построению | any - приведения и боксинг |
полная |
| Когда выигрывает | почти всегда |
(1) пишем один раз, читаем много; (2) непересекающиеся ключи у разных горутин |
высокая конкуренция, много ядер |
| Когда проигрывает | десятки ядер бьются за один мьютекс | смешанная нагрузка read/write - dirty-мапа постоянно пересоздаётся | усложняет код, ломает атомарность операций «через все шарды» |
len() |
дёшево | нет вообще - только Range с подсчётом |
сумма по шардам, неатомарна |
| Аллокации | нет лишних | боксинг ключей и значений в any |
нет лишних |
До Go 1.24 это была пара мап. read, атомарный
неизменяемый снимок, читается вообще без блокировки;
dirty лежит под мьютексом, туда идут новые ключи.
Когда промахов по read накопилось достаточно,
dirty целиком становится новой read.
Отсюда и профиль: чтение существующих ключей почти бесплатно, а
вот поток новых ключей заставляет постоянно копировать всю мапу.
В Go 1.24 реализацию заменили на
HashTrieMap, конкурентное хеш-дерево без патологии
с пересозданием dirty. Спросят на собесе «почему sync.Map
медленная на записи», уточни, о какой версии речь.
Какие типы могут быть ключами
Ключ обязан быть comparable, то есть к нему применим
==. Это все базовые типы, указатели, каналы,
интерфейсы, массивы из comparable-элементов и структуры, у которых
comparable все поля. Нельзя: слайсы, мапы, функции и любая
структура, которая их содержит.
type Key struct {
UserID int64
Region string
} // ок - оба поля comparable
m := map[Key]int{{1, "ru"}: 10}
type BadKey struct {
Tags []string
}
// mb := map[BadKey]int{} // ошибка компиляции: invalid map key type BadKey
// Массив можно, слайс нельзя, поэтому так:
m2 := map[[16]byte]string{} // фиксированный размер = comparable
var id [16]byte
copy(id[:], someSlice)
m2[id] = "value"
// Или сериализуем ключ в строку (строка иммутабельна и comparable):
m3 := map[string]int{}
m3[strings.Join(tags, "\x00")] = 1
-
Интерфейс как ключ.
map[any]intкомпилируется, но положи туда значение с не-comparable динамическим типом, и получишь панику в рантайме:runtime error: hash of unhashable type []int. Компилятор здесь бессилен. -
NaN как ключ.
math.NaN() != math.NaN(), поэтому записанное по такому ключу значение невозможно ни прочитать, ни удалить. Каждая запись создаёт новый элемент,lenрастёт, память течёт. Классический вопрос-добивка про float-ключи.
m := map[float64]string{}
m[math.NaN()] = "a"
m[math.NaN()] = "b"
fmt.Println(len(m)) // 2 - две записи по "одному" ключу
fmt.Println(m[math.NaN()]) // "" - не найдено
delete(m, math.NaN()) // ничего не удалит
for k, v := range m { fmt.Println(k, v) } // обе записи видны, но только через range
// NaN a
// NaN b
// порядок между ними случайный: 5 прогонов дали 4 раза "a, b" и 1 раз "b, a"
Память: мапа не сжимается
delete помечает слот пустым и обнуляет ключ и
значение (чтобы GC мог собрать то, на что они указывали), но
массив бакетов не уменьшается никогда. Мапа, в которую
положили 10 млн ключей и потом всё удалили, продолжает держать
место под 10 млн, пока жива сама переменная.
m := make(map[int][128]byte)
for i := 0; i < 10_000_000; i++ { m[i] = [128]byte{} }
for i := 0; i < 10_000_000; i++ { delete(m, i) }
fmt.Println(len(m)) // 0 - а RSS процесса всё ещё гигабайты
// Вариант 1: пересоздать мапу и дать старой умереть
m = make(map[int][128]byte)
// Вариант 2 (Go 1.21+): clear() работает как delete всех ключей,
// то есть память тоже не отдаёт и пересоздание не заменяет.
clear(m)
// Вариант 3: хранить указатели, тогда «толстые» значения соберёт GC,
// а в бакетах останутся только 8-байтовые указатели
mp := make(map[int]*[128]byte)
«Мапа растёт, но не сжимается. delete и
clear освобождают значения для GC, но не
возвращают массив бакетов. Если мапа работает долгоживущим кэшем
с большим оборотом ключей, её надо либо периодически
пересоздавать, либо менять на кэш с вытеснением, либо
шардировать и пересоздавать шарды по очереди.» Заодно упомяни,
что до Go 1.21 вместо clear(m) писали
for k := range m { delete(m, k) }: компилятор
узнавал этот паттерн и разворачивал в быстрый
mapclear.
Множество (set) через мапу
Отдельного типа set в стандартной библиотеке нет, вместо него
пишут map[T]struct{}: struct{} занимает
0 байт, поэтому в бакете под массив значений память вообще
не выделяется.
// Идиоматичный set
seen := make(map[string]struct{}, 1024)
seen["a"] = struct{}{}
if _, ok := seen["a"]; ok { /* есть */ }
delete(seen, "a")
// Альтернатива map[string]bool - до Go 1.24 на байт больше на элемент,
// зато читается лучше и можно писать одно выражение вместо comma-ok:
seenB := map[string]bool{}
seenB["a"] = true
if seenB["a"] { /* есть */ } // false и для "нет ключа", и для "ключ есть, но false"
map[T]struct{} |
map[T]bool |
|
|---|---|---|
| Память на элемент | только ключ | ключ + 1 байт (плюс возможный padding) |
| Проверка | _, ok := s[k] |
s[k] - короче |
| Двусмысленность | нет: ключ либо есть, либо нет | есть: false ≠ «отсутствует» |
| Когда брать | большие множества, hot path | маленькие множества, читаемость важнее |
Сложность и деградация
| Операция | Средняя | Худшая | Комментарий |
|---|---|---|---|
Поиск m[k] |
O(1) | O(n) | худшая - при массовых коллизиях в один бакет |
| Вставка | амортизированная O(1) | O(n) | отдельная вставка может запустить рост |
| Удаление | O(1) | O(n) | память не возвращается |
Обход range |
O(n) | O(n + бакеты) | после массовых удалений обход дороже: пустые бакеты всё равно просматриваются |
len(m) |
O(1) | O(1) | это поле hmap.count |
Мапа деградирует в четырёх ситуациях:
- Плохой хеш / атака. Много ключей с одинаковыми низкими битами хеша → длинные overflow-цепочки → O(n) на операцию. От внешней атаки защищает случайный seed.
-
Разреженность после удалений. Бакеты остались, элементов
мало, обход и кэш-локальность проседают. Частично лечится
sameSizeGrow. -
Большие ключи и значения.
map[string][4096]byteкопирует значение целиком при каждом чтении и записи; бакеты огромные, кэш не помогает. Лучшеmap[string]*T. -
Рост без преаллокации. Залить 10 млн ключей без
make(map[..].., 10_000_000)значит пройти ~23 удвоения и переложить все элементы заново. Скажешь размер заранее, и почти вся эта работа отпадёт.
// Преаллоцируем: рантайм сразу выберет нужный B
m := make(map[string]int, len(rows)) // одна аллокация вместо цепочки ростов
for _, r := range rows { m[r.Key] = r.Val }
comma-ok
У чтения из мапы две формы. Односложная всегда возвращает
значение, нулевое, если ключа нет. Двухсложная
(comma-ok) добавляет флаг наличия. Это
единственный способ отличить «ключа нет» от «ключ есть и в
нём нулевое значение».
m := map[string]int{"a": 0}
v := m["a"] // 0
v2 := m["zzz"] // 0 - не отличить от предыдущего
v3, ok := m["a"] // 0, true
v4, ok2 := m["zzz"] // 0, false
// Типичная ошибка в проде: флаги в map[string]bool
flags := map[string]bool{"debug": false}
if flags["debug"] { } // false
if flags["typo_debug"] { } // тоже false - опечатка не заметна
if v, ok := flags["debug"]; ok && v { } // так честно
// Одна идиома языка, тот же синтаксис у трёх других конструкций:
v, ok := i.(MyType) // type assertion
v, ok := <-ch // чтение из канала: ok == false, если канал закрыт и пуст
_, ok := m[k] // мапа
Используй comma-ok везде, где нулевое значение это валидные
данные (счётчики, флаги, суммы). Односложную форму оставляй для
случаев «нулевое значение и есть ответ по умолчанию»:
counts[k]++, sum += m[k]. И помни, что
m[k]++ корректно работает даже для отсутствующего
ключа: он создастся с нулём и станет единицей.
Что выведет код?
// (A)
m := map[string][]int{}
m["a"] = append(m["a"], 1)
m["a"] = append(m["a"], 2)
fmt.Println(m, len(m["b"]))
type S struct {
N int
}
ms := map[string]S{"x": {1}}
v := ms["x"]
v.N++
fmt.Println(ms["x"].N)
// (B)
m := map[int]int{1: 1, 2: 2, 3: 3}
for k := range m {
delete(m, k)
}
fmt.Println(len(m))
var n map[string]int
fmt.Println(n == nil, len(n), n["q"])
n2 := map[string]int{}
fmt.Println(n2 == nil, len(n2))
(A) → map[a:[1 2]] 0, затем 1.
append(m["a"], 1) работает, потому что чтение
отсутствующего ключа даёт nil-слайс, а в него можно
аппендить. А вот v := ms["x"] копирует структуру:
инкремент ушёл в копию, в мапе осталась единица. Чтобы поменять,
нужно ms["x"] = v или map[string]*S.
(B) → 0, затем true 0 0 и
false 0. Удаление во время range
легально, и спецификация гарантирует, что удалённый непосещённый
ключ не будет посещён, поэтому мапа честно пустеет. Второй блок
про то, что nil-мапа и пустая мапа неотличимы по
len, но различимы по == nil, и в первую
нельзя писать.
Вопросы
12map[K]V - указатель на
hmap; внутри массив из 2^B бакетов
по 8 слотов; коллизии - цепочки overflow-бакетов; рост -
удвоение с инкрементальной эвакуацией.
Структура
hmap хранит count (это и есть
len(m)), B (логарифм числа
бакетов), hash0 (случайный seed), указатели
buckets и oldbuckets, счётчик
nevacuate и флаги. В бакете (bmap)
лежат tophash [8]uint8, затем
[8]K, затем [8]V, затем указатель
на overflow-бакет. Ключи и значения идут отдельными
массивами, чтобы не платить padding между K и
V.
Поиск
- Считаем
h = hash(key, hash0). - Низкие
Bбит дают номер бакета. -
Старший байт хеша (
tophash) сравниваем с восемью байтами в бакете. Дешёвый фильтр: сами ключи он не трогает. -
Только для совпавших
tophashсравниваем ключ полностью. - Не нашли, идём по цепочке overflow-бакетов.
Рост
Триггеров два. Load factor
count > 6.5 * 2^B даёт удвоение
(B++). А слишком много overflow-бакетов
при нормальном count включает
sameSizeGrow: перекладку в массив того же
размера, чтобы схлопнуть разреженность после массовых
удалений.
Эвакуация инкрементальная: старый массив остаётся в
oldbuckets, каждая вставка или удаление
переселяет один-два бакета. При удвоении старый бакет
i расщепляется ровно на два, i и
i + 2^oldB: в выборе бакета стал участвовать
ещё один бит хеша. Пока рост не закончен, чтение ищет в
обоих массивах, а память занята обоими.
«Именно из-за инкрементальной эвакуации вставка стоит амортизированную O(1), а не гарантированную: отдельная вставка может оказаться дорогой. И поэтому же в момент роста мапа занимает почти вдвое больше памяти, живы оба массива.» Дальше уместно добавить, что в Go 1.24 всё это заменили на Swiss Tables.
Что именно поменялось
- Хеш делится на h1 (старшие биты, они выбирают группу и последовательность проб) и h2 (младшие 7 бит, они ложатся в control-байт слота).
- Control word собирает 8 однобайтовых меток группы в одно 64-битное слово. Вопрос «есть ли в группе слот с таким h2» решается парой инструкций без ветвлений (на amd64 через SSE-инструкции, на остальных архитектурах битовыми трюками над тем же словом), а на выходе битовая маска кандидатов.
- Коллизии разрешаются открытой адресацией с квадратичным пробингом по группам, а не цепочками overflow-бакетов. Указателей между группами нет, значит и промахов кэша меньше.
-
Специальные значения control-байта:
EMPTY(можно прекратить поиск) иDELETED, надгробие. Через него поиск идёт дальше, иначе порвалась бы цепочка проб. - Большая мапа разбита на directory из независимых таблиц; растёт по одной таблице, а не целиком, и латентность выходит ровнее.
Что НЕ поменялось
Ни семантика, ни гарантии. Порядок обхода по-прежнему
случайный, мапа по-прежнему не потокобезопасна,
&m[k] по-прежнему запрещён, comma-ok тот
же. Отличие видно только в производительности: чтение
больших мап быстрее примерно на 30–60 %, вставка на треть,
при сопоставимой памяти. Плюс сломались тесты, которые
нелегально завязались на конкретный порядок итерации.
# вернуть старую реализацию (в Go 1.27 опции уже нет)
$ GOEXPERIMENT=noswissmap go build ./...
Переписали sync.Map: теперь под ним
HashTrieMap вместо пары read/dirty. Появились
generic type aliases. Добавили
testing.B.Loop(), он закрывает проблему
«компилятор выбросил тело бенчмарка». Если спрашивают «что
нового в Go», Swiss Tables и есть самый заметный пункт
1.24.
Свойства, которые от хеша нужны
- Детерминизм. Один ключ всегда даёт одно число (в пределах одной мапы: seed у каждой свой).
- Равномерность. Ключи размазываются по бакетам, иначе всё вырождается в список.
-
Скорость. Хеш считается на каждой операции. На
amd64 Go берёт
memhashна инструкциях AES-NI.
Функцию выбирает компилятор по типу ключа: строкам и
байтовым срезам достаётся memhash, числам
короткое смешивание, структурам комбинация хешей полей. У
типов с указателями внутри хешируется содержимое, а не
адрес.
Seed и hash flooding
Каждая мапа при создании получает случайный
hash0. Без него атакующий, знающий алгоритм,
подобрал бы тысячи ключей в один бакет и превратил бы O(1) в
O(n). Классическая DoS-атака на сервис, который складывает
заголовки или query-параметры запроса в мапу. Побочный
эффект: хеш одного и того же ключа различается между мапами
и между запусками, поэтому на порядок итерации нельзя
полагаться в принципе.
Способы разрешения коллизий вообще
| Способ | Идея | Где используется |
|---|---|---|
| Chaining | в ячейке список элементов | Java HashMap (список → дерево при 8+) |
| Bucket chaining | цепочка блоков по N элементов | Go ≤ 1.23: бакеты по 8 |
| Открытая адресация | ищем следующий свободный слот | Go 1.24+ (Swiss), Python dict |
| Robin Hood / Cuckoo | перекладываем элементы для выравнивания проб | Rust hashbrown (тоже Swiss), спец-хранилища |
При открытой адресации нельзя просто пометить слот пустым:
если ключ был вставлен «дальше по цепочке проб», поиск
оборвётся на дырке и не найдёт его. Поэтому вводят
надгробие (DELETED): слот свободен для
вставки, но поиск через него идёт дальше. Когда надгробий
набирается много, таблицу перестраивают. Хороший пункт из
серии «глубже, чем спросят».
Эвакуация копирует пары ключ-значение в новый массив
бакетов. Если бы язык разрешил сохранить
&m[k], то после первой же вставки,
запустившей рост, этот указатель смотрел бы в старую память:
запись через него молча терялась бы, а чтение возвращало
устаревшее значение. Go предпочитает запретить операцию на
этапе компиляции.
У слайса такой проблемы нет: там при росте выделяется новый
массив, но старый остаётся валидным, и указатель на
старый элемент продолжает указывать на живую память (просто
перестаёт быть связан со слайсом). Поэтому
&s[0] легален.
Что можно, а что нельзя
m := map[string]int{"a": 1}
// p := &m["a"] // ошибка: cannot take address of m["a"]
m["a"]++ // можно: разворачивается в чтение + сложение + запись
m["a"] += 5 // можно
m["new"]++ // можно: отсутствующий ключ = 0, станет 1
type S struct {
N int
}
ms := map[string]S{"a": {1}}
// ms["a"].N = 2 // ошибка: cannot assign to struct field in map
v := ms["a"]; v.N = 2; ms["a"] = v // читаем-меняем-пишем
mp := map[string]*S{"a": {1}}
mp["a"].N = 2 // работает: адресуем то, на что указывает значение
sl := map[string][]int{"a": {1}}
sl["a"] = append(sl["a"], 2) // слайс менять можно только через переприсваивание
sl["a"][0] = 9 // а элемент можно напрямую: индексация идёт
// по указателю внутри значения, а не по самому значению
«Почему m[k]++ компилируется, а
m[k].Field = v нет?» Потому что инкремент
компилятор разворачивает в три операции над копией, а
присваивание полю потребовало бы адреса значения. Отсюда
практическое правило: если в мапе лежат структуры, которые
надо часто менять, храни map[K]*V. Но помни,
что тогда GC придётся сканировать эти указатели, а сами
структуры разъедутся по куче.
Порядок обхода и так определяется числом бакетов, seed'ом и
историей вставок. Окажись он стабильным «на практике», код
начал бы на него опираться, и любое улучшение рантайма
(например, переход на Swiss Tables в 1.24) ломало бы прод.
Команда Go сделала недетерминированность явной и обязательной:
mapiterinit выбирает
случайный стартовый бакет и случайное смещение внутри
бакета
на каждый range.
Мотив номер два: безопасность. Вместе со случайным
hash0 это прячет внутреннее распределение
ключей.
Как получить детерминированный обход
keys := make([]string, 0, len(m))
for k := range m { keys = append(keys, k) }
slices.Sort(keys)
for _, k := range keys { fmt.Println(k, m[k]) }
// Go 1.23+: на итераторах это одна строка:
for _, k := range slices.Sorted(maps.Keys(m)) { fmt.Println(k, m[k]) }
// для m := map[string]int{"b": 2, "a": 1, "c": 3} оба варианта
// печатают одно и то же при любом запуске:
// a 1
// b 2
// c 3
-
fmt.Println(m)печатает мапу отсортированной по ключам начиная с Go 1.12. Это поведение пакетаfmt, сделанное ради воспроизводимых тестов и логов, а не свойство мапы. -
Одна мапа с одним и тем же содержимым даст разный
порядок при двух
rangeподряд в одном и том же запуске: рандомизируется каждая итерация, а не только процесс.
len, range и
delete на nil-мапе легальны и
безопасны; запись -
panic: assignment to entry in nil map.
var m map[string]int // nil: указатель на hmap равен nil
m == nil // true
len(m) // 0
m["x"] // 0 - чтение легально
v, ok := m["x"] // 0, false
for range m {} // ноль итераций
delete(m, "x") // no-op, без паники
m["x"] = 1 // panic: assignment to entry in nil map
Почему асимметрия
Чтение честно вернёт нулевое значение, вообще не обращаясь к
памяти: рантайм видит нулевой указатель и сразу отдаёт
zero, false. Запись обязана эту память создать,
а создавать негде. Аллоцировать hmap сам
рантайм не может: мапа передана по значению, и новый
указатель некуда записать так, чтобы его увидел вызывающий.
Сравни со слайсом, там append возвращает
новый заголовок, поэтому и работает с nil.
Где это стреляет в реальном коде
type Config struct {
Labels map[string]string // забыли инициализировать в конструкторе
}
c := &Config{}
c.Labels["env"] = "prod" // panic
// JSON: если в документе поля нет - мапа останется nil
var c2 Config
json.Unmarshal([]byte(`{}`), &c2)
c2.Labels["env"] = "prod" // panic
// Правильно:
func NewConfig() *Config { return &Config{Labels: map[string]string{}} }
// или ленивая инициализация в сеттере:
func (c *Config) Set(k, v string) {
if c.Labels == nil { c.Labels = make(map[string]string) }
c.Labels[k] = v
}
Если у структуры есть поле-мапа, инициализируй её в
конструкторе или лениво в методе-сеттере. Никогда не
рассчитывай, что вызывающий заполнит её сам, и тем более,
что её заполнит json.Unmarshal.
fatal error: concurrent map writes - это
throw, а не паника, его
не ловит recover. Дефолтная защита -
RWMutex; sync.Map - только под два
конкретных паттерна.
Механика детектора
В заголовке мапы есть флаг записи: с Go 1.24 поле writing, раньше бит hashWriting в hmap.flags. Запись и удаление ставят его на входе и снимают на выходе. Любая операция, увидевшая флаг, вызывает fatal. Процесс падает целиком,
defer не выполняются, recover
бесполезен: состояние таблицы уже могло быть повреждено, и
продолжать небезопасно.
Детектор best-effort: он ловит только пересечение
окон операций. Гонку, где записи разошлись по времени, он не
заметит, а данные всё равно испортятся. Настоящий инструмент
тут go test -race. Параллельное чтение
многими горутинами без единого писателя законно и безопасно.
Чем защищаться
-
sync.RWMutex+ map, выбор по умолчанию. Полная типизация, предсказуемая стоимость,lenработает. Проседает, только когда десятки ядер бьются за один мьютекс. -
sync.Mapоправдана в двух случаях, прямо названных в документации: (1) ключ пишется один раз и много раз читается; (2) горутины работают с непересекающимися наборами ключей. Минусы:anyвместо типов, боксинг и лишние аллокации, нетlen. - Шардирование: массив из N структур «мьютекс + мапа», ключ ложится в шард по хешу. Снимает конкуренцию за один замок, но ломает атомарность операций «по всей мапе».
-
Иммутабельный снимок на
atomic.Pointer[map[K]V]: читатели берут указатель без блокировок, писатель копирует мапу целиком и подменяет указатель. Отлично для конфигов, которые обновляются раз в минуту.
// RLock не спасает от "проверил-и-записал"
c.mu.RLock()
_, ok := c.m[k]
c.mu.RUnlock()
if !ok {
c.mu.Lock()
c.m[k] = compute(k) // между RUnlock и Lock кто-то мог уже положить
c.mu.Unlock()
}
// Правильно: под Lock перепроверить (double-checked), либо singleflight
c.mu.Lock()
if _, ok := c.m[k]; !ok { c.m[k] = compute(k) }
c.mu.Unlock()
До Go 1.24 это была пара мап: атомарно читаемая
read и защищённая мьютексом
dirty; когда промахов накапливалось много,
dirty целиком становилась новой
read, отсюда и провал на потоке новых ключей.
В Go 1.24 внутренность заменили на
HashTrieMap, и эта патология ушла.
Рассказываешь про read/dirty, обязательно скажи «до 1.24»,
иначе это выглядит как устаревшие знания.
Список
| Тип | Ключ? | Комментарий |
|---|---|---|
числа, string, bool |
да | основной случай |
| указатель, канал | да | сравнивается адрес, а не содержимое |
массив [N]T |
да, если T comparable |
частый обход запрета на слайс |
struct |
да, если все поля comparable | составные ключи - идиоматично |
| интерфейс | компилируется | паника в рантайме, если динамический тип не comparable |
| слайс, мапа, функция | нет |
invalid map key type на компиляции
|
float64 |
формально да | но NaN ломает всё - см. ниже |
type Key struct {
UserID int64
Region string
}
m := map[Key]int{{1, "ru"}: 10}
fmt.Println(m[Key{1, "ru"}]) // 10 - сравнение пополевое
// Слайс нельзя, но можно массив фиксированной длины:
var id [16]byte
copy(id[:], uuidBytes)
byID := map[[16]byte]string{id: "user"}
// Или сериализовать ключ в строку:
byTags := map[string]int{}
byTags[strings.Join(tags, "\x00")] = 1 // разделитель, который не встречается в данных
// Интерфейс-ключ: компилируется, но падает
var anyMap = map[any]int{}
anyMap[42] = 1 // ок: int comparable
anyMap[[]int{1}] = 1 // panic: runtime error: hash of unhashable type []int
math.NaN() != math.NaN() по стандарту IEEE
754. Значит, записанное по NaN-ключу значение невозможно
ни прочитать, ни удалить, а каждая запись создаёт новый
элемент. len растёт, память течёт, увидеть
эти пары можно только через range. Отсюда
правило: не используй float как ключ мапы.
Если очень нужно, нормализуй заранее (округли и переведи в
целое или строку).
Констрейнт comparable в дженериках означает
ровно то же множество типов. Тонкость: до Go 1.20
any не удовлетворял
comparable, потому что интерфейс может
паниковать при сравнении. С Go 1.20 правило смягчили:
теперь интерфейсы удовлетворяют comparable, а
за возможную панику отвечает разработчик. Удобный пункт,
чтобы связать две темы.
delete обнуляет слот, чтобы GC
собрал значение, но массив бакетов
никогда не уменьшается. Единственный способ вернуть
память - пересоздать мапу.
Рантайм не умеет уменьшать B. Мапа, в которой
было 10 млн ключей, после delete всех ключей
продолжает держать бакеты под 10 млн: len(m)
равен нулю, а RSS процесса не меняется. Работает только
sameSizeGrow: он схлопывает overflow-цепочки,
но основной массив не трогает.
// Не помогает вернуть память:
delete(m, k) // обнуляет слот, бакеты остаются
clear(m) // Go 1.21+: как "удалить все ключи", память тоже не отдаёт
// Помогает:
m = make(map[K]V) // старая мапа становится мусором целиком
// Помогает частично: значения соберёт GC, в бакетах останутся указатели по 8 байт
mp := map[K]*V{}
Как это выглядит в проде
-
Кэш без вытеснения, который растёт до пика трафика
и остаётся на этом уровне навсегда. Симптом: RSS вырос и
не падает,
lenмаленький. -
Ключи-строки. Даже после
deleteстроки собираются, но если они были подстроками одного большого буфера, держится весь буфер (см. главу про строки). - Мапа как очередь: пишем тысячи ключей в секунду, удаляем сразу, а бакеты вырастают до пикового размера и остаются.
Что делать
-
Периодически пересоздавать: скопировать живые ключи в
новую мапу нужного размера (
make(map[K]V, len(old))) и заменить. - Взять кэш с вытеснением (LRU) вместо голой мапы.
- Шардировать и пересоздавать шарды по очереди, тогда паузы копирования размажутся.
-
Хранить
map[K]*V, если тяжёлые именно значения.
«Мапа в Go растёт, но не сжимается. delete и
clear освобождают значения для сборщика, но
массив бакетов остаётся раздутым до смерти самой мапы. Это
же верно и для слайса: s = s[:0] не уменьшает
cap.» Такую параллель ценят: видно, что тему
понимаешь, а не вызубрил.
map[T]struct{}, потому что
struct{} занимает 0 байт и под массив значений
в бакете память не выделяется.
set := make(map[string]struct{}, 1024)
set["a"] = struct{}{}
_, ok := set["a"] // проверка
delete(set, "a")
len(set) // размер
// Операции над множествами пишем руками:
func Union(a, b map[string]struct{}) map[string]struct{} {
r := make(map[string]struct{}, len(a)+len(b))
for k := range a { r[k] = struct{}{} }
for k := range b { r[k] = struct{}{} }
return r
}
Почему именно struct{}
unsafe.Sizeof(struct{}{}) == 0. Все экземпляры
пустой структуры лежат по одному адресу,
runtime.zerobase. В бакете мапы массив значений
[8]struct{} занимает ноль байт, то есть
накладных расходов на значение нет вообще. Для
map[T]bool это был бы [8]bool = 8
байт на бакет плюс возможный padding.
map[T]struct{} |
map[T]bool |
|
|---|---|---|
| Память | только ключи | +1 байт на элемент |
| Проверка | _, ok := s[k] |
s[k] - короче и читаемее |
Смысл false |
невозможен | двусмысленно: «нет ключа» или «есть, но false» |
| Запись | s[k] = struct{}{} - шумно |
s[k] = true |
Ещё две идиомы с пустой структурой
done := make(chan struct{}) // сигнальный канал: важен факт, а не значение
close(done) // broadcast всем читателям
type NoopLogger struct{} // тип без состояния - экземпляры бесплатны
func (NoopLogger) Log(string) {}
Для множеств в горячем пути и на миллионы элементов бери
map[T]struct{}. Для десятка флажков в конфиге
сойдёт map[T]bool, читаемость важнее байта. В
обоих случаях указывай ожидаемый размер в
make: это убирает цепочку удвоений. И помни
про slices.Contains из Go 1.21+: на маленьких
наборах (до ~10 элементов) линейный поиск по слайсу
быстрее мапы за счёт кэша.
| Операция | Средняя | Худшая | Почему худшая такая |
|---|---|---|---|
m[k] |
O(1) | O(n) | все ключи в одном бакете → длинная overflow-цепочка |
m[k] = v |
амортиз. O(1) | O(n) | эта вставка запустила рост и переселение |
delete |
O(1) | O(n) | та же цепочка коллизий |
range |
O(n) | O(n + бакеты) | пустые бакеты всё равно просматриваются |
len |
O(1) | O(1) | поле hmap.count |
Четыре сценария деградации
- Коллизии. В обычной жизни их мало благодаря хорошему хешу, но специально подобранные ключи (или очень плохой самописный хеш в другой реализации) дают O(n). В Go от внешней атаки защищает случайный seed, поэтому подобрать коллизии снаружи нельзя.
-
Разреженность. Положили миллион, удалили 990 тысяч,
а бакетов по-прежнему миллион.
rangeпроходит по всем, локальность кэша плохая. -
Тяжёлые значения.
map[string][4096]byte: каждое чтение и запись копирует 4 КБ, бакет огромный, кэш не работает. Решение:map[string]*T. -
Нет преаллокации. Насыпать 10 млн ключей «с нуля»
стоит ~23 удвоений, и каждое перекладывает всё заново.
make(map[K]V, n)сразу выбирает нужныйB.
// Разницу легко измерить:
func BenchmarkNoPrealloc(b *testing.B) {
for b.Loop() { // Go 1.24+: b.Loop вместо b.N
m := map[int]int{}
for i := 0; i < 1_000_000; i++ { m[i] = i }
}
}
func BenchmarkPrealloc(b *testing.B) {
for b.Loop() {
m := make(map[int]int, 1_000_000)
for i := 0; i < 1_000_000; i++ { m[i] = i }
}
}
// преаллоцированная версия обычно быстрее в 1.5–2 раза и делает
// на порядок меньше аллокаций
На маленьких наборах (до 8–16 элементов) линейный поиск по
слайсу обгоняет мапу: нет хеширования, данные лежат в
одной-двух кэш-линиях. Компилятор Go даже оптимизирует
switch по строкам лучше, чем поиск по мапе.
Если на собесе спросят «как ускорить», уместно предложить
измерить слайс против мапы, а не сразу тюнить мапу.
m := map[string]int{"a": 0}
v1 := m["a"] // 0
v2 := m["zzz"] // 0 - неотличимо
v3, ok := m["a"] // 0, true
v4, ok := m["zzz"] // 0, false
Синтаксическая форма, а не функция: компилятор видит
присваивание в две переменные и вызывает
mapaccess2 вместо mapaccess1.
Поэтому её нельзя «передать дальше»: f(m[k])
всегда односложная форма.
Тот же синтаксис у трёх других конструкций
v, ok := m[k] // мапа: ok == есть ли ключ
v, ok := i.(MyType) // type assertion: ok == совпал ли динамический тип
v, ok := <-ch // канал: ok == false, если канал закрыт и опустошён
for i, ok := it.Next(); ok; i, ok = it.Next() {} // ручной итератор
Где обязательно
- Значение это число или bool, и ноль/false валиден: счётчики, лимиты, фича-флаги.
- Нужно отличить «не настроено» от «настроено в ноль», например в конфиге.
-
Кэш: в
v, ok := cache[k]значениеok == falseозначает промах, а не «в кэше лежит ноль».
Где не нужно
counts[word]++ // отсутствующий ключ = 0, станет 1, comma-ok не нужен
total += prices[id] // «нет цены» и «цена 0» здесь одно и то же
sums[k] = append(sums[k], v) // nil-слайс аппендится нормально
Фича-флаги в map[string]bool и проверка
if flags["enable_new_flow"]. Опечатка в имени
флага даёт false, ровно то же, что
выключенный флаг, и баг живёт месяцами. Лечится либо
comma-ok с явной ошибкой на неизвестный ключ, либо
типизированным конфигом со структурой вместо мапы.
Что стоит запомнить
- Ключ должен быть сравнимым; порядок range не определён.
- Comma-ok различает zero value и отсутствие ключа.
- Map не защищает конкурентные записи.
Частые ошибки
- Не опирайся на порядок обхода.
- Нельзя брать адрес map[key].
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Проследи значения
Что напечатает фрагмент? Объясни, почему получился такой результат.
m := map[string]int{"ready": 0}
a := m["ready"]
b := m["missing"]
_, ok := m["ready"]
fmt.Println(a, b, ok)
Ответ и разбор
Печатается 0 0 true: чтение отсутствующего ключа возвращает zero value, но comma-ok отдельно показывает наличие ключа.
Код-ревью
2. Найди ошибку и исправь
Два worker вызывают add одновременно. Исправь защиту так, чтобы чтение и запись выполнялись под одной блокировкой.
var counts = map[string]int{}
func add(key string) { counts[key]++ }
Ответ и разбор
Map не синхронизирует запись. Добавь общий sync.Mutex и держи его на всей операции: var mu sync.Mutex; func add(key string) { mu.Lock(); defer mu.Unlock(); counts[key]++ }. Все чтения той же map тоже должны использовать этот mutex либо другую согласованную стратегию.
Применение
3. Реши задачу
Нужно изменить поле Age у значения map[string]User. Почему m["ann"].Age++ не компилируется и как записать исправление?
Ответ и разбор
Элемент map нельзя адресовать как стабильное место хранения. Скопируй значение, измени копию и присвой обратно: u := m["ann"]; u.Age++; m["ann"] = u. Если значения большие или часто меняются, можно хранить map[string]*User, отдельно учитывая владение указателями.
Применение
4. Реши задачу
Код обходит map и строит список ключей. Требуется одинаковый список при каждом запуске. Как получить детерминированный порядок?
Ответ и разбор
Собери ключи в слайс и отсортируй через slices.Sort(keys) перед использованием. Спецификация не задаёт порядок map iteration, поэтому нельзя использовать порядок обхода как порядок результата.