Skip to content

Performance and tuning

aeronet is designed to serve requests with few system calls, few allocations, and no copies of request or response data. The defaults suit most deployments; this page explains what the server does on the hot path and which settings change its trade-offs.

How a request is served

  • One event loop per thread, nothing shared. Each worker of a MultiHttpServer has its own listening socket (SO_REUSEPORT), event loop, connections, and copy of the router: no lock on the request path. See Servers and lifecycle.
  • Parsing without copies. The request is parsed in place in the connection buffer: path, headers, parameters, and body are views into it (see Requests). Duplicate headers are merged in place, without allocation.
  • One read per request. The edge-triggered read loop stops on a short read, which proves the kernel queue empty, instead of reading again until EAGAIN. Plaintext sockets use recv() / send() / sendmsg(), which skip the file layer checks of read() / write().
  • Pipelined requests served in place, answered by a single write. The HTTP/1.1 requests pipelined in a read are served where they are in the input buffer, then erased at once: the rest of the buffer moves once per read, rather than once per served request. Their responses are written together, in a single gather write once the requests of the read are served, rather than each by its own write: a syscall per response is saved, and, with Nagle's algorithm (plaintext connections without TCP_NODELAY), the responses no longer wait for the client to acknowledge the first one (40 ms with delayed acknowledgments). A batch is written as soon as it reaches 64 KiB, or with a response carrying a file; the response to the last request of the read is written right away, so that a connection receiving one request at a time writes each response immediately. Tests: http-additional_test.cpp (HttpPipeline.*). Benchmark: http1-pipeline_bench.cpp.
  • One buffer per response. Status line, headers, and an inline body are laid out in a single buffer, as they are sent; a large owned body is sent from its own buffer. Head and body go out in a single gather write (writev, WSASend on Windows).
  • No copy of waiting output. When a client reads slowly, the HTTP/1.1 responses and streamed chunks produced while earlier output waits for the socket are queued behind it in their own buffers: neither they nor the waiting output are copied (small ones, up to 4 KiB together, are merged), and they go out several at a time, in a single gather write. While the socket is full, the small ones wait for its writable event rather than retrying a write each.
  • Few segments. On Linux, with TCP_NODELAY, a flush taking several writes is done under TCP_CORK, so that its output is not split in partial segments: a response head followed by its file, output queued around a file, or user-space TLS output longer than one record (OpenSSL writes each record on its own). A flush made of a single write - most responses, and kTLS output, whose records the kernel builds - is not corked, which would only cost two setsockopt() calls. Tests: connection-state_test.cpp (ConnectionStateCorkTest.*).
  • Reuse. Closed connection objects and their buffers are kept for reuse (maxCachedConnections), and output buffers keep their capacity across the requests of a connection.
  • One hot input buffer per event loop. A connection without pending input (HTTP/1.1, HTTP/2 or WebSocket) reads into the input buffer of the previous such read of its event loop, still in the CPU caches, instead of its own, and gives it back once its input is served: an idle keep-alive connection holds no input buffer. A connection only keeps one while it holds a partial request or frame. A large WebSocket payload sent back from onMessage is written from that buffer, without copy, and the permessage-deflate streams of the connections negotiated without context takeover are shared by the event loop (see WebSocket).
  • Cheap timers. Keep-alive deadlines live in a min-heap updated once per keepAliveTimeout for an active connection, not once per event; timeouts are driven by a timer, independently of traffic.

Large payloads avoid user-space copies: files are sent with sendfile() (TransmitFile on Windows), large buffers with MSG_ZEROCOPY on Linux, and TLS payloads through kernel TLS when available. Responses can be compressed while they are written.

Tests: http-core_test.cpp (DeferredReadDoesNotWaitForThePollTimeout, PartialRequestsKeepTheirInputWhileOtherConnectionsAreServed), event-loop_test.cpp (polls without waiting).

Settings

Setting Default Trade-off
nbThreads hardware threads Event loops of a MultiHttpServer. More threads than cores rarely helps; fewer leave room for other processes or deferWork() threads.
maxPerEventReadBytes 128 KiB Fairness budget of a connection per readable event, reads and writes included. Beyond it, other ready connections are served first, then the rest of its input at the end of the loop iteration (the next poll does not wait while such input is left). Raise it for few connections with large bodies, lower it for many concurrent clients. A large budget takes several reads, so it does not enlarge the input buffers.
minReadChunkBytes 4 KiB Initial and smallest read size of a connection. The read size doubles after each read filling it, up to 256 KiB (HttpServerConfig::kMaxReadChunkBytes), and halves after a read filling less than a quarter of it: small requests are read in small reads, into little memory, while uploads are read in large reads.
maxAcceptBatchSize 64 Connections accepted per loop iteration, so that bursts do not starve open connections. 0 is unlimited.
pollInterval, pollIntervalMinFactor, pollIntervalMaxFactor 500 ms, 1.0, 1.0 Longest sleep of an idle loop: bounds how fast stop() and predicates are noticed. Timeouts do not depend on it. Factors let saturated loops poll faster and idle loops back off.
tcpNoDelay Auto TCP_NODELAY on TLS and HTTP/2 connections, where small records would otherwise stall on Nagle's algorithm combined with delayed acknowledgments. Enabled or Disabled force it.
zerocopyMode, zerocopyMinBytes Opportunistic, 128 KiB MSG_ZEROCOPY for the response writes of at least zerocopyMinBytes of plaintext HTTP/1.1 connections. Below about 10 KiB, pinning pages costs more than copying.
maxZerocopyPendingBytes 4 MiB Response bytes a connection may retain until the completion of their zerocopy sends; beyond it, its writes are copied until completions release some.
maxOutboundBufferBytes 4 MiB Response data queued for a connection that reads slowly. Beyond it, an HTTP/1.1 connection is closed once flushed, and HTTP/2 stops reading frames.
maxCachedConnections 10 Closed connection objects kept for reuse.
minCapturedBodySize 1 KiB Owned response bodies smaller than this are copied next to the head.

On Linux, a write to a connection reset by the peer fails with EPIPE instead of raising SIGPIPE, whose default action kills the process: plaintext writes use MSG_NOSIGNAL, and event-loop threads block SIGPIPE while they run, for the writes that cannot pass a flag (sendfile(), OpenSSL). The process-wide SIGPIPE disposition is left untouched; threads created from an event-loop thread inherit the blocked signal.

MSG_ZEROCOPY

On Linux, the response writes of plaintext HTTP/1.1 connections of at least zerocopyMinBytes use MSG_ZEROCOPY: the kernel sends the bytes from the response buffer instead of copying them into the socket buffer, saving CPU and memory bandwidth on large responses. The listening socket enables it (SO_ZEROCOPY, inherited by the accepted sockets) when the kernel supports it (4.14 and later); otherwise, and with zerocopyMode Disabled, responses are copied as usual.

The kernel reads the bytes after the write returned, until it reports the completion of the send, once the peer acknowledged them. Until then, the server keeps the buffer alive and unchanged:

  • Completions arrive in the socket error queue, which the event loop reads when the socket reports EPOLLERR, releasing each buffer as soon as its sends completed. A static body (bodyStatic()) must stay unchanged until then too.
  • A connection retains at most maxZerocopyPendingBytes of response bytes, each retained buffer counting for at least zerocopyMinBytes (a single larger write may use zerocopy when nothing else is retained): beyond it, its writes are copied until completions release retained buffers.
  • Output produced while a response waits for a slow reader is queued in its own buffers, never appended to the one the kernel still reads: neither is copied, and the queued buffers are sent with MSG_ZEROCOPY too, several per send.
  • A graceful close (Connection: close, peer EOF, drain) waits for the completion of the sends. An idle keep-alive connection whose sends are still in flight at keepAliveTimeout is closed once they complete, as the kernel would finish sending copied data after a close.
  • The sends in flight must progress: a connection whose sends make no progress during a whole keepAliveTimeout without any other activity (a client that stopped reading), with or without keep-alive, is reset. So is a connection forcibly closed with sends in flight (server stop, drain deadline, timeouts): the kernel then drops its send queue, and the server keeps its buffers one more second, for the packets already handed to the network device.

Opportunistic mode (the default) skips loopback peers, and stops using MSG_ZEROCOPY on a connection once the kernel reports that it copied the data anyway: a peer of the same host, through a container network for instance, or a network device without scatter-gather. Enabled uses it for every connection, which is only useful to test it locally: the kernel always copies the data sent to a local socket, so measuring the gains needs the client on another host.

MSG_ZEROCOPY is not used for TLS (kTLS sockets reject it, and user-space TLS copies the data while encrypting it), HTTP/2, WebSocket and CONNECT tunnels (their output buffers are reused once written), nor for files, which sendfile() already sends without copy.

Each zerocopy send pins its pages, which counts against the locked memory limit of the user (RLIMIT_MEMLOCK, unless the process has CAP_IPC_LOCK) and the option memory of the socket (net.core.optmem_max). When they are exhausted, the send fails with ENOBUFS: the server logs it once, and copies its responses for one second before trying again. Containers often start with a 64 KiB or 8 MiB locked memory limit: raise it (docker run --ulimit memlock=..., LimitMEMLOCK= in a systemd unit) for servers sending many large responses at once.

Tests: zerocopy_test.cpp (sends, completions out of order, real loopback sends), connection-state_test.cpp (buffer retention, output queued behind a zerocopy send), output-queue_test.cpp (queue of the waiting output), tests/http-core_test.cpp (ZerocopyMode.*: slow and stalled readers, retention limit, ENOBUFS, local peers; HttpBackpressure.*: streamed and pipelined responses, files among them, to slow readers). Benchmark: zerocopy_bench.cpp (large responses, and a response streamed to a slow reader).

Writing fast handlers

  • Build responses with req.makeResponse(), and fill them in order: status, headers, body, trailers (see Responses).
  • Move large bodies into responses (body(std::move(buffer))) instead of copying them, and serve files with file() or the static file handler.
  • Prefer headerAddLine() to header() when the header cannot be present yet.
  • Never block the event loop: move blocking work to deferWork() in an async handler, whose measurements show the cost of each approach.
  • Restrict compression to compressible content types (contentTypeAllowList), and pick codec levels that fit your CPU budget.

Measuring

server.stats() returns per-server counters; these describe output backpressure and MSG_ZEROCOPY:

ServerStats field Meaning
totalBytesQueued Response bytes accepted for sending.
totalBytesWrittenImmediate, totalBytesWrittenFlush Bytes written at once, and bytes written later when the socket became writable.
deferredWriteEvents, flushCycles How often output had to wait for the socket.
maxConnectionOutboundBuffer Largest output queue of a connection: compare it with maxOutboundBufferBytes.
zerocopySends, zerocopyBytes MSG_ZEROCOPY sends, and the bytes they sent.
zerocopyCopiedSends Zerocopy sends whose data the kernel copied anyway (local peer, device without scatter-gather).
zerocopyNoBufferSpaceFallbacks Zerocopy sends that failed with ENOBUFS (locked or socket option memory exhausted), each followed by one second of copies.
zerocopyRetainLimitFallbacks Writes copied because their connection already retained maxZerocopyPendingBytes.
zerocopyResetConnections Connections reset with zerocopy sends in flight (stalled reader, forced close).

MultiHttpServer::stats() returns the counters of each worker (per) and their sum (total). The benchmarks page describes the benchmark suites, how CI runs them, and how to profile the server.