Мапы: хэш, comma-ok, concurrent write

О лекции

Map в Go — хэш-таблица: ключ находится быстро, порядка обхода нет. Нулевая map паникует на записи. Конкурентная запись без блокировки — data race и часто падение процесса.

Типы и память Go golang собеседование

Map — это словарь: ключ → значение. Внутри хэш-таблица, не дерево. Поиск по ключу в среднем занимает примерно одно и то же время, сколько бы ключей ни было. Порядка ключей нет: обход каждый раз может идти иначе.

В бэкенде map нужна постоянно: индекс по id в памяти, набор флагов, дедупликация, кэш на процесс. Тип пишут так: map[K]V.

Аналогия: шкаф с ящиками. Хэш ключа говорит, в какой ящик смотреть. Два разных ключа могут попасть в один ящик — тогда внутри ящика ищут дальше. Это коллизия, не ошибка.

Comma-ok и нулевая map

Нулевое значение map — nil. Чтение из nil даёт нуль типа, как будто ключа нет. Запись в nil паникует: некуда класть ящики. Поэтому map создают через make или литерал, а не «объявили var и начали писать».

Индексация m[k] всегда возвращает значение. Если ключа нет, это нуль типа: 0, false, пустая строка, nil. Без второго результата нельзя понять: в map лежит настоящий ноль или ключа не было. Отсюда идиома comma-ok: v, ok := m[k].

func count(m map[string]int, k string) int {
	v, ok := m[k]
	if !ok {
		return 0 // ключа нет; v тоже 0, но ok это показывает
	}
	return v
}

func mustStore() {
	var m map[string]int // nil
	// m["a"] = 1 // panic: assignment to entry in nil map
	m = make(map[string]int)
	m["a"] = 1
}

ok отличает отсутствие ключа от нулевого значения. Запись в nil map паникует.

Пример. Счётчик ошибок: ключа timeout нет. m["timeout"] вернёт 0. Без ok не отличить «ноль таймаутов» от «ключ ещё не заводили».

delete(m, k) на отсутствующем ключе безопасен. len(nil map) равен нулю. Цикл for k, v := range m специально идёт в случайном порядке. Даже на двух ключах порядок может меняться от запуска к запуску. Нужен стабильный вывод — снимите ключи и отсортируйте сами.

Рост и обход

Когда таблица заполняется, она расширяется: ящики копируются. На огромной map в горячем процессе это редкий, но видимый пик нагрузки. Запрос «все ключи от A до Z» map не умеет — только полный перебор. Для порядка и диапазона нужны дерево, отсортированный слайс или база.

Пример. Нужны пользователи с id от 100 до 200. Map по id так не спросишь. Либо перебрать все ключи, либо хранить их ещё и в слайсе / в БД.

Конкурентность

Две горутины меняют одну map без блокировки. Это data race. Рантайм часто роняет процесс: fatal error: concurrent map writes. Неважно, что ключи разные: общая сама таблица. delete, len и range — тоже доступы. Их защищают тем же мьютексом.

Пример. Два хендлера одновременно делают cache[id] = user. Ключи разные, map одна. Без лока процесс может упасть, а не «просто потерять одно значение».

type counters struct {
	mu sync.Mutex
	n  map[string]int
}

func (c *counters) Inc(k string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.n == nil {
		c.n = make(map[string]int)
	}
	c.n[k]++
}

Обычная map и мьютекс. Без Lock это data race, даже если ключи разные.

Много читателей и редкая запись — sync.RWMutex или шарды по ключу. sync.Map заточен под ключи, которые почти не меняются после записи, и под сценарий «пишут редко, читают часто». Это не замена обычной map в каждом сервисе. Для счётчика запросов часто хватает mutex вокруг обычной map.

Типичная ошибка

Считать map потокобезопасной «потому что ключи не пересекаются». Или защитить только запись, а range оставить без лока. Гонка на map — не потерянный инкремент, а потенциальный краш процесса. Вторая ловушка: писать в nil map после var m map[K]V.

На собеседовании

Map не потокобезопасна. Чтение без ключа даёт нуль типа — отличайте через comma-ok. Запись в nil паникует. Range идёт в случайном порядке. Delete, len и обход — тоже доступы к таблице. sync.Map — узкий случай, не замена всем map.