Когда я впервые увидел новости про Swiss Table в Go, у меня была простая мысль: "Окей, звучит неплохо, но что это реально меняет для обычного разработчика?" Снаружи почти ничего. Мы всё так же пишем map[string]int, читаем, записываем, удаляем значения и обходим map через range. Но внутри рантайма map теперь устроена иначе, и это как раз тот случай, когда изменение под капотом может заметно повлиять на производительность без переписывания бизнес-логики.

Начиная с Go 1.24, встроенная map использует новую реализацию на базе Swiss Table. Это не сторонняя структура данных и не новый синтаксис языка, а замена внутреннего механизма хранения пар "ключ - значение". В релизных материалах команда Go прямо пишет, что новая map стала одной из причин снижения накладных расходов рантайма в среднем на 2-3% на наборе репрезентативных тестов. При этом они отдельно предупреждают: результат зависит от конкретного приложения.

Главный тезис статьи такой: Swiss Table - это не новая коллекция для Go, а новая начинка старой map. И чтобы нормально понять, почему вокруг неё столько внимания, нужно разобрать три вещи: как вообще работали старые подходы к хеш-таблицам, в чём сила Swiss Table и почему Go не мог просто взять реализацию из Abseil один в один.

Почему про это вообще стоит говорить

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

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

Поэтому смотреть на Swiss Table полезно не как на волшебную кнопку, а как на хороший инженерный апгрейд рантайма. Программисту на Go это даёт сразу две выгоды: во-первых, лучшее понимание того, почему встроенные map ведут себя именно так; во-вторых, более трезвый взгляд на оптимизации. Иногда не нужно изобретать собственную структуру данных, достаточно понять, что стандартная map стала умнее.

Коротко: что такое Swiss Table

Swiss Table - это семейство реализаций хеш-таблиц, которое стало широко известно после публикации Google и открытой реализации в Abseil. Суть идеи в том, чтобы сделать поиск по хеш-таблице более пакетным: не проверять ячейки строго по одной, а за один шаг быстро отфильтровывать сразу несколько кандидатов на совпадение.

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

Ключевая фишка здесь - служебные байты. Для группы ячеек хранится отдельное компактное описание их состояния. В Go runtime группа содержит 8 ячеек и 8-байтное служебное слово. Каждый байт показывает, пустая ячейка, удалённая или занятая, а если занятая - хранит нижние 7 бит хеша, так называемый H2. Благодаря этому поиск не обязан каждый раз полноценно сравнивать ключи во всех соседних ячейках.

Старая идея с overflow-цепочками против группы со служебными control bytes
Старая идея с overflow-цепочками против группы со служебными control bytes

Как это работает на практике

Представим, что у нас есть хеш ключа. Go runtime делит его на две части: верхние 57 бит - это H1, нижние 7 бит - H2H1 нужен, чтобы выбрать стартовую точку поиска, а H2 используется как быстрый фильтр внутри группы. Грубо говоря, сначала мы очень дёшево спрашиваем: "Есть ли среди этих 8 ячеек хоть кто-то, у кого хвост хеша похож на мой?" И только для подходящих кандидатов делаем уже полное сравнение ключей.

Это важный момент. Полное сравнение ключа может быть дорогим, особенно если ключ - строка, а не маленькое число. Swiss Table уменьшает количество таких сравнений, потому что сначала работает через компактный отпечаток хеша. Вероятность ложного совпадения у 7-битного H2 есть, но она достаточно мала, а окончательная проверка ключа всё равно сохраняется.

В официальном описании Go runtime отдельно подчёркивается, что внутри группы можно фактически проверить сразу 8 кандидатов за одну операцию. На платформах с SIMD такие идеи масштабируются ещё лучше, но даже без специальных инструкций подход полезен, потому что хорошо ложится на битовые операции и локальность данных.

Go не растит всю map целиком, а работает через несколько независимых таблиц.
Go не растит всю map целиком, а работает через несколько независимых таблиц.

Почему Go не мог просто скопировать Abseil

Если читать только популярные разборы Swiss Table, можно подумать: "Ну так возьмите Abseil и перенесите идею в Go runtime". На практике всё сложнее. У встроенной map в Go есть собственные требования языка и рантайма, и именно из-за них реализация в Go отличается от классической Swiss Table.

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

Команда Go давно бережно относится к этой теме, поэтому встроенные map исторически росли постепенно. В статье про Swiss Table разработчики прямо пишут: Go часто используют в сервисах, чувствительных к задержкам, и встроенные типы не должны вносить непредсказуемо большой хвост задержки в одну отдельную операцию.

Чтобы сохранить это свойство, Go не делает одну гигантскую Swiss Table на всю map. Вместо этого map состоит из одной или нескольких независимых таблиц. Каждая отдельная таблица хранит максимум 1024 записи, а верхние биты хеша выбирают, в какую таблицу идти. Формально такой подход можно описать как вариант расширяемого хеширования.

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

Вторая проблема: итерация и изменение map

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

Для реализации это нетривиально. Если во время итерации map вырастет и перераспределит данные, простой проход по памяти может начать видеть уже не ту картину, с которой итерация стартовала. В официальной статье про Swiss Table команда Go отдельно разбирает эту проблему и объясняет, почему именно семантика языка делает задачу сложнее, чем в Abseil.

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

Что именно изменилось внутри runtime

Если смотреть в исходники internal/runtime/maps, там прямо написано: дизайн основан на Abseil Swiss Table, но изменён под требования Go. В комментариях вводится своя терминология: slotgroupcontrol wordH1H2tablemapdirectory. Уже по этим определениям видно, что новая реализация мыслит не корзинами и цепочками переполнения, а группами ячеек и набором таблиц.

Группа в Go - это 8 ячеек плюс 8-байтное служебное слово. Каждый байт соответствует одной ячейке. Один бит уходит под служебное состояние, остальные 7 - под H2. Поиск сначала вычисляет хеш, по H1 находит стартовую группу, а затем по служебным байтам быстро отбирает кандидатов. Если совпадений нет и группа не содержит пустого места, поиск продолжается дальше.

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

Интересно, что переход на Swiss Table не означает полного отказа от компромиссов, связанных именно с Go. Ещё в обсуждении внедрения разработчики писали, что пробовали разные варианты расположения данных в памяти, в том числе более близкие к классической Swiss Table, но не все из них давали реальные выигрыши в рантайме. То есть итоговая реализация - это не копия статьи из интернета, а именно инженерная адаптация под Go.

Что это меняет для обычного Go-разработчика

В 99% кода на уровне синтаксиса вообще ничего не меняется. Твой map[string]User так и остаётся map[string]User. Все привычные свойства тоже сохраняются: порядок обхода через range по-прежнему не гарантирован, в nil mapвсё так же нельзя записывать значения, а доступ из нескольких потоков без синхронизации всё так же остаётся небезопасным.

Изменения касаются в первую очередь скорости работы и использования памяти. Благодаря более эффективному поиску внутри таблицы, лучшей организации служебных байтов и более плотному заполнению новая реализация может быстрее выполнять чтение и вставку. Но это не отменяет базовых правил хорошего Go-кода: если map используется из нескольких потоков, нужен mutex или другой способ синхронизации; если ключи тяжёлые, стоимость вычисления хеша и сравнения никуда не девается; если ты каждую миллисекунду создаёшь большие временные map, это всё равно может заметно нагружать сборщик мусора.

Иначе говоря, Swiss Table - это ускорение стандартной map, а не её магическая замена. Хорошая новость в том, что встроенная map стала быстрее и сильнее. Плохая новость - это всё ещё не повод перестать смотреть в профилирование и думать о реальной нагрузке.

Когда выигрыш будет заметнее всего

Сильнее всего эффект обычно проявляется в сценариях, где map действительно часто используется: много чтений, много вставок, большая горячая структура, высокий процент времени внутри mapaccess и mapassign, много строковых или других нетривиальных ключей. Если профилировщик уже раньше показывал, что заметная часть времени процессора уходит на работу с map, переход на Go 1.24 может дать вполне ощутимый бонус даже без изменения исходников.

Если же map у тебя есть, но она не является узким местом, никакой магии не произойдёт. Производительность приложения определяется не одной структурой. Очень типичная ошибка - увидеть новость про Swiss Table и ожидать ускорения всего проекта на десятки процентов. Официальные релизные заметки говорят лишь о среднем снижении накладных расходов рантайма на 2-3% по набору тестов, а не о гарантии для любого сервиса.

Особенно осторожным стоит быть с заявлениями вроде "стало быстрее и экономнее по памяти всегда". У Swiss Table действительно есть предпосылки для более эффективного использования памяти за счёт более плотного заполнения, но конкретный результат зависит от распределения ключей, размеров значений и поведения конкретной программы. Единственный взрослый способ это подтвердить - измерить у себя.

Когда не стоит делать громких выводов

Во-первых, не стоит путать новую реализацию встроенной map с полной сменой модели программирования. Это не отдельный контейнер из стандартной библиотеки и не аналог sync.Map. Никаких новых API здесь нет.

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

В-третьих, не надо думать, что теперь встроенная map автоматически лучше любой специализированной структуры во всех случаях. Есть задачи для битовых наборов, B-tree, radix tree, кольцевого буфера, сегментированной map или даже простого слайса с бинарным поиском. Swiss Table делает стандартную mapсильнее, но не превращает её в универсальный ответ на все вопросы.

Как проверить эффект у себя

Самый нормальный путь - взять свой код и прогнать тесты производительности до и после обновления Go через testing.Bbenchstatpprof и реальный сценарий нагрузки. Если у тебя есть две версии toolchain, сравни не только время, но и allocs/opB/op и профили процессора.

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

Ещё одна важная деталь: в Go 1.24 новую map можно отключить через GOEXPERIMENT=noswissmap на этапе сборки. Это удобный способ сделать честное A/B-сравнение на одном и том же приложении и убедиться, где именно у тебя есть выигрыш, а где его почти нет.

go test -bench=. -benchmem ./...

# сравнить две сборки:
# 1) обычная Go 1.24
# 2) Go 1.24 + GOEXPERIMENT=noswissmap

GOEXPERIMENT=noswissmap go test -bench=. -benchmem ./...

Нужно ли теперь менять собственные привычки работы с map

Сильно - нет, но кое-что полезно помнить.

Первое: заранее выделять разумную ёмкость всё ещё полезно, когда ты знаешь примерный размер map. Это снижает число промежуточных расширений.

Второе: выбор ключа всё ещё важен. Маленький int как ключ и длинная строка как ключ - это не одинаковая стоимость.

Третье: конкурентные чтение и запись в одну map без синхронизации всё так же остаются ошибкой.

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

Вывод

Для меня история со Swiss Table - это очень хороший пример эволюции Go без слома привычного кода. Язык не заставляет разработчика переписывать API, не вводит новую коллекцию и не устраивает революцию в синтаксисе. Вместо этого команда улучшает одну из самых базовых структур прямо в runtime и делает это так, чтобы обычный разработчик просто получил бесплатный бонус после обновления toolchain.

При этом самое ценное здесь даже не сами проценты ускорения, а инженерный подход. В новой map видно, что Go не гонится за красивой теорией в вакууме. Дизайн Swiss Table был адаптирован под реальные требования языка: предсказуемый рост, корректную итерацию при изменениях, совместимость с уже существующей семантикой. Это не порт чужой идеи, а аккуратная работа по встраиванию сильной идеи в экосистему Go.

Swiss Table в Go 1.24 - это важное изменение. Не потому, что теперь у нас появилась какая-то магическая новая мапа, а потому, что старая добрая map стала умнее, быстрее и интереснее с точки зрения внутреннего устройства. А это как раз тот тип улучшений, которые в системном языке особенно приятно видеть.

Вкратце

  • В Go 1.24 встроенная map получила новую реализацию на базе Swiss Table.

  • Снаружи API не изменился: это всё тот же встроенный map.

  • Внутри появились группы по 8 ячеек и служебные байты с H2.

  • Go адаптировал идею Swiss Table под свои требования, а не скопировал её один в один.

  • Чтобы сохранить предсказуемость роста, map разбивается на несколько таблиц.

  • Ожидать пользу стоит там, где map реально является горячей точкой.

  • Любые выводы о выигрыше нужно подтверждать тестами производительности на своём коде.

Источники

  1. Go Blog - Faster Go maps with Swiss Tables, 26 Feb 2025.

  2. Go 1.24 Release Notes - раздел Runtime.

  3. Исходный код Go runtime: src/internal/runtime/maps/map.go.

  4. Обсуждение внедрения: golang/go issue #54766.

Комментарии (0)