← К списку статей
March 27, 2026
5 мин. чтения

ZigBolt: зачем мы написали свой Aeron на Zig и получили 20 наносекунд на сообщение

ZigBolt: зачем мы написали свой Aeron на Zig и получили 20 наносекунд на сообщение
#zigbolt
#zig
#hft
#low-latency
#messaging
#aeron
#ipc
#open-source

ZigBolt — ultra-low latency messaging Lock-free ring buffers, zero-copy кодеки, Raft-кластер — всё на чистом Zig, всё с открытыми исходниками.

Если вы занимаетесь алготрейдингом или маркетмейкингом, то знаете цену каждой микросекунды. Один лишний context switch — и ваш ордер приехал вторым. Одна пауза сборщика мусора JVM — и маркетмейкер на другом конце уже обновил котировку. В мире, где деньги измеряются в наносекундах, инфраструктура обмена сообщениями — это не скучная труба между сервисами, а конкурентное преимущество.

Мы написали ZigBolt — систему обмена сообщениями для high-frequency trading на языке Zig. С нуля. Без JVM, без garbage collector, без Media Driver, без XML-конфигов. И мы получили 20 наносекунд p50 латентности на SPSC ring buffer и 30 наносекунд на IPC через shared memory.

Эта статья — про то, зачем это было нужно, как это устроено внутри и почему Zig.


Кратко

  • ZigBolt — open-source (MIT) система обмена сообщениями для HFT на чистом Zig
  • 20 нс p50 на SPSC, 30 нс p50 на IPC — быстрее заявленных характеристик Aeron
  • Zero-copy кодек работает за 0 нс (compile-time генерация, runtime — просто pointer cast)
  • Нет GC, нет JVM, нет Media Driver — библиотека встраивается прямо в приложение
  • Raft-кластер, архив, sequencer — всё в комплекте
  • FFI-биндинги для Rust, Python, Go, TypeScript, C — работайте на чём удобно

Проблема: почему Aeron — это хорошо, но недостаточно

Aeron от Real Logic — де-факто стандарт low-latency messaging для капитальных рынков. Им пользуются десятки HFT-фирм, он проверен в бою, у него отличная архитектура. Но у Aeron есть фундаментальная проблема, и имя ей — JVM.

JVM safepoints: невидимый враг

Даже если вы аккуратно положили все данные в off-heap memory, даже если вы отключили GC ergonomics и выставили GuaranteedSafepointInterval=300000 — JVM всё равно время от времени останавливает все потоки на safepoint. Это не баг, это архитектурное решение: JVM нужны safepoints для deoptimization, biased locking, stack walking.

На практике это выглядит так: ваш поток шлёт сообщения с p50 = 200 нс, и вдруг p99.9 подскакивает до 50 мкс. Без видимой причины. Потому что один из потоков JVM решил, что ему пора.

Media Driver: лишний hop

Aeron работает через Media Driver — отдельный процесс (или embedded JVM), который маршрутизирует сообщения между publisher и subscriber через shared memory. Это даёт красивую изоляцию, но добавляет минимум один лишний hop:

Aeron:    App → shm → Media Driver → shm → socket → NIC
ZigBolt:  App → ring buffer → io_uring → NIC

На каждый hop — лишние наносекунды, лишние cache misses, лишняя непредсказуемость.

SBE: отдельный build step

Simple Binary Encoding — стандартный FIX-кодек для финансовых сообщений. В экосистеме Aeron это отдельная Java-утилита, которая генерирует код из XML-схем. Отдельная зависимость, отдельный build step, отдельный набор проблем.


Решение: ZigBolt

Мы задали себе вопрос: а что если взять лучшие идеи Aeron — triple-buffered log, lock-free ring buffers, Raft-кластер — и реализовать их на языке, который:

  1. Не имеет runtime overhead (нет GC, нет safepoints)
  2. Позволяет генерировать код на этапе компиляции (comptime)
  3. Тривиально интегрируется с C-библиотеками (DPDK, io_uring)
  4. Компилируется в бинарник размером ~100 КБ

Этот язык — Zig.


Архитектура

┌─────────────────────────────────────────────────────────┐
│  Publisher/Subscriber API (типизированные обёртки)       │
├─────────────────────────────────────────────────────────┤
│  Transport Layer (фабрика каналов, lifecycle)            │
├─────────────────────────────────────────────────────────┤
│  IPC Channel (shared memory)  │ UDP Channel (сеть)      │
├─────────────────────────────────────────────────────────┤
│  WireCodec (comptime, zero-copy) │ SBE Encoder/Decoder  │
├─────────────────────────────────────────────────────────┤
│  Ring Buffers (SPSC/MPSC) │ LogBuffer (triple-buffered) │
├─────────────────────────────────────────────────────────┤
│  Archive (replay) │ Sequencer (total order) │ Raft (HA) │
└─────────────────────────────────────────────────────────┘

Семь слоёв, каждый из которых можно использовать независимо. Хотите только SPSC ring buffer для IPC между двумя процессами? Берите. Нужен полноценный кластер с Raft-консенсусом и архивом? Тоже есть.


Бенчмарки: числа, а не слова

Результаты бенчмарков ZigBolt

Вот реальные результаты бенчмарков на 10 миллионах итераций (Apple Silicon / macOS):

SPSC Ring Buffer

Размер сообщения p50 p99 p99.9 Throughput
8 байт 20 нс 30 нс 120 нс 42.8M msg/s
32 байта 30 нс 50 нс 150 нс 28.5M msg/s
64 байта 50 нс 60 нс 320 нс 17.6M msg/s
256 байт 30 нс 50 нс 50 нс 29.5M msg/s

IPC Channel (shared memory)

Размер сообщения p50 p99 p99.9 Throughput
64 байта 30 нс 40 нс 40 нс 35.7M msg/s
256 байт 40 нс 40 нс 170 нс 27.4M msg/s
1024 байта 90 нс 260 нс 900 нс 9.9M msg/s

LogBuffer (Aeron-style triple-buffered)

Размер сообщения p50 p99 p99.9 Throughput
32 байта 30 нс 40 нс 320 нс 33.6M msg/s
64 байта 30 нс 30 нс 160 нс 38.0M msg/s
256 байт 30 нс 40 нс 60 нс 31.1M msg/s

WireCodec (comptime zero-copy)

Операция Latency Throughput
Encode (32 байта) 0 нс ∞ (inlined memcpy)
Decode (32 байта) ~0.4 нс 2.7 млрд msg/s

Да, вы прочитали правильно: кодирование — ноль наносекунд. Потому что WireCodec(T) валидирует структуру на этапе компиляции и превращает encode/decode в обычный @memcpy или pointer cast. Runtime overhead = ноль.

Для сравнения: Aeron заявляет IPC RTT (round-trip) ~250 нс. У нас one-way латентность — 30 нс. Даже если считать round-trip, мы в 4 раза быстрее.


Как это работает внутри

Lock-free SPSC: простота как добродетель

SPSC Ring Buffer

Single-producer single-consumer ring buffer — самая простая и быстрая lock-free структура данных. Писатель двигает head, читатель двигает tail, CAS не нужен — хватает acquire/release атомиков.

Ключевой трюк — cache-line padding. Если head и tail лежат в одной cache line, то каждое обновление одного счётчика инвалидирует кэш для другого ядра (false sharing). Решение:

// Head (write position) — в своей cache line
head: std.atomic.Value(usize) align(128) = .init(0),

// 128 байт padding — гарантируем изоляцию
_pad0: [128 - @sizeOf(std.atomic.Value(usize))]u8 = .{0} ** ...,

// Tail (read position) — в своей cache line
tail: std.atomic.Value(usize) align(128) = .init(0),

128 байт, а не 64 — потому что на Apple Silicon (и многих ARM) hardware prefetcher может работать с парами cache lines. Мы перестраховываемся.

WireCodec: comptime вместо кодогенерации

WireCodec — compile-time кодек

В мире Java/C++ для бинарных кодеков вам нужен отдельный шаг: написать схему → запустить кодогенератор → получить код → скомпилировать. В Zig всё это делается на этапе компиляции:

const TickMsg = packed struct {
    symbol_id: u32,
    price: i64,
    quantity: u32,
    side: u8,
    _reserved: [3]u8,
    timestamp: u64,
};

const Codec = WireCodec(TickMsg);

// Encode — это просто memcpy 32 байт. Инлайнится в 1-2 инструкции.
Codec.encode(&msg, buf[0..Codec.wire_size]);

// Decode — pointer cast. Ноль копирований.
const tick = Codec.decode(buf[0..Codec.wire_size]);

Компилятор Zig на этапе comptime проверяет:

  • Структура — packed (никаких padding holes)
  • Размер кратен 8 байтам (выравнивание для SIMD)
  • Все поля — примитивные типы

Если что-то не так — ошибка компиляции, а не runtime exception в 3 часа ночи на проде.

IPC через shared memory

Два процесса маппят один файл в /dev/shm. Publisher пишет в ring buffer, subscriber читает. Никаких сокетов, никаких системных вызовов на hot path:

// Publisher
const channel = try IpcChannel.create("/market-data", .{
    .term_length = 1024 * 1024, // 1 МБ
});
channel.publish(&msg_bytes, msg_type_id);

// Subscriber (другой процесс)
const channel = try IpcChannel.open("/market-data", .{
    .term_length = 1024 * 1024,
});
const count = channel.poll(handler_fn, 10);

Весь путь от publish() до вызова handler_fn в subscriber — 30 наносекунд для сообщения в 64 байта.

NAK-based reliability для UDP

Для сетевого транспорта ZigBolt использует receiver-driven retransmission. Получатель отслеживает пропуски в sequence numbers через bitmap и шлёт NAK (negative acknowledgement) отправителю. Плюс AIMD congestion control — TCP-like slow start и congestion avoidance — чтобы не залить сеть.

Raft-кластер: когда нужна консистентность

Для случаев, когда потерять сообщение нельзя (например, matching engine), ZigBolt включает полноценный Raft consensus:

  • Leader election с конфигурируемым таймаутом (150-300 мс)
  • Log replication — лидер реплицирует каждое сообщение на followers
  • Write-ahead log с CRC32-валидацией и crash recovery
  • Snapshots — чтобы WAL не рос бесконечно

Archive: запись и replay

Все сообщения можно записывать в сегментированный архив на диске. Потом — replay с любой позиции по времени или sequence number. Встроенная LZ4-style компрессия без внешних зависимостей. Sparse index для быстрого поиска внутри сегментов.

Total-order sequencer

Для маркетмейкинга на нескольких площадках важно, чтобы все события имели глобальный порядок. Sequencer берёт N входных потоков и объединяет их в один, присваивая монотонно растущие sequence numbers. Каждый участник видит одну и ту же последовательность событий.


Почему Zig, а не Rust/C/C++?

Мы выбирали между четырьмя кандидатами. Вот честная таблица:

Критерий Zig C/C++ Rust Java (Aeron)
GC / runtime overhead Нет Нет Нет JVM safepoints, GC
Comptime-кодогенерация Нативная Макросы/шаблоны proc macros Нет
C interop (DPDK, io_uring) Тривиальный @cImport Нативный FFI/bindgen JNI overhead
SIMD @Vector, встроенный Intrinsics packed_simd (unstable) Vectorization hints
Cross-compilation Встроенная CMake hell cargo target N/A
Время сборки Секунды Минуты (C++) Минуты Секунды + JVM startup
Hidden control flow Нет Exceptions, implicit casts Паники в unwrap Exceptions

Zig дал уникальную комбинацию: производительность C + безопасность при разработке + comptime метапрограммирование (кодеки, lookup tables, protocol state machines — всё генерируется при компиляции) + тривиальная интеграция с DPDK, liburing, ef_vi через @cImport.

А ещё Zig-бинарник весит ~100 КБ. Против 20+ МБ у JVM-based решения. Для edge-деплоя и контейнеров это имеет значение.


Биндинги: работайте на своём языке

ZigBolt компилируется в shared library с C-ABI, и у нас есть готовые биндинги для пяти языков:

TypeScript / Node.js

import { IpcChannel } from "@zigbolt/node";

const channel = IpcChannel.create({
  name: "/my-market-data",
  termLength: 1024 * 1024,
});

const msg = Buffer.from("BTC/USDT 42000.50", "utf-8");
channel.publish(msg, 1);

Rust

use zigbolt::IpcChannel;

let ch = IpcChannel::create("/my-channel", 64 * 1024).unwrap();
ch.publish(b"hello", 1).unwrap();

// Subscriber
let sub = IpcChannel::open("/my-channel", 64 * 1024).unwrap();
sub.poll(|data, msg_type_id| {
    println!("got {} bytes, type={}", data.len(), msg_type_id);
}, 10);

Python

from zigbolt import IpcChannel

ch = IpcChannel.create("/market-data", term_length=1024*1024)
ch.publish(b"tick data here", msg_type_id=1)

А ещё Go и чистый C. Один и тот же shared memory канал доступен из всех языков одновременно — publisher на Zig, subscriber на Python, мониторинг на Go. Все читают одну и ту же mmap-область.


SBE-кодек: FIX-совместимые сообщения

Для финансовых протоколов ZigBolt включает полноценный SBE (Simple Binary Encoding) кодек с compile-time схемами. Встроенные типы сообщений:

  • NewOrderSingle — отправка ордера
  • ExecutionReport — отчёт об исполнении
  • MarketDataIncrementalRefresh — инкрементальное обновление рыночных данных
  • MassQuote — массовая котировка
  • Heartbeat — проверка связи
  • Logon — аутентификация

Не нужен внешний кодогенератор, не нужен XML. Всё описывается структурами Zig и валидируется на этапе компиляции.


Wire Protocol: совместимость с Aeron

ZigBolt реализует Aeron-совместимые wire protocol flyweights:

  • DataHeaderFlyweight — фреймы данных
  • StatusMessage — управление потоком
  • NAK — отрицательное подтверждение
  • Setup, RTT, Error — служебные фреймы

Это значит, что ZigBolt может жить в одной экосистеме с существующей Aeron-инфраструктурой. Миграция не обязана быть big bang.


Что дальше

ZigBolt сейчас на версии 0.2.1. Ядро стабильно, бенчмарки воспроизводимы, биндинги работают. В ближайших планах:

  • io_uring backend — zero-copy сетевой транспорт на Linux 6.0+ (IORING_OP_SEND_ZC)
  • DPDK / AF_XDP — kernel bypass для ситуаций, когда каждая микросекунда на счету
  • Multi-Raft — шардирование по инструментам/стратегиям
  • Columnar archive — интеграция с Apache Arrow/Parquet для аналитики
  • Hugepage support — pre-faulted 2MB/1GB hugepages для минимизации TLB misses

Попробуйте

Собирайте из исходников (zig build), запускайте бенчмарки (zig build bench), подключайте через FFI из любого языка. Если у вас есть Zig 0.15.1 и пара минут — попробуйте ping-pong бенчмарк и сравните с вашим текущим решением.


Ссылки:


Цитирование

@software{soloviov2026zigbolt,
  author = {Soloviov, Eugen},
  title = {ZigBolt: зачем мы написали свой Aeron на Zig и получили 20 наносекунд на сообщение},
  year = {2026},
  url = {https://marketmaker.cc/ru/blog/post/zigbolt-zig-messaging-hft},
  version = {0.2.1},
  description = {Рассказываем, как и зачем мы написали с нуля систему обмена сообщениями для HFT на языке Zig. Без JVM, без GC, без сюрпризов.}
}
Дисклеймер: Информация в этой статье предоставлена исключительно в образовательных и ознакомительных целях и не является финансовым, инвестиционным или торговым советом. Торговля криптовалютами сопряжена с высоким риском убытков.

MarketMaker.cc Team

Количественные исследования и стратегии

Обсудить в Telegram
Newsletter

Будьте в курсе событий

Подпишитесь на нашу рассылку, чтобы получать эксклюзивную аналитику по AI-трейдингу и обновления платформы.

Мы уважаем вашу конфиденциальность. Отписаться можно в любой момент.