Skip to content
Async: cost and scaling

Async: cost and scaling

Choosing sync, blocking, or async - and what async costs

A Channel / AdaptiveIpc handle answers all three calling conventions; the choice is per call site, not baked into the type. This guide covers when to reach for each, and the measured cost of the async path so the choice is informed.

One handle, three conventions

ConventionCallBlocks whatReach for it when
Syncsend / recvnothing (returns Full / Empty)you have your own loop or poll cadence and want the floor.
Blockingsend_blocking / recv_blockingthe calling thread (parks it)one dedicated thread per endpoint, and you want it asleep when idle.
Asyncsend_async / recv_asyncthe task (suspends it)many endpoints multiplexed onto few threads.

Async runs on any executor: tokio, smol, async-std, or the crate’s runtime-free block_on . The wake crosses a process boundary (a per-receiver reactor) and a machine boundary (net_bridge over blocking std::net) behind the same .await.

Async is the scaling path, not the latency path

Async on this substrate is not a faster single operation. The sync recv skips all Waker machinery (one relaxed load on an internal gate); the async recv constructs a future and, once async is engaged, every op drives the wake machinery the sync path avoids. The cost is real and measurable.

Per-op overhead

A single-threaded round-trip on one Channel<u64>, item always available (the fast path, nothing parks), 8-byte payload. Measured on an AMD Ryzen 7 2700 (Zen+); reproduce with cargo bench --bench async_overhead -p subetha-cxc.

ConventionRound-tripvs sync
send / recv~18 ns1.0x
send_blocking / recv_blocking~51 ns~2.8x
send_async / recv_async (on block_on)~377 ns~21x
Per-op round-trip latency on Zen+: sync ~18 ns, blocking ~51 ns, async ~377 ns

The async path is an order of magnitude heavier per op. If you are optimizing a single hot producer-consumer pair for latency, use sync.

What async buys: fan-out on a fixed thread count

The payoff is structural. An awaiting consumer is a suspended task, not a parked thread, so one bounded executor drives an unbounded number of them. Delivering the same item stream two ways - a TaskPool of available_parallelism workers vs one OS thread per consumer running block_on - over the same WakerRing primitive. Measured on an AMD Ryzen 7 2700; reproduce with cargo bench --bench async_fanout -p subetha-cxc.

    flowchart TB
  subgraph FP["Fixed pool - async tasks"]
    T["N suspended tasks"]
    POOL["TaskPool<br/>available_parallelism workers"]
    T -. woken by a push .-> POOL
  end
  subgraph TPC["One thread per consumer"]
    C["N consumers"]
    TH["N OS threads"]
    C --> TH
  end
  classDef good fill:#0f766e,color:#fff
  classDef heavy fill:#9a3412,color:#fff
  class POOL good
  class TH heavy
  
DriverN = 1,000N = 10,000N = 100,000OS threads
Fixed pool (async tasks)4.50 M items/s4.93 M items/s4.57 M items/s20 (constant)
Thread per consumer0.86 M items/s0.67 M items/s(needs 100,004 threads)N + 4
Fan-out throughput: fixed pool holds 6-7 M items/s on 20 threads from N=1k to N=100k; thread-per-consumer stays below 1 M items/s and needs one thread per consumer

The fixed pool holds 4.5-5 M items/s on a constant 20 OS threads from a thousand consumers to a hundred thousand. That 20 is available_parallelism workers (16 on the Zen+ 8-core / 16-thread part) plus the bench’s 4 producer threads, so it tracks the machine rather than being a tuned constant. The thread-per-consumer design sits below 1 M items/s and needs one OS thread per consumer - 10,004 threads at N = 10,000, and 100,004 at N = 100,000, which is the point at which it stops being practical; the bench does not run that last cell for the same reason. Same ring, same recv() future; only the driver differs.

Both tables are one captured sweep. Repeat runs on this machine move the absolutes by 20-40% in either direction - the gap between the two drivers is the durable result, not the digits.

Rule of thumb

  • One hot pair, latency-sensitive: sync, in your own loop.
  • A handful of long-lived endpoints, each on its own thread: blocking.
  • Hundreds to hundreds of thousands of endpoints, or composing under an existing async app: async, on a TaskPool / RingExecutor or your runtime.

See also