WEEK 12 · 15-WEEK TRACK · SESSION 8 · 9-SESSION TRACK

Async I/O & Serialization

Last week you learned what the bytes mean. This week you stop paying full price for them. Two costs sit between the wire and your strategy — the syscall that fetches the bytes and the codec that turns them into numbers — and both of them are usually larger than the strategy itself. You will make one thread service many sockets without ever blocking, and you will make a decode that touches only the fields you trade on.

Lab: include/u64toa.hpp — edges, fuzz, and a ns/op metric Then: targeted extract on the arena's book_snapshot Batching buys throughput with the latency you are graded on
Part 1

1What is it? One thread, many sockets, never blocked

A blocking recv() parks your thread in the kernel until bytes arrive. That is fine for one socket and catastrophic for many: a quiet connection freezes the thread that a busy connection needs. The fix is to invert the question — instead of “give me bytes from this socket”, ask “which of my sockets has bytes right now?” and do the reads yourself. That is readiness, and it is what select, poll and epoll answer.

One event loop · 1,000 connections

select / poll — O(n) scan epoll / kqueue — O(ready) thread per connection

Mechanism

Wake-up overhead
—
Handler work
—
Last socket served at
—
Threads
—
—

The chart plots multiplexing overhead only — the cost of finding out which sockets are ready, before a single recv — at a fixed ready fraction, sweeping the connection count. select and poll hand the kernel the whole interest set on every call and get the whole set back, so their cost grows with registered descriptors — which is exactly the wrong variable. epoll registers once with epoll_ctl and returns only the ready ones, so its cost grows with ready descriptors. At two per cent activity that is a fiftyfold difference in the wrong direction.

Edge versus level triggering

Level-triggered means “this socket is still readable”; edge-triggered means “this socket became readable”. Edge gives you fewer wake-ups and one hard obligation: you must drain the socket until EAGAIN, because nothing will remind you. Step the two side by side.

Three messages land in one burst

Socket state

Bytes in kernel buffer
—
Messages delivered
—
Wake-ups used
—
Loop status
—
—
—
Part 2

2How does it work? Syscalls, batches, and frames

Once you are non-blocking, the remaining cost is the syscall itself: about 600 ns of round trip into the kernel and back, per call, doing nothing useful. There are exactly three ways to pay less of it — call it less often per message, amortise it over many messages, or stop calling it at all.

a · Busy-poll the socket, or wait to be woken?

Cost per message received

Per message

Syscalls
—
Kernel overhead
—
Added queueing
—
CPU while idle
—
—
receive-side cost per message
—

recvmmsg is the zero-copy-adjacent idea you can actually reach from portable code: one syscall fills an array of mmsghdr structures, so 64 datagrams cost one kernel transition instead of 64. Note what the widget shows about it, though — a batch of 64 only helps if 64 messages are already waiting. If they are not, the call either returns short (no gain) or waits for them (pure added latency). Batching converts throughput headroom into latency, and on a tick path you have the opposite preference.

b · Framing a byte stream under partial reads

TCP does not preserve your send boundaries. A single recv can deliver half a message, two messages, or two and a half. The buffer below receives the same three messages in awkward chunks; watch the framing loop cope, and switch the framing strategy.

Receive buffer · framing in place

len_ = 0

Buffer state

Bytes buffered
0
Complete frames
0
Bytes still needed
—
Bytes scanned
0
—
The one-line summary of this widget. A length prefix costs you four bytes and one comparison per frame; a delimiter costs you a scan of every payload byte, plus an escaping scheme so the delimiter cannot appear inside the payload. Venues use length prefixes for exactly that reason. The arena's WebSocket layer already frames for you — which is the one piece of convenience in its wire format that is also the fast choice.
Part 3

3Why it matters: the codec is on your critical path

Between the byte arriving and your strategy seeing a number sits the codec, and on the arena's JSON wire it is the single largest item in your tick-to-trade budget. Four ways to turn the same book_snapshot into a best bid and ask. Race them, then read the allocation column, because that is where your p99.9 lives.

Decode book_snapshot → bid, ask, mid

ApproachnsAllocsCopiedp99.9

Selected approach

—

—

Budget at 200 k msg/s

Core time on codec
—
Allocations / s
—
vs flat memcpy
—
p99.9 decode
—

Look at the p99.9 column rather than the mean. The DOM parser's median is bad and its tail is far worse, because every one of its twenty-two allocations can be the one that walks a free list or asks the kernel for a page. A codec with zero allocations has a tail that looks like its median — that is the whole reason to hunt allocations rather than instructions.

Allocation-free parsing: string_view is the whole trick

// SLOW: every call constructs and destroys a string
std::string get_symbol(const json& j) {
  return j["symbol"].get<std::string>();
}                       // 1 malloc + 1 free + a copy

// FAST: a view into bytes you already have
std::string_view get_symbol(std::string_view frame) {
  auto k = frame.find("\"symbol\":\"");
  if (k == frame.npos) return {};
  k += 10;
  auto e = frame.find('"', k);
  return frame.substr(k, e - k);
}                       // 0 malloc, 0 copy

A string_view is a pointer and a length. Returning one costs sixteen bytes in registers; returning a std::string costs an allocation, a copy, and a destructor — three times, if the value passes through two more functions on its way to your strategy.

The obligation you take on is lifetime. The view is only valid while the receive buffer holds those bytes, so you must not store it past the end of on_book, and you must not compact the buffer while a view into it is live. That is a real constraint and it is the reason this is a deliberate technique rather than a default: you are trading a guarantee the type system was giving you for about 500 ns.

The same reasoning covers std::span for binary payloads, from_chars instead of stod (no locale, no allocation, and it tells you where it stopped), and passing const T& rather than T through your own call chain.

The other direction: u64toa

Serializing is the half people forget. Every outbound order stamps an id, a quantity and a price into a buffer, and the obvious tools are both terrible: snprintf parses a format string and consults the locale at run time, and std::to_string allocates a std::string and destroys it. Twenty lines beat both. Step the digits.

Reverse-then-flip, one digit per step

Scratch buffer tmp[20] — least significant digit first

Caller's buffer out — flipped, most significant first

ns per conversion

The grader checks 0, 7, 12345, 1000000, 9999999999, UINT64_MAX, fuzzes 200,000 values against std::to_string, and prints your u64toa_ns_per_op next to std_to_string_ns_per_op.

Why do/while, not while

A while (v) loop emits nothing for v == 0 and returns length zero, so your order id prints as an empty string and the venue rejects the message. A do/while runs the body once regardless, so 0 becomes "0". It is a one-word fix and it is the first edge case the grader checks.

Skipping the kernel entirely: shared memory

Two processes on one box do not need a network stack to talk. A ring in a shared-memory region — the starter's shm_ring.hpp — is the same SPSC structure from Week 10 with one extra constraint: no pointers, because the region maps at a different address in each process. Compare it against the alternatives.

Hand a message to another process

shared-memory ring Unix domain socket TCP loopback TCP over the wire

Per message

Shared memory
—
TCP loopback
—
Speed-up
—
Syscalls · copies
—
// Project Phase 4 — a POD ring in a shared region.
// NO pointers: they mean different things in
// each process. Offsets and indices only.
struct ShmRing {
  static constexpr uint32_t CAPACITY = 1024;
  void init();                  // creator, pre-fork
  bool push(uint64_t v);         // producer process
  bool pop(uint64_t& out);       // consumer process
  std::atomic<uint32_t> head, tail;
  uint64_t buf[CAPACITY];
};

Two details that catch everyone. First, std::atomic<uint32_t> is fine across processes only because it is lock-free and address-free on every platform you will use — check is_always_lock_free and do not put a std::mutex in shared memory. Second, the struct must be standard-layout and contain no pointers, no std::vector, no std::string: the region maps at a different virtual address in each process, so a pointer written by one is garbage to the other. Indices are position-independent; addresses are not.

Part 4

4Five ways this goes wrong

Two of these hang, two of them lie, and one of them corrupts memory. Open them.

Edge-triggered, one recv per event — and the connection hangs

You registered with EPOLLET because it was described as the fast option, then wrote the obvious handler: one recv, process it, return to epoll_wait. Three messages arrive in one segment. You read the first. The socket is still readable, but it did not become readable again, so no further event fires — and the remaining two messages sit in the kernel buffer forever while your loop waits patiently for nothing.

It is worse than a plain hang, because it is load-dependent. At low rates one message per segment is the norm and everything works; at high rates messages coalesce and the connection stalls. So this bug appears exactly when the system is busy.

Fix: with edge-triggering, always for (;;) recv(...) until it returns -1 with EAGAIN. If you do not want that obligation, use level-triggering, which will simply re-notify you — a handful of extra wake-ups is a cheap price for a loop you cannot deadlock.

EAGAIN is treated as an error and the connection is closed

The mirror-image bug. On a non-blocking socket, recv returning -1 with errno == EAGAIN (or EWOULDBLOCK) means “nothing right now” — the single most common outcome, and completely normal. Code that treats every negative return as fatal tears down healthy connections under light load.

The neighbouring case is EINTR: the call was interrupted by a signal and must simply be retried. And recv returning 0 is not an error at all — it is an orderly peer close, which is a different code path from a failure.

Fix: three explicit branches. n > 0 is data; n == 0 is peer close; n < 0 splits into EAGAIN / EWOULDBLOCK (stop reading, return to the loop), EINTR (retry), and everything else (real error, close). Never a bare if (n < 0) close(fd);.

A string_view outlives the buffer it points into

You did the right thing and switched to views instead of strings — then stored one in a member to use on the next tick. The next recv overwrites the buffer, or your framing loop compacts the partial tail forward, and the view now describes bytes that mean something else entirely. There is no crash and no warning: the symbol field just becomes a fragment of the following message.

The subtler version is a view returned from a function whose buffer was a local. That one at least has a chance of being caught by -Wdangling or a sanitizer, but only sometimes.

Fix: treat a view as valid only within the callback that received the frame. If you must keep a value, copy it into a fixed char[16] — the same choice the starter's NewOrder makes for symbol while keeping clordid a view. Run the build under ASan with a compaction step and it will find these immediately.

The benchmark says the fast codec wins, and production disagrees

You timed both codecs in a tight loop over the same frame two million times. The frame is in L1, the branch predictor has learned it, and the allocator's free list is perfectly warm — so the DOM parser looks only three times slower instead of ten, and your targeted extract looks like a modest win. Then you deploy, every frame is cold, and the real gap appears.

The opposite error is just as common: reporting a mean. A codec that allocates has a bimodal distribution, and the mean sits in the empty valley between the two modes, describing nothing that ever happens.

Fix: benchmark on a replay tape of real frames, not one frame repeated. Report p50, p99 and p99.9 side by side. And use the same harness the grade uses — latency_replay.py feeding your bot, then latency_report.py — so you are optimising the number you are scored on.

A std::string is put in the shared-memory ring

The ring itself is correct, the atomics are right, and the payload type is std::string — which holds a pointer into the writing process's heap. The reading process maps the region at a different virtual address, dereferences that pointer, and either reads unrelated memory or takes a fault. The same applies to std::vector, any type with a vtable, and any struct holding a pointer to something else in the region.

The related trap is a std::mutex in the region. It is not required to be process-shareable, and on some platforms it stores a thread id or an address, so it silently fails to provide mutual exclusion across processes.

Fix: the payload must be a POD — fixed-size, standard-layout, no pointers, no virtuals. Store offsets rather than addresses if you need indirection, use std::atomic types whose is_always_lock_free is true, and static_assert(std::is_standard_layout_v<T>) on the payload so the compiler enforces it for you.

Part 5

5Check yourself

Three questions, instant feedback, nothing recorded.

Q1You have 10,000 registered connections and about 20 are readable on any given wake-up. Why does epoll beat poll here?

A while (v) loop tests first, emits nothing for zero, and returns a length of zero — so your order id serializes to an empty string and the venue rejects the message. The grader checks 0 as its very first edge case for exactly this reason. The other answers describe real properties of the routine but not of the do/while: tmp[20] is what makes UINT64_MAX safe (20 decimal digits), the reverse-then-flip structure comes from % 10 yielding the least significant digit first, and the two-digit lookup table is a separate optimisation that halves the number of divisions.">

Q2Why must u64toa's digit loop be a do/while rather than a while?

Q3recvmmsg with a batch of 64 clearly raises throughput. Why is it usually the wrong default on a market-data socket?

Part 6

6Do the work

In the lab (~45–60 min)

  • Finish u64toa in include/u64toa.hpp until make u64toa passes u64toa_edges and u64toa_fuzz. Confirm u64toa_ns_per_op beats std_to_string_ns_per_op.
  • Stretch: the two-digit lookup table from step 4 of the lab. Halve the divisions and re-measure.
  • Set O_NONBLOCK on a socket and drain it until EAGAIN. Then read one message per event under edge-triggering and watch the connection stall — that contrast is the lesson.
  • Time nlohmann::json::parse against a targeted extract on one real book_snapshot frame, with warm-up, a sink, and percentiles.

After the session

  • Replace your bot's on_book parse with a targeted extract: scan for the two or three keys you trade on, read them in place with string_view, allocate nothing.
  • Hand-roll the send path for place_order and cancel_order into a reused buffer, using your u64toa for the integer fields.
  • Prove it offline: python scripts/latency_replay.py --cmd "hft/cpp_client/build/hft_bot --replay" then python scripts/latency_report.py. Show the p50 / p99 / p99.9 table for both codecs.
  • Turn in the fast parser and serializer plus both percentile tables. Due at the start of the next session.
Get the code
15-week track starter
Header stubs, the autograder and the C++ arena client, one topic per session.
Get the code
9-session track starter
The same stubs and the same grader, packaged for the condensed nine-session schedule.
Back
← Week 11 · Network Protocols & Market Data
What the bytes mean, and how you find out that you missed some.
Next
Week 13 · Low-Latency Design — SIMD & Kernel Bypass →
The codec is off the critical path. Now attack the syscall and the network stack.