Карта в Go: группа из восьми, порог 7/8 и промах, который дороже попадания
Собеседование по картам идёт лестницей: что лежит в переменной — как устроен поиск — как она расширяется — какая асимптотика — почему порядок обхода «случайный» — что будет при конкурентном доступе. Урок проходит её целиком, и на каждой ступени показывает механизм: почему восьмую часть слотов карта держит пустой намеренно, почему расширение происходит при 7/8, а не при заполнении, и почему промах ищется дольше попадания.
Полное техническое изложение
TL;DR
Карта отвечает на один вопрос: что лежит по этому ключу. Отсутствующий ключ
— не ошибка, а нулевое значение, и по значению он неотличим от ключа, у
которого значение действительно ноль; различает только форма с двумя
результатами — v, ok := m[k]. Ключ обязан быть сравнимым, порядок обхода не
гарантирован, а одновременный доступ из нескольких горутин карта не переживает:
защищать её приходится самому.
Отсюда то, что ломается. nil-карту можно читать, обходить и даже удалять
из неё — шесть операций работают, седьмая роняет программу: assignment to entry in nil map. Несравнимый ключ ловит компилятор — invalid map key type []int, — кроме
map[any]T, где проверка откладывается до времени выполнения. Одновременная запись —
это fatal error, а не паника: recover не поможет, стек не разматывается,
отложенные вызовы не выполняются. А в переменной типа map лежит указатель
на структуру рантайма — поэтому карта, переданная в функцию, меняется у
вызывающей стороны.
Дальше — устройство go1.24 и числа. В нынешней реализации поиск идёт
группами по восемь слотов и останавливается, только встретив свободный
слот, — отсюда то, чего никто не ожидает: промах дороже попадания. Измерено
на этой машине: 19,80 нс против 28,96 — в 1,46 раза. Восьмую часть слотов
реализация держит пустой намеренно: таблица расширяется при заполнении
7/8, а не «когда заполнилась», — измерены расширения на размерах 9, 15,
29, 57, 113, 225, 449, то есть, начиная с 15, ровно «7/8 вместимости плюс один» (9 — маленькая карта из одной группы: её восемь слотов заполняются целиком).
Асимптотика — O(1) в среднем. Вставка, попавшая на расширение, дороже, но
в go1.24 она перекладывает не всю карту, а одну таблицу: до 1024 слотов таблица
удваивается, дальше делится надвое. Измерено: от тысячи до миллиона записей
крупнейшая разовая порция памяти на вставку выросла с 36992 до 55360 байт —
в полтора раза при тысячекратном росте карты. Про порядок обхода контракт говорит одно:
он не гарантирован; а реализация, судя по наблюдениям, добивается этого не
перемешиванием, а случайной точкой старта, и потому код, зависящий от порядка, проходит тесты и
ломается в проде. И память карта не отдаёт: измерено 4617 КБ до удаления всех
ключей и 4618 после — ни delete, ни clear таблицу не уменьшают.
- что такое пара «ключ — значение»: по ключу кладут, по нему же достают;
- один и тот же ключ встречается в карте один раз;
- функция получает аргумент копией — и от того, что именно скопировано, зависит, увидит ли вызывающая сторона изменения.
- хеш, группы слотов, управляющий байт, порог заполнения, Swiss Table;
- чем
fatal errorотличается от паники, что такоеsync.Mapи зачем хеш засеивают случайным числом; - форма
v, ok := m[k]и слово «сравнимый» про тип ключа — оба разбираются по дороге.
Что здесь на самом деле спрашивают
Лестница почти всегда одна и та же, и первые два вопроса решают больше, чем кажется:
- «Что вернёт
m[k], если ключа нет?» — разминка, на которой отсеиваются те, кто ждёт ошибки. - «А как отличить отсутствующий ключ от нулевого значения?» — здесь начинается содержание.
- «Что будет, если писать из двух горутин?» — проверяют, знаете ли вы, что это не паника.
- «Как устроен поиск?» — назовёте ли вы хеш и группу слотов или ограничитесь словом «хеш-таблица».
- «Что происходит при расширении?» — знаете ли вы про порог и назовёте ли его числом.
- «Какая сложность?» — отличаете ли среднее от худшего.
- «Почему порядок обхода случайный?» — вопрос-ловушка: он не случайный.
Урок идёт по этой лестнице, и идёт он в одну сторону: сначала то, что карта обещает, потом то, как реализация это обещание выполняет. Числа появляются последними — как проверка предсказания, а не как набор неожиданных фактов.
База: что карта делает
Карта решает одну задачу: хранить пары «ключ — значение» так, чтобы по ключу сразу получить значение, а не перебирать всё подряд. Отсюда и вся обычная работа с ней: по ключу кладут, по тому же ключу читают.
Спросить карту можно про любой ключ — в том числе про тот, которого в ней нет. Ошибки при этом не будет: вместо неё вернётся нулевое значение того типа, который карта хранит, — ноль для чисел, пустая строка для строк. Ничего не сломается, и в этом главная тонкость темы: ноль вернулся потому, что ключа нет, или потому, что по ключу и правда лежит ноль? По одному значению эти два случая неразличимы. Поэтому у чтения есть вторая форма — та, что отдаёт не только значение, но и ответ на вопрос «а был ли ключ». Всё, что дальше в уроке называется comma-ok, — это она.
Ключом может быть не что угодно. Карта ищет ключ сравнением, значит тип ключа обязан уметь сравниваться на равенство: числа, строки, структуры из таких же полей годятся, а срез или другая карта — нет.
Обходить карту можно, но никакой порядок обхода не обещан: язык называет карту неупорядоченной совокупностью элементов, и два обхода одной и той же карты вправе выдать записи по-разному.
И последнее из того, что нужно знать до всякого устройства: карта ничем не защищена. Пока с ней работает одна горутина, всё в порядке; как только их становится две и хотя бы одна пишет, синхронизацию надо обеспечить самому.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования. Дальше — про то, что каждое из этих правил проверяемо и что именно происходит при его нарушении, а в конце — про то, как реализация ухитряется находить значение за постоянное время и чем за это платит.
Механизм 1: что карта обещает
Теперь то же самое, но проверенное прогоном и сказанное словами спецификации. Начнём с того, что не изменится ни от версии Go, ни от машины, — с контракта. Всё остальное в уроке объясняет, как реализация его выполняет, но сам контракт от реализации не зависит.
Отсутствующий ключ — не ошибка, а нулевое значение. Прогон
bench/gomap/contract.go:
scores["alice"] 10
scores["carol"] 0
scores["dave"] 0
Три строки, два разных случая — и по значению они неразличимы. У carol ключ
есть, и её счёт действительно ноль; у dave ключа нет вовсе. Это первая
ловушка темы: if m[k] == 0 не отвечает на вопрос «есть ли ключ».
Отличить помогает вторая переменная — форма, которую называют comma-ok:
v, ok := scores["carol"] 0 true
v, ok := scores["dave"] 0 false
Правило, которое стоит произнести дословно: ok отвечает на вопрос о
наличии, значение — о содержимом, и подменять первое вторым нельзя. Такая
ошибка не роняет программу и не попадает в лог — код просто молча принимает
отсутствующего пользователя за пользователя с нулём.
nil-карту можно читать, обходить и даже удалять из неё — но не писать в
неё. Тот же прогон:
nilMap == nil true
len(nilMap) 0
nilMap["anything"] 0
_, ok := nilMap["anything"] false
for range nilMap 0
delete(nilMap, ...) ok
nilMap["anything"] = 1 panic: assignment to entry in nil map
Шесть операций работают, седьмая роняет программу. Спецификация формулирует это одной
фразой: A nil map is equivalent to an empty map except that no elements may be
added
(nil-карта эквивалентна пустой карте, за исключением того, что в неё нельзя добавлять элементы).
Коварство именно в первых шести строках. nil-карта в структуре, которую
забыли проинициализировать, ведёт себя совершенно нормально — до первой записи,
которая может случиться через неделю и в другом коде.
Ключ обязан быть сравнимым, и это проверяет компилятор. bench/gomap/contract.sh
пробует собрать карту с ключом-срезом:
badkey.go:4:17: invalid map key type []int
--- go build exit code: 1
Структуры и массивы сравнимы поэлементно и в ключи годятся; срез, карта и функция — нет. Важно здесь то, когда это выясняется: на сборке, а не в проде. «Карта с ключом-срезом» — не редкий баг, а несуществующая программа.
Исключение одно, и оно опасное: у map[any]T тип ключа формально сравним, и
проверка откладывается до времени выполнения. Положите туда срез — получите панику
в той строке, где положили.
Порядок обхода не гарантирован. Это тоже часть контракта, а не свойство реализации: полагаться на порядок нельзя, и точка. А вот как именно реализована негарантированность — деталь, из-за которой такие ошибки ловятся тестами хуже, чем можно подумать; к ней урок вернётся в «Глубже».
Механизм 2: карта и горутины
Этот вопрос стоит вторым, а не последним, потому что он про безопасность контракта, а не про тонкость: карта в Go не защищена ничем, и цена ошибки здесь выше, чем у всего остального в уроке.
Вопрос «что будет, если две горутины пишут в одну карту» проверяет одну вещь: знаете ли вы, что это не паника.
fatal error: concurrent map writes
Сообщений три, и по ним видно, какие операции столкнулись: concurrent map writes,
concurrent map read and map write, concurrent map iteration and map write.
И recover тут не поможет. Разница не косметическая: panic разматывает
стек, выполняет отложенные вызовы и может быть перехвачена; fatal не делает
ничего из этого — процесс останавливается на месте. В комментарии к самой
fatal гонка на картах названа образцовым случаем: fatal is equivalent to throw, but is
used when user code is expected to be at fault for the failure, such as racing
map writes
(fatal эквивалентен throw, но используется, когда в сбое предполагается вина пользовательского кода — например, при состязающихся записях в карту).
Почему выбрана остановка, а не «как получится». Модель памяти Go объясняет
это прямо: races on multiword data structures can lead to inconsistent values
not corresponding to a single write
(гонки на многословных структурах данных могут приводить к несогласованным значениям, не соответствующим ни одной отдельной записи) — и дальше про то, что такие гонки
ведут к произвольному повреждению памяти. Упасть громко лучше, чем тихо
испортить чужие данные.
Отдельно про детектор гонок. Проверка внутри карты — это не детектор: она работает
всегда, в обычной сборке, без -race. Но она не гарантия: две горутины
могут разминуться, и тогда программа просто испортит таблицу. go test -race
находит такие гонки надёжнее, потому что следит за обращениями, а не за флагом.
Лечится это обычным мьютексом рядом с картой. sync.Map выглядит как готовый
ответ, но он оптимизирован под другой профиль — ключ пишется один раз и потом
много раз читается, — и на равномерной смеси проигрывает карте под мьютексом:
в bench/gomap/syncmap.go при половине записей 105,66 нс на операцию против
43,97 нс, в 2,4 раза медленнее (go1.24.7, два ядра, bench/gomap/runs/syncmap.txt).
Механизм 3: почему изменения видны после передачи в функцию
Здесь урок переходит от того, что карта обещает, к тому, как переменная устроена, — и первое наблюдение обычно делают раньше, чем узнают объяснение.
Карта, переданная в функцию, меняется у вызывающей стороны:
func fill(m map[string]int) { m["a"] = 1 } // видно снаружиОбъяснение простое: map[K]V — это указатель на структуру рантайма, а не
сама структура. Копируется указатель, а таблица за ним общая.
Здесь карта отличается от среза интереснее, чем кажется. У среза функция может изменить элементы, но не может его удлинить — потому что длина лежит в копии заголовка. У карты «удлинить» тоже получается, потому что размер лежит за указателем, а не рядом с ним.
Обратите внимание на направление рассуждения. Утверждение «карта — это указатель» само по себе ничего не объясняет и легко запоминается неверно; полезно оно ровно тем, что из него выводятся два наблюдаемых факта — общая таблица после передачи и запрет брать адрес элемента.
Адрес элемента взять нельзя. &m[k] не компилируется, и причина — в
расширении, которое урок разберёт в «Глубже»: записи при нём физически
переезжают, и адрес перестал бы быть действительным. У среза адрес взять можно, потому что там
переезд виден снаружи через append, а у карты он происходит молча.
Механизм 4: краевые случаи, которые стоят падения
Сравнимость ключа проверяет компилятор — кроме одного случая.
Спецификация требует: The comparison operators == and != must
be fully defined for operands of the key type; thus the key type must not be a
function, map, or slice
(Операторы сравнения == и != должны быть полностью определены для операндов типа ключа; поэтому тип ключа не может быть функцией, картой или срезом). Обычно это ловится на сборке — «Механизм 1»
показывает текст ошибки. Но у map[any]T тип ключа формально сравним, и
проверка откладывается до времени выполнения: положите туда срез — получите панику
в той строке, где положили.
И самый тонкий случай: NaN. float64 — тип сравнимый, но NaN != NaN.
Карта ищет ключ сравнением, поэтому запись по NaN в карту попадает, а найтись
не может:
nan := math.NaN()
m := map[float64]int{}
m[nan] = 1
m[nan] = 2
// len(m) == 2 — ДВЕ записи по одному «ключу»
// m[nan] == 0 — ни одну не найти
// delete(m, nan) — не удаляет ничегоЗаписи, которые нельзя ни прочитать, ни удалить по ключу. Избавиться от них
можно только целиком: clear(m) (Go 1.21+) или выбросить карту. На собеседовании это отличный ответ на вопрос
«а бывает, что ключ есть, а достать нельзя».
Глубже: как карта устроена внутри
Всё, что было выше, — контракт: он не зависит от версии Go и не изменится. Дальше идут утверждения другого рода — устройство сегодняшней реализации. Оно объясняет числа, которые иначе выглядят произволом, но правилом языка не является: до Go 1.24 карта была устроена иначе и снова будет устроена иначе.
Зачем карте хеш
Устройство удобнее разбирать в два прохода. Сначала — обычная хеш-таблица, какая описана в любом учебнике: без неё группы по восемь и управляющие байты приходится запоминать, а не понимать.
Задача у карты одна: по ключу за постоянное время найти значение. Перебор отпадает — он линеен. Значит, нужно вычислить, где должно лежать значение, а не искать его.
Отсюда сразу три следствия, которые дальше объясняют всё остальное:
- Хеш обязан быть быстрым, иначе экономия на поиске съедается его вычислением. Поэтому ключ и должен быть сравнимым и хешируемым — тип, для которого хеш посчитать нельзя, в карту не годится.
- Разные ключи могут дать один номер. Это коллизия, и она не редкость, а норма: корзин всегда меньше, чем возможных ключей. Значит, в корзине надо хранить ещё и сам ключ и сравнивать его.
- Заполненная таблица работает плохо. Чем меньше свободных мест, тем длиннее цепочки просмотра, поэтому таблице приходится расти заранее.
Вот и вся классическая модель. Теперь конкретика: в Go 1.24 она реализована в виде Swiss Table, и три решения этой конструкции — группы, управляющий байт и правило остановки — объясняют результаты замеров, которые иначе выглядят произволом.
Группа из восьми и правило остановки
Хеш ключа делится на две части: старшие биты выбирают группу, младшие семь попадают в управляющий байт — короткую метку слота.
Группа — это восемь слотов и восемь управляющих байтов, лежащих рядом. Поиск сравнивает искомую метку сразу со всеми восемью — одной инструкцией, а не циклом. Совпадение метки ещё не означает совпадения ключа (семи бит мало), зато несовпадение наверняка означает, что ключа в этом слоте нет, и полное сравнение ключа делается только для совпавших меток.
Главное здесь — правило остановки, и именно оно даёт неожиданное следствие. Поиск идёт по группам, пока не найдёт ключ или не встретит группу со свободным слотом. Свободный слот означает «дальше этот ключ положить было бы некуда, значит его нет».
Отсюда предсказание, которое стоит сделать до того, как смотреть на числа: промах должен быть дороже попадания. Попадание останавливается, найдя ключ. Промах обязан идти до свободного слота — а свободных мало, потому что карта допускает заполнение до 7/8.
Почти все ожидают обратного: «промах же ничего не нашёл, значит быстрее». Механизм говорит иначе, и ниже урок это проверит замером.
Как карта расширяется
Вопрос «когда карта расширяется» почти всегда получает ответ «когда заполнится». Это неверно, и число известно: при заполнении 7/8.
Расширения можно поймать — по скачкам числа выделений памяти. Вот на каких размерах они случились:
9 15 29 57 113 225 449
Проверьте сами: 7/8 от 16 — это 14, и расширение случилось на пятнадцатой записи. 7/8 от 32 — это 28, расширение на двадцать девятой. И так далее. Правило: расширение на «7/8 вместимости плюс один», а таблица при этом удваивается — пока не дорастёт до 1024 слотов. Что происходит дальше, разобрано ниже, в «Асимптотике».
Почему именно 7/8, а не «под завязку». Это не запас прочности и не подобранная константа — это прямая плата за правило остановки из предыдущего подраздела. Если заполнить таблицу целиком, свободных слотов не останется, и промах придётся искать по всей таблице. Восьмая часть пустых слотов — цена за то, чтобы поиску было где остановиться.
Вот связка, которую полезно произнести на собеседовании целиком: группы по восемь дают быстрое сравнение; остановка на свободном слоте делает промах дороже попадания; и ради этой остановки карта держит 7/8 заполнения, то есть сознательно тратит восьмую часть памяти. Три факта, и они не независимы.
Что это стоит
Теперь, когда механизм разобран, числа перестают быть фактами для запоминания и становятся проверкой предсказания.
Предсказание про правило остановки подтверждается. Замер на карте из ста тысяч записей:
| время | |
|---|---|
| попадание | 19,80 нс |
| промах | 28,96 нс |
| кратность | 1,46 |
Промах дороже попадания в полтора раза — ровно потому, что обязан дойти до свободного слота. Это тот случай, когда механизм позволяет предсказать результат до его показа; если он всё ещё выглядит неожиданным, стоит вернуться к правилу остановки.
Асимптотика и что прячется за средним
Правильный ответ — O(1) в среднем. Но, как и у среза, за ним стоят два разных утверждения, и второе обычно теряется.
Среднее O(1) держится на том, что хеш распределяет ключи по группам равномерно, и длина пути поиска не зависит от размера карты. Именно равномерность, а не «магия хеш-таблицы»: при плохом хеше всё вырождается.
Вставка, попавшая на расширение, дороже остальных — но перекладывает она не
всю карту. В go1.24 карта — это каталог (directory) и под ним отдельные
Swiss-таблицы; таблицу выбирают старшие биты хеша. Таблица меньше 1024 слотов
растёт удвоением: её записи переезжают в новую, вдвое большую. Таблица,
дошедшая до 1024 слотов, не удваивается, а делится на две, и переносятся
только её записи — не больше 896, то есть 7/8 от 1024. Если для раскола не
хватает разрядов каталога, удваивается сам каталог — массив указателей на
таблицы, а не записей. Комментарий в internal/runtime/maps/map.go говорит об
этом прямо: таблица растёт целиком, но каждый такой рост — лишь небольшая часть
карты.
Это видно и снаружи — по байтам, которые выделяет одна вставка
(bench/gomap/growth.go big, go1.24.7, map[int]int до 1 048 576 записей):
| размер карты | вставок с выделением | крупнейшая разовая порция, байт |
|---|---|---|
| 256 — 1024 | 2 | 36944 |
| 1024 — 4096 | 6 | 36992 |
| 16384 — 65536 | 96 | 38128 |
| 262144 — 1048576 | 1536 | 55360 |
Карта выросла в тысячу раз, а крупнейшая разовая порция — в полтора: это раскол одной таблицы на две по 1024 слота плюс подрастающий каталог. Если бы расширение перестраивало всю карту, порция удваивалась бы вместе с ней.
Поэтому «худший случай вставки — O(n), потому что перекладывается всё» для
go1.24 неверно, если n — число записей карты. Точнее так: перенос записей
при расширении ограничен одной таблицей, не больше 1024 слотов; иногда к нему
добавляется рост каталога, пропорциональный числу таблиц, — он растёт с картой,
но несёт указатели, по одному на таблицу, а не записи. Вся работа на построение
остаётся линейной, O(1) на вставку в среднем: тот же прогон выделил 13855 КБ
на диапазоне от 65536 до 262144 записей и 55419 КБ на вчетверо большем. Всё
это — устройство go1.24, а не обещание языка: спецификация не говорит ни о
схеме роста, ни о сложности.
Для сервиса с бюджетом на p99 вывод смягчается, но не исчезает: расширение —
это выделение и перенос сотен записей на одной вставке, и карта, растущая под
нагрузкой, даёт редкие более дорогие вставки. Подсказка размера в make
убирает их.
И худший случай поиска тоже O(n) — если все ключи попали в одну группу. В Go до этого не доводят: хеш карты засеян случайным числом при создании карты, поэтому подобрать ключи, которые все столкнутся, снаружи нельзя. Это защита от алгоритмической DoS-атаки: когда хеши ещё не засеивали, атака «послать сто тысяч ключей с одинаковым хешем» роняла веб-серверы на других языках.
Отсюда практический вывод, который стоит назвать: если конечный размер
известен, скажите его в make. Не ради скорости поиска — она не изменится, —
а чтобы расширений не было вовсе. Что именно это экономит — ниже.
delete, clear и подсказка размера
Три близких вопроса, и на все три интуиция отвечает неверно.
delete не возвращает память. Измерено на карте из двухсот тысяч записей:
| состояние | занято |
|---|---|
| карта заполнена | 4617 КБ |
после delete всех ключей, len = 0 | 4618 КБ |
после clear() | 4618 КБ |
Карта в Go не уменьшается. Таблица остаётся той, до которой доросла;
delete только помечает слот, а clear обнуляет записи, не трогая размер.
Единственный способ отдать память — создать новую карту и бросить старую.
Практическое следствие названо в задачах на проектирование: кеш в карте,
который однажды вырос до пика нагрузки, останется такого размера навсегда.
Лечится это не delete, а пересозданием карты по расписанию.
Подсказка размера уменьшает не карту, а мусор. Тот же замер, две карты на двести тысяч записей:
| выделений | всего выделено | размер готовой | |
|---|---|---|---|
make(map[int]int) | 1044 | 9236 КБ | 4617 КБ |
make(map[int]int, 200000) | 514 | 4618 КБ | 4617 КБ |
Готовая карта — одинаковая. Вдвое отличается работа по дороге: без подсказки рантайм выделяет и бросает все промежуточные таблицы, и это ровно удвоение выделенных байт. Экономится не память карты, а работа сборщика.
Порядок обхода: контракт и наблюдение
В «Механизме 1» сказано, что порядок обхода не гарантирован, — это контракт. Здесь про то, как негарантированность реализована, и разница между этими двумя утверждениями оборачивается багами в проде.
«Порядок обхода карты случаен» знают все. Утверждение недоговорено, и недоговорено в опасную сторону.
Обход начинается со случайной точки, а дальше идёт по таблице подряд. То есть различных порядков ровно столько, сколько записей, — это сдвиги одной и той же последовательности, а не её перестановки. Для карты из девяти записей порядков девять, а не 362 880.
Почему это опаснее настоящей случайности. Код, случайно зависящий от того, что ключ A встретится раньше B, при настоящем перемешивании падал бы примерно в половине запусков — и был бы пойман в первый же день. При сдвиге он падает только на тех сдвигах, которые разрезают ленту между этими двумя ключами. Такой код не падает на тестах — он их проходит, а ломается в проде.
Правильный ответ на собеседовании: «порядок не гарантирован, и полагаться на него нельзя; реализована эта негарантированность случайной точкой старта, а не перемешиванием — поэтому зависимость от порядка ловится тестами хуже, чем кажется».
Как отвечать на собеседовании
Короткий ответ: карта — это пары «ключ — значение» с доступом по ключу, и
отсутствующий ключ даёт не ошибку, а нулевое значение; отличить его от
настоящего нуля позволяет форма v, ok := m[k]. Добавьте вторым
предложением то, что спрашивают сразу следом: в переменной лежит указатель на
структуру рантайма — поэтому карта, переданная в функцию, меняется у вызывающей
стороны, и поэтому nil-карту можно читать, но не писать.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
Про устройство говорите связкой, а не перечнем — и с оговоркой, что это устройство нынешней реализации, а не правило языка. Группы по восемь → сравнение метки сразу со всеми → остановка на свободном слоте → отсюда промах дороже попадания → и ради этой остановки заполнение держится на 7/8. Связка показывает понимание; перечень фактов — заучивание.
Число называйте. «В go1.24 расширяется при 7/8; таблица удваивается до 1024 слотов, дальше делится надвое» весит больше, чем «когда заполняется». Если помните — добавьте, что расширение случается на 9, 15, 29, 57 записях: это видно замером и звучит как «я это проверял».
Асимптотику разделяйте. «В среднем O(1). Вставка, попавшая на расширение, дороже, но в go1.24 переносит записи одной таблицы, не больше 1024 слотов: маленькая таблица удваивается, дошедшая до предела делится надвое, а каталог над таблицами иногда удваивается сам — это указатели, а не записи. Худший случай поиска — O(n), но от подобранных ключей защищает случайное сеяние хеша». И оговорка: это устройство go1.24, спецификация сложность роста не обещает.
Про порядок обхода не говорите «случайный». Скажите «не гарантирован», и добавьте, что реализовано это случайной точкой старта, поэтому код, зависящий от порядка, проходит тесты. Это тот ответ, который запоминают.
Про конкурентный доступ обязательно скажите «не паника». fatal error,
recover не помогает, лечится мьютексом. Половина кандидатов отвечает «будет
паника» — и это ровно та разница, которую вопрос проверяет.
Дальше спросят
Раз карта — указатель, зачем тогда make? Почему нельзя просто объявить переменную?
Потому что объявленная переменная — это nil-указатель, и структуры за ним
нет. Читать по такому указателю рантайм умеет (возвращает нулевое значение), а
писать некуда: нет ни таблицы, ни счётчика.
Это и объясняет асимметрию, которая иначе выглядит произволом: чтение из
nil-карты работает, запись паникует. Спецификация описывает nil-карту как
эквивалент пустой «за исключением того, что в неё нельзя добавлять элементы».
Почему нельзя взять адрес элемента карты?
Потому что при расширении записи физически переезжают в новую таблицу, и адрес
перестал бы указывать на нужное. Запрет стоит на этапе компиляции —
&m[k] просто не соберётся, — а не оставлен на «будьте осторожны».
Отсюда практическое следствие, о котором спрашивают следом: значение из карты
нельзя изменить «на месте». m[k].field = 1 для структуры не компилируется;
надо достать значение, изменить и положить обратно — либо хранить в карте
указатели.
Если карта не уменьшается, как чистить долгоживущий кеш?
Пересоздавать. delete освободит слот под будущую запись, но таблицу не
уменьшит, и clear тоже: замер даёт 4618 КБ после удаления всех двухсот тысяч
ключей против 4617 до.
Обычный приём — держать две карты и периодически переключаться: новая наполняется, старая целиком уходит сборщику. Другой — ограничивать рост заранее, потому что карта, однажды доросшая до пика нагрузки, останется такого размера до конца жизни процесса.
Почему промах дороже попадания — разве не наоборот?
Наоборот было бы, если бы поиск умел сразу сказать «такого нет». Он не умеет: единственный признак отсутствия — встреченный свободный слот, потому что если бы ключ существовал, он лежал бы не дальше первого свободного места.
Попадание останавливается раньше — на найденном ключе. Промах обязан дойти до свободного слота, а их по построению мало: карта допускает заполнение до 7/8. Измерено: 28,96 нс против 19,80, в 1,46 раза.
Можно ли подобрать ключи так, чтобы карта выродилась в список?
Теоретически да — если все ключи попадут в одну группу, поиск станет O(n). Практически снаружи нельзя: хеш карты засеян случайным числом при её создании, поэтому одинаковые ключи дают разные хеши в разных картах и запусках.
Это защита от алгоритмической атаки, а не украшение: пока хеши не засеивали, приём «прислать сто тысяч ключей с одинаковым хешем» ронял веб-серверы на других языках. Побочный эффект сеяния — тот самый непредсказуемый порядок обхода.
Когда брать sync.Map вместо карты с мьютексом?
Когда профиль совпадает с тем, под который он сделан: ключи пишутся один раз и
потом много раз читаются, либо разные горутины работают с непересекающимися
наборами ключей. До go1.24 внутри у него были две карты — «чистая» для чтения без
блокировки и «грязная» для записей; с go1.24 это конкурентное хеш-дерево
(HashTrieMap): чтение идёт без блокировки, запись блокирует только свой узел.
Выигрыш в обоих случаях берётся из того, что чтения не сталкиваются.
На равномерной смеси чтений и записей он проигрывает обычной карте под
мьютексом, и это измерено (bench/gomap/syncmap.go, go1.24.7, два ядра):
при 50 % записей по общим ключам — 105,66 нс на операцию против 43,97 нс у
карты с Mutex, в 2,4 раза медленнее. Каждая перезапись в нём кладёт новый
узел дерева — 0,50 выделения на операцию при половине записей, у карты под
мьютексом ноль. На одних чтениях наоборот: 24,16 нс против 41,94, в 1,74 раза
быстрее. Оговорка к замеру: ядер два, и на них sync.Map проиграл и на
непересекающихся ключах (84,14 нс против 46,64 у RWMutex) — выигрыш, который
документация обещает в этом случае, это меньшая борьба за замок, а на двух
ядрах бороться почти некому. Ответ «всегда берём sync.Map, он
потокобезопасный» — не тот, которого ждут.
Частые заблуждения
карта расширяется, когда заполнится
В нынешней реализации (go1.24) расширение происходит при заполнении 7/8, и восьмая часть слотов держится пустой намеренно: поиск останавливается, только встретив свободный слот, и без пустых слотов промах пришлось бы искать по всей таблице. Измерено: расширения на размерах 9, 15, 29, 57, 113, 225, 449 — начиная с 15, ровно «7/8 вместимости плюс один»; 9 — маленькая карта из одной группы, заполненная целиком.
расширение карты перекладывает все её записи, поэтому большая карта растёт тем дольше, чем она больше
В go1.24 карта состоит из каталога и отдельных таблиц. Таблица до 1024 слотов растёт удвоением, а дошедшая до предела делится на две — и переносятся записи только этой таблицы, не больше 896. Каталог при этом иногда удваивается, но в нём указатели на таблицы, а не записи. Измерено на map[int]int: от 1024 до 1 048 576 записей крупнейшая разовая порция памяти на вставку выросла лишь с 36992 до 55360 байт. Это устройство реализации, а не гарантия языка.
промах в карте быстрее попадания — там же ничего не нашли
Наоборот. Попадание останавливается на найденном ключе; промах обязан идти, пока не встретит группу со свободным слотом, — это единственный признак отсутствия. Измерено на ста тысячах записей: 28,96 нс против 19,80, то есть в 1,46 раза на той машине, где шёл прогон; переносится сюда не число, а его знак.
порядок обхода карты случайный
Контракт говорит только одно: порядок не гарантирован. Реализация, судя по наблюдениям, добивается этого не перемешиванием, а сдвигом: обход начинается со случайной точки и дальше идёт по таблице подряд. Различных порядков столько, сколько записей, а не n!. Это опаснее настоящей случайности: код, зависящий от порядка, падал бы в половине запусков при перемешивании — а при сдвиге он тесты проходит и ломается в проде.
delete освобождает память, занятую картой
Не освобождает, и clear тоже. Измерено на двухстах тысячах записей: 4617 КБ до, 4618 КБ после удаления всех ключей, 4618 после clear(). Карта в Go не уменьшается — таблица остаётся той, до которой доросла. Отдать память можно только новой картой.
подсказка размера в make делает карту компактнее
Размер готовой карты она почти не меняет: 4617 КБ против 4617. Меняет она работу по дороге — без подсказки рантайм выделяет и бросает все промежуточные таблицы. Замер: 1044 выделения и 9236 КБ против 514 и 4618 КБ. Экономится работа сборщика мусора, а не память карты.
одновременная запись в карту вызовет панику, её можно перехватить
Это fatal error, а не паника: стек не разматывается, отложенные вызовы не выполняются, recover не срабатывает. Так сделано намеренно — модель памяти Go разрешает реализации остановить программу при гонке, потому что races on multiword data structures can lead to inconsistent values
(гонки на многословных структурах данных могут приводить к несогласованным значениям), вплоть до повреждения памяти.
sync.Map — это просто потокобезопасная карта, её и надо брать
Он оптимизирован под узкий профиль: запись ключа один раз и много чтений, либо непересекающиеся наборы ключей у разных горутин. На равномерной смеси чтений и записей обычная карта под мьютексом обходит его — в замере bench/gomap/syncmap.go в 2,4 раза, — потому что каждая запись в sync.Map платит за его внутреннюю синхронизацию (до go1.24 — двух внутренних карт, с go1.24 — узлов хеш-дерева: перезапись кладёт новый узел).
если тип сравнимый, он годится в ключи без оговорок
float64 сравним — и всё равно ломается на NaN, потому что NaN != NaN. Две записи по одному NaN дают len == 2, поиск возвращает нулевое значение, а delete не удаляет ничего. Записи, которые нельзя ни прочитать, ни удалить по ключу; избавиться от них можно только целиком — clear(m) (Go 1.21+) или вместе с картой.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
nan := math.NaN()
m := map[float64]int{}
m[nan] = 1
m[nan] = 2
fmt.Println(len(m))
fmt.Println(m[nan])
delete(m, nan)
fmt.Println(len(m))Практика · оцените
Проверка знаний
Функция принимает map[string]int и делает m["a"] = 1, ничего не возвращая. Увидит ли вызывающая сторона запись?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Карта отвечает на один вопрос: что лежит по этому ключу. Отсутствующий ключ — не ошибка, а нулевое значение, и по значению он неотличим от ключа, у которого значение действительно ноль; различает только форма с двумя результатами —
v, ok := m[k]. Ключ обязан быть сравнимым, порядок обхода не гарантирован, а одновременный доступ из нескольких горутин карта не переживает: защищать её приходится самому. - Отсюда то, что ломается.
nil-карту можно читать, обходить и даже удалять из неё — шесть операций работают, седьмая роняет программу:assignment to entry in nil map. Несравнимый ключ ловит компилятор —invalid map key type []int, — кромеmap[any]T, где проверка откладывается до времени выполнения. Одновременная запись — этоfatal error, а не паника:recoverне поможет, стек не разматывается, отложенные вызовы не выполняются. А в переменной типаmapлежит указатель на структуру рантайма — поэтому карта, переданная в функцию, меняется у вызывающей стороны. - Дальше — устройство go1.24 и числа. В нынешней реализации поиск идёт группами по восемь слотов и останавливается, только встретив свободный слот, — отсюда то, чего никто не ожидает: промах дороже попадания. Измерено на этой машине: 19,80 нс против 28,96 — в 1,46 раза. Восьмую часть слотов реализация держит пустой намеренно: таблица расширяется при заполнении 7/8, а не «когда заполнилась», — измерены расширения на размерах 9, 15, 29, 57, 113, 225, 449, то есть, начиная с 15, ровно «7/8 вместимости плюс один» (9 — маленькая карта из одной группы: её восемь слотов заполняются целиком). Асимптотика — O(1) в среднем. Вставка, попавшая на расширение, дороже, но в go1.24 она перекладывает не всю карту, а одну таблицу: до 1024 слотов таблица удваивается, дальше делится надвое. Измерено: от тысячи до миллиона записей крупнейшая разовая порция памяти на вставку выросла с 36992 до 55360 байт — в полтора раза при тысячекратном росте карты. Про порядок обхода контракт говорит одно: он не гарантирован; а реализация, судя по наблюдениям, добивается этого не перемешиванием, а случайной точкой старта, и потому код, зависящий от порядка, проходит тесты и ломается в проде. И память карта не отдаёт: измерено 4617 КБ до удаления всех ключей и 4618 после — ни
delete, ниclearтаблицу не уменьшают.
На самом деле
- В нынешней реализации (go1.24) расширение происходит при заполнении 7/8, и восьмая часть слотов держится пустой намеренно: поиск останавливается, только встретив свободный слот, и без пустых слотов промах пришлось бы искать по всей таблице. Измерено: расширения на размерах 9, 15, 29, 57, 113, 225, 449 — начиная с 15, ровно «7/8 вместимости плюс один»; 9 — маленькая карта из одной группы, заполненная целиком.
- В go1.24 карта состоит из каталога и отдельных таблиц. Таблица до 1024 слотов растёт удвоением, а дошедшая до предела делится на две — и переносятся записи только этой таблицы, не больше 896. Каталог при этом иногда удваивается, но в нём указатели на таблицы, а не записи. Измерено на
map[int]int: от 1024 до 1 048 576 записей крупнейшая разовая порция памяти на вставку выросла лишь с 36992 до 55360 байт. Это устройство реализации, а не гарантия языка. - Наоборот. Попадание останавливается на найденном ключе; промах обязан идти, пока не встретит группу со свободным слотом, — это единственный признак отсутствия. Измерено на ста тысячах записей: 28,96 нс против 19,80, то есть в 1,46 раза на той машине, где шёл прогон; переносится сюда не число, а его знак.
- Контракт говорит только одно: порядок не гарантирован. Реализация, судя по наблюдениям, добивается этого не перемешиванием, а сдвигом: обход начинается со случайной точки и дальше идёт по таблице подряд. Различных порядков столько, сколько записей, а не n!. Это опаснее настоящей случайности: код, зависящий от порядка, падал бы в половине запусков при перемешивании — а при сдвиге он тесты проходит и ломается в проде.
- Не освобождает, и
clearтоже. Измерено на двухстах тысячах записей: 4617 КБ до, 4618 КБ после удаления всех ключей, 4618 послеclear(). Карта в Go не уменьшается — таблица остаётся той, до которой доросла. Отдать память можно только новой картой. - Размер готовой карты она почти не меняет: 4617 КБ против 4617. Меняет она работу по дороге — без подсказки рантайм выделяет и бросает все промежуточные таблицы. Замер: 1044 выделения и 9236 КБ против 514 и 4618 КБ. Экономится работа сборщика мусора, а не память карты.
- Это
fatal error, а не паника: стек не разматывается, отложенные вызовы не выполняются,recoverне срабатывает. Так сделано намеренно — модель памяти Go разрешает реализации остановить программу при гонке, потому что races on multiword data structures can lead to inconsistent values, вплоть до повреждения памяти. - Он оптимизирован под узкий профиль: запись ключа один раз и много чтений, либо непересекающиеся наборы ключей у разных горутин. На равномерной смеси чтений и записей обычная карта под мьютексом обходит его — в замере
bench/gomap/syncmap.goв 2,4 раза, — потому что каждая запись вsync.Mapплатит за его внутреннюю синхронизацию (до go1.24 — двух внутренних карт, с go1.24 — узлов хеш-дерева: перезапись кладёт новый узел). float64сравним — и всё равно ломается наNaN, потому чтоNaN != NaN. Две записи по одному NaN даютlen == 2, поиск возвращает нулевое значение, аdeleteне удаляет ничего. Записи, которые нельзя ни прочитать, ни удалить по ключу; избавиться от них можно только целиком —clear(m)(Go 1.21+) или вместе с картой.
Что разобрано
- Что здесь на самом деле спрашивают
- База: что карта делает
- Механизм 1: что карта обещает
- Механизм 2: карта и горутины
- Механизм 3: почему изменения видны после передачи в функцию
- Механизм 4: краевые случаи, которые стоят падения
- Глубже: как карта устроена внутри
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
4 ИСТОЧНИКА
- Спецификация Go — Map typesОфициальная документация. Определение: «A map is an unordered group of elements of one type, called the element type, indexed by a set of unique keys of another type, called the key type» (Карта — это неупорядоченная совокупность элементов одного типа, называемого типом элемента, проиндексированная набором уникальных ключей другого типа, называемого типом ключа). Требование к ключу и единственное место, где оно проверяется в рантайме: «The comparison operators == and != must be fully defined for operands of the key type; thus the key type must not be a function, map, or slice. If the key type is an interface type, these comparison operators must be defined for the dynamic key values; failure will cause a run-time panic» (Операторы сравнения == и != должны быть полностью определены для операндов типа ключа; поэтому тип ключа не может быть функцией, картой или срезом. Если тип ключа — интерфейс, эти операторы должны быть определены для динамических значений ключа; иначе будет паника во время выполнения). И про nil: «A nil map is equivalent to an empty map except that no elements may be added» (nil-карта эквивалентна пустой карте, за исключением того, что в неё нельзя добавлять элементы).https://go.dev/ref/spec
- The Go Memory ModelОфициальная документация. Правило про одновременный доступ живёт здесь, а не в спецификации языка. Разрешение рантайму остановить программу: «An implementation may always react to a data race by reporting the race and terminating the program» (Реализация всегда вправе отреагировать на гонку данных, сообщив о ней и завершив программу). И причина, по которой для карты выбрана именно остановка, а не порча данных: «races on multiword data structures can lead to inconsistent values not corresponding to a single write … such races can in turn lead to arbitrary memory corruption» (гонки на многословных структурах данных могут приводить к несогласованным значениям, не соответствующим ни одной отдельной записи … такие гонки, в свою очередь, могут приводить к произвольному повреждению памяти).https://go.dev/ref/mem
- internal/runtime/maps и runtime/panic.go на go1.24.7Исходный код Go. Проверка, из которой растут все три сообщения об одновременном доступе:
if m.writing != 0 { fatal("concurrent map writes") }вruntime_mapassign. Почему это не паника, сказано в комментарии к самойfatalвruntime/panic.go: «fatal is equivalent to throw, but is used when user code is expected to be at fault for the failure, such as racing map writes» (fatal эквивалентен throw, но используется, когда в сбое предполагается вина пользовательского кода — например, при состязающихся записях в карту), и рядом проfatalthrow: «implements an unrecoverable runtime throw» (реализует неперехватываемый бросок рантайма).https://github.com/golang/go/blob/go1.24.7/src/runtime/panic.go - internal/runtime/maps/map.go и table.go — каталог, рост и раскол таблицы (go1.24.7)Исходный код Go. Почему расширение не перестраивает всю карту: «A map starts with a single table. Up to [maxTableCapacity], growth simply replaces this table with a replacement with double capacity. Beyond this limit, growth splits the table into two» (Карта начинается с одной таблицы. До maxTableCapacity рост просто заменяет её таблицей двойной вместимости. За этим пределом рост делит таблицу на две). И там же: «while an entire table must grow at once, each of these grows is only a small portion of a map» (таблица должна вырасти целиком, но каждый такой рост — лишь небольшая часть карты). В table.go: maxTableCapacity = 1024, а rehash выбирает между grow и split. Рост каталога — installTableSplit в map.go: массив указателей на таблицы удваивается, когда раскалываемой таблице не хватает разрядов.https://github.com/golang/go/blob/go1.24.7/src/internal/runtime/maps/map.go