← Назад

Double Buffering для атомарных обновлений

Ping-pong буферы в UserArena, переключение через atomic flag. Как MEMORIA обеспечивает consistency без блокировок и race conditions при обновлении состояния 15 миллионов пользователей.

2
буфера
128B
каждый
0
мьютексов
~0ns
переключение
Содержание
  1. Проблема: race conditions
  2. Идея: два буфера вместо одного
  3. Структура UserArena
  4. Active vs Inactive слоты
  5. Атомарное переключение
  6. Создание снапшотов
  7. Обновление баланса
  8. Гарантии consistency
  9. Бенчмарки
  10. Выводы

Проблема: race conditions

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

Время │ Поток 1 (читает) │ Поток 2 (пишет) ────────┼──────────────────────┼──────────────────── t=0 │ Читает balance=100 │ t=1 │ │ Пишет balance=200 t=2 │ Читает lastActive │ t=3 │ │ Пишет lastActive=now t=4 │ Создаёт снапшот │ │ [balance=100, │ │ lastActive=now] │ ← НЕСОГЛАСОВАННО!

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

Классическое решение — sync.RWMutex:

type UserState struct {
    mu         sync.RWMutex
    balance    int64
    lastActive uint32
}

func (u *UserState) GetSnapshot() Snapshot {
    u.mu.RLock()
    defer u.mu.RUnlock()
    return Snapshot{
        Balance:    u.balance,
        LastActive: u.lastActive,
    }
}

func (u *UserState) UpdateBalance(newBalance int64) {
    u.mu.Lock()
    defer u.mu.Unlock()
    u.balance = newBalance
    u.lastActive = nowSecCached()
}Go

Это работает, но создаёт проблемы:

Цель

Нам нужно обеспечить атомарность чтения/записи без единой блокировки. Читатели всегда должны видеть согласованное состояние — либо полностью старое, либо полностью новое, но никогда промежуточное.

Идея: два буфера вместо одного

Double buffering (двойная буферизация) — классический паттерн из компьютерной графики. Вместо одного буфера используются два:

После завершения записи буферы меняются ролями атомарно — просто переключается указатель.

┌─────────────────────────────────────────┐ │ UserArena │ │ │ │ ┌─────────────┐ ┌─────────────┐ │ │ │ PING │ │ PONG │ │ │ │ (128B) │ │ (128B) │ │ │ │ │ │ │ │ │ │ Active=true │ │ Active=false│ │ │ └─────────────┘ └─────────────┘ │ │ ↑ │ │ │ └───────────────┘ │ │ │ │ │ ▼ │ │ atomic flag │ │ (0 или 1) │ └─────────────────────────────────────────┘ Читатели: читают из PING (active) Писатели: пишут в PONG (inactive) Переключение: atomic.StoreUint32(&active, 1)

В MEMORIA это реализовано через два поля в структуре UserArena:

type UserArena struct {
    ping [128]byte  // Hot slot
    pong [128]byte  // Cold slot
    // ...
}Go

Структура UserArena

Каждый из 128-байтовых буферов содержит структуру ArenaHotSlot:

type ArenaHotSlot struct {
    Balance      int64     // 8 байт, смещение 0
    LastActive   uint32    // 4 байта, смещение 8
    LastClaim    uint32    // 4 байта, смещение 12
    LastSnapshot uint32    // 4 байта, смещение 16
    LastTransfer uint32    // 4 байта, смещение 20
    Flags        uint16    // 2 байта, смещение 24
    Epoch        uint16    // 2 байта, смещение 26
    TxOffset     uint32    // 4 байта, смещение 28
    _            [96]byte  // Padding до 128 байт
}Go

Важные детали:

  1. Balance на смещении 0 — самое часто читаемое поле, лежит в начале для лучшей локальности
  2. Padding до 128 байт — размер кратен кэш-линии (64 байта), два буфера занимают ровно 2 кэш-линии
  3. Все поля выровнены — int64 по 8 байт, uint32 по 4 байта

Вся структура UserArena (2048 байт) включает:

type UserArena struct {
    // Двойной буфер
    ping    [128]byte  // ArenaHotSlot
    pong    [128]byte  // ArenaHotSlot
    
    // Транзакционный лог
    txBuf   [640]byte  // 10 × 64 байта
    
    // Кэши подписей
    cachedClaimSig    [32]byte
    cachedSnapshotSig [32]byte
    claimSigCached    uint8
    snapshotSigCached uint8
    _                 [2]byte
    
    // Метаданные
    active      uint32    // 0 = ping active, 1 = pong active
    peerID      [20]byte
    _           [4]byte
    lastActive  int64
    txCount     uint32
    txHead      uint32
    
    // Криптография
    userKey     [32]byte
    userKeySet  bool
    
    // Кэш снапшотов
    snapshotCache     [128]byte
    snapshotCached    uint8
    snapshotBalance   int64
    snapshotTxCount   uint32
    
    // Padding до 2048 байт
    _ [860]byte
}Go

Active vs Inactive слоты

Функции для получения указателей на активный и неактивный слоты:

//go:nosplit
//go:nocheckptr
func (ua *UserArena) getActiveSlotPtr() *ArenaHotSlot {
    active := atomic.LoadUint32(&ua.active)
    if active == 0 {
        return (*ArenaHotSlot)(unsafe.Pointer(&ua.ping[0]))
    }
    return (*ArenaHotSlot)(unsafe.Pointer(&ua.pong[0]))
}

//go:nosplit
func (ua *UserArena) getInactiveSlot() *ArenaHotSlot {
    active := atomic.LoadUint32(&ua.active)
    if active == 0 {
        return (*ArenaHotSlot)(unsafe.Pointer(&ua.pong[0]))
    }
    return (*ArenaHotSlot)(unsafe.Pointer(&ua.ping[0]))
}Go

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

  1. Атомарно читаем флаг active (0 или 1)
  2. Если active == 0 — ping активен, pong неактивен
  3. Если active == 1 — pong активен, ping неактивен
  4. Приводим указатель на массив байт к указателю на ArenaHotSlot

Директива //go:nocheckptr нужна, потому что Go в режиме race detector мог бы заподозрить нелегальное приведение указателей. Но мы знаем, что ping и pong выровнены правильно, так что это безопасно.

Атомарное переключение

Процесс обновления состояния:

func (ua *UserArena) UpdateBalance(newBalance int64) {
    // 1. Получаем указатель на inactive слот
    inactive := ua.getInactiveSlot()
    
    // 2. Пишем новые данные в inactive
    inactive.Balance = newBalance
    inactive.LastActive = nowSecCached()
    
    // 3. Атомарно переключаем active flag
    oldActive := atomic.LoadUint32(&ua.active)
    newActive := 1 - oldActive  // 0 → 1, 1 → 0
    atomic.StoreUint32(&ua.active, newActive)
    
    // 4. Сбрасываем кэш снапшота
    ua.snapshotCached = 0
}Go

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

─────────────────────────────────────────────┐ │ T0: Чтение баланса │ │ active = atomic.Load(&ua.active) // 0 │ │ slot = &ua.ping │ │ balance = slot.Balance // 100 │ └─────────────────────────────────────────────┘ │ ▼ ─────────────────────────────────────────────┐ │ T1: Обновление баланса │ │ inactive = &ua.pong │ │ inactive.Balance = 200 │ │ atomic.Store(&ua.active, 1) ← SWITCH! │ └─────────────────────────────────────────────┘ │ ▼ ─────────────────────────────────────────────┐ │ T2: Новое чтение баланса │ │ active = atomic.Load(&ua.active) // 1 │ │ slot = &ua.pong │ │ balance = slot.Balance // 200 │ └─────────────────────────────────────────────┘

Создание снапшотов

Снапшот создаётся из активного слота:

func (ua *UserArena) buildArenaSnapshot(balance int64) []byte {
    activeSlot := ua.getActiveSlotPtr()
    
    var snap [128]byte
    
    // Magic
    copy(snap[0:4], []byte("SNAP"))
    
    // PeerID
    copy(snap[4:24], ua.peerID[:])
    
    // Balance
    binary.LittleEndian.PutUint64(snap[24:32], uint64(balance))
    
    // UserKey
    copy(snap[32:64], ua.userKey[:])
    
    // Padding (нули)
    // ...
    
    // Signature
    msg := make([]byte, 0, 128)
    msg = append(msg, ua.peerID[:]...)
    msg = binary.LittleEndian.AppendUint64(msg, uint64(balance))
    msg = append(msg, ua.userKey[:]...)
    msg = append(msg, make([]byte, 32)...)
    
    h := blake3.New(32, snapshotKey[:])
    h.Write(msg)
    sig := h.Sum(nil)
    copy(snap[96:128], sig)
    
    return snap[:]
}Go

Важно: getActiveSlotPtr() возвращает указатель на текущий активный слот. Пока мы читаем из него для создания снапшота, писатель может обновлять неактивный слот. Они не мешают друг другу.

Обновление баланса

Реальная функция обновления баланса из MEMORIA:

//go:nosplit
//go:nocheckptr
func (ua *UserArena) AddBalance(amount int64) int64 {
    active := atomic.LoadUint32(&ua.active)
    var base uintptr
    if active == 0 {
        base = uintptr(unsafe.Pointer(&ua.ping[0]))
    } else {
        base = uintptr(unsafe.Pointer(&ua.pong[0]))
    }
    slot := (*ArenaHotSlot)(unsafe.Pointer(base))
    
    newBal := slot.Balance + amount
    if newBal > MAX_BALANCE {
        newBal = MAX_BALANCE
    }
    
    slot.Balance = newBal
    slot.LastActive = nowSecCached()
    
    ua.snapshotCached = 0
    
    runtime.KeepAlive(slot)
    return newBal
}Go

Обратите внимание: здесь мы пишем напрямую в активный слот! Это безопасно, потому что:

  1. Запись одного поля (Balance) атомарна для 64-битных значений на x86/x64
  2. Мы не читаем из активного слота в другом потоке — только пишем
  3. Читатели (создание снапшотов) видят либо старое значение, либо новое, но никогда частичное

Для полной безопасности при модификации активного слота можно использовать atomic.AddInt64:

// Атомарное сложение
atomic.AddInt64(&slot.Balance, amount)Go

Гарантии consistency

Double buffering обеспечивает следующие гарантии:

1. Atomicity (атомарность)

Читатель видит согласованное состояние — все поля из одного слота (либо ping, либо pong). Невозможно прочитать balance из ping, а lastActive из pong.

2. Visibility (видимость)

После атомарного переключения active все новые читатели увидят новые данные. Порядок гарантируется memory barrier'ом в atomic.StoreUint32.

3. No locks (без блокировок)

Ни читатели, ни писатели не блокируются. Читатели работают с активным слотом, писатели — с неактивным. Переключение — одна атомарная операция.

4. No races (нет гонок)

Поскольку каждый поток работает со своим слотом (читатели с active, писатели с inactive), гонок данных нет.

Ограничение

Double buffering работает, когда один писатель. Если несколько писателей одновременно обновляют один inactive слот — нужна синхронизация между ними. В MEMORIA это решается шардированием: каждый user arena имеет своего владельца (воркер), так что конфликтов нет.

Бенчмарки

Реальные цифры переключения буферов:

BenchmarkDoubleBuffer_Switch-8    1000000000    0.35 ns/op
BenchmarkDoubleBuffer_Read-8      1000000000    0.35 ns/op
BenchmarkDoubleBuffer_Write-8     1000000000    0.94 ns/opgo test -bench

Сравним с мьютексом:

✗ sync.RWMutex
  • Чтение (без конкуренции): ~25 ns
  • Чтение (с конкуренцией): ~100-500 ns
  • Запись: ~50-200 ns
  • Системные вызовы: есть (парковка)
  • GC нагрузка: нет
✓ Double Buffering
  • Чтение: ~0.35 ns
  • Запись: ~0.94 ns
  • Переключение: ~0.35 ns
  • Системные вызовы: нет
  • GC нагрузка: нет

Разница: 70-140 раз быстрее на чтение. И главное — предсказуемость. Double buffering всегда работает за 0.35-0.94 ns, независимо от нагрузки. RWMutex деградирует при конкуренции.

Выводы

Double buffering — мощный паттерн для lock-free программирования:

В MEMORIA double buffering используется для:

  1. Атомарного обновления баланса
  2. Создания согласованных снапшотов
  3. Кэширования transaction log

Паттерн особенно эффективен в сочетании с arena-based памятью и lock-free шардированием — все три техники вместе обеспечивают zero-allocation, zero-lock архитектуру.

Главный урок

Иногда лучшее решение — не синхронизировать доступ к данным, а избежать конфликта. Double buffering позволяет читателям и писателям работать параллельно без единой блокировки. Цена — удвоение памяти (2 × 128 байт = 256 байт на пользователя). Для 15 миллионов пользователей это 3.8 GB — приемлемая плата за скорость и простоту.

В следующей статье мы разберём, как transaction log в кольцевом буфере обеспечивает восстановление состояния после сбоев.