feat(async): add AsyncClient(max_concurrency=...) - #80
Conversation
AsyncClient dispatches every request with `loop.run_in_executor(None, ...)`,
which selects asyncio's *default* executor. That has two consequences:
1. Concurrency is silently capped at `min(32, cpu_count + 4)` -- 16 on a
12-core machine -- no matter how many requests the caller has in flight.
Downstream this made async feeding *slower* than a plain thread pool over
the sync client.
2. It monopolises a pool the library does not own. `asyncio.to_thread`
resolves to that same executor, so httpr requests contend with every
`to_thread` call in the host application, and vice versa: a user's slow
blocking work stalls HTTP requests.
Give each AsyncClient its own ThreadPoolExecutor, sized by `max_concurrency`
(default 64). Threads are created on demand and ThreadPoolExecutor retires
them via a weakref callback once the client is collected, so an idle client
costs nothing and there is nothing new to release -- `close`, `aclose` and
`__aexit__` keep their existing behaviour, and a client stays reusable across
several `async with` blocks. `max_concurrency=None` restores dispatch on
asyncio's default executor.
Concurrency only pays off when there is latency to hide. Against a local
server with 50ms latency, 64 requests:
max_concurrency=None (previous behaviour) 316ms peak in-flight 16
max_concurrency=64 143ms peak in-flight 64 2.2x
On localhost the same comparison is ~1.0x, which is why the regression guard
asserts on observed peak concurrency rather than on wall-clock. It uses a
Barrier, so an undersized pool deadlocks and times out instead of merely
being slower, and it fails against the previous `run_in_executor(None)`
implementation.
Also adds an async concurrency benchmark group. It runs against the async
benchmark server rather than the httpbin fixture -- that fixture is a
single-threaded WSGI server, so 64 concurrent requests queue on it and the
numbers measure the server serialising, with a standard deviation ~200x the
signal. The benchmark tracks dispatch overhead; it is not the regression
guard, for the localhost reason above.
This does not make AsyncClient natively async; it makes the thread-pool
ceiling explicit and tunable. Since reqwest is already async internally,
native async via pyo3-async-runtimes (plus a multi-thread tokio runtime in
place of the current `new_current_thread`) remains the longer-term fix.
There was a problem hiding this comment.
Looks good! Having native async like it is described in "Not in scope" would probably be the best solution to have many concurrent requests without the cost of threads, but I don't know how that works with the rust to python bridge. Could be something worth looking at later.
Agree that would be the best. When I looked into that about a year ago, there were some compatibility issues that made it seem difficult to work across all versions, but I'll create an issue to investigate current state and feasibility. |
This code was written by Claude (Fable/Opus5) - but understood, tested and vouched for by me.
Problem
AsyncClientdispatches every request withloop.run_in_executor(None, ...). PassingNoneselects asyncio's default executor, which has two consequences:Concurrency is silently capped at
min(32, cpu_count + 4)— 16 on a 12-core machine — no matter how many requests the caller has in flight. This was found from the other direction: in pyvespa, async feeding was measurably slower than a plain thread pool over the syncClient, which should never happen.It monopolises a pool the library doesn't own.
asyncio.to_threadresolves to that same executor, so httpr requests contend with everyto_threadcall in the host application — and conversely, a user's slow blocking work stalls HTTP requests.Change
Each
AsyncClientgets its ownThreadPoolExecutor, sized by a newmax_concurrency(default 64).Threads are created on demand and
ThreadPoolExecutorretires them via a weakref callback once the client is collected, so an idle client costs nothing and there is nothing new to release.close,acloseand__aexit__keep their existing behaviour, and a client stays reusable across severalasync withblocks — verified by a test, because downstream users (pyvespa) rely on it.The diff on the client is three things: strip the new kwarg in
__new__before it reaches the RustRClient.__new__, build the pool in__init__, and pass it torun_in_executor.Measurements
Concurrency only pays off when there is latency to hide. Local server, 50 ms latency, 64 requests:
max_concurrency=None(previous behaviour)max_concurrency=642.2×. On localhost the same comparison is ~1.0× — there is no latency for concurrency to hide. That is why the regression guard asserts on observed peak concurrency, not wall clock.
Tests
test_concurrency_is_not_capped_by_the_default_executorcomputesmin(32, cpu_count + 4), asks for 16 more than that, and uses athreading.Barrier— so an undersized pool deadlocks and times out rather than merely running slower. Confirmed it fails against the oldrun_in_executor(None)implementation (5 of 10 tests in the file fail when reverted).Also adds an async concurrency benchmark group. It runs against the async benchmark server rather than the
base_urlhttpbin fixture: that fixture is a single-threaded WSGI server, so 64 concurrent requests queue on it and the numbers measure the server serialising — my first attempt showed higher concurrency as 20× slower with a standard deviation ~200× the signal, which would have flapped permanently against the 150% alert threshold. Retargeted, it's stable (StdDev ~1 ms) and honest: more threads cost slightly more dispatch overhead and buy nothing on localhost. It tracks overhead; it is not the regression guard.Compatibility
Checked and safe: subclassing
AsyncClient, positional constructor args, thread growth (lazy — 30 clients × 4 concurrent = 61 threads, back to baseline after GC).deepcopy/picklefail, but they already did on the Rust object — pre-existing, not a regression.One real behaviour change, so this wants a minor bump and a release note. An app that throttled httpr by shrinking asyncio's default executor loses that throttle. With
set_default_executor(ThreadPoolExecutor(4))and 32 requests:max_concurrency=None: peak 4 in flightSuch an app would silently hit its server ~8× harder.
max_concurrency=Nonerestores the old behaviour exactly; an explicit value is the better fix. Relatedly, the old default executor was also an accidental global cap shared by every client in a process; the ceiling is now per-client, so worst-case thread use scales with client count.Not in scope
This does not make
AsyncClientnatively async — it makes the thread-pool ceiling explicit and tunable. Since reqwest is already async internally (built without theblockingfeature; the sync API ispy.detach(|| RUNTIME.block_on(future))), the longer-term fix ispyo3-async-runtimes::tokio::future_into_pyplus swappingnew_current_thread()fornew_multi_thread(). That's a bigger change — it alters cancellation semantics — and worth doing separately. For reference, the current single-threaded runtime plateaus around 19k req/s against nginx, so it is a real but distant ceiling, not what caused this issue.Draft: the pyvespa side needs a released version to pin against before it can merge.