|
cofetch
Chainable, high-performance async HTTP client for C++ event loops
|
cofetch vs cpr and cpp-httplib, plus the raw-epoll client cofetch grew out of (baseline/, kept as an internal no-regression reference — not a competitor).
One-time setup (Debian/Ubuntu):
Then, from the repo root:
run_bench.sh [total] [concurrency] [chain_count] — defaults 20000 100 2000. It configures a Release build with -DCOFETCH_BUILD_BENCH=ON (FetchContent pulls cpr and cpp-httplib), starts nginx on 127.0.0.1:18081 with bench/nginx.conf (keep-alive, access_log off), and prints one CSV row per run.plot_bench.py needs matplotlib (sudo apt install python3-matplotlib) and writes docs/benchmark-{light,dark}.svg.run_netem.sh [rtt_ms] shapes loopback with tc netem (delay rtt/2 each direction), reruns the tier-1 comparison plus 100-thread sync pools, and tags rows throughput-rtt<ms>. Request counts scale with RTT so each run holds ~10 s of steady state. The plot script skips the RTT panel when those rows are absent.
Driver knobs (set as env vars for bench_cofetch):
COFETCH_BENCH_POLL=1 — busy-poll io_context::poll() instead of run()COFETCH_BENCH_LOOPS=N — N threads, each with its own event loop and Client, splitting the workloadCOFETCH_BENCH_CB=1 — chain scenario with callbacks instead of a coroutinetotal GETs with concurrency kept in flight. Sync clients (cpr, cpp-httplib) get one thread per unit of concurrency; cofetch keeps them in flight on one thread.tc netem. Loopback RTT≈0 actually flatters sync clients (every request returns instantly, so the race is pure CPU); with RTT, a sync thread parks for a full round trip per request while an async client keeps its pipeline full.Debian 13 (WSL2, 20 logical cores), gcc 14 -O3, libcurl 8.14, asio 1.38. Raw data: results.csv. WSL run-to-run variance is roughly ±10%; interleaved re-runs confirm the ordering is stable.
One thread each, 20,000 GETs
| client | in flight | time | req/s |
|---|---|---|---|
cofetch (callbacks, busy-poll()) | 100 | 1.10 s | 18,240 |
cofetch (callbacks, run()) | 100 | 1.15 s | 17,412 |
| cpr (sync session) | 1 | 1.98 s | 10,127 |
| cpp-httplib (sync client) | 1 | 1.55 s | 12,889 |
| epoll baseline (internal reference) | 100 | 0.99 s | 20,270 |
With network RTT (tc netem on loopback), one thread each unless noted
At 10 ms RTT (measured 10.2 ms):
| client | threads | in flight | req/s |
|---|---|---|---|
| cofetch | 1 | 100 | 9,353 |
| cpp-httplib (100-thread pool) | 100 | 100 | 9,571 |
| cpr (100-thread pool) | 100 | 100 | 9,345 |
| cpp-httplib (sync) | 1 | 1 | 95 |
| cpr (sync) | 1 | 1 | 94 |
(100-in-flight runs do 20,000 GETs; sync runs do 1,000 — counts scale with the RTT ceiling so every run measures ~10 s of steady state.)
At 50 ms RTT: cofetch 1,819 req/s on one thread; sync clients 20 req/s each; 100-thread pools ~1,918.
Throughput here is pinned by in-flight/RTT (100/10 ms = 10,000 req/s ceiling), and that is the point: one cofetch thread runs ~98× ahead of a single-threaded sync client and ties a 100-thread pool, because it multiplexes 100 requests where sync burns an OS thread per in-flight request.
One thread per core (20 threads each), 20,000 GETs
| client | threads | time | req/s |
|---|---|---|---|
| cofetch (one event loop per core) | 20 | 0.12 s | 166,646 |
| cpr (thread pool) | 20 | 0.16 s | 122,122 |
| cpp-httplib (thread pool) | 20 | 0.16 s | 127,333 |
Sequential chain, 2,000 dependent requests, one thread
| client | time | req/s |
|---|---|---|
cofetch (callbacks, busy-poll()) | 0.14 s | 14,804 |
cofetch (callbacks, run()) | 0.18 s | 11,116 |
cofetch (coroutine, run()) | 0.19 s | 10,547 |
| cpr | 0.20 s | 10,209 |
| cpp-httplib | 0.16 s | 12,761 |
| epoll baseline (internal reference) | 0.13 s | 15,566 |
The chain scenario is per-request latency. In blocking run() mode a reactor pays one kernel sleep/wake per request that a raw blocking recv() (cpp-httplib) does not — busy-poll mode removes it, which is exactly what that mode is for. The README chart uses busy-poll for the chain panel because it is cofetch's documented latency mode.
asio::post, timer reschedules skipped when a pending expiry is early enough, reactor handlers on asio::recycling_allocator, completion invoked without the type-erased dispatch hop) the loopback gap is ~14% throughput and ~5% chain (busy-poll). Callback vs coroutine chains differ ~5%.asio::io_context io(1) in single-threaded apps — the concurrency hint removes internal locking.-DASIO_HAS_IO_URING -DASIO_DISABLE_EPOLL, link -luring) was measured +14% throughput before these optimizations; re-measure if pursuing.sch_netem, with CONFIG_MODVERSIONS=y (so modules need matching symbol CRCs) and no __crc_* symbols in kallsyms. What worked (2026-07-10): clone microsoft/WSL2-Linux-Kernel at the linux-msft-wsl-$(uname -r | cut -d- -f1) tag, seed .config from zcat /proc/config.gz, scripts/config --module NET_SCH_NETEM, touch .scmversion (else vermagic grows a +), then a full make -j$(nproc) — modules_prepare alone leaves no Module.symvers and the CRC check rejects the module (--force included). Keep DEBUG_INFO_BTF as in the running config (its fields change struct module, i.e. the module_layout CRC), strip the module's own BTF before loading (objcopy --remove-section=.BTF), insmod. Lasts until WSL shuts down.