Проблема: race conditions
Представьте ситуацию: один поток читает баланс пользователя для создания снапшота, а другой поток в это же время обновляет баланс после транзакции. Без синхронизации мы получим data race:
Снапшот содержит частично обновлённые данные: старый баланс, но новое время 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
Это работает, но создаёт проблемы:
- Блокировки — читатели блокируют писателей и наоборот
- Системные вызовы — при конкуренции горутины паркуются
- Задержки — мьютекс стоит ~25 ns без конкуренции, до микросекунд при конкуренции
Нам нужно обеспечить атомарность чтения/записи без единой блокировки. Читатели всегда должны видеть согласованное состояние — либо полностью старое, либо полностью новое, но никогда промежуточное.
Идея: два буфера вместо одного
Double buffering (двойная буферизация) — классический паттерн из компьютерной графики. Вместо одного буфера используются два:
- Front buffer (active) — используется для чтения
- Back buffer (inactive) — используется для записи
После завершения записи буферы меняются ролями атомарно — просто переключается указатель.
В 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
Важные детали:
- Balance на смещении 0 — самое часто читаемое поле, лежит в начале для лучшей локальности
- Padding до 128 байт — размер кратен кэш-линии (64 байта), два буфера занимают ровно 2 кэш-линии
- Все поля выровнены — 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
Что происходит:
- Атомарно читаем флаг
active(0 или 1) - Если
active == 0— ping активен, pong неактивен - Если
active == 1— pong активен, ping неактивен - Приводим указатель на массив байт к указателю на
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 — атомарное переключение. После этой инструкции все новые читатели увидят новые данные. Старые читатели, которые уже начали чтение, продолжат читать старый буфер — это безопасно, потому что мы не модифицируем активный буфер.
Создание снапшотов
Снапшот создаётся из активного слота:
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
Обратите внимание: здесь мы пишем напрямую в активный слот! Это безопасно, потому что:
- Запись одного поля (Balance) атомарна для 64-битных значений на x86/x64
- Мы не читаем из активного слота в другом потоке — только пишем
- Читатели (создание снапшотов) видят либо старое значение, либо новое, но никогда частичное
Для полной безопасности при модификации активного слота можно использовать 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
Сравним с мьютексом:
- Чтение (без конкуренции): ~25 ns
- Чтение (с конкуренцией): ~100-500 ns
- Запись: ~50-200 ns
- Системные вызовы: есть (парковка)
- GC нагрузка: нет
- Чтение: ~0.35 ns
- Запись: ~0.94 ns
- Переключение: ~0.35 ns
- Системные вызовы: нет
- GC нагрузка: нет
Разница: 70-140 раз быстрее на чтение. И главное — предсказуемость. Double buffering всегда работает за 0.35-0.94 ns, независимо от нагрузки. RWMutex деградирует при конкуренции.
Выводы
Double buffering — мощный паттерн для lock-free программирования:
- Простота — два буфера, один флаг
- Скорость — 0.35 ns на чтение, 0.94 ns на запись
- Безопасность — нет race conditions, нет блокировок
- Предсказуемость — константное время независимо от нагрузки
В MEMORIA double buffering используется для:
- Атомарного обновления баланса
- Создания согласованных снапшотов
- Кэширования transaction log
Паттерн особенно эффективен в сочетании с arena-based памятью и lock-free шардированием — все три техники вместе обеспечивают zero-allocation, zero-lock архитектуру.
Иногда лучшее решение — не синхронизировать доступ к данным, а избежать конфликта. Double buffering позволяет читателям и писателям работать параллельно без единой блокировки. Цена — удвоение памяти (2 × 128 байт = 256 байт на пользователя). Для 15 миллионов пользователей это 3.8 GB — приемлемая плата за скорость и простоту.
В следующей статье мы разберём, как transaction log в кольцевом буфере обеспечивает восстановление состояния после сбоев.