← Назад

Arena-based Memory Management в Go

Как предвыделение памяти и zero-allocation hot path позволяют достичь 0.35 наносекунд на операцию чтения — скорости, сопоставимой с L1-кэшем процессора. Разбор архитектуры, которая обслуживает 15 миллионов пользователей на одном сервере.

0.35ns
ReadBalance
0
аллокаций
2KB
на юзера
336M
ops/sec
Содержание
  1. Проблема: GC в Go как узкое место
  2. Что такое Arena-based память
  3. Структура UserArena: разбор по байтам
  4. unsafe.Pointer: прямой доступ к памяти
  5. Double buffering через ping-pong
  6. Сравнение со стандартным подходом
  7. Реальные бенчмарки
  8. Выводы

Проблема: GC в Go как узкое место

Go — отличный язык для высоконагруженных систем. Но у него есть фундаментальная проблема для real-time приложений: garbage collector. Даже с оптимизированным GC в современных версиях Go, каждая аллокация в куче (heap) создаёт работу для сборщика мусора.

Типичная архитектура веб-сервиса выглядит так:

func HandleRequest(w http.ResponseWriter, r *http.Request) {
    user := &User{...}           // аллокация 1
    balance := user.GetBalance() // аллокация 2 внутри
    response := Response{        // аллокация 3
        Balance: balance,
    }
    json.NewEncoder(w).Encode(response) // аллокация 4+
}

Каждый HTTP-запрос создаёт десятки, а то и сотни временных объектов. GC вынужден постоянно сканировать кучу, останавливать мир (stop-the-world паузы), и всё это добавляет микросекунды задержки к каждой операции.

Ключевая мысль

Для системы, которая обрабатывает миллионы операций в секунду, каждая микросекунда на GC — это катастрофа. Нам нужен был способ полностью исключить аллокации в горячем пути выполнения.

Что такое Arena-based память

Arena (или pool, или region) — это техника управления памятью, при которой большой блок памяти выделяется один раз при старте приложения, а затем делится на меньшие фиксированные части по мере необходимости.

Преимущества подхода:

В MEMORIA каждый пользователь получает свой собственный arena-блок размером ровно 2048 байт. Это не случайное число — размер подобран так, чтобы структура идеально вписывалась в кэш-линии процессора и не создавала фрагментации.

Структура UserArena: разбор по байтам

Вот как выглядит реальная структура UserArena из кода MEMORIA:

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

Обратите внимание на несколько важных деталей:

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

В коде есть проверка на этапе компиляции:

var _ [unsafe.Sizeof(UserArena{}) - USER_ARENA_SIZE]byteGo

Если размер структуры изменится — код не скомпилируется. Это гарантирует, что мы всегда работаем с ровно 2048 байтами на пользователя.

2. Padding для выравнивания

Поля _[2]byte, _[4]byte и _[860]byte — это не мусор. Это padding для выравнивания по границам 8 байт. Процессор читает память эффективнее, когда данные выровнены по своим естественным границам.

3. Ping-pong буферы

Поля ping и pong по 128 байт каждое — это два слота для double buffering. Один слот активен для чтения/записи, другой используется для создания снапшотов. Это позволяет обновлять состояние атомарно, без блокировок.

unsafe.Pointer: прямой доступ к памяти

Чтобы получить скорость 0.35 ns, нам пришлось выйти за рамки безопасного Go и использовать unsafe.Pointer. Вот как выглядит чтение баланса:

//go:nosplit
//go:nocheckptr
func (ua *UserArena) ReadBalance() 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))
    return slot.Balance
}Go

Что здесь происходит:

  1. Атомарно читаем флаг active — какой слот сейчас активен
  2. Получаем сырой указатель на начало массива ping или pong
  3. Интерпретируем эти 128 байт как структуру ArenaHotSlot
  4. Читаем поле Balance напрямую из памяти
Директивы компилятора

//go:nosplit — запрещает вставку проверок стека (stack split check). Это убирает несколько инструкций из горячего пути.

//go:nocheckptr — отключает проверки указателей. Без этой директивы Go в режиме race detector мог бы паниковать при приведении указателей.

Структура ArenaHotSlot описывает, как интерпретировать эти 128 байт:

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
    _            [224]byte // padding до 128 байт... 
}Go

Поле Balance находится в самом начале структуры, по смещению 0. Это значит, что для его чтения CPU нужно сделать ровно одну загрузку из кэша — ни больше, ни меньше.

Double buffering через ping-pong

Double buffering — ключевой паттерн в MEMORIA. Он решает проблему атомарного обновления состояния без блокировок.

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

Решение: два слота, которые меняются ролями:

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]))
}

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. Пишем новые данные в inactive слот
  2. Атомарно переключаем флаг active
  3. Теперь старый inactive стал новым active

Читатели всегда видят согласованное состояние — либо старое, либо новое, но никогда промежуточное. И всё это без единого мьютекса.

Сравнение со стандартным подходом

Давайте сравним два подхода на конкретном примере — чтении баланса пользователя:

✗ Стандартный подход
  • HTTP handler+50 μs
  • ORM запрос+100 μs
  • JSON сериализация+50 μs
  • Драйвер БД+2 ms
  • PostgreSQL+5 ms
  • Диск I/O+3 ms
  • Итого~10 ms
✓ MEMORIA Arena
  • UDP пакет~0 ns
  • Поиск шарда~0 ns
  • Чтение active~0 ns
  • ReadBalance()0.35 ns
  • Аллокаций0
  • Блокировок0
  • Итого0.35 ns

Разница: ~30 миллионов раз. И это не преувеличение — 10 миллисекунд против 0.35 наносекунд.

Реальные бенчмарки

Все цифры взяты из реальных тестов на Intel Core i7-4790 @ 3.60GHz:

BenchmarkUserArena_ReadBalance-8      336672242    0.3535 ns/op    0 B/op    0 allocs/op
BenchmarkUserArena_UpdateBalance-8    127592797    0.9370 ns/op    0 B/op    0 allocs/op
BenchmarkUserArena_AddBalance-8        47328166    2.413 ns/op     0 B/op    0 allocs/op
BenchmarkUserArena_GetSnapshot-8      334690240    0.3606 ns/op    0 B/op    0 allocs/opgo test -bench

Обратите внимание на колонку allocs/opвезде ноль. Это и есть тот самый zero-allocation hot path. Ни одна операция в горячем пути не создаёт новых объектов в куче.

Для сравнения, стандартный HTTP handler в Go с чтением из PostgreSQL показывает:

BenchmarkHTTPHandler-8    10000    12500 ns/op    2048 B/op    25 allocs/opтипичный результат

Разница в 35 000 раз по скорости и бесконечность по аллокациям (0 против 25).

Выводы

Arena-based memory management — это не серебряная пуля. У подхода есть свои ограничения:

Но для систем, где каждая наносекунда на счету, эти жертвы оправданы. MEMORIA доказывает, что на одном сервере с 32 GB RAM можно обслуживать 15 миллионов пользователей с задержкой чтения 0.35 ns — скоростью, сопоставимой с прямым доступом к L1-кэшу процессора.

Главный урок

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

В следующих статьях мы разберём, как на базе этой arena-архитектуры построены lock-free структуры данных, бинарный UDP-протокол и криптографические снапшоты.