← Назад

Lock-free структуры данных на практике

Как custom кэши с linked lists в массивах, free list для O(1) аллокации и шардирование на 256 частей позволяют обрабатывать миллионы операций в секунду без единой блокировки в горячем пути.

256
шардов
O(1)
аллокация
0
contention
4.5ns
alloc op
Содержание
  1. Проблема: mutex как узкое место
  2. Идея: linked list в массиве
  3. Анатомия шарда
  4. O(1) аллокация через free list
  5. Хэширование и бакеты
  6. Шардирование: как победить contention
  7. Три применения в MEMORIA
  8. Бенчмарки
  9. Компромиссы

Проблема: mutex как узкое место

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

1. Contention (конкуренция за блокировку). Если 100 горутин одновременно пытаются прочитать один кэш, они выстраиваются в очередь. Даже RWMutex с его read-locks не спасает, когда на запись приходит каждая десятая операция.

2. Системные вызовы. Когда мьютекс занят, Go паркует горутину через runtime. Парковка-распарковка — это системный вызов, который стоит сотни наносекунд. Для системы, где вся операция чтения занимает 0.35 ns, это катастрофа.

Цифры говорят

Один захват sync.Mutex без конкуренции стоит ~25 ns. При конкуренции — от 100 ns до микросекунд. Наша цель — уложиться в 4.5 ns на операцию аллокации в кэше. С mutex это физически невозможно.

Идея: linked list в массиве

Классический linked list в Go — это map или указатели на структуры в куче. Оба варианта создают нагрузку на GC. Мы используем другой подход: массив фиксированного размера с индексами вместо указателей.

Вместо того чтобы хранить указатель на следующий элемент (*Entry), мы храним индекс в массиве (uint32). Это даёт три преимущества:

┌─────────────────────────────────────────────┐ │ arena [256]verifyCacheEntry │ ├─────────────────────────────────────────────┤ │ [0] key=0xA3F2 value=true next=5 │ │ [1] key=0x11BB value=false next=0xFFFF │ ← свободен │ [2] key=0x77CC value=true next=12 │ │ [3] key=0x44DD value=false next=0xFFFF │ ← свободен │ ... │ │ [5] key=0x99EE value=true next=0xFFFF │ │ ... │ │ [12] key=0x22FF value=false next=0xFFFF │ └─────────────────────────────────────────────┘ ↑ freeList = 1 → 3 → 7 → ... → 0xFFFFFFFF

Анатомия шарда

Вот как выглядит реальный шард кэша верификации в MEMORIA:

type verifyCacheEntry struct {
    key   uint64   // Хэш сообщения
    value bool     // Результат верификации
    next  uint32   // Индекс следующего элемента (0xFFFFFFFF = конец)
    _     [4]byte  // Padding для выравнивания до 24 байт
}

type verifyCacheShard struct {
    arena    [256]verifyCacheEntry  // Предвыделенный массив
    freeList uint32                 // Голова списка свободных слотов
    head     [256]uint32            // Головы 256 бакетов
    mutex    sync.RWMutex           // Только для редких записей
    count    uint32                 // Текущее количество элементов
}Go

Разберём каждое поле:

arena [256]verifyCacheEntry

Массив из 256 записей. Каждая запись — ровно 24 байта. Весь шард занимает 6 KB в памяти. Это предвыделяется при старте приложения и никогда не освобождается.

freeList uint32

Индекс первого свободного слота. Это односвязный список: каждый свободный элемент хранит в поле next индекс следующего свободного элемента. Специальное значение 0xFFFFFFFF означает «конец списка».

head [256]uint32

Массив из 256 голов бакетов. Это хэш-таблица с цепочками (chaining). Ключ хэшируется в диапазон 0-255, и мы получаем индекс бакета. Все коллизии разрешаются через linked list.

O(1) аллокация через free list

Вот как выглядит аллокация нового элемента:

//go:nosplit
func (vc *verifyCacheShard) alloc() (*verifyCacheEntry, uint32) {
    if vc.freeList == 0xFFFFFFFF || vc.freeList >= uint32(len(vc.arena)) {
        return nil, 0  // Кэш переполнен
    }
    
    idx := vc.freeList
    vc.freeList = vc.arena[idx].next
    vc.arena[idx].next = 0xFFFFFFFF
    vc.count++
    
    return &vc.arena[idx], idx
}Go

Что происходит:

  1. Проверяем, что free list не пуст
  2. Берём первый свободный индекс
  3. Сдвигаем голову free list на следующий свободный элемент
  4. Помечаем взятый элемент как «конец списка» (0xFFFFFFFF)
  5. Увеличиваем счётчик

Вся операция — 5 присваиваний. Никаких системных вызовов, никаких аллокаций в куче. Бенчмарки показывают 4.5 наносекунды на операцию.

Освобождение элемента симметрично:

func (vc *verifyCacheShard) free(idx uint32) {
    vc.arena[idx].next = vc.freeList
    vc.freeList = idx
    vc.count--
}Go

Два присваивания. O(1). Всегда.

Почему это работает

Free list — это классическая техника из системного программирования. Она используется в аллокаторах памяти (jemalloc, tcmalloc), игровых движках и высоконагруженных базах данных. Идея проста: если вы знаете, что вам нужно не более N объектов, выделите их все сразу и управляйте ими через список свободных.

Хэширование и бакеты

Поиск элемента в кэше использует хэш-таблицу с цепочками:

func (vc *verifyCacheShard) Get(key uint64) bool {
    bucket := key & 255  // Быстрый modulo через bitwise AND
    
    entryIdx := vc.head[bucket]
    for entryIdx != 0xFFFFFFFF && entryIdx < uint32(len(vc.arena)) {
        entry := &vc.arena[entryIdx]
        if entry.key == key {
            return entry.value
        }
        entryIdx = entry.next
    }
    return false
}Go

Обратите внимание на key & 255. Это оптимизация: вместо операции деления key % 256 мы используем побитовое AND. Поскольку 256 — это степень двойки (2^8), младшие 8 бит числа и есть остаток от деления на 256. Это работает в ~10 раз быстрее, чем деление на современных CPU.

Вставка работает аналогично, но добавляет элемент в начало цепочки:

//go:nosplit
func (vc *verifyCacheShard) Set(key uint64, value bool) bool {
    bucket := key & 255
    
    // Сначала ищем, нет ли уже такого ключа
    entryIdx := vc.head[bucket]
    for entryIdx != 0xFFFFFFFF && entryIdx < uint32(len(vc.arena)) {
        entry := &vc.arena[entryIdx]
        if entry.key == key {
            entry.value = value  // Обновляем существующий
            return true
        }
        entryIdx = entry.next
    }
    
    // Ключа нет — аллоцируем новый
    newEntry, idx := vc.alloc()
    if newEntry == nil {
        return false  // Кэш переполнен
    }
    
    newEntry.key = key
    newEntry.value = value
    newEntry.next = vc.head[bucket]  // Вставляем в начало цепочки
    vc.head[bucket] = idx
    
    return true
}Go

Шардирование: как победить contention

Даже с lock-free чтением, операции записи требуют мьютекс. Если все 15 миллионов пользователей обращаются к одному кэшу — contention убьёт производительность.

Решение: разделить один большой кэш на 256 независимых шардов:

const SHARD_COUNT = 256
const SHARD_MASK = SHARD_COUNT - 1  // 0xFF

var verifyCaches [SHARD_COUNT]verifyCacheShard

func getShard(key uint64) *verifyCacheShard {
    return &verifyCaches[key & SHARD_MASK]
}Go

Теперь каждый шард обслуживает только 1/256 часть трафика. Вероятность того, что две горутины одновременно захотят один шард, снижается в 256 раз.

Запрос с key=0xA3F2 │ ▼ ┌─────────────────┐ │ key & 0xFF │ │ = 0xF2 = 242 │ └────────┬────────┘ │ ▼ ┌────────────────────────────────┐ │ verifyCaches[0] (mutex 0) │ │ verifyCaches[1] (mutex 1) │ │ ... │ │ verifyCaches[242] (mutex 242) │ ◄── Запрос идёт сюда │ ... │ │ verifyCaches[255] (mutex 255) │ └────────────────────────────────┘

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

Три применения в MEMORIA

Один и тот же паттерн используется в трёх разных местах кода:

1. Verify Cache — кэш верификации подписей

Когда клиент отправляет снапшот с подписью BLAKE3, сервер проверяет подпись. Вычисление хэша стоит ~100 ns. Чтобы не делать это повторно для одинаковых снапшотов, результат кешируется:

type verifyCacheEntry struct {
    key   uint64   // xxhash от сообщения
    value bool     // true если подпись валидна
    next  uint32
}

var verifyCaches [256]verifyCacheShardGo

Размер одного шарда: 256 × 24 байта = 6 KB. Всего 256 шардов × 6 KB = 1.5 MB на весь кэш.

2. ReqID Cache — защита от replay-атак

Каждый P2P-перевод имеет уникальный reqID (8 байт). Чтобы злоумышленник не мог отправить один и тот же пакет дважды, мы запоминаем все reqID за последние 10 секунд:

type reqIDCacheEntry struct {
    reqID     [8]byte
    timestamp uint32
    next      uint32
    _         [4]byte
}

var reqIDCaches [256]reqIDCacheShardGo

Функция CheckReplay возвращает true, если reqID новый, и false, если он уже был использован в окне 10 секунд.

3. IP Limiter — защита от DDoS

Чтобы один IP-адрес не мог завалить сервер запросами, мы ограничиваем количество операций с одного IP до 10 в секунду:

type ipLimiterEntry struct {
    ipHash      uint64
    count       uint16
    lastSec     uint32
    lastNewUser uint32
    next        uint32
    _           [4]byte
}

var ipLimiterShards [64]ipLimiterShardGo

Здесь шардов меньше (64 вместо 256), потому что IP-адресов меньше, чем ключей кэша, и каждый элемент больше (32 байта вместо 24).

Бенчмарки

Реальные цифры с Intel Core i7-4790:

BenchmarkArena_Alloc-8          24736123    4.572 ns/op    0 B/op    0 allocs/op
BenchmarkArena_Alloc_Small-8    26194135    4.706 ns/op    0 B/op    0 allocs/op
BenchmarkArena_Alloc_Large-8    25316743    4.842 ns/op    0 B/op    0 allocs/opgo test -bench

Сравним с стандартными структурами Go:

✗ sync.Map
  • Чтение: ~50 ns
  • Запись: ~200 ns
  • Аллокаций: 2-5 на op
  • Память: динамическая
  • GC нагрузка: высокая
✓ MEMORIA Shard
  • Чтение: ~5 ns
  • Запись: ~10 ns
  • Аллокаций: 0 на op
  • Память: фиксированная
  • GC нагрузка: нулевая

Разница в 10-20 раз по скорости и бесконечность по аллокациям. Но главное — предсказуемость. MEMORIA-кэш всегда работает за 5-10 ns, независимо от нагрузки. sync.Map может деградировать до микросекунд при высокой конкуренции.

Компромиссы

У подхода есть свои ограничения, о которых важно знать:

1. Фиксированный размер

Если кэш переполняется (все 256 слотов в шарде заняты), новые элементы не добавляются. В MEMORIA мы решаем это подбором размера: 256 слотов на шард × 256 шардов = 65 536 записей. При TTL в несколько секунд этого достаточно для миллионов операций.

2. Нет удаления по ключу

Мы не удаляем элементы по ключу — только по истечении TTL или при переполнении. Это упрощает код, но требует, чтобы размер кэша был достаточным.

3. Фрагментация внутри шарда

Со временем free list может фрагментироваться. Но поскольку у нас нет реального «освобождения» памяти (только возврат в free list), это не проблема — просто список свободных слотов становится длиннее.

4. Сложность кода

Работа с индексами вместо указателей, ручное управление free list, padding для выравнивания — всё это увеличивает сложность кода. Одна ошибка может привести к memory corruption. Поэтому в init() есть проверки:

func init() {
    testShard := &ipLimiterShards[0]
    entry, idx := testShard.alloc()
    if entry == nil {
        log.Fatal("alloc failed in init check")
    }
    expectedPtr := uintptr(unsafe.Pointer(&testShard.arena[idx]))
    actualPtr := uintptr(unsafe.Pointer(entry))
    if expectedPtr != actualPtr {
        log.Fatalf("⚠️ Pointer mismatch: idx=%d, expected=%x, got=%x", 
            idx, expectedPtr, actualPtr)
    }
    // Возвращаем элемент обратно
    testShard.arena[idx].next = testShard.freeList
    testShard.freeList = idx
    testShard.count--
}Go
Главный урок

Lock-free структуры данных — это не магия. Это компромисс: вы меняете гибкость динамических структур на предсказуемость и скорость статических. Если вы знаете границы своей задачи (максимум N элементов, фиксированный размер ключа), вы можете построить структуру, которая будет работать в 10-100 раз быстрее стандартных решений. Цена — сложность кода и потеря гибкости. Для высоконагруженных систем эта цена более чем оправдана.

В следующей статье мы разберём, как на базе этих lock-free структур построен бинарный UDP-протокол, который обрабатывает 256 пакетов за один системный вызов.