Сервис развивается: тестируем формат, собираем идеи, улучшаем сервис. Есть идеи?

Написать
Войти
Дайджесты
Шардирование блокировок в Go

Ускорение in-memory кэша в Go с помощью шардирования мьютексов

Рост числа процессорных ядер в production-серверах может парадоксально снизить производительность in-memory кэшей на Go из-за конкуренции за кэш-линии глобального мьютекса. Использование шардирования блокировок (lock striping) решает эту проблему, сохраняя высокую пропускную способность под любой нагрузкой.

Ускорение in-memory кэша в Go с помощью шардирования мьютексов

Проблема масштабирования кэша при увеличении числа ядер

Разработка высоконагруженных сервисов на Go часто требует создания быстрых in-memory хранилищ данных (кэшей). Простейшая реализация представляет собой обычную хэш-таблицу map, доступ к которой синхронизируется через единственный глобальный мьютекс sync.Mutex или sync.RWMutex. На локальном компьютере разработчика или при небольшом числе параллельных запросов эта схема работает быстро.

Однако при развертывании в многопоточном production-окружении на многоядерных серверах производительность такого решения начинает падать. Вместо роста пропускной способности с добавлением процессорных ядер разработчики сталкиваются с отрицательным масштабированием (negative scaling). Причина кроется в конкуренции за кэш-линии процессора (cache-line contention): ядро процессора, захватывающее блокировку мьютекса, инвалидирует состояние кэша во всех остальных ядрах. В результате процессорные ядра тратят больше времени на согласование состояния памяти блокировки, чем на выполнение полезных операций с данными.

Описание бенчмарка: Методология и дизайн тестирования

Для выявления оптимального подхода к синхронизации было проведено детальное бенчмарк-исследование. Цель эксперимента — измерить накладные расходы различных подходов на чистой стандартной библиотеке Go без внешних зависимостей.

Параметры тестирования:

  • Объем данных: кэш формата string -> string емкостью 1 000 000 ключей;
  • Длина значений: зафиксирована на уровне 64 байт (строки в Go передаются через иммутабельные заголовки, поэтому размер значения не влияет на пропускную способность, изменяя лишь нагрузку на сборщик мусора GC);
  • Профили нагрузки: чтение (100% Get), преобладание чтения (90% Get / 10% Set), сбалансированный режим (50/50) и преобладание записи (10% Get / 90% Set);
  • Профили распределения ключей: равномерное распределение и распределение Зипфа (Zipfian, коэффициент s=1.1), симулирующее наличие «горячих» ключей;
  • Аппаратная платформа: процессор Intel Core i7-14700K (использовались 8 производительных P-ядер, жестко привязанных к горутинам через маску аффинити 0x5555 во избежание миграции потоков на энергоэффективные E-ядра).

Шесть подходов к синхронизации in-memory структур

В рамках тестирования были реализованы шесть стратегий:

  1. naive: Обычная хэш-таблица map без синхронизации. Служит базовой линией производительности, но не является потокобезопасной и завершает процесс аварийно при первой параллельной записи.
  2. mutex: Карта, защищенная одним глобальным мьютексом sync.Mutex.
  3. rwmutex: Использование sync.RWMutex, позволяющего выполнять параллельные чтения.
  4. syncmap: Встроенный тип sync.Map из стандартной библиотеки Go.
  5. sharded (Lock Striping): Разделение кэша на 256 независимых сегментов (шардов), каждый из которых защищен собственным мьютексом. Шард для ключа определяется хэшированием.
  6. cow (Copy-On-Write): Реализация на базе atomic.Pointer, обеспечивающая чтение вообще без блокировок, но требующая полного копирования хэш-таблицы при каждой записи.

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

Бенчмарки показали следующие результаты времени выполнения операций в наносекундах на операцию (ns/op, меньше — лучше) под равномерной нагрузкой на 8 ядрах:

Метод синхронизацииЧтение (100% Get)Преобладание чтения (90/10)Сбалансированный (50/50)Запись (90% Set)
mutex168168190208
rwmutex53259282222
syncmap30375773
sharded21222425
cow11.512 000 00046 500 00082 500 000

Анализ цифр выявляет важные паттерны:

  • Глобальный Mutex: Демонстрирует худшую масштабируемость. На 8 ядрах его производительность падает до 0.66× от показателей на одном ядре из-за острой конкуренции за кэш-линию блокировки.
  • RWMutex: Хорошо масштабируется только на чтении (до ), но при появлении хотя бы 10% записей деградирует сильнее обычного мьютекса (259 ns/op против 168 ns/op), так как счетчик читателей сам становится точкой конкуренции.
  • Copy-On-Write: Абсолютный лидер на чистом чтении (11.5 ns/op). Однако при наличии записей производительность падает до критических 82 миллисекунд (82 500 000 наносекунд) на одну операцию Set, поскольку копирование карты на миллион записей парализует процессор.
  • Sharded: Единственный подход, обеспечивающий стабильно высокую производительность на всех профилях нагрузки (21–25 ns/op).

Анатомия шардированного кэша (Lock Striping)

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

Ниже представлен готовый к использованию код шардированного кэша в Go с хэшированием FNV-1a:

package cache

import "sync"

const shards = 256 // Степень двойки для быстрой битовой маски h&(shards-1) вместо деления с остатком

type part struct {
    mu sync.Mutex
    m  map[string]string
}

type Sharded struct {
    parts [shards]*part
}

func NewSharded() *Sharded {
    s := &Sharded{}
    for i := 0; i < shards; i++ {
        s.parts[i] = &part{m: make(map[string]string)}
    }
    return s
}

func (c *Sharded) at(key string) *part {
    h := uint64(14695981039346656037) // Смещение FNV-1a
    for i := 0; i < len(key); i++ {
        h = (h ^ uint64(key[i])) * 1099511628211 // Прайм FNV-1a
    }
    return c.parts[h&(shards-1)]
}

func (c *Sharded) Get(key string) (string, bool) {
    p := c.at(key)
    p.mu.Lock()
    v, ok := p.m[key]
    p.mu.Unlock()
    return v, ok
}

func (c *Sharded) Set(key, value string) {
    p := c.at(key)
    p.mu.Lock()
    p.m[key] = value
    p.mu.Unlock()
}

Оптимизация defer и стоимость Copy-On-Write при операциях записи

Обратите внимание на явные вызовы p.mu.Unlock() вместо привычной конструкции defer p.mu.Unlock(). В ранних версиях Go вызов defer приносил значительные накладные расходы. В современных версиях (включая Go 1.26) компилятор оптимизирует defer, однако замеры на горячем пути выполнения все еще фиксируют разницу в +8% времени выполнения операции (около 1 наносекунды) в пользу явного освобождения мьютекса. Для критически важных участков кода (hot paths) явный анлок остается стандартом.

Пошаговые рекомендации по выбору стратегии синхронизации

При проектировании in-memory хранилищ данных руководствуйтесь следующими правилами:

  1. По умолчанию используйте sharded (шардирование мьютексов). Это наиболее сбалансированный и предсказуемый паттерн для большинства конкурентных задач в Go.
  2. Используйте cow (Copy-On-Write) только для редких записей. Эта стратегия идеальна для статических конфигураций, таблиц маршрутизации или feature-флагов, которые обновляются раз в несколько часов, но читаются миллионы раз в секунду.
  3. Используйте sync.Map только в ее узкой нише. Она подходит, когда ключи записываются один раз, а читаются многократно, либо когда разные потоки работают с непересекающимися наборами ключей. В общих сценариях она аллоцирует память (40-72 байт на операцию) и уступает шардированию.
  4. Не переоценивайте sync.RWMutex. Вопреки интуиции, разделяемый мьютекс эффективен только на малом числе ядер и при тотальном преобладании чтения.
  5. Начинайте с простого мьютекса, если нагрузка невелика. Оптимизируйте архитектуру только на основе реальных замеров производительности.