Deep Engineering
Продвинутый·Опубликовано·60 МИН

Карта в Go: контракт языка, Swiss Table и четыре следствия устройства

Три части и один порядок: сначала то, что карта обещает как конструкция языка — нулевое значение вместо ошибки, форма с двумя результатами, неопределённый порядок обхода и синхронизация; потом устройство поиска в Go 1.24 — отпечаток, группа из восьми и правило остановки; и только потом четыре следствия с замерами: промах дороже попадания, порядок провёрнут, а не перемешан, адрес элемента взять нельзя, а len(m) == 0 не значит, что память вернулась.

Полное техническое изложение

TL;DR

Сначала то, что гарантирует язык. map[K]V — встроенный тип для хранения значений по ключу. Чтение отсутствующего ключа возвращает нулевое значение типа элемента, а не ошибку, поэтому наличие проверяют формой v, ok := m[k]. Порядок обхода спецификацией не определён. Ключ обязан быть сравнимым. Одновременный доступ с записью требует синхронизации — этого правила в разделе про карты нет вовсе, оно живёт в модели памяти.

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

И только потом — следствия, каждое с замером.

  • Поиск останавливается на группе со свободным слотом, а удалённый слот его не останавливает. Отсюда: промах дороже попадания — 28,62 нс против 17,83.
  • Обход идёт по таблице подряд, случаен только начальный сдвиг. Отсюда: наблюдаемый порядок провёрнут, а не перемешан, и код, зависящий от порядка, пройдёт тесты и сломается в проде.
  • Рост перестраивает таблицу целиком, записи переезжают. Отсюда: адрес элемента взять нельзя.
  • Память освобождается тоже таблицами. Отсюда: len(m) == 0 не значит, что память вернулась.

И границы. Устройство Swiss Table — деталь реализации конкретной версии рантайма. Прикладной код обязан опираться на гарантии спецификации, а числа ниже — читать в границах go1.24.7, этой машины и этой формы карты.

В статье три части, и порядок у них не случайный. Сначала — что карта обещает как конструкция языка: этого достаточно, чтобы писать корректный код, и здесь нет ни слова про устройство. Потом — как устроен поиск в Go 1.24. И только после этого — следствия, которые из устройства вытекают и которые без него пришлось бы просто запоминать.

Часть I. Что гарантирует язык

Всё в этой части — контракт: оно верно в любой версии Go и не зависит от того, как карта устроена внутри.

Карта за пять минут

Шесть операций, и других нет:

GO
scores := map[string]int{
    "alice": 10,
    "bob":   7,
}                                   // создать
 
scores["mike"] = 12                 // записать
score := scores["alice"]            // прочитать
score, ok := scores["john"]         // прочитать с проверкой наличия
delete(scores, "bob")               // удалить
n := len(scores)                    // сколько записей
 
for name, s := range scores {       // обойти
    fmt.Println(name, s)
}

Спецификация определяет карту так:

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.

перевод

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

Спецификация Go — Map types

Слово неупорядоченная здесь не про то, что порядок случайный, а про то, что его нет как понятия: карта не обещает никакого порядка, и опираться не на что.

Про тип ключа спецификация требует одного — сравнимости:

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.

перевод

Операторы сравнения == и != должны быть полностью определены для операндов типа ключа; поэтому тип ключа не может быть функцией, картой или срезом.

Спецификация Go — Map types

Числа, строки, булевы, указатели, каналы, а также структуры и массивы из сравнимых частей — годятся. Срез, карта и функция — нет, и это ошибка компиляции. Интерфейс годится с оговоркой, к которой мы вернёмся в краевых случаях.

И nil-карта: её нулевое значение — это не пустая карта, а «карты нет».

GO
var m map[string]int   // nil
fmt.Println(len(m))    // 0
fmt.Println(m["a"])    // 0
delete(m, "a")         // ничего не делает
for range m { }        // ноль итераций
m["a"] = 1             // паника: assignment to entry in nil map

A nil map is equivalent to an empty map except that no elements may be added.

перевод

nil-карта эквивалентна пустой карте, за исключением того, что в неё нельзя добавлять элементы.

Спецификация Go — Map types

Асимметрия тут ровно одна: паникует только запись. Поэтому забытый make доживает до первой вставки — и падает не там, где ошибка.

Ноль вместо ошибки: зачем нужна форма с двумя результатами

Чтение отсутствующего ключа не паникует и не возвращает ошибку — оно даёт нулевое значение типа элемента: 0 у int, пустую строку у string, nil у указателя.

Отсюда вопрос, который и есть вся суть этого раздела:

Как отличить существующий ключ со значением 0 от отсутствующего ключа?

Одноместной формой — никак: она вернёт ноль в обоих случаях. Различает только форма с двумя результатами.

Правило простое: v := m[k] — когда ноль и отсутствие означают одно и то же (счётчики, суммы, накопители). v, ok := m[k] — когда не одно и то же (кеши, конфигурация, «есть ли у пользователя настройка»).

Цену этой формы мы измерим позже, в третьей части: там будет видно, что она практически нулевая, — но сначала должно быть понятно, зачем форма вообще нужна.

Почему изменения видны после передачи в функцию

Это самое частое практическое отличие карты от среза, и оно наблюдаемо из программы:

GO
func add(m map[string]int) { m["x"] = 1 }
 
func main() {
    m := make(map[string]int)
    add(m)
    fmt.Println(m["x"])   // 1
}

А вот append внутри функции снаружи не виден:

GO
func grow(s []int) { s = append(s, 42) }   // снаружи НЕ видно

Формулировать это лучше через наблюдаемое поведение, а не через устройство: передача карты в функцию копирует значение карты, но оно продолжает ссылаться на ту же структуру данных, поэтому изменения элементов видны вызывающему. У среза копируется заголовок с длиной, и новая длина остаётся в копии.

Практических следствий три, и все они из контракта, а не из реализации: карту не нужно возвращать из функции, чтобы изменения дошли; «скопировать» карту присваиванием нельзя — нужен явный обход; и nil-карта, переданная в функцию, останется nil для вызывающего, даже если внутри ей присвоят make.

Как именно значение карты представлено в рантайме — деталь реализации. Подтвердить её можно, но опираться на неё в рассуждениях не стоит:

GO
unsafe.Sizeof(map[int]int{})   // 8 — одно слово
unsafe.Sizeof([]int{})         // 24 — три
unsafe.Sizeof("")              // 16 — два

Что обещает язык, а что делает реализация

Дальше в статье появятся управляющие байты, группы и замеры, и их легко перепутать с гарантиями. Разделительная линия проходит здесь, и держать её стоит до конца:

Контракт языка — верен всегда, на нём можно строить код:

  • ключ обязан быть сравнимым;
  • отсутствующий ключ даёт нулевое значение, форма с ok сообщает о наличии;
  • порядок обхода не определён;
  • nil-карта читается, но не пишется;
  • одновременный доступ с записью требует синхронизации.

Реализация рантайма — верна для конкретной версии, объясняет поведение, но не гарантирует его:

  • Swiss Table, группы по восемь, H1 и H2, управляющее слово;
  • зондирование, надгробия, порог заполнения 7/8;
  • директория таблиц и предел в 1024 слота на таблицу.

Наблюдения замера — верны для конкретной машины, версии и формы данных:

  • 28,62 нс против 17,83 на промахе и попадании;
  • девять порядков обхода у карты из девяти записей;
  • 36,0 МБ, не освобождённые после clear.

Дальше при каждом числе будет сказано, к какому из трёх уровней оно относится.

Карта и горутины

Эта тема стоит здесь, а не в конце, потому что она нужна раньше всего остального: неверный ответ на неё стоит дороже, чем любое незнание про Swiss Table.

Читать карту можно из скольких угодно горутин сразу. Как только к ним добавляется хотя бы одна пишущая, доступ обязана синхронизировать сама программа.

Первое, что стоит знать про это правило: в спецификации языка его нет. Раздел про карты определяет тип ключа, поведение nil-карты и рост — про горутины там не сказано ничего. Правило живёт в модели памяти:

A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the sync/atomic package.

перевод

Гонка данных определяется как запись в ячейку памяти, происходящая одновременно с другим чтением или записью в ту же ячейку, если только все участвующие обращения не являются атомарными обращениями к данным, предоставляемыми пакетом sync/atomic.

The Go Memory Model

Падение рантайма — не механизм синхронизации

Про карту принято говорить «будет fatal error», и из этого делают вывод, что ошибку хотя бы видно. Это неверно, и разница показана прогоном bench/gomap/concurrent.go в двух режимах.

Две горутины пишут — рантайм замечает и роняет процесс:

fatal error: concurrent map writes

goroutine 8 [running]:
internal/runtime/maps.fatal(...)
	/usr/local/go1.24.7/src/runtime/panic.go:1058

Флаг «идёт запись» рантайм проверяет на входе в каждую операцию, поэтому сообщений три, и по ним видно, что именно столкнулось:

что делали одновременночто печатает рантайм
две записиfatal error: concurrent map writes
чтение и записьfatal error: concurrent map read and map write
обход и записьfatal error: concurrent map iteration and map write

Горутина читает, пока другая пишет — тот же прогон, без детектора гонок:

  выполнение завершилось
--- код возврата: 0

Ни сообщения, ни ненулевого кода возврата. Тот же самый код под детектором гонок:

==================
WARNING: DATA RACE
Write at 0x00c00009a0f0 by goroutine 7:
  runtime.mapassign_fast64()
Previous read at 0x00c00009a0f0 by goroutine 8:
  runtime.mapaccess1_fast64()
--- код возврата: 141

Вывод, ради которого этот замер сделан: обнаружение конкурентного доступа рантаймом — не контракт, а проверка «на удачу». Она срабатывает, когда две горутины оказались внутри карты одновременно, и молчит, когда не оказались. Корректность строится на модели памяти и на синхронизации, а не на надежде упасть.

И ещё одно: recover тут не поможет. В прогоне выше стоит defer с recover — он не напечатал ничего, потому что это не паника:

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, но используется, когда в сбое предполагается вина пользовательского кода — например, при состязающихся записях в карту.

runtime/panic.go

Разница не косметическая: panic разматывает стек, выполняет отложенные вызовы и может быть перехвачена; fatal не делает ничего из этого — рядом с ней в том же файле стоит fatalthrow, «неперехватываемый бросок рантайма».

Почему выбрана именно такая жёсткость. Карта — многословная структура, и гонка на ней портит не значение, а согласованность:

This means that races on multiword data structures can lead to inconsistent values not corresponding to a single write. When the values depend on the consistency of internal (pointer, length) or (pointer, type) pairs, as can be the case for interface values, maps, slices, and strings in most Go implementations, such races can in turn lead to arbitrary memory corruption.

перевод

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

The Go Memory Model

Немедленная остановка здесь — не строгость ради строгости, а замена повреждения памяти на падение, которое видно в логе. Но, как показал прогон выше, случается она не всегда, и рассчитывать на неё нельзя.

Три работающие модели

Первая — карта под мьютексом. Самая частая и почти всегда правильная. sync.RWMutex, если читателей заметно больше писателей; обычный sync.Mutex иначе. Тот же прогон под детектором гонок с мьютексом не находит ничего:

  записей в карте: 160000 — столько и ожидалось
  ни fatal error, ни находки детектора гонок

Вторая — владение одной горутиной. Карта живёт внутри одной горутины, а остальные общаются с ней сообщениями через канал. Синхронизация получается из устройства программы, а не из блокировок, и гонок нет по построению.

Третья — sync.Map, и только под свои случаи. Его собственная документация от него отговаривает:

The Map type is specialized. Most code should use a plain Go map instead, with separate locking or coordination, for better type safety and to make it easier to maintain other invariants along with the map content.

перевод

Тип Map — специализированный. Большей части кода следует вместо него использовать обычную карту Go с отдельной блокировкой или координацией — ради лучшей типобезопасности и чтобы было проще поддерживать прочие инварианты вместе с содержимым карты.

Пакет sync — тип Map

Случаев названо два: кеш, который только растёт, и непересекающиеся наборы ключей у разных горутин. Всё остальное — обычная карта с мьютексом.

Проверять это надо детектором гонок

Гонку на карте не найдёт ни компилятор, ни ревью. Находит её -race:

go test -race ./...
go run -race ./cmd/service

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

Any implementation can, upon detecting a data race, report the race and halt execution of the program. Implementations using ThreadSanitizer (accessed with "go build -race") do exactly this.

перевод

Любая реализация вправе, обнаружив гонку данных, сообщить о ней и остановить выполнение программы. Реализации, использующие ThreadSanitizer (доступный через „go build -race“), делают именно это.

The Go Memory Model

Зелёный прогон под -race означает «на этих входах гонка не проявилась», а не «гонки нет». Поэтому тесты с конкурентным доступом стоит писать так, чтобы обращения действительно пересекались, а не так, чтобы код просто выполнился.

Часть II. Как устроен поиск

Отсюда и до конца второй части — реализация Go 1.24. Всё, что здесь написано, объясняет поведение, но ничего не гарантирует: в другой версии рантайма устройство может быть другим, а контракт из первой части останется прежним.

Зачем карте хеш: отпечаток и полное сравнение

Общая идея у всех хеш-таблиц одна, и она не про Go.

Ключ прогоняется через хеш-функцию, получается число. Часть этого числа говорит, где искать — в какой участок таблицы смотреть. Проблема в том, что разные ключи попадают в один участок, поэтому мало найти место — надо ещё понять, тот ли ключ там лежит.

Наивный ответ — сравнивать ключи целиком. Он верен и дорог: сравнение строки или структуры стоит заметно больше, чем сравнение двух чисел.

Отсюда приём, на котором держится Swiss Table: вторая, короткая часть хеша хранится рядом со слотом как отпечаток. Отпечаток не отвечает «этот ключ» — он короткий, совпадения у разных ключей неизбежны. Зато несовпадение исключает слот наверняка, и полное сравнение достаётся немногим кандидатам:

Всё, что дальше, — это конкретные ответы Go на четыре вопроса: как из хеша получить участок, сколько слотов проверять за раз, сколько бит отдать под отпечаток и когда прекращать поиск.

These improvements include a new builtin map implementation based on Swiss Tables, more efficient memory allocation of small objects, and a new runtime-internal mutex implementation.

перевод

В число этих улучшений входят новая реализация встроенного типа map на основе Swiss Tables, более эффективное выделение памяти под мелкие объекты и новая внутренняя реализация мьютекса рантайма.

Заметки к выпуску Go 1.24

Таблица — это массив групп: откуда берётся номер

Прежде чем разбирать устройство группы, надо сказать, что такое таблица, — иначе слово «группа» останется абстракцией, а «хеш выбирает группу» — заклинанием.

Группа — восемь слотов и управляющее слово при них. Таблица — массив таких групп, лежащих подряд и пронумерованных от нуля. Больше в ней ничего нет:

группа 0   [упр. слово: 8 байт][слот 0][слот 1] … [слот 7]
группа 1   [упр. слово: 8 байт][слот 0][слот 1] … [слот 7]
…
группа 7   [упр. слово: 8 байт][слот 0][слот 1] … [слот 7]

Отсюда сразу ответ на вопрос, что значит «H1 назвал группу». Он не нашёл ключ, не выбрал слот и вообще никуда ещё не заглядывал: он дал индекс в этом массиве — число от 0 до N−1. Поиск начнётся уже после, внутри названной группы, по её управляющему слову.

Как из H1 получается индекс

Число групп в таблице — всегда степень двойки, и окупается это решение прямо здесь:

h1    = h >> 7            отбросили 7 бит метки
номер = h1 & (N − 1)      взяли младшие биты того, что осталось

Вторая строка — остаток от деления на число групп, записанный одной операцией «И». Так можно ровно потому, что N — степень двойки: на любом другом числе & (N − 1) остатком быть перестаёт.

Смотреть на это лучше в битах. Вот младшие 16 бит хеша четырёх ключей при восьми группах, разрезанные дважды — справа метка, левее номер:

ключмладшие 16 бит хешаномер группыметка
k0001000 110 000111060x0e
k1001001 001 100000110x41
k2000111 111 010100070x28
k7001010 000 010011100x27

Средняя тройка бит и есть номер: его не вычисляют, его берут. Правые семь — метка, она уйдёт в управляющий байт того слота, куда ляжет ключ. Левые шесть — остаток H1: в номере они не участвуют, но участвуют в пути, если названная группа окажется занята.

Почему номер берут у H1, а не у всего хеша

Затем, чтобы биты номера и биты метки не пересекались. Цену пересечения можно посчитать. Возьмём номер у младших бит — тех самых, что уже заняты меткой, — и посмотрим, сколько разных меток тогда может оказаться внутри одной группы. Замер на 400 000 ключах при восьми группах:

  • номер от H1, как в Go, — в группе встречаются все 128 значений метки;
  • номер от младших бит — 16.

Метка теряет три бита, ложные совпадения при поиске становятся примерно в восемь раз чаще, и за каждое платят полным сравнением ключа. Разрез хеша на непересекающиеся части — не аккуратность, а условие, при котором метка вообще что-то отсеивает.

У ключа нет «своей» группы

Без этого всё сказанное выше вводит в заблуждение. Номер — функция от хеша и от текущего размера таблицы. Хеш ключа не меняется никогда, маска меняется при каждом росте:

ключ4 группы8163264
k026666
k111999
k237153163
k70001616

Удвоение добавляет к маске один бит, и этот бит либо оставляет ключ на месте, либо переносит его ровно на прежнее число групп вперёд: k1 идёт 1 → 9, k7 — 0 → 16. На 100 000 ключей номер меняет примерно половина: 50,07 % при переходе с 4 групп на 8 и 49,97 % с 32 на 64. Отсюда и следствие третье из третьей части: рост перестраивает таблицу целиком, а значит, адрес элемента карты взять нельзя.

Ниже весь путь проигран по шагам — от 64-битного хеша до подсвеченной ячейки массива, — а на второй вкладке видно, как тот же неизменный хеш меняет номер при росте таблицы:

Анатомия группы: восемь слотов, восемь байтов и три состояния

Задача хеш-таблицы всегда одна: по ключу быстро найти его место. Хеш даёт номер, номер даёт позицию — но два разных ключа рано или поздно дадут одну и ту же позицию, и надо решить, что делать дальше.

Старая карта Go решала это цепочками: позиция вела в «бакет» на восемь пар, а если он переполнялся — к следующему бакету по ссылке. Swiss Table решает иначе, и всё её устройство подчинено одной цели: сделать так, чтобы восемь кандидатов проверялись одним сравнением, а не циклом из восьми.

Для этого рядом с восемью слотами лежит короткая сводка о них. Хеш ключа делится на две неравные части:

  • старшие 57 бит (H1) выбирают, с какой группы начинать поиск;
  • младшие 7 бит (H2) записываются в отдельный байт — метку слота.

Что делает H1, разобрано разделом выше: его младшие биты — индекс группы, с которой начинается поиск. Здесь важно другое: на этом его работа кончается. H1 не хранится, не пишется ни в один байт и ни с чем не сравнивается — в управляющий байт ложится H2. Момент, в который H1 перестаёт участвовать, виден не в устройстве группы, а в целой операции, и разобран ниже, на вставке.

Термины заданы в самом рантайме:

Group: A group of abi.SwissMapGroupSlots (8) slots, plus a control word. H1: Upper 57 bits of a hash. H2: Lower 7 bits of a hash.

перевод

Группа: группа из abi.SwissMapGroupSlots (8) слотов плюс управляющее слово. H1: старшие 57 бит хеша. H2: младшие 7 бит хеша.

internal/runtime/maps/map.go

Вот как это лежит в памяти. Управляющий байт и слот — это одна позиция, а не два разных места: восемь байтов идут подряд, а за ними восемь пар «ключ — значение». Нажимайте слоты — под схемой сказано, что означает каждое состояние и что делает поиск, дойдя до него:

Дальше идёт то, что обычно пропускают, — а без этого механизм не собирается. Байт восьмибитный, метка семибитная. Куда делся восьмой бит?

Он потрачен на то, чтобы один и тот же байт различал три состояния слота. Шаблоны выписаны в исходнике дословно:

  свободен   1 0 0 0 0 0 0 0
  удалён     1 1 1 1 1 1 1 0
  занят      0 h h h h h h h     ← семь бит метки

Старший бит здесь — флаг занятости, причём перевёрнутый: у занятого слота он снят. Из этого одного соглашения выходят сразу три проверки, и каждая — это операция над одним и тем же битом:

  • слот занят — старший бит снят;
  • слот свободен или удалён — старший бит выставлен;
  • слот свободен — старший бит выставлен, а второй снизу нет (у удалённого он выставлен, у свободного нет).

Так что метка семибитная не потому, что «семи бит достаточно», а потому, что восьмой отдан под состояние. Эта плата и делает возможным всё остальное.

Как восемь сравнений становятся одним

Восемь управляющих байтов лежат в памяти подряд и составляют одно 64-битное число — управляющее слово. Обычно на фразе «сравнивает восемь слотов за одну операцию» объяснение и заканчивается, а понять её нельзя, пока не видно, что происходит с байтами. Происходит вот что.

Шаг первый — размножить метку. Искомые семь бит копируются во все восемь байтов: умножением на 0x0101010101010101. Теперь сравнивать надо не со слотом, а со всем словом сразу.

Шаг второй — XOR. Байт обнуляется ровно там, где два байта совпали. Задача «найти совпавшие слоты» превратилась в задачу «найти нулевые байты», а её уже умеют решать арифметикой.

Шаг третий — поймать нулевой байт заёмом. Из каждого байта вычитается единица: v - 0x0101010101010101. У нулевого байта вычитать не из чего — он занимает разряд и становится 0xFF, то есть его старший бит загорается. У ненулевого байта старший бит так не загорится; а если он был выставлен ещё до вычитания, его гасит часть &^ v.

Шаг четвёртый — оставить по биту на слот: & 0x8080808080808080. Это готовый ответ: восемь бит, по одному на слот.

Одной строкой это и есть тело функции из рантайма:

GO
v := uint64(g) ^ (bitsetLSB * uint64(h))
return bitset(((v - bitsetLSB) &^ v) & bitsetMSB)

Ниже те же четыре шага выполнены на настоящих байтах — переключайте шаги и метку. Все строки битов напечатаны программой bench/gomap/controlword.go, которая выполняет эти формулы и сверяет их с честным перебором восьми байтов:

Три арифметические операции над одним числом вместо цикла из восьми сравнений. На amd64 те же три шага делает одна SIMD-инструкция, а формула выше — переносимый вариант для машин, где такой инструкции нет.

Теперь про то, чем метка не является. Семь бит — это 128 значений, поэтому совпадение метки у разных ключей — дело обычное. Метка отвечает не «этот ключ», а «может быть, этот»: после совпадения ключ сверяется целиком. Зато несовпадение исключает слот наверняка, и в этом вся экономия — полное сравнение достаётся одному-двум слотам из восьми, а не всем восьми.

Сколько именно стоит эта неточность, оценено в самом рантайме:

The expected number of objects with an h2 match is then k/128. Measurements and analysis indicate that even at high load factors, k is less than 32, meaning that the number of false positive comparisons we must perform is less than 1/8 per find.

перевод

Ожидаемое число слотов с совпавшей меткой — k/128. Замеры и анализ показывают, что даже при высоком заполнении k меньше 32, то есть ложных сравнений приходится меньше 1/8 на поиск.

internal/runtime/maps/table.go

Есть и вторая, более редкая неточность — уже у самой формулы. Заём при вычитании переходит из младшего байта в старший, поэтому изредка загорается лишний бит. Комментарий в group.go называет конкретный пример: у слова 0x0302 при поиске метки 0x02 формула называет слоты 0 и 1, а честный перебор — только слот 0. На корректности это не сказывается ровно потому, что метка и так не ответ: лишний кандидат стоит одного сравнения ключа. Прогон bench/gomap/controlword.go на двухстах тысячах случайных групп это и подтверждает: настоящих совпадений пропущено ноль, лишних названо 0,03 %.

Правило остановки: свободный слот против удалённого

Группа не всегда та, что выбрал хеш: если в ней места нет, поиск идёт к следующей по пути. Путь этот не случаен и не «просто следующая» — смещение растёт треугольником, p(i) = (i² + i)/2 + H1, и при числе групп, равном степени двойки, такая последовательность обходит каждую группу ровно один раз.

Значит, нужен признак, по которому поиск можно прекратить и сказать «ключа нет». Признак ровно один:

Probing stops when it finds a group with an empty slot.

перевод

Поиск останавливается, найдя группу со свободным слотом.

internal/runtime/maps/map.go

Логика простая: если бы ключ существовал, при вставке он лёг бы не дальше первого свободного места. Дошли до свободного слота — значит, дальше искать незачем.

Тонкость в слове свободный. Удалённый слот свободным не считается, и в исходнике объяснено, почему:

When deleting from a completely full group, we must not mark the slot as empty, as there could be more slots used later in a probe sequence and this deletion would cause probing to stop too early.

перевод

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

Там же

Вот зачем понадобилось третье состояние байта. Удалённый слот свободен для вставки, но занят для поиска: вставка спрашивает «свободен или удалён», поиск — «свободен». Один бит различает первое, два бита различают второе, и обе проверки остаются одной операцией над словом.

И последнее, что нужно для полной картины: свободные слоты обязаны быть. Если заполнить таблицу под завязку, поиску негде остановиться, и промах пришлось бы искать по всей таблице. Поэтому карта растёт, не дожидаясь заполнения: порог — 7/8. Восьмая часть слотов держится пустой намеренно, и это не запас прочности, а цена, заплаченная за правило остановки.

Вставка целиком: что делает H1, а что H2

Откуда берётся номер группы, разобрано выше; сколько бит у метки и почему восьмой отдан под состояние — тоже. Осталось последнее, чего не видно ни в одном из этих разделов по отдельности: почему половин хеша две и зачем такое разделение труда. Разница между H1 и H2 не в виде — оба просто куски одного числа, — а в моменте: первый работает один раз до первого взгляда на таблицу, второй на каждом шаге и потом остаётся в памяти. Момент виден только на целой операции. Возьмём вставку.

Опорное утверждение, из которого следует всё остальное:

Вставка — это поиск, который закончился неудачей. Не «нашли свободное место и положили туда». Сначала карта обязана убедиться, что такого ключа в ней нет, — и только потом занимает место. Иначе одна и та же строка m["a"] = 1, выполненная дважды, дала бы две записи.

Вот весь путь, шаг за шагом.

Шаг 0. Хеш режется по седьмому биту. h1 = h >> 7, h2 = h & 0x7f. Ни один бит не участвует в обеих ролях: совпадение метки ничего не говорит о группе, а номер группы ничего не говорит о метке.

Шаг 1. H1 применяется — один-единственный раз. h1 & (число групп − 1) даёт номер стартовой группы, и он же служит семенем треугольного пути. Всё, больше H1 не участвует ни в одном сравнении. Он не хранится, не проверяется и после этого шага не нужен.

Шаг 2. H2 вступает в дело — и участвует на каждом шаге. В каждой группе на пути её управляющее слово сравнивается с меткой одной операцией. Он же единственный, кто останется в памяти: когда ключ ляжет в слот, в управляющий байт запишется именно H2.

Шаг 3. В группе ищется КЛЮЧ, а не место. Совпадение метки даёт кандидатов, каждый кандидат сверяется полным сравнением ключа. Сошлось — значение переписывается на месте: нового слота не появляется, управляющий байт не меняется, len остаётся прежним. Вставка и обновление — одна и та же операция, разошедшаяся на последнем сравнении.

Шаг 4. Ключа в группе нет — конец ли это пути? Тот самый вопрос из предыдущего раздела. Группа занята целиком — шаг к следующей. Есть свободный слот — путь окончен, ключа в таблице точно нет. А если первым незанятым попался удалённый — место запоминается, но путь продолжается.

Шаг 5. Запись. Если по дороге был запомнен удалённый слот, ключ ложится в него, а не туда, где путь остановился, — и запас роста на него не тратится, он уже был учтён. Если запаса роста нет, таблица сначала пересобирается.

Пятый шаг — самый неочевидный, и он же самый полезный. Из него следует цена:

Вставка нового ключа стоит полного поиска промаха. Не «нашли дырку и положили» — пока не встретился свободный слот, неизвестно, нет ли этого ключа дальше по пути, а значит, занять первое подвернувшееся место нельзя.

Ниже те же пять сценариев проиграны по шагам. Смотреть стоит на две вещи: в какой момент перестаёт участвовать H1 и в какой слот в итоге уходит запись в последнем сценарии:

Осталась одна деталь, из-за которой эти две половины путают чаще всего. В group.go шаблон занятого слота подписан так:

full: 0 h h h h h h h  // h represents the H1 hash bits

Комментарий называет H1 — а код рядом пишет в управляющий байт H2. Обе строки из одного пакета:

GO
seq := makeProbeSeq(h1(hash), t.groups.lengthMask) // H1 задаёт путь
g.ctrls().set(i, ctrl(h2(hash)))                   // H2 ложится в байт

И над самой функцией h2 в map.go сказано прямо:

Extracts the H2 portion of a hash: the 7 bits not used for h1. These are used as an occupied control byte.

перевод

Извлекает часть H2 хеша: 7 бит, не занятых под h1. Они используются как управляющий байт занятого слота.

internal/runtime/maps/map.go

Так что в управляющем байте лежит H2; комментарий в group.go разошёлся с кодом. Проверено на go1.24.7 — и это стоит держать в голове, читая исходники: имена H1 и H2 пришли из Abseil, и путаница в комментарии тянется оттуда же.

Теперь устройство описано целиком: таблица → группы по восемь → H1 выбирает стартовую группу → метка H2 в управляющем слове → одна операция вместо восьми сравнений → остановка на свободном слоте → заполнение 7/8. Дальше четыре следствия, и каждое проверяется замером.

Часть III. Следствия устройства

Всё, что дальше, — следствия из второй части, и каждое подпёрто замером. Числа здесь относятся к третьему уровню из разделения выше: конкретная машина, конкретная версия, конкретная форма карты. Устойчиво в них не значение, а направление.

Следствие первое (поиск): промах дороже попадания

Попадание останавливается, найдя ключ, — в среднем на первой же группе. Промах обязан идти до группы со свободным слотом, а их по построению мало: заполнение 7/8.

Замерено на карте из ста тысяч записей:

время
попадание17,83 нс
промах28,62 нс
отношение1,6

Это ровно противоположно ожиданию: «промах ведь ничего не нашёл, значит должен быть быстрее». Механизм говорит иначе, и практический вывод из этого прямой: проверка if _, ok := m[k]; !ok дороже чтения существующего ключа, и в горячем цикле, где промахи часты, это заметно.

Та же причина видна в сравнении с прежней реализацией

Go 1.24 позволяет сравнить две реализации честно — старая осталась под флагом сборки:

The new builtin map implementation and new runtime-internal mutex may be disabled by setting GOEXPERIMENT=noswissmap and GOEXPERIMENT=nospinbitmutex at build time respectively.

перевод

Новую реализацию встроенного map и новый внутренний мьютекс рантайма можно отключить, задав при сборке GOEXPERIMENT=noswissmap и GOEXPERIMENT=nospinbitmutex соответственно.

Заметки к выпуску Go 1.24

Случай редкий: компилятор один, машина одна, замер один, отличается ровно один флаг. Прогоны чередовались — на виртуальной машине частота плавает, и два прогона подряд показали бы разницу, которой нет.

операцияSwiss Tableстарая
попадание, int64, форма v, ok :=17,95–18,8835,22–36,58новая быстрее вдвое
попадание, string25,59–28,1451,89–54,70новая быстрее вдвое
промах, int6428,96–29,6620,57–21,71новая медленнее в 1,4 раза
обход 100 000 записей847 706–893 8511 046 614–1 079 682новая быстрее на ~25 %

Диапазоны по четырём раундам не перекрываются ни в одной строке — это не шум.

Строка про промах объясняется тем же правилом остановки, только с другой стороны. В старой карте промах смотрел один бакет — восемь байт tophash — и кончался там же, если цепочки переполнения не было. В Swiss Table промах обязан идти по пути поиска до свободного слота. То, что делает попадание быстрым, делает промах длинным.

Блог команды Go оговорку про ухудшения делает, но случая не называет:

Some edge cases do regress compared to Go 1.23.

перевод

Некоторые краевые случаи по сравнению с Go 1.23 всё же ухудшились.

Faster Go maps with Swiss Tables

Вот один такой случай, и краевым он не выглядит: проверка «есть ли ключ» — обычная операция. Оговорка нужна и к самому замеру: это одна форма карты (map[int64]int, сто тысяч записей, ключи подряд), а цена промаха зависит от заполнения таблицы и от того, как разложены ключи.

Следствие второе (обход): единственное правило — не зависеть от порядка

Из всего этого раздела в работу надо унести одну фразу, и вот она:

Код не должен зависеть от порядка обхода карты. Никакого порядка карта не обещает, и наблюдаемый порядок не является контрактом.

Всё остальное ниже — исследование реализации. Оно объясняет, почему ошибочный код может долго не ломаться, и именно этим полезно: сама ошибка от него не становится менее ошибкой.

Что порядок обхода не определён, знают все. Рантайм при этом не просто «не гарантирует» — он рандомизирует намеренно:

Iteration order is unspecified. In the implementation, it is explicitly randomized.

перевод

Порядок обхода не определён. В реализации он явно рандомизирован.

internal/runtime/maps/map.go

Деталь реализации: почему ошибочный код может долго не ломаться

Недоговорено другое — как именно рандомизирован. И вот здесь опять вступает таблица: обход идёт по ней подряд, а случайна только точка старта.

Одна и та же карта из девяти записей, обойдённая две тысячи раз, выдаёт ровно девять различных порядков. Не 362 880. И все девять — сдвиги одной и той же последовательности. Причина в одной строке итератора:

GO
entryIdx := (it.entryIdx + it.entryOffset) & entryMask

entryOffset берётся один раз, при создании итератора. Дальше слоты обходятся подряд. То есть последовательность одна, а случаен только её сдвиг. Сама последовательность у каждого запуска программы своя — карта засевает хеш при старте; постоянно другое: внутри одного запуска порядки отличаются только сдвигом.

Почему это опаснее настоящей случайности. Код, случайно завязавшийся на то, что ключ A встретится раньше ключа B, при настоящем перемешивании падал бы примерно в половине запусков и был бы пойман в первый же день. При сдвиге он падает только на тех сдвигах, что разрезают ленту между этими двумя ключами, — а их тем меньше, чем ближе ключи лежат друг к другу. То есть он проходит тесты, проходит ревью и ломается в проде, когда карта чуть изменится.

Граница правила считается из констант — и это та же таблица

Всё сказанное верно, пока карта помещается в одну таблицу. Предел таблицы — maxTableCapacity = 1024, а заполняется она до 7/8, то есть до 896 записей. Дальше карта делится на несколько таблиц, у итератора появляется второе независимое смещение — по директории таблиц (it.dirOffset), — лент становится несколько, и они тасуются между собой.

Замерено ровно там, где предсказывают константы: на 896 записях различных порядков 896, на 897 — уже 1174.

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

И сразу о границах этого наблюдения. Оно относится к go1.24.7, к этой форме карты и к этому размеру. Это не контракт range: в другой версии рантайма порядков может стать сколько угодно, и код, который «работал», сломается без единого изменения в нём самом. Наблюдение объясняет отсрочку, а не даёт разрешения.

Практический вывод один и старый: нужен порядок — соберите ключи и отсортируйте. slices.Sorted(maps.Keys(m)) делает это одной строкой.

Следствие третье (рост): записи переезжают

Когда заполнение доходит до 7/8, таблица не «дописывается», а строится заново вдвое большего размера, и все записи раскладываются по новым местам. Отсюда два практических следствия, которые обычно узнают порознь.

Адрес элемента взять нельзя.

GO
p := &m["a"]        // cannot take the address of m["a"]
m["a"].field = 2    // cannot assign to struct field

Это не каприз компилятора: указатель, взятый до роста, вёл бы в чужую память. Запрет стоит на этапе компиляции, а не оставлен на «будьте осторожны». Обходной путь — map[K]*V: тогда переезжает указатель, а объект стоит на месте.

И оговорка к разделу про обход выше. Карта крупнее maxTableCapacity = 1024 хранится не одной таблицей, а несколькими — и растёт тогда по одной за раз: переезжают записи только той таблицы, которая переполнилась. Единицей роста таблица остаётся и здесь; меняется лишь то, что таблиц становится больше одной. Ровно поэтому граница правила про порядок обхода проходит по 896 записям.

Подсказка размера убирает не байты в карте, а промежуточные таблицы. Здесь ожидание обманывается в другую сторону. Миллион записей:

выделено всеговыделенийосталось жить
make(map[int]int)75 407 864 б8 18837 776 744 б
make(map[int]int, n)37 832 960 б4 10137 832 752 б
отношение×1,99×2,00×1,00

Готовая карта одинакова — последний столбец. Экономятся не байты в ней, а выброшенные по дороге таблицы: без подсказки карта растёт удвоением, и сумма всех промежуточных таблиц примерно равна размеру итоговой. Отсюда ровно вдвое.

То есть подсказка помогает нагрузке на сборщик мусора, а не потреблению памяти. Если вам говорили обратное — теперь есть чем проверить.

Следствие четвёртое (память): таблица не сжимается

Рост идёт таблицами — и освобождение тоже. Миллион записей map[int]int, живая куча:

миллион записей:                 36,0 МБ,  len = 1 000 000
после delete всех ключей:        36,0 МБ,  len = 0
после clear(m):                  36,0 МБ,  len = 0
после m = make(map[int]int):      0,1 МБ

Ни delete, ни clear не отдают память: таблица остаётся той же величины, просто пустой. Освобождает только замена самой карты.

Это не оптимизация, а разница между работающим сервисом и утечкой. Кеш, который живёт долго и чистится по расписанию, будет держать память по своему историческому максимуму — навсегда.

Лечится ровно одним способом: m = make(map[K]V) вместо clear(m), когда чистка означает «начать с нуля».

На этом четыре следствия из устройства кончились.

Что это значит при выборе

Устройство разобрано; остались практические цены, которые из него прямо не следуют, но нужны, когда выбираешь между вариантами.

Форма с запятой не стоит ничего. v, ok := m[k] — 18,43 нс против 17,83 у v := m[k]; на промахе 28,44 против 28,62, то есть даже в другую сторону. Разница внутри разброса прогонов, и оба числа каждой пары взяты из одного блока замеров — иначе сравнивать их было бы нельзя. Компилятор генерирует один и тот же вызов рантайма, просто во втором случае берёт второе возвращённое значение.

Строковый ключ дороже целого в полтора раза — 28,19 против 18,06. Дело не в карте: хеш строки считается по её содержимому, а хеш int64 — по восьми байтам. Составной ключ [2]int64 при той же ширине стоит 24,95: для целых и строк у рантайма есть отдельные быстрые пути (map_fast64_swiss.go, map_faststr_swiss.go), для остальных типов работает общий код.

Если ключи — плотный диапазон целых, карта не нужна. Одно чтение по ключу, который уже под рукой: 15,55 нс из карты против 1,12 из среза. Эти два числа сопоставляются друг с другом, но не с 18,06 выше: там ключ ещё доставали из среза ключей. Обход ста тысяч записей: 855 702 нс против 37 434. В четырнадцать и в двадцать три раза.

Краевые случаи, которые стоит знать

Всё ниже — редкое, но каждое из этого хотя бы раз стоило кому-то рабочего дня. К основной модели карты эти случаи ничего не добавляют, и читать их стоит последними.

Контракт ключа: сравнимый — не значит рефлексивный

Требование к типу ключа спецификация формулирует через операторы:

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.

перевод

Операторы сравнения == и != должны быть полностью определены для операндов типа ключа; поэтому тип ключа не может быть функцией, картой или срезом.

Спецификация Go, Map types

Требование выглядит исчерпывающим, но между «операторы определены» и «карта работает как ожидается» есть зазор, и в него проваливаются три случая.

NaN: запись, которую нельзя ни найти, ни удалить

float64 требованию удовлетворяет: операторы для него определены. Но определённость — не рефлексивность, а карта ищет ключ именно по ==:

Floating-point types are comparable and ordered. Two floating-point values are compared as defined by the IEEE 754 standard.

перевод

Типы с плавающей точкой сравнимы и упорядочены. Два значения с плавающей точкой сравниваются так, как определено стандартом IEEE 754.

Спецификация Go, Comparison operators

Что из этого выходит, видно запуском (bench/gomap/nankey.go):

после m[nan] = 1 → len(m) = 1
после m[nan] = 2 → len(m) = 2
после m[nan] = 3 → len(m) = 3
после m[nan] = 4 → len(m) = 4
после m[nan] = 5 → len(m) = 5

m[nan]        → значение 0, найдено false
len до delete: 5
len после двух delete: 5

Пять присваиваний с одной и той же переменной в качестве ключа дали пять записей. Обычный ключ дал бы одну: присваивание перезаписывает найденный ключ, а найти NaN нельзя. Поиск не находит; delete не находит и молчит, потому что удалять ему нечего. Записи при этом на месте: перебор их видит, значения хранит, память они занимают. Недоступны они только по ключу.

Это не побочный эффект, а известное поведение, записанное в рантайме:

NOTE: Because NaN != NaN, a map can contain any number of (mostly useless) entries keyed with NaNs. To avoid long hash chains, we assign a random number as the hash value for a NaN.

перевод

ПРИМЕЧАНИЕ: поскольку NaN != NaN, карта может содержать сколько угодно (по большей части бесполезных) записей с ключами-NaN. Чтобы избежать длинных цепочек хеширования, мы назначаем NaN случайное число в качестве значения хеша.

runtime/alg.go

Рантайм смягчает последствие — раздаёт значениям NaN случайный хеш, чтобы они не собирались в одну цепочку, — но не отменяет его. И там же, в реализации карты, сказано, что с такой записью вообще можно сделать:

One exception is keys that don't compare equal to themselves (e.g., NaN). These keys cannot be looked up, so getWithKey will fail even if the key exists. However, we are in luck because such keys cannot be updated and they cannot be deleted except with clear.

перевод

Одно исключение — ключи, которые не равны сами себе (например, NaN). Такие ключи невозможно найти, поэтому getWithKey не найдёт их, даже если ключ существует. Однако нам повезло: такие ключи нельзя обновить, и их нельзя удалить иначе как через clear.

internal/runtime/maps/table.go

Проверено: clear(m) убирает их все и возвращает len к нулю. Другого штатного способа нет.

Практический вывод. Написать NaN литералом нельзя — константное 0.0/0.0 компилятор отвергает: invalid operation: division by zero. NaN приходит из данных: деление нулевых переменных (var z float64; z/z), math.Sqrt(-1), разбор строки "NaN". Карта, чьи ключи — числа из внешнего источника, растёт от таких значений без предела, и ни одна запись из неё не переиспользуется. Отсекать надо на входе, проверкой math.IsNaN: снять их потом можно только вместе со всей картой.

И контроль, чтобы не обобщить лишнего: на остальных числах карта странностей не показывает. +0.0 и -0.0 по IEEE 754 равны, и карта честно считает их одним ключом — вторая вставка перезаписала первую, len остался 1. Ломается ровно то место, где == перестаёт быть рефлексивным, и это только NaN.

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

map[any]T — единственное место, где несравнимый ключ не ловится компилятором:

GO
m := map[any]int{}
m[[]int{1}] = 1   // panic: runtime error: hash of unhashable type []int

И там же: int(1) и int64(1)разные ключи, потому что в сравнение входит тип.

nil-карта: где именно это стреляет

Само правило разобрано в первой части: читается всё, паникует только запись. Здесь важно то, где это проявляется на практике, — а проявляется оно всегда поздно.

Поле структуры. Структура с необъявленной картой внутри создаётся без ошибок, читается нормально и падает на первой записи — иногда сильно позже, чем её создали, и в другом пакете. Лечится конструктором, который делает make для всех карт структуры.

Возврат из функции. func load() map[string]int вполне может вернуть nil в ветке ошибки, и вызывающий, который только читает, ничего не заметит. Ловушка захлопнется у следующего вызывающего, который решит дописать запись.

Присваивание внутри функции. func fill(m map[string]int), сделавшая m = make(...), не изменит ничего для вызывающего: копируется значение карты, и новая карта останется в копии. Карту, которую надо создать, возвращают.

Ключ крупнее 128 байт переезжает из слота

В internal/abi стоит SwissMapMaxKeyBytes = 128. Замерено на десяти тысячах записей:

ключвыделеновыделений
[128]byte2 359 984 б34
[136]byte1 735 600 б10 034

За порогом происходит две вещи, и они разнонаправленные: выделений становится по одному на каждый ключ, а байт — меньше. Второе не опечатка: слоты в группе резервируются все восемь сразу, занят слот или нет, а вынесенный ключ выделяется ровно по размеру.

Как воспроизвести числа

Замеры — bench/gomap/layout.go (раскладка) и bench/gomap/cost_test.go (цена). Времени не меряет вовсе bench/gomap/nankey.go: он печатает len, результаты поиска и delete для NaN-ключа. Расширения таблицы ловит bench/gomap/growth.go. Битовую арифметику управляющего слова выполняет по шагам и сверяет с честным перебором bench/gomap/controlword.go — он тоже не меряет времени, а печатает сами биты. Путь вставки целиком проигрывает bench/gomap/insert.go, а разбор хеша на номер группы и метку — bench/gomap/groupindex.go; оба переносят правила из рантайма построчно и проверяют себя, а не просят верить на слово. Это отдельный модуль Go, поэтому запускаются они из него:

go run bench/gomap/layout.go          # работает и из корня
go run bench/gomap/growth.go
go run bench/gomap/controlword.go
go run bench/gomap/insert.go
go run bench/gomap/groupindex.go
./bench/gomap/race.sh                 # три прогона concurrent.go подряд
cd bench/gomap
go test -run '^$' -bench . -benchmem .
./ab.sh                               # старая реализация против новой

layout.go печатает наблюдения без единого замера времени. race.sh запускает bench/gomap/concurrent.go в трёх режимах — с детектором гонок и без — и складывает выводы рядом: два из трёх прогонов обязаны завершиться ненулевым кодом, и это часть результата, а не сбой скрипта. ab.sh чередует сборки с GOEXPERIMENT=noswissmap и без него и печатает строку cpu: для каждого прогона: если она в раундах разная, раунд надо выбросить, а не считать разницу.

Опубликованный прогон: go1.24.7 linux/amd64, Intel Xeon 2,10 ГГц, август 2026 года. Времена зависят от машины; столбцы B/op и allocs/op — нет.

Расхожие заблуждения

Утверждение

Карта в Go — это бакеты по восемь элементов с цепочкой переполнения

На самом деле

Так было до Go 1.24. С 1.24 встроенная карта устроена как Swiss Table: группы по восемь слотов, на группу одно 64-битное управляющее слово, в нём — младшие семь бит хеша каждого ключа. Старая реализация никуда не делась, но включается флагом сборки: GOEXPERIMENT=noswissmap. Именно поэтому две реализации и удалось сравнить на одной машине.

Утверждение

Проверить отсутствие ключа дешевле, чем найти его

На самом деле

Наоборот, и это прямое следствие устройства: Probing stops when it finds a group with an empty slot (Поиск останавливается, найдя группу со свободным слотом). Попадание останавливается, найдя ключ; промах обязан идти, пока не встретит группу со свободным слотом, а при коэффициенте заполнения 7/8 таких групп мало. Замерено на ста тысячах записей: промах 28,62 нс против 17,83 у попадания — в 1,6 раза.

Утверждение

Новая реализация карты быстрее старой

На самом деле

Не во всём. На попадании — вдвое (17,95–18,88 против 35,22–36,58 нс), на обходе — примерно на четверть. А на промахе новая МЕДЛЕННЕЕ старой в 1,4 раза: 28,96–29,66 против 20,57–21,71, и диапазоны четырёх чередующихся раундов не перекрываются. Блог команды Go оговорку делает — Some edge cases do regress compared to Go 1.23 (Некоторые краевые случаи по сравнению с Go 1.23 всё же ухудшились), — но случая не называет. Проверка «есть ли ключ» краевым случаем не выглядит. Механизм разный: в старой карте промах смотрит один бакет и кончается там же, а в Swiss Table идёт до группы со свободным слотом. Замерена одна форма карты: map[int64]int, 100 000 записей, ключи подряд.

Утверждение

Форма v, ok := m[k] стоит дороже, чем v := m[k]

На самом деле

Не стоит ничего. Замерено в одном блоке: попадание 18,43 против 17,83 нс, промах 28,44 против 28,62 — разница внутри разброса, причём на промахе даже в другую сторону. Компилятор генерирует тот же вызов рантайма и во втором случае просто берёт второе возвращённое значение. Выбирать между этими формами по скорости не из чего; выбирают по тому, нужно ли отличать «нет ключа» от «есть, но нулевое значение».

Утверждение

Порядок обхода карты случаен

На самом деле

Он рандомизирован, но не перемешан. У карты из девяти записей за две тысячи обходов получается ровно ДЕВЯТЬ различных порядков, а не 362 880, и все девять — сдвиги одной и той же последовательности. Правило держится до 896 записей — это maxTableCapacity (1024), умноженный на 7/8: дальше карта не помещается в одну таблицу, у итератора появляется второе смещение по директории таблиц, и на 897 записях порядков уже 1174. В итераторе стоит entryIdx := (it.entryIdx + it.entryOffset) & entryMask, и entryOffset берётся один раз на весь обход. Практически это опаснее настоящей случайности: код, зависящий от порядка пары ключей, падает только на тех сдвигах, что разрезают ленту между ними, — то есть проходит тесты.

Утверждение

delete или clear(m) возвращают память

На самом деле

Ни то, ни другое. Замерено: миллион записей map[int]int занимает 36,0 МБ живой кучи; после удаления всех ключей — те же 36,0 МБ, после clear(m) — тоже. Таблица остаётся прежней величины, просто пустой. Память освобождает только замена самой карты: m = make(map[K]V). Для долгоживущего кеша это разница между «память вернулась» и «не вернулась никогда».

Утверждение

make(map[K]V, n) делает карту меньше

На самом деле

Готовая карта получается ровно такой же: 37 776 744 байта против 37 832 752 — разница в промилле. Вдвое меньше становится другое — выделенное ПО ДОРОГЕ: 75 407 864 байта против 37 832 960, и 8 188 выделений против 4 101. Без подсказки карта растёт удвоением, и сумма всех выброшенных таблиц примерно равна итоговой. То есть подсказка снимает нагрузку со сборщика мусора, а потребление памяти оставляет тем же.

Утверждение

Крупный ключ делает карту тяжелее

На самом деле

За порогом SwissMapMaxKeyBytes = 128 — легче, и это измеримо. 10 000 записей с ключом [128]byte: 2 359 984 байта и 34 выделения. С ключом [136]byte: 1 735 600 байт и 10 034 выделения. Байт меньше, потому что слоты в группе резервируются все восемь сразу, а вынесенный за порог ключ выделяется ровно по размеру. Платить приходится другим: по одному выделению на каждый ключ вместо трёх десятков на всю карту.

Проверьте себя

Вопрос 1 из 5

Что дороже на заполненной карте: найти ключ или убедиться, что его нет?

Источники и что читать дальше

9 ИСТОЧНИКОВ

  1. Заметки к выпуску Go 1.24, раздел RuntimeОфициальная документация. Место, где смена реализации объявлена: «These improvements include a new builtin map implementation based on Swiss Tables, more efficient memory allocation of small objects, and a new runtime-internal mutex implementation» (В число этих улучшений входят новая реализация встроенного типа map на основе Swiss Tables, более эффективное выделение памяти под мелкие объекты и новая внутренняя реализация мьютекса рантайма). Там же названа возможность вернуть старую: «The new builtin map implementation and new runtime-internal mutex may be disabled by setting GOEXPERIMENT=noswissmap and GOEXPERIMENT=nospinbitmutex at build time respectively» (Новую реализацию встроенного map и новый внутренний мьютекс рантайма можно отключить, задав при сборке GOEXPERIMENT=noswissmap и GOEXPERIMENT=nospinbitmutex соответственно). Именно этот флаг делает возможным честное сравнение двух реализаций на одной машине.https://go.dev/doc/go1.24
  2. Faster Go maps with Swiss Tables — блог команды GoОфициальная документация. Разбор устройства от авторов изменения: «Each group has a 64-bit control word for metadata. Each of the 8 bytes in the control word corresponds to one of the slots in the group» (У каждой группы есть 64-битное управляющее слово под метаданные. Каждый из 8 байтов управляющего слова соответствует одному из слотов группы). Заявленный выигрыш: «map operations are up to 60% faster than in Go 1.23» (операции с картой до 60 % быстрее, чем в Go 1.23), и рядом честная оговорка, ради которой эту страницу и стоит читать целиком: «some edge cases do regress compared to Go 1.23» (некоторые краевые случаи по сравнению с Go 1.23 всё же ухудшились). Какие именно — не сказано; один такой случай измерен в этой статье.https://go.dev/blog/swisstable
  3. internal/runtime/maps/map.go — вводный комментарий пакетаИсходный код Go. Самый подробный источник по устройству. Термины заданы там же: «Group: A group of abi.SwissMapGroupSlots (8) slots, plus a control word» (Группа: группа из abi.SwissMapGroupSlots (8) слотов плюс управляющее слово). И там же про биты хеша: «H1: Upper 57 bits of a hash. H2: Lower 7 bits of a hash» (H1: старшие 57 бит хеша. H2: младшие 7 бит хеша). Оттуда же правило остановки, из которого растёт вся цена промаха: «Probing stops when it finds a group with an empty slot» (Поиск останавливается, найдя группу со свободным слотом), и объяснение, почему удалённый слот не считается свободным: «when deleting from a completely full group, we must not mark the slot as empty, as there could be more slots used later in a probe sequence and this deletion would cause probing to stop too early» (удаляя из полностью заполненной группы, нельзя пометить слот свободным: дальше по пути поиска могут быть занятые слоты, и такое удаление остановило бы поиск слишком рано). И про обход прямо: «Iteration order is unspecified. In the implementation, it is explicitly randomized» (Порядок обхода не определён. В реализации он явно рандомизирован).https://go.dev/src/internal/runtime/maps/map.go
  4. internal/runtime/maps/table.go — итератор и предел размера таблицыИсходный код Go. Строка, которая объясняет, почему «случайный» порядок оказывается сдвигом: `entryIdx := (it.entryIdx + it.entryOffset) & entryMask`, где `it.entryOffset = rand()` берётся один раз при создании итератора. Комментарий рядом: «Randomize iteration order by starting iteration at a random slot offset» (Рандомизировать порядок обхода, начиная его со случайного смещения слота). Оттуда же `const maxTableCapacity = 1024` с честной пометкой авторов: «TODO: Completely made up value. This should be tuned for performance vs grow latency» (TODO: значение взято с потолка. Его следует подобрать, взвесив производительность против задержки роста). Оттуда же взят комментарий про NaN: «However, we are in luck because such keys cannot be updated and they cannot be deleted except with clear» (Однако нам повезло: такие ключи нельзя обновить, и их нельзя удалить иначе как через clear) — единственный штатный способ убрать такую запись назван прямо.https://go.dev/src/internal/runtime/maps/table.go
  5. Спецификация 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
  6. The Go Memory ModelОфициальная документация. Правило про одновременный доступ живёт здесь, а не в спецификации языка — в разделе про карты спецификация о горутинах молчит вовсе. Определение: «A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the sync/atomic package» (Гонка данных определяется как запись в ячейку памяти, происходящая одновременно с другим чтением или записью в ту же ячейку, если только все участвующие обращения не являются атомарными обращениями к данным, предоставляемыми пакетом sync/atomic). Разрешение рантайму: «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» (гонки на многословных структурах данных могут приводить к несогласованным значениям, не соответствующим ни одной отдельной записи … такие гонки, в свою очередь, могут приводить к произвольному повреждению памяти). Оттуда же про детектор: «Any implementation can, upon detecting a data race, report the race and halt execution of the program» (Любая реализация вправе, обнаружив гонку данных, сообщить о ней и остановить выполнение программы).https://go.dev/ref/mem
  7. internal/runtime/maps/runtime_swiss.go и runtime/panic.goИсходный код Go. Проверка, из которой растут все три сообщения: `if m.writing != 0 { fatal("concurrent map writes") }` в `runtime_mapassign`; тот же флаг проверяют `runtime_mapaccess1` (печатает `concurrent map read and map write`) и итератор в `table.go` (печатает `concurrent map iteration and map write`). Почему это не паника, сказано в комментарии к самой `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://go.dev/src/internal/runtime/maps/runtime_swiss.go
  8. Пакет sync — тип MapОфициальная документация. Отговорка от sync.Map в его собственной документации: «The Map type is specialized. Most code should use a plain Go map instead, with separate locking or coordination, for better type safety and to make it easier to maintain other invariants along with the map content» (Тип Map — специализированный. Большей части кода следует вместо него использовать обычную карту Go с отдельной блокировкой или координацией — ради лучшей типобезопасности и чтобы было проще поддерживать прочие инварианты вместе с содержимым карты). Два случая, под которые он сделан: «(1) when the entry for a given key is only ever written once but read many times, as in caches that only grow, or (2) when multiple goroutines read, write, and overwrite entries for disjoint sets of keys» ((1) когда запись для данного ключа пишется ровно один раз, но читается многократно — как в кэшах, которые только растут; или (2) когда несколько горутин читают, пишут и перезаписывают записи для непересекающихся наборов ключей).https://pkg.go.dev/sync#Map
  9. runtime/alg.go — хеш чисел с плавающей точкой и NaNИсходный код Go. Комментарий перед `f32hash`/`f64hash`, где накопление NaN-записей названо известным поведением, а не дефектом: «NOTE: Because NaN != NaN, a map can contain any number of (mostly useless) entries keyed with NaNs. To avoid long hash chains, we assign a random number as the hash value for a NaN» (ПРИМЕЧАНИЕ: поскольку NaN != NaN, карта может содержать сколько угодно (по большей части бесполезных) записей с ключами-NaN. Чтобы избежать длинных цепочек хеширования, мы назначаем NaN случайное число в качестве значения хеша). Тут же видно и как это сделано: `case f != f: return c1 * (c0 ^ h ^ uintptr(rand()))`.https://go.dev/src/runtime/alg.go