Async handlers¶
An async handler is a C++20 coroutine returning RequestTask<HttpResponse>. It can suspend while it waits for the request body or for blocking work running on a background thread, and the event loop serves the other connections meanwhile. This guide explains when async handlers pay off, how they behave, which rules keep them safe, and what they cost.
In one sentence
Use a synchronous handler for anything fast and in memory, and an async handler with co_await req.deferWork(...) for anything that blocks: database calls, calls to other services, file system or CPU-heavy work. Never block inside the coroutine body itself.
Why the event loop must never block¶
An aeronet server thread runs one event loop that serves all its connections. A synchronous handler runs on that thread: while it runs, no other request of the same thread progresses, whatever its connection.
That is the right model for short handlers, which is most of them: no thread switch, no allocation, no synchronization. It breaks down as soon as a handler waits: one millisecond of blocking per request caps the event loop at roughly 1,000 requests per second, and every other client waits in line (see Performance).
An async handler splits the request in two:
- the coroutine body, which still runs on the event loop thread, and must stay short;
deferWork()calls, which run the blocking parts on a background thread and resume the coroutine on the event loop once they complete.
Choosing a handler type¶
| Workload | Handler | Why |
|---|---|---|
| In-memory logic, cache hits, small computations (microseconds) | Synchronous | Cheapest path: no coroutine frame, no thread. |
| Large or slow uploads, early validation of the request head | Async (bodyAwaitable(), readBodyAsync()) |
The handler is dispatched as soon as the head arrives. |
| Blocking I/O: database, other HTTP services, blocking SDKs, file system | Async + deferWork() |
The event loop keeps serving other connections. |
| CPU-heavy work (hashing, image processing, compression) | Async + deferWork() if the machine has spare cores, otherwise synchronous on more server threads |
A background thread only helps when it can run in parallel. |
| Large generated responses | Streaming (HttpResponseWriter) |
Sends the response piece by piece; see Streaming responses. |
| Many clients waiting for server-side events | WebSocket | One deferWork() thread per waiting client does not scale to thousands; see WebSocket. |
Your first async handler¶
#include <aeronet/aeronet-server.hpp>
#include <chrono>
#include <string>
#include <string_view>
#include <thread>
#include <utility>
using namespace aeronet;
namespace {
// Stands for any blocking call: a database client, a blocking SDK, a file read...
std::string LoadGreeting(std::string_view name) {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
return "Hello, " + std::string(name) + "!\n";
}
} // namespace
int main() {
Router router;
router.setPath(http::Method::GET, "/greet/{name}", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
// Runs on a background thread: the event loop serves the other connections meanwhile.
std::string greeting =
co_await req.deferWork([&req] { return LoadGreeting(req.pathParamValueOrEmpty("name")); });
// Back on the event loop thread.
co_return HttpResponse(200).body(std::move(greeting));
});
SingleHttpServer server(HttpServerConfig{}.withPort(8080), std::move(router));
server.run();
}
Three things make this a coroutine handler:
- it takes a non-const
HttpRequestView&(the request is not copied, and it stays valid until the coroutine completes); - it returns
RequestTask<HttpResponse>; - it uses
co_await/co_return.
Router::setPath() recognizes the signature, nothing else is needed. The work function captures the request by reference: this is safe, see Lifetime and thread-safety rules.
A runnable program with several endpoints is in examples/async-handlers.cpp (binary aeronet-async-handlers).
Registering async handlers¶
Every registration API accepts coroutine handlers: paths, route groups and the default handler. Route options (CORS, middleware, limits, timeouts) apply to them like to the other handler kinds.
#include <string>
#include <string_view>
Router router;
// Several methods for the same handler.
router.setPath(http::Method::GET | http::Method::HEAD, "/status",
[](HttpRequestView&) -> RequestTask<HttpResponse> { co_return HttpResponse(200).body("up\n"); });
// Route groups.
auto api = router.group("/api/v1");
api.setPath(http::Method::POST, "/jobs", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
const std::string_view body = co_await req.bodyAwaitable();
co_return HttpResponse(http::StatusCodeAccepted).body("queued " + std::to_string(body.size()) + " bytes\n");
});
// Fallback handler.
router.setDefault(
[](HttpRequestView& req) -> RequestTask<HttpResponse> { co_return req.makeResponse(http::StatusCodeNotFound); });
A handler may also return immediately with co_return, without awaiting anything: it then completes during its dispatch, like a synchronous handler (at a slightly higher cost, see Performance).
An exception escaping the coroutine (from its body, or rethrown by co_await) produces a 500 Internal Server Error, exactly like a throwing synchronous handler.
How a request flows¶
HTTP/1.1¶
- The request head is parsed. The handler is dispatched as soon as the head is complete, even if the body is still being received: the coroutine can validate the head, start work, or decide to reject the request before the body has arrived.
- Request middleware, CORS checks and
OPTIONS/TRACEhandling run first, like for any route. When one of them answers before the body was received, the server closes the connection after the response: the rest of the body cannot be skipped reliably. - The coroutine runs on the event loop until its first suspension:
co_await req.bodyAwaitable()/readBodyAsync()suspends until the whole body is received (it completes right away if it already is);co_await req.deferWork(work)suspends untilworkreturns on its background thread.
- On
co_return, the response middleware runs, then the response is sent. If the handler completed without reading the body, the response is sent once the body was fully received, to keep the connection in sync. - The requests pipelined behind it on the same connection are then served, in order.
While a handler runs with its whole request received, the server does not read the next bytes of the connection: they stay in the socket until the handler completes. This keeps the request memory stable (its views stay valid), bounds what a client can make the server buffer, and lets TCP flow control push back on a client sending more than it should.
HTTP/2¶
- Each stream is independent: a suspended handler never delays the other streams of the connection.
- The handler is dispatched once the whole request body is received (
END_STREAM):bodyAwaitable()andreadBodyAsync()complete right away, and the per-route body limit is checked before the dispatch. - A stream reset by the client (
RST_STREAM) or by a route timeout does not interrupt the running work: the coroutine is destroyed once its work completes, without being resumed.
Summary¶
| Behavior | HTTP/1.1 | HTTP/2 |
|---|---|---|
| Dispatch | As soon as the head is parsed | Once the whole body is received |
| Concurrency on one connection | One request at a time (pipelined requests are served in order) | One handler per stream, all in parallel |
| Client disconnects while work runs | Noticed after the handler completes (or by the route timeout) | RST_STREAM: the stream is closed, the work completes in the background |
| Route timeout expires | 408 Request Timeout, then the connection is closed |
408 Request Timeout, then RST_STREAM |
| Keep-alive idle timeout | Does not apply while the handler runs | Does not apply to a connection with active streams |
Awaitables¶
Reading the request body¶
#include <cstdint>
#include <string>
#include <string_view>
Router router;
// Aggregated: the whole body at once.
router.setPath(http::Method::POST, "/upload", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
const std::string_view body = co_await req.bodyAwaitable();
co_return HttpResponse(200).body("received " + std::to_string(body.size()) + " bytes\n");
});
// In chunks, for instance to feed an incremental parser or a hash.
router.setPath(http::Method::POST, "/checksum", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
std::uint32_t checksum = 0;
while (req.hasMoreBody()) {
const std::string_view chunk = co_await req.readBodyAsync(16UL * 1024UL);
for (const char ch : chunk) {
checksum = (checksum * 31U) + static_cast<unsigned char>(ch);
}
}
co_return HttpResponse(200).body(std::to_string(checksum) + "\n");
});
- The body is buffered by the server (decompressed if needed, de-chunked), within the
maxBodyByteslimits of the server and of the route.readBodyAsync()gives a chunked access to it; it does not stream the upload from the socket. - A request is read either with
bodyAwaitable()/body(), or withreadBodyAsync()/readBody(): once one style was used, the other one throwsstd::logic_error. - Trailers (chunked requests, HTTP/2) are available through
req.trailers()once the body was awaited. Expect: 100-continueis answered by the server when the handler is dispatched.
Running blocking work with deferWork()¶
req.deferWork(work) starts work on a new background thread, suspends the coroutine, and resumes it on the event loop thread with the value returned by work.
#include <cstddef>
#include <memory>
#include <stdexcept>
#include <string>
struct Report {
explicit Report(int lines) : lineCount(lines) {}
int lineCount;
};
Router router;
router.setPath(http::Method::POST, "/reports", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
// A work function may return nothing...
co_await req.deferWork([] { /* warm a cache, write an audit record... */ });
// ... be move-only...
auto settings = std::make_unique<std::string>("compact");
const std::size_t settingsSize = co_await req.deferWork([owned = std::move(settings)] { return owned->size(); });
// ... and return a type that is not default constructible.
const Report report = co_await req.deferWork([] { return Report(42); });
// An exception thrown by the work function is rethrown by co_await.
std::string error;
try {
co_await req.deferWork([] { throw std::runtime_error("database unavailable"); });
} catch (const std::exception& ex) {
error = ex.what();
}
if (!error.empty()) {
co_return HttpResponse(http::StatusCodeServiceUnavailable).body(error);
}
co_return HttpResponse(200).body(std::to_string(report.lineCount + static_cast<int>(settingsSize)));
});
Key properties:
- Return value:
co_awaitreturns the value returned by the work function (a returned reference is copied, the work being over when the coroutine resumes). - Exceptions: thrown by the work function, they are rethrown by
co_await. If no thread can be started (thread limit reached), thestd::system_erroris rethrown byco_awaitas well. - One thread per call: each call creates (and detaches) an OS thread. Group consecutive blocking calls in one
deferWork()when they do not need the event loop in between. - Signals: on Linux, the thread inherits the signal mask of the event loop, which blocks
SIGPIPE(see Performance): a write to a closed pipe or socket from the work function fails withEPIPEinstead of raisingSIGPIPE. - Resumption: the coroutine always resumes on the event loop thread of its connection. Everything between two
co_awaitruns there, like a synchronous handler. - No cancellation: a running work function cannot be interrupted. Bound its duration in the blocking call itself (database statement timeouts, client timeouts...).
Supported awaitables¶
A RequestTask coroutine accepts exactly these awaitables (the RequestTaskAwaitable concept): bodyAwaitable(), readBodyAsync(), deferWork(), std::suspend_always and std::suspend_never. Any other awaitable fails to compile.
The reason: the server knows when its own awaitables complete. A coroutine suspended by an awaitable it does not know is resumed right away, and an awaitable resuming the coroutine by itself later (from another thread, typically) would resume it twice. To integrate an asynchronous library, call its blocking API (or wait for its future) inside deferWork().
An awaitable that never suspends, or that resumes the coroutine before returning from await_suspend(), can opt in with an AeronetAwaitableTag member:
#include <coroutine>
// Completes synchronously (await_ready() is always true): safe to await in a RequestTask.
struct CachedValue {
using AeronetAwaitableTag = void;
[[nodiscard]] bool await_ready() const noexcept { return true; }
void await_suspend(std::coroutine_handle<> /*handle*/) const noexcept {}
[[nodiscard]] int await_resume() const noexcept { return value; }
int value;
};
static_assert(RequestTaskAwaitable<CachedValue>);
Router router;
router.setPath(http::Method::GET, "/cached", [](HttpRequestView&) -> RequestTask<HttpResponse> {
const int value = co_await CachedValue{42};
co_return HttpResponse(200).body(std::to_string(value));
});
A RequestTask is not awaitable itself: share code between handlers with regular functions, called from the coroutine or from a work function.
Lifetime and thread-safety rules¶
What stays valid¶
While a handler is running, including across suspensions:
- The request (method, path, headers, query and path parameters, body, trailers): all its views stay valid until the coroutine completes. The server copies the request head out of the connection buffer when the handler is dispatched, and does not touch the input buffer while the handler holds it.
- The coroutine frame (its local variables) and the request stay alive until the running work completes, even if the connection is closed or the HTTP/2 stream reset meanwhile. The coroutine is then destroyed without being resumed. A work function may therefore capture locals and the request by reference.
- The handler object (the lambda and its captures) stays alive until the coroutine completes, even if the router replaces or removes the route meanwhile.
- The server: destroying a server waits for the work functions still running for it.
What a work function may do¶
A work function runs on its own thread, concurrently with the event loop and with other work functions.
| In a work function | Safe? |
|---|---|
Read the request head: path(), method(), headers, query and path parameters |
Yes: the server does not modify them while the coroutine is suspended |
Read the body obtained with co_await req.bodyAwaitable() before calling deferWork() |
Yes |
| Read or write local variables of the coroutine (captured by reference) | Yes: the coroutine is suspended until the work completes |
Call req.body(), readBody(), or await anything on the request |
No: await the body in the coroutine first, then pass it to the work |
Build the HttpResponse |
Prefer the coroutine, after co_await |
| Use application state shared with other requests | Only with synchronization (mutex, atomics, thread-safe pool) |
Use the router, the server, or HttpServerConfig |
No |
Keep string_views of the request after the handler completes |
No: copy what must outlive the request |
Application state¶
Work functions of different requests run in parallel: any state they share must be thread-safe. Objects used by work functions must also outlive the server, since the server waits for running work in its destructor: declare them before the server.
#include <charconv>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
using namespace aeronet;
namespace {
// Stands for a blocking database client. Used by concurrent work functions: it must be thread-safe (here a mutex,
// typically a connection pool).
class UserRepository {
public:
std::optional<std::string> findName(int id) {
std::scoped_lock lock(_mutex);
const auto it = _names.find(id);
if (it == _names.end()) {
return std::nullopt;
}
return it->second;
}
private:
std::mutex _mutex;
std::unordered_map<int, std::string> _names{{1, "Alice"}, {2, "Bob"}};
};
int ParseId(std::string_view text) {
int id = 0;
std::from_chars(text.data(), text.data() + text.size(), id);
return id;
}
} // namespace
int main() {
// Declared before the server: destroyed after it, once the running work completed.
UserRepository repository;
Router router;
router.setPath(http::Method::GET, "/users/{id}", [&repository](HttpRequestView& req) -> RequestTask<HttpResponse> {
const int id = ParseId(req.pathParamValueOrEmpty("id"));
const std::optional<std::string> name = co_await req.deferWork([&repository, id] { return repository.findName(id); });
if (!name) {
co_return req.makeResponse(http::StatusCodeNotFound);
}
co_return req.makeResponse("{\"name\":\"" + *name + "\"}", http::ContentTypeApplicationJson);
});
SingleHttpServer server(HttpServerConfig{}.withPort(8080), std::move(router));
server.run();
}
Background threads are short-lived
Each deferWork() call runs on a new thread, so thread_local caches do not survive from one call to the next. Keep reusable resources (database connections, HTTP clients) in a thread-safe pool owned by the application.
Use cases¶
Process an upload off the event loop¶
The body stays valid while the coroutine is suspended: the work function can read it in place, without a copy.
#include <string>
#include <string_view>
Router router;
router.setPath(http::Method::POST, "/thumbnails", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
const std::string_view image = co_await req.bodyAwaitable();
std::string thumbnail = co_await req.deferWork([image] {
// CPU-heavy processing of the uploaded bytes (decode, resize, encode...).
return std::string(image.substr(0, image.size() / 4));
});
co_return req.makeResponse(thumbnail, "image/png");
});
Reject a request before its upload¶
An async route is dispatched as soon as the request head arrives. Request middleware runs before the handler: a middleware answering before the body was received makes the server respond right away and close the connection, so a client is not kept uploading a body that will be refused.
#include <string>
#include <string_view>
Router router;
auto& upload = router.setPath(http::Method::PUT, "/files/{name}", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
const std::string_view content = co_await req.bodyAwaitable();
co_return HttpResponse(http::StatusCodeCreated).body("stored " + std::to_string(content.size()) + " bytes\n");
});
upload.before([](HttpRequestView& req) {
if (req.headerValueOrEmpty("authorization") != "Bearer secret-token") {
return MiddlewareResult::ShortCircuit(HttpResponse(http::StatusCodeUnauthorized, "missing credentials\n"));
}
return MiddlewareResult::Continue();
});
upload.maxBodyBytes(64UL << 20U);
The same applies to CORS rejections. A coroutine that returns its response without awaiting the body is different: its response is only sent once the body was received.
Call another HTTP service¶
HttpClient is blocking: call it from a work function. An HttpClient instance is not thread-safe, so do not share one between concurrent work functions without a lock.
#include <aeronet/http-client.hpp>
#include <string>
#include <utility>
Router router;
router.setPath(http::Method::GET, "/weather/{city}", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
std::string url = "http://weather.internal/api/" + std::string(req.pathParamValueOrEmpty("city"));
auto upstream = co_await req.deferWork([url = std::move(url)] {
HttpClient client;
return client.get(url);
});
if (!upstream) {
co_return req.makeResponse(http::StatusCodeBadGateway, "weather service unreachable\n");
}
co_return req.makeResponse(upstream->bodyInMemory(), http::ContentTypeApplicationJson);
});
Chain dependent calls, group independent ones¶
Each deferWork() costs a thread creation and a round trip through the event loop. Chain calls when a step needs the event loop (to inspect a result, or to decide whether to continue); otherwise run them in a single work function.
#include <future>
#include <string>
Router router;
// Dependent steps: the second call needs the first result.
router.setPath(http::Method::GET, "/orders/{id}/invoice", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
const std::string customer = co_await req.deferWork([&req] {
return "customer-of-" + std::string(req.pathParamValueOrEmpty("id")); // first query
});
if (customer.empty()) {
co_return req.makeResponse(http::StatusCodeNotFound);
}
std::string invoice = co_await req.deferWork([&customer] { return "invoice for " + customer; }); // second query
co_return req.makeResponse(invoice);
});
// Independent calls: one background thread, and std::async to run them in parallel.
router.setPath(http::Method::GET, "/dashboard", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
std::string page = co_await req.deferWork([] {
auto orders = std::async(std::launch::async, [] { return std::string("orders"); });
auto invoices = std::async(std::launch::async, [] { return std::string("invoices"); });
return orders.get() + "," + invoices.get();
});
co_return req.makeResponse(page);
});
Bound the number of running work functions¶
Every running deferWork() holds an OS thread, a coroutine frame and its connection. Under a traffic burst, or when a backend slows down, their number can grow quickly; past the system thread limit, co_await throws std::system_error. Bound them explicitly and shed the excess load with 503 Service Unavailable:
#include <atomic>
#include <string>
#include <system_error>
#include <utility>
using namespace aeronet;
namespace {
// Holds a slot of a bounded counter for its lifetime. The coroutine frame owning it is destroyed when its work is over,
// even if the connection was closed meanwhile: the counter tracks the threads actually running.
class Slot {
public:
Slot(std::atomic<int>& counter, int limit) : _counter(counter) {
_acquired = _counter.fetch_add(1, std::memory_order_relaxed) < limit;
}
Slot(const Slot&) = delete;
Slot& operator=(const Slot&) = delete;
~Slot() { _counter.fetch_sub(1, std::memory_order_relaxed); }
[[nodiscard]] bool acquired() const noexcept { return _acquired; }
private:
std::atomic<int>& _counter;
bool _acquired;
};
HttpResponse Busy() {
HttpResponse response(http::StatusCodeServiceUnavailable, "busy, retry later\n");
response.header(http::RetryAfter, "1");
return response;
}
} // namespace
int main() {
// Shared by all the server threads.
std::atomic<int> running{0};
static constexpr int kMaxRunning = 512;
Router router;
router.setPath(http::Method::GET, "/reports/{id}", [&running](HttpRequestView& req) -> RequestTask<HttpResponse> {
const Slot slot(running, kMaxRunning);
if (!slot.acquired()) {
co_return Busy();
}
std::string report;
bool started = true;
try {
report = co_await req.deferWork([&req] { return "report " + std::string(req.pathParamValueOrEmpty("id")); });
} catch (const std::system_error&) {
started = false; // no thread available
}
if (!started) {
co_return Busy();
}
co_return req.makeResponse(report);
});
HttpServer server(HttpServerConfig{}.withPort(8080).withNbThreads(4), std::move(router));
server.run();
}
Time out slow requests¶
A per-route timeout answers 408 Request Timeout and closes the connection (HTTP/1.1) or resets the stream (HTTP/2) when the handler takes too long. The work function keeps running until it returns (its result is then discarded): pair the route timeout with a timeout in the blocking call itself, so that threads do not pile up behind a stuck backend.
#include <chrono>
#include <string>
#include <thread>
Router router;
auto& slowReport = router.setPath(http::Method::GET, "/slow-report", [](HttpRequestView& req) -> RequestTask<HttpResponse> {
std::string report = co_await req.deferWork([] {
std::this_thread::sleep_for(std::chrono::seconds(1)); // e.g. a query with a 2 s statement timeout
return std::string("report\n");
});
co_return req.makeResponse(report);
});
slowReport.timeout(std::chrono::seconds(3));
// The same timeout for a whole group of routes.
auto reports = router.group("/reports");
reports.withTimeout(std::chrono::seconds(3));
reports.setPath(http::Method::GET, "/daily",
[](HttpRequestView& req) -> RequestTask<HttpResponse> { co_return req.makeResponse("daily\n"); });
There is no timeout by default: without one, a work function that never returns holds its connection (and its thread) forever, and blocks the destruction of the server.
Wait for an event (long polling)¶
A work function can wait for an application event, with a deadline. Each waiting client holds a thread: this suits a moderate number of clients; prefer WebSocket to push events to many of them.
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <mutex>
#include <optional>
#include <string>
#include <utility>
using namespace aeronet;
namespace {
struct EventBus {
std::mutex mutex;
std::condition_variable changed;
std::uint64_t version{0};
std::string lastEvent;
};
} // namespace
int main() {
// Declared before the server: work functions may still wait on it until the server is destroyed.
EventBus bus;
Router router;
router
.setPath(http::Method::GET, "/events/next",
[&bus](HttpRequestView& req) -> RequestTask<HttpResponse> {
const auto since = req.queryParamInt<std::uint64_t>("since").value_or(0);
std::optional<std::string> event = co_await req.deferWork([&bus, since] -> std::optional<std::string> {
std::unique_lock lock(bus.mutex);
if (!bus.changed.wait_for(lock, std::chrono::seconds(25), [&] { return bus.version > since; })) {
return std::nullopt;
}
return bus.lastEvent;
});
if (!event) {
co_return req.makeResponse(http::StatusCodeNoContent);
}
co_return req.makeResponse(*event);
})
.timeout(std::chrono::seconds(30));
SingleHttpServer server(HttpServerConfig{}.withPort(8080), std::move(router));
server.run();
}
Router updates, drain and shutdown¶
- Router updates (
server.router(),postRouterUpdate()) can be applied while handlers are suspended. A suspended handler keeps its own handler object alive. The response middleware and the CORS policy applied to its response are the ones of the route matching its request when it completes (a removed route no longer contributes any). - Graceful drain (
beginDrain()): in-flight async requests complete normally (their responses close the connection) within the drain deadline; connections still open at the deadline are closed. - Stop:
stop()closes the connections; their running work functions complete in the background, without resuming their coroutines. - Destruction: the destructor of a server waits for the work functions still running (it logs a warning every 5 seconds while waiting). Keep work functions bounded in time.
Performance¶
Cost model¶
| Step | Cost |
|---|---|
| Dispatching an async handler | One allocation for the coroutine frame (its size depends on the local variables), a copy of the request head, and a reference count increment on the handler |
co_await of the body, already received |
Nothing: it completes without suspending |
co_await req.deferWork(work) |
One OS thread created (by the event loop thread) and detached, a few small allocations (result state, thread start), one wake-up of the event loop, one resumption |
| Waiting | Nothing on the event loop: a suspended handler costs memory only (its frame, its connection buffers, and the stack of its background thread) |
Measurements¶
Indicative measurements of a single event loop thread (one SingleHttpServer, release build), on a 12-core AMD Ryzen AI 9 HX PRO 370 under Linux. Load generated by wrk -t4 -c64 over loopback, on 4 other cores. Each handler answers a 2-byte body.
| Endpoint | Handler | Server CPUs | Requests/s | Latency p50 | Latency p99 |
|---|---|---|---|---|---|
| Trivial | Synchronous | 1 | 190,000 | 0.33 ms | 0.38 ms |
| Trivial | Async, no suspension | 1 | 178,000 | 0.36 ms | 0.45 ms |
| Trivial | Async + deferWork() |
1 | 53,000 | 1.07 ms | 2.4 ms |
| Trivial | Async + deferWork() |
8 | 70,000 | 0.88 ms | 1.5 ms |
| 1 ms blocking call | Synchronous | 1 | 900 | 68 ms | 75 ms |
| 1 ms blocking call | Async + deferWork() |
1 | 22,000 | 2.6 ms | 5 to 13 ms |
| 1 ms blocking call | Async + deferWork() |
8 | 53,000 | 1.2 ms | 1.6 ms |
| 50 µs of CPU work | Synchronous | 1 | 17,600 | 3.6 ms | 4.2 ms |
| 50 µs of CPU work | Async + deferWork() |
1 | 12,200 | 5.2 ms | 9.4 ms |
| 50 µs of CPU work | Async + deferWork() |
8 | 60,000 | 0.97 ms | 1.9 ms |
"Server CPUs" is the set of cores the server process (its event loop and the threads of deferWork()) may run on. Absolute numbers depend on the machine, the network and the handlers; the ratios between the rows are what matters.
What the numbers mean¶
- A coroutine without suspension costs a few percent (6% here, on a handler that does nothing): the frame allocation and the head copy. For handlers doing real work, the difference vanishes; still, do not turn fast synchronous handlers into coroutines without a reason.
deferWork()costs about 10 to 15 µs of event loop time per call in this setup, mostly the thread creation. It pays off for blocking calls lasting from a few hundred microseconds; below that, a synchronous handler is cheaper.- Blocking the event loop is the worst case: with 1 ms of blocking per request, the synchronous handler serves 25 to 60 times fewer requests, and every client waits behind the others (68 ms median latency instead of 1 to 3 ms).
- CPU-heavy work only benefits from
deferWork()with spare cores: on a single core, it adds thread overhead and is slower than a synchronous handler. Without spare cores, run more server threads (HttpServerConfig::withNbThreads()) instead.
Guidelines¶
- Keep the coroutine body short: everything outside
deferWork()runs on the event loop thread, blocking calls included. - Group consecutive blocking calls in one work function; chain
deferWork()calls only when a step needs the event loop. - Bound the number of running work functions (see Bound the number of running work functions): each one holds a thread (whose stack reserves 8 MiB of virtual memory on Linux by default, a few pages being actually used), a frame and a connection.
- Set route timeouts, and timeouts in the blocking calls themselves.
- For parallelism within one client, prefer HTTP/2 (independent streams) or several connections: an HTTP/1.1 connection serves its requests one at a time.
- Size the server threads (
withNbThreads()) for the work done on the event loops, and the machine for the work done in the background threads.
Common pitfalls¶
- Blocking in the coroutine body: only the work function runs on a background thread. A blocking call written directly in the coroutine blocks the event loop like a synchronous handler.
- Reading the body from a work function before awaiting it: on HTTP/1.1 the handler may start before the body is received. Await it in the coroutine, then pass it to the work function.
- Unsynchronized shared state: concurrent work functions run on different threads.
- Unbounded concurrency: one thread per running
deferWork(). A slow backend can exhaust the threads of the process. - Expecting cancellation: a closed connection, a reset stream or a timeout does not stop a running work function.
- Foreign awaitables: only aeronet's awaitables (and
std::suspend_always/std::suspend_never) compile in aRequestTask; wrap other asynchronous APIs indeferWork().
Limitations¶
- Each
deferWork()call creates a thread: there is no built-in thread pool yet. - Running work cannot be cancelled.
- Request bodies are buffered (within
maxBodyBytes) beforebodyAwaitable()/readBodyAsync()complete: there is no incremental upload streaming. - Coroutines cannot await other coroutines (
RequestTaskis not awaitable).
Reference¶
- Request views and their lifetime: Memory management and string_view safety.
- Route options (timeouts, limits): Routing and requests and the server configuration reference.
- Runnable example: examples/async-handlers.cpp.