GaiaEx AcademyGaiaEx Academy
C++ dla systemów handlowych o niskim opóźnieniu
DeweloperProgramowanie13 min read

C++ dla systemów handlowych o niskim opóźnieniu

Czemu najszybsze firmy handlowe piszą wszystko w C++

Udostępnij posty

Czemu C++ jest językiem szybkości

W stosie czułym na opóźnienia C++ wciąż zajmuje ścieżkę dopasowywania zleceń: platformy w stylu Globex, wiele feedów akcyjnych i silniki kryptowalutowe (włączając infrastrukturę klasy Hyperliquid) kompilują się do przewidywalnego kodu maszynowego bez pauzy garbage collectora skrywającej się gdzieś w środku.

Co czyni C++ wyjątkowo odpowiednim do handlu o niskim opóźnieniu?

  • Brak garbage collectora — brak nieprzewidywalnych pauz. Kontrolujesz precyzyjnie, kiedy pamięć jest alokowana i zwalniana.
  • Bliskość sprzętu — bezpośredni dostęp do linii pamięci podręcznej CPU, instrukcji SIMD, wejścia/wyjścia mapowanego w pamięci oraz sieci z obejściem jądra.
  • Obliczenia w czasie kompilacji — metaprogramowanie szablonów przesuwa pracę z czasu wykonania do czasu kompilacji, produkując kod tak szybki jak ręcznie napisany asembler.
  • Przewidywalne opóźnienie — przy uważnym kodowaniu można osiągnąć opóźnienie tick-to-trade poniżej mikrosekundy, z minimalnym jitterem.

Sprawdzenie z rzeczywistością: budżety tick-to-trade na najszybszych deskach handlowych są często poniżej mikrosekundy. Jedna błędna alokacja albo cache miss może zjeść cały budżet — więc zespoły chronią gorącą ścieżkę jak linię produkcyjną.

Why the hot path stays in native code Deterministic • No GC safepoints on path • Explicit memory & layout • SIMD / cache control • Kernel bypass friendly Measured in ns/µs • p99 > p50 matters • Jitter kills co-location edge • Replayable binaries • Fixed pools / arenas Interop • NIC / FPGA vendors ship C/C++ • DPDK, kernel modules • FIX / binary feeds • Same ABI as OS
Stosy giełdowe cenią przewidywalne opóźnienie i ścisłe powiązanie ze sprzętem — C++ jest domyślnym narzędziem do tego zadania.

Zarządzanie pamięcią: stos, kopiec i własne alokatory

W C++ o niskim opóźnieniu to, jak alokujesz pamięć, ma większe znaczenie niż to, co obliczasz. Różnica między alokacją na stosie a na kopcu może być 100x pod względem opóźnienia.

// Stack allocation — near-instant, deterministic
struct OrderUpdate {
    uint64_t order_id;
    double price;
    uint32_t quantity;
    char side;  // 'B' or 'S'
};

void process_tick(const MarketData& tick) {
    OrderUpdate update{};  // Stack — zero allocation cost
    update.price = tick.mid_price();
    // ... process on the hot path
}

// Heap allocation — slow, non-deterministic (avoid on hot path)
auto* order = new OrderUpdate{};  // Calls malloc — BAD on hot path

Systemy produkcyjne opierają się na własnych alokatorach, żeby gorąca ścieżka nigdy nie wywoływała ogólnego kopca:

  • Alokatory pulowe (pool allocators) — wstępnie alokują duży blok i wykrawają z niego fragmenty o ustalonym rozmiarze. Brak fragmentacji, alokacja O(1).
  • Alokatory arenowe (arena allocators) — przesuwają wskaźnik do przodu przy każdej alokacji, zwalniają wszystko naraz. Idealne do przetwarzania na wiadomość.
  • Wielkie strony (huge pages) — strony 2 MB lub 1 GB redukują błędy TLB, co jest krytyczne, gdy dane księgi zleceń rozciągają się na megabajty.

Nowoczesny C++ czyni bezpieczne zarządzanie pamięcią wygodnym za pomocą RAII (Resource Acquisition Is Initialization — pozyskanie zasobu to inicjalizacja) i smart pointerów:

// RAII — resource lifetime tied to scope
{
    auto connection = std::make_unique<TcpConnection>(endpoint);
    connection->send(order_message);
}  // Connection automatically closed here

// Shared ownership for reference-counted resources
auto config = std::make_shared<TradingConfig>(load_config());
engine.set_config(config);  // Multiple owners, automatic cleanup
Stack vs heap on the hot path Stack / thread-local OrderUpdate on stack — bounded, LIFO, cache-hot Pool / arena — O(1) reuse, no malloc churn Heap (generic) new/malloc — allocator locks, fragmentation Unpredictable latency — avoid per tick
Trzymaj struktury na stosie albo w wcześniej rozgrzanych pulach; traktuj każde trafienie do kopca w handlerze ticków jako błąd.

Struktury bez blokad i współbieżność

Muteksy są wrogiem kodu o niskim opóźnieniu. Jedno wywołanie std::mutex::lock() może zająć 20-100 nanosekund nawet bez rywalizacji — a pod rywalizacją może zablokować wątek na mikrosekundy. Systemy handlowe używają zamiast tego struktur bez blokad (lock-free).

Najbardziej krytyczną strukturą lock-free w handlu jest kolejka jednego producenta i jednego konsumenta (SPSC):

template<typename T, size_t Capacity>
class SPSCQueue {
    alignas(64) std::array<T, Capacity> buffer_;
    alignas(64) std::atomic<size_t> head_{0};
    alignas(64) std::atomic<size_t> tail_{0};

public:
    bool try_push(const T& item) {
        const auto tail = tail_.load(std::memory_order_relaxed);
        const auto next = (tail + 1) % Capacity;
        if (next == head_.load(std::memory_order_acquire))
            return false;  // Full
        buffer_[tail] = item;
        tail_.store(next, std::memory_order_release);
        return true;
    }

    bool try_pop(T& item) {
        const auto head = head_.load(std::memory_order_relaxed);
        if (head == tail_.load(std::memory_order_acquire))
            return false;  // Empty
        item = buffer_[head];
        head_.store((head + 1) % Capacity, std::memory_order_release);
        return true;
    }
};

Kluczowe zasady projektowe:

  • alignas(64) — każda atomowa zmienna otrzymuje własną linię pamięci podręcznej, zapobiegając false sharing między rdzeniami CPU.
  • Porządek pamięci — semantyka acquire/release jest tańsza niż seq_cst i wystarczająca dla wzorców producent-konsument.
  • Rozmiar w postaci potęgi dwójki — w produkcji używaj rozmiarów takich jak 1024 czy 4096, żeby modulo stało się bitowym AND.

Typowe okablowanie: wątek karty sieciowej dokłada do kolejki, wątek strategii pobiera — bez muteksu na ścieżce przesyłowej, jeśli topologia jest właściwa.

SPSC ring buffer (one writer, one reader) Producer feed / I/O thread Power-of-two slots • acquire/release atomics Consumer strategy thread alignas(64) head/tail — separate cache lines to kill false sharing Memory order: relaxed on local index, acquire/release across handoff
Jeden producent i jeden konsument pozwala pominąć muteksy; właściwe wyrównanie chroni rdzenie przed walką o tę samą linię pamięci podręcznej.

Szablony: obliczenia w czasie kompilacji

Szablony C++ pozwalają przesunąć pracę z czasu wykonania do czasu kompilacji. W handlu znaczy to, że twój plik binarny jest wyspecjalizowany do dokładnych protokołów, instrumentów i strategii, którymi handlujesz — brak rozgałęzień w czasie wykonania na gorącej ścieżce.

// Compile-time FIX protocol field parsing
template<int Tag>
struct FIXField;

template<> struct FIXField<35> {  // MsgType
    static constexpr const char* name = "MsgType";
    using type = char;
};

template<> struct FIXField<44> {  // Price
    static constexpr const char* name = "Price";
    using type = double;
};

// Zero-overhead dispatch based on message type
template<char MsgType>
void handle_message(const char* raw, size_t len) {
    if constexpr (MsgType == 'D') {
        // New Order Single — inline at compile time
        parse_new_order(raw, len);
    } else if constexpr (MsgType == '8') {
        // Execution Report
        parse_execution(raw, len);
    }
}

Rozgałęzienia if constexpr są rozstrzygane całkowicie w czasie kompilacji — wygenerowany kod maszynowy zawiera tylko właściwą ścieżkę z zerowym narzutem rozgałęziania. Ta technika, połączona z optymalizacją w czasie linkowania (LTO), produkuje pliki binarne, w których parsowanie protokołu jest w zasadzie rozwinięte w liniową sekwencję odczytów pamięci.

Nowoczesne cechy C++20/23, takie jak consteval, koncepty i kontenery działające w czasie kompilacji, przesuwają to jeszcze dalej, umożliwiając obliczenie całych potoków walidacji zleceń w czasie kompilacji.

Projektowanie przyjazne pamięci podręcznej i sieć z obejściem jądra

Przy opóźnieniach poniżej mikrosekundy hierarchia pamięci podręcznej CPU staje się najważniejszym celem optymalizacji. Cache miss do pamięci głównej kosztuje ~100 nanosekund — to wieczność, gdy twój całkowity budżet opóźnienia wynosi 500 ns.

// BAD: Array of Pointers (AoP) — cache-hostile
std::vector<std::unique_ptr<Order>> orders;  // Each access = pointer chase + cache miss

// GOOD: Struct of Arrays (SoA) — cache-friendly
struct OrderBook {
    std::vector<double> prices;      // Contiguous in memory
    std::vector<uint32_t> quantities; // Contiguous in memory
    std::vector<uint64_t> order_ids;  // Contiguous in memory
};
// Iterating prices = sequential cache line reads = fast

Jeśli mowa o sieci, stos TCP/IP jądra Linuksa dodaje 5-15 mikrosekund opóźnienia na pakiet. Firmy handlowe całkowicie go obchodzą:

  • DPDK (Data Plane Development Kit) — odpytuje kartę sieciową bezpośrednio z przestrzeni użytkownika, obchodząc jądro. Osiąga przetwarzanie pakietów poniżej mikrosekundy.
  • Solarflare OpenOnload — obejście jądra ze znanym API socketów. Powszechnie używany w handlu akcjami i futures.
  • Karty sieciowe FPGA — Xilinx Alveo i podobne karty mogą parsować dane rynkowe i generować zlecenia w sprzęcie, osiągając opóźnienia na poziomie nanosekund.

Nawet giełdy zdecentralizowane korzystają z tych zasad. Łańcuch L1 Hyperliquid — który zasila platformy takie jak GaiaEx — został zaprojektowany z myślą o konsensusie o wysokiej przepustowości i niskim opóźnieniu, a animatorzy rynku łączący się z nim używają zoptymalizowanych klientów C++, żeby zminimalizować czas między otrzymaniem aktualizacji ceny a złożeniem zlecenia.

Jak budowane są silniki dopasowywania zleceń giełd

W sercu każdej giełdy znajduje się silnik dopasowywania zleceń (matching engine) — komponent, który paruje zlecenia kupna i sprzedaży. To najbardziej czuły na opóźnienia element oprogramowania w całych finansach, i jest niemal zawsze napisany w C++.

Uproszczona architektura silnika dopasowywania:

class MatchingEngine {
    // One order book per instrument
    std::unordered_map<Symbol, OrderBook> books_;

    void on_new_order(const Order& order) {
        auto& book = books_[order.symbol];
        auto matches = book.match(order);

        for (const auto& fill : matches) {
            publish_execution(fill);      // To trading firms
            update_market_data(fill);     // To data feed
        }

        if (order.remaining_qty > 0) {
            book.insert(order);           // Rest on the book
        }
    }
};

Produkcyjne silniki dopasowywania optymalizują dużo dalej niż ten szkielet:

  • Priorytet cena-czas — zlecenia po tej samej cenie są realizowane w kolejności napływu, śledzonej znacznikami czasu w nanosekundach.
  • Wstępnie zaalokowane pule zleceń — brak alokacji kopca podczas dopasowywania. Zlecenia są recyklingowane z ustalonych pul.
  • Projekt bez blokad — księga każdego instrumentu działa na dedykowanym rdzeniu. Nie ma potrzeby blokowania między księgami.
  • Deterministyczne odtwarzanie — każde zlecenie i dopasowanie jest zapisywane w dzienniku trwałego magazynu danych, dla zgodności regulacyjnej i odtwarzania po awarii.

Budowanie systemów na tym poziomie jest tym, w czym C++ prawdziwie błyszczy. Żaden inny popularny język nie daje ci jednoczesnej kontroli nad układem pamięci, harmonogramowaniem wątków, zachowaniem pamięci podręcznej i I/O sieciowym — czterema pilarami inżynierii ultra-niskich opóźnień. Niezależnie od tego, czy budujesz kolejną giełdę, łączysz się z nią jako animator rynku, czy optymalizujesz wykonanie w firmie proprietary trading, C++ pozostaje niezaprzeczalnym królem.