ZigBolt: зачем мы написали свой Aeron на Zig и получили 20 наносекунд на сообщение
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-кластер — и реализовать их на языке, который:
- Не имеет runtime overhead (нет GC, нет safepoints)
- Позволяет генерировать код на этапе компиляции (comptime)
- Тривиально интегрируется с C-библиотеками (DPDK, io_uring)
- Компилируется в бинарник размером ~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-консенсусом и архивом? Тоже есть.
Бенчмарки: числа, а не слова

Вот реальные результаты бенчмарков на 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: простота как добродетель

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 вместо кодогенерации

В мире 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
Попробуйте
- Сайт: zigbolt-landing.vercel.app
- Документация: zigbolt-landing.vercel.app/getting-started/introduction/
- Исходный код: github.com/suenot/zigbolt
- Лицензия: MIT
Собирайте из исходников (zig build), запускайте бенчмарки (zig build bench), подключайте через FFI из любого языка. Если у вас есть Zig 0.15.1 и пара минут — попробуйте ping-pong бенчмарк и сравните с вашим текущим решением.
Ссылки:
- ZigBolt Landing: zigbolt-landing.vercel.app
- GitHub: github.com/suenot/zigbolt
- Aeron (для сравнения): github.com/real-logic/aeron | наш обзор Aeron
- Язык Zig: ziglang.org
- Marketmaker.cc: marketmaker.cc
Цитирование
@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
Количественные исследования и стратегии