18. Map. Хеш-таблица в Go

В стандартной библиотеке C++ тип std::map реализован как красно-черное дерево с логарифмической сложностью поиска $O(\log N)$. Если разработчику требуется классическая ассоциативная таблица, ему приходится явно подключать std::unordered_map.

Создатели Go подошли к вопросу с чисто практической точки зрения Bell Labs: ассоциативный массив на бэкенде в 99% случаев используется для сверхбыстрого кэширования, индексации и поиска за константное время. Поэтому встроенный в язык тип map всегда и везде представляет собой высокоэффективную хеш-таблицу (Hash Table), обеспечивающую среднюю сложность поиска, вставки и удаления за $O(1)$.

В этой главе мы изучим синтаксис и коварные ловушки (gotchas) при работе с хеш-таблицами, выясним, почему итерация по мапе заставляет нервничать разработчиков из мира PHP, как мапы физически передаются в функции и почему рантайм Go беспощадно уничтожает процесс при обнаружении конкурентной записи.


Инициализация: Главная ловушка с nil

Как и срезы, хеш-таблицу в Go можно инициализировать несколькими способами. И здесь заложена одна из самых частых причин аварийных остановок Go-сервисов в продакшене:

Go
package main

// 1. Nil-мапа (не аллоцирована)
var m1 map[string]int 

func main() {
    // 2. Пустая мапа (аллоцирована)
    m2 := map[string]int{} 

    // 3. Мапа с преаллокацией (Лучшая инженерная практика!)
    m3 := make(map[string]int, 100) 

    _, _ = m2, m3
}

Преаллокация и Mechanical Sympathy

Инициализация через конструкцию make(map[K]V, capacity) — это признак зрелого кода. Если вам заранее известно, что в таблицу будет загружено 10 000 записей пользователей, укажите это число немедленно.

Рантайм Go заблаговременно выделит в куче место под нужное число элементов (в классической реализации до Go 1.23 — корзины, бакеты; с Go 1.24, в Swiss Tables, — группы слотов и таблицы). Если преаллокацию проигнорировать, по мере наполнения таблицы рантайм будет вынужден многократно запускать тяжелую процедуру эвакуации (rehashing): запрашивать новые массивы памяти, пересчитывать хэши и разносить ключи по новым бакетам, создавая паразитные паузы и нагружая процессор.


Базовые операции и идиома Comma Ok

Чтение несуществующего ключа в Go намеренно спроектировано без паник: оно возвращает стандартное нулевое значение (Zero Value):

Go
m := make(map[string]int)
m["alice"] = 25

age := m["bob"] 
fmt.Println(age) // Выведет 0. Но 0 может быть и реальным возрастом новорожденного!

Как достоверно отличить реальный ноль от полного отсутствия ключа в словаре? Для этого в Go принята классическая идиома «Comma Ok»:

Go
if age, exists := m["bob"]; exists {
    fmt.Printf("Боб найден, ему %d лет\n", age)
} else {
    fmt.Println("Боба нет в базе")
}

Для удаления элементов используется встроенная функция delete:

Go
delete(m, "alice")
delete(m, "charlie") // Безопасно! Удаление несуществующего ключа ничего не делает.

Требования к ключам: Что такое Comparable?

В Go нельзя использовать произвольный тип данных в роли ключа мапы. Ключ обязан удовлетворять системному требованию сравнимости (comparable). Это означает, что для типа на этапе компиляции должны быть строго определены операторы проверки равенства == и !=.

  • Разрешено использовать: базовые типы int, float64, string, bool, указатели, каналы, интерфейсы, массивы фиксированной длины и структуры, если все их поля являются сравнимыми.
  • Запрещено использовать: срезы (slices), хеш-таблицы (maps) и функции (functions).

Ключом может быть и интерфейс, но если в нём окажется несравнимый динамический тип (срез, мапа, функция), операция запаникует уже во время выполнения: m := map[any]int{}; m[[]int{1}] = 1 → panic: runtime error: hash of unhashable type []int.


Итерация по мапе: Намеренный хаос

В ряде языков (например, в PHP или в упорядоченных коллекциях C# Dictionary) разработчики привыкают к тому, что ассоциативный массив помнит хронологию вставки ключей.

В Go итерация по хеш-таблице с помощью for range принципиально не гарантирует никакого порядка. Более того, порядок обхода элементов будет разным при каждом новом запуске цикла!

Go
m := map[int]string{1: "A", 2: "B", 3: "C"}
for k, v := range m {
    fmt.Printf("%d:%s ", k, v)
}
// Запуск 1: 2:B 3:C 1:A
// Запуск 2: 3:C 1:A 2:B

Если вашей задаче требуется детерминированный порядок вывода, алгоритм прост: извлеките ключи в отдельный срез, отсортируйте его с помощью sort.Strings или slices.Sort и обойдите срез.


Мапа передается по ссылке? (Снова иллюзия)

Вспомним фундаментальный закон из лекции 10. Функции. Аргументы, return, multiple return values: в языке Go абсолютно всё передается строго по значению. Почему же модификация мапы внутри функции немедленно отражается на вызывающем коде?

Потому что тип map[K]V на машинном уровне — это синтаксический сахар над обычным указателем. Физически переменная мапы представляет собой 8-байтный указатель на структуру runtime.hmap (с Go 1.24 — maps.Map из internal/runtime/maps), размещенную в куче (Heap):

flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph Caller["Вызывающая функция"]
        M1["m<br>(указатель 8 байт)"]:::process
    end
    subgraph Func["Вызываемая функция"]
        M2["arg<br>(копия указателя 8 байт)"]:::process
    end
    subgraph Heap["Куча - Heap"]
        HMAP["runtime.hmap<br>Хеш-таблица"]:::system
    end

    M1 -->|"Указывает на"| HMAP
    M2 -.->|"Смотрит туда же"| HMAP
  

Когда вы передаете мапу в функцию, процессор копирует эти 8 байт адреса. И оригинальная переменная, и аргумент функции указывают на один и тот же адрес заголовка runtime.hmap.


Потокобезопасность (Thread Safety)

Встроенные мапы Go категорически не являются потокобезопасными.

Если одна горутина выполняет чтение из мапы, а другая в этот же миллисекундный отрезок производит запись (или обе горутины пишут одновременно), рантайм может это обнаружить (проверка флага записи, не гарантированная: гонка может пройти и незамеченной) и завершит процесс фатальной ошибкой рантайма:

Text
fatal error: concurrent map read and map write

Эту ошибку невозможно перехватить через recover(). Она намеренно обходит механизм паник: функция рантайма fatal сразу печатает трассы стека и завершает процесс с кодом 2 (это не сигнал abort от ядра ОС, а решение самого рантайма).

Почему так жестоко? Потому что конкурентная запись без синхронизации способна повредить внутренние указатели бакетов в hmap, что привело бы к незаметному разрушению памяти процесса. Рантайм детектирует гонку по флагу записи (hashWriting в классической реализации, поле writing в Swiss Tables), проверяемому при операциях с таблицей.

Если требуется параллельный доступ к словарю из разных потоков, применяют два стандартных подхода:

  1. Защита стандартной мапы с помощью мьютекса sync.RWMutex.
  2. Использование специализированной структуры sync.Map из стандартной библиотеки (эффективной в ситуациях, когда ключи записываются один раз, а читаются миллионы раз; с Go 1.24 она реализована на конкурентном хэш-префиксном дереве HashTrieMap).

(Подробный разбор многопоточных структур данных мы проведем в главе 40. sync.WaitGroup, Mutex, RWMutex).


Итог

  1. map — встроенная хэш-таблица с амортизированной сложностью $O(1)$ для поиска, вставки и удаления.
  2. Семантика nil: Чтение из nil-мапы возвращает нулевые значения (Zero Value). Запись в nil-мапу вызывает немедленную панику. Инициализируйте мапы через make.
  3. Преаллокация: Указание размера в make(map, size) (или make(map[K]V, capacity)) исключает дорогостоящие операции повторного хэширования (rehashing).
  4. Comma Ok: Единственный надежный способ отличить присутствие нулевого значения от отсутствия ключа: val, ok := m[key].
  5. Сравнимость ключей: Ключом может быть любой тип, для которого компилятор поддерживает операторы == и != (тип comparable).
  6. Рандомизация обхода: Порядок итерации намеренно случаен при каждом вызове range, чтобы предотвратить скрытые зависимости от порядка корзин.
  7. Потокобезопасность: Доступ без мьютексов при параллельной записи может вызвать неустранимый фатальный крах fatal error: concurrent map read and map write.

Мы рассмотрели синтаксис и правила безопасной работы с мапами. Но как устроена хэш-таблица на самом низком уровне? Как Go разрешает коллизии хэшей? Почему размер бакета равен строго 8 слотам и как происходит постепенная эвакуация данных без остановки всей программы?

Ответы на эти вопросы мы узнаем в следующей лекции: 19. Map под капотом. hmap, buckets и рост.