Backend Systems · Request Handling

The Accept Loop: From listen() to an Accepted Connection

What the kernel does between the TCP handshake and a readable socket, why listen() takes a backlog argument, and how accept wakeups distribute work across processes.

A server that answers an HTTP request has already done three things the application layer never shows: the kernel completed a TCP handshake, a file descriptor was allocated for the result, and some thread of execution was made runnable so it could pick that descriptor up. The loop that performs the last step is the accept loop, and its shape governs behaviour under bursts of arriving connections far more than the request handler does.

The split between kernel and process is the first thing to internalise. listen() does not connect anything; it moves the socket into a passive state and instructs the kernel to complete handshakes on the application’s behalf. Between the arrival of a SYN and the return of a descriptor from accept(), the connection exists entirely inside kernel data structures. The process only learns about it when it asks.

The two queues behind listen()

A passive socket owns two queues. The first holds connections that have received a SYN and are waiting for the third packet of the handshake; entries here are incomplete, and they carry retransmission state. The second holds connections for which the handshake has finished and which are waiting for the application to collect them. accept() removes the oldest entry from the second queue.

The backlog argument to listen() sizes that second queue, and only as a hint. The kernel clamps the value to its own ceiling, exposed on Linux as net.core.somaxconn; a program asking for 65535 on a system configured lower gets the lower number, silently. Older kernels used a small default such as 128, which is why applications that never thought about the argument ended up with a queue that overflowed during a restart of an upstream service. The kernel also applies its own rounding and, on Linux, limits the queue to a fraction of the memory available for socket buffers.

With tcp_abort_on_overflow disabled, which is the default, a full accept queue causes the kernel to drop the final ACK of the handshake. The client believes the connection is established and starts sending; the server’s kernel has discarded the state, so the client’s data is answered with a reset or simply ignored, and the client retries the handshake on its own schedule. The failure therefore appears to the client as latency, not as a refused connection. With the option enabled, the kernel sends a reset immediately, and the client sees an error at once. Neither behaviour is visible in application logs, because the application was never involved.

What accept() actually returns

The returned descriptor refers to a fully established connection whose receive buffer may already contain data. A client that sends its request in the same flight as the handshake ACK has its bytes sitting in the socket when the server first reads, which is why a server that accepts and then immediately blocks on the first read usually finds the request already present.

accept4() accepts the same arguments plus a flag word, and the two flags that matter are SOCK_NONBLOCK and SOCK_CLOEXEC. Setting them at creation avoids two subsequent fcntl() calls, which matters in a loop that runs once per connection: the syscall cost of connection setup is dominated by the calls around the accept, not by the accept itself. A descriptor that leaks into a child process because FD_CLOEXEC was never set is a class of bug that survives restarts, since the inherited descriptor keeps the connection alive after the original process is gone.

On Linux, a descriptor returned by accept() inherits almost nothing from the listening socket. Socket options set on the listener before the call are inherited, but options set later are not, and the accepted socket does not inherit the listener’s non-blocking flag. This asymmetry is the usual reason a server that carefully made its listener non-blocking still blocks on the first read().

Single acceptor, many workers

There are three arrangements in common use, and they differ in where the queue lives.

The simplest is for every worker process to call accept() on the same listening descriptor inherited across fork(). All of them sleep on the same kernel wait queue. When a connection completes, the kernel wakes one waiter, so the herd is bounded, but the woken process may lose the race to another that has just finished a request, and the connection sits in the queue until someone calls accept() again. Latency under low load therefore depends on how quickly a sleeper can be scheduled, and a busy worker holding the CPU can delay a connection that another idle worker could have taken.

The second is a dedicated acceptor that hands descriptors to workers over a Unix domain socket with SCM_RIGHTS, or pushes them into a shared ring. This keeps the wakeup logic in one place and allows admission decisions before handoff, at the cost of one extra descriptor transfer per connection and a queue that the application must bound itself.

The third is SO_REUSEPORT, where each worker creates its own listening socket on the same address and port. The kernel hashes the four-tuple of each arriving connection to one of the sockets, so each process has a private accept queue and no cross-process contention at all. The trade is that the distribution is a hash of addresses and ports, so it is even in expectation and lumpy in small samples, and a worker that restarts changes the mapping for the connections it would have received.

How many processes call accept() is the concurrency-model decision covered in thread-per-connection and event-loop models; the queue exists either way.

Sizing the queue is a decision about tolerance for a stalled application. A server that accepts without delay needs only a few slots, since the queue is drained at the same rate it fills. A server that blocks, garbage collects, or pauses for a configuration reload needs enough slots to hold every connection that completes during the longest pause it expects. The queue holds no payload, only connection state, so a few hundred entries cost little; the failure mode of a queue that is too small is silent retransmission, and the failure mode of one that is too large is that clients wait in a queue nobody drains instead of failing fast and retrying elsewhere. Neither shows up as a request in the access log.

Level and edge readiness

An event-driven server registers the listening descriptor with epoll and accepts when it becomes readable. The distinction that causes real outages is what happens after the notification.

In level-triggered mode, readiness is a property of the queue: as long as at least one completed connection is waiting, every epoll_wait() reports the descriptor. A server that accepts one connection per notification and returns to the loop simply gets notified again, which is correct but wasteful. In edge-triggered mode (EPOLLET), the notification fires on the transition from empty to non-empty, so the handler must call accept() in a loop until it returns EAGAIN. Stopping after one accept leaves the remaining connections in the queue with no future notification, and they wait until another connection arrives to re-trigger the edge. This is the classic edge-triggered accept bug: it looks fine in testing, because a test client rarely opens two connections in the same instant.

Two error cases have to be handled inside that drain loop. EINTR means a signal arrived and the call should be retried. EMFILE and ENFILE mean the process or system descriptor table is full; the connection stays in the accept queue, the descriptor stays readable, and a level-triggered loop spins at full speed failing to accept. The standard remedy is to reserve a descriptor, and on exhaustion close the reserve, accept the connection, close it immediately, and reopen the reserve, which sheds load instead of burning CPU.

for (;;) {
    int fd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
    if (fd >= 0) {
        handle(fd);
        continue;
    }
    if (errno == EINTR) continue;
    if (errno == EAGAIN || errno == EWOULDBLOCK) break;  /* drained */
    if (errno == EMFILE || errno == ENFILE) { shed(); break; }
    break;
}

Where the loop ends

Accepting is the cheapest part of request handling. It costs one syscall per connection, several hundred nanoseconds on a warm cache, and the connection it produces has no idea what protocol will be spoken on it. The server must still read bytes, decide where the message ends, and dispatch. That next step, deciding the length of an HTTP message from its header block, is where the first real parsing cost and the first framing ambiguity appear; HTTP/1.1 message framing covers how the boundary is determined and why getting it wrong is a security problem rather than a correctness one.

One consequence of the split deserves emphasis. Queue overflow, reset-on-close, and backlog clamping are all invisible to application metrics, because the application never saw the connection. A server reporting zero errors while clients time out during a deploy is often reporting accurately: the losses happened in the kernel, before anything was accepted. The counters that reveal them live at the socket layer, in the listen queue overflow and SYN drop statistics, not in the request log.

References

  1. RFC 9293: Transmission Control Protocol (TCP) — IETF
  2. listen(2) — Linux manual page — Linux man-pages project
  3. Unix Network Programming, Volume 1: The Sockets Networking API — W. Richard Stevens, Bill Fenner, Andrew Rudoff, Addison-Wesley, 2003
  4. The C10K problem — Dan Kegel