Networking in C · intermediate · ~10 min

listen() and accept() — the server side

- Move a socket from the *bound* state into the *listening* state with `listen()`. - Explain what the `backlog` argument really controls (the completed-handshake queue), and pick a sane value. - Use `accept()` to pull one connection off the queue and understand why it returns a **brand-new** file descriptor. - Write a correct, leak-free accept loop that keeps the listener open while servicing each client. - Read the peer's address out of the `sockaddr` that `accept()` fills in, and diagnose the common failure modes.

Overview

In the previous lesson you took a fresh socket and pinned it to an address with bind() — you claimed 127.0.0.1:8080 for your process. A bound TCP socket is reserved but it is not yet serving: the kernel will not let any client connect to it. This lesson adds the two calls that finish the server setup. listen() flips the bound socket into a passive, connection-accepting mode, and accept() hands you one connected client at a time.

The key mental shift from bind() is that you now juggle two kinds of sockets. The socket you bound and listen on is the listening socket; it never carries user data. Every call to accept() mints a separate connection socket that talks to exactly one client. Getting that distinction right is the whole game on the server side.

Why it matters

Every TCP server you have ever used — web servers, SSH daemons, database engines — sits in an accept loop built from exactly these two calls. Choosing a good backlog is a real availability and security concern: too small and legitimate clients get refused under load, and the completed-connection queue is the exact structure a SYN-flood tries to exhaust. Leaking per-client descriptors (forgetting to close() them) is one of the most common server bugs in production, quietly climbing until the process hits its file-descriptor limit and every new accept() fails with EMFILE. Understanding which socket is which, and which one to close, is what separates a server that runs for months from one that falls over after a few thousand requests.

Core concepts

From bound to listening: what listen() actually changes

A TCP socket moves through distinct states. bind() gives it a local address but leaves it active (the kind of socket a client would use to connect() outward). listen() converts it into a passive socket and, crucially, tells the kernel to start completing TCP handshakes on your behalf and to hold the finished connections in a queue until you ask for them.

 socket()        bind()           listen()              accept()
   |               |                 |                     |
   v               v                 v                     v
 [CLOSED] ---> [bound, active] --> [LISTEN, passive] --> returns a NEW
  no addr        has addr           kernel queues          connected fd
                                    handshakes here        (one per client)

After listen(), the listening socket's only job is to be a factory. It produces connection sockets; it never sends or receives application bytes itself.

The backlog: two queues behind one number

The second argument to listen(fd, backlog) bounds how many fully-established connections the kernel will hold waiting for you to accept() them. Modern Linux keeps two internal queues:

            SYN arrives                3-way handshake done         accept()
 client  ------------------>  [ SYN queue ] ----------------> [ accept queue ] ------> your
           (half-open)         incomplete                       completed              code
                               handshakes                       connections
  • The SYN queue (a.k.a. incomplete queue) holds half-finished handshakes. Its size is governed mainly by net.ipv4.tcp_max_syn_backlog.
  • The accept queue (completed queue) holds connections that finished the handshake and are waiting for your accept() call. On Linux this is what backlog caps.

If the accept queue is full when a handshake completes, the kernel drops or refuses the connection (the client typically retries or gets a reset). This is why a server that is slow to call accept() starts refusing clients even though its listen() "succeeded".

Concern Small backlog (e.g. 1) Large backlog (e.g. 1024)
Burst tolerance Poor — spikes get refused Good — absorbs bursts
Memory per socket Low Higher (kernel holds more sockets)
Latency under load Refusals, retries Queued, served in order
Typical lab value fine to use 32 overkill for a demo

The kernel silently caps backlog at net.core.somaxconn (often 128 or 4096), so passing a huge number does not automatically give you a huge queue.

Knowledge check: after listen(sock, 32), a client completes its TCP handshake but your program is stuck in a slow request and hasn't called accept() yet. Where does that connection sit, and what happens if 33 clients do this?

The completed connection sits in the accept queue, ready and waiting — the handshake is already done, the client thinks it is connected. Up to the backlog (32 here, capped by somaxconn) can wait there. When the queue is full, further completed handshakes are dropped/refused by the kernel; those clients see a connection failure or timeout even though your listening socket is perfectly healthy. The fix is to call accept() faster (or offload work to other threads), not to crank backlog to infinity.

accept() returns a new socket

This is the concept beginners most often miss. accept() does not return the listening socket, and it does not "turn" the listener into a client connection. It removes one completed connection from the accept queue and returns a fresh file descriptor bound to that single peer.

  file-descriptor table (server process)
  +-----+---------------------------------+
  | fd 3| listening socket (127.0.0.1:8080)|  <- stays open, keeps accepting
  +-----+---------------------------------+
  | fd 5| connection to client A           |  <- returned by accept() #1
  +-----+---------------------------------+
  | fd 6| connection to client B           |  <- returned by accept() #2
  +-----+---------------------------------+

You read()/write() on fd 5 and fd 6. You never read or write fd 3. When you finish with client A you close(5) — and fd 3 is untouched, still listening.

The address-out parameters

accept(fd, addr, addrlen) optionally fills in who connected. You pass a struct sockaddr_in (for IPv4) and a socklen_t initialised to its size; the kernel writes the peer's IP and port and updates the length. Initialising addrlen correctly matters — it is a value-result parameter. Pass NULL for both if you do not care who connected.

The canonical accept loop

listen(srv, 32);
for (;;) {
    struct sockaddr_in peer; socklen_t plen = sizeof peer;
    int c = accept(srv, (struct sockaddr *)&peer, &plen);
    if (c < 0) {
        if (errno == EINTR) continue;   /* interrupted by a signal: retry */
        perror("accept"); break;        /* real error */
    }
    handle(c);   /* read/write */
    close(c);    /* close the PER-CLIENT fd, keep looping on srv */
}

One client per iteration, handled then closed, then back to waiting. This is iterative (one client at a time); real servers usually fork, thread, or use poll/epoll so slow clients don't block others — but the accept loop at the core is identical.

Syntax notes

#include <sys/socket.h>

int listen(int sockfd, int backlog);
  • sockfd: a socket already bind()-ed to a local address. Type must be connection-oriented (SOCK_STREAM).
  • backlog: maximum length of the queue of completed connections awaiting accept(). Silently clamped to net.core.somaxconn.
  • Returns 0 on success, -1 on error with errno set (e.g. EADDRINUSE-adjacent issues surface at bind; here you may see EOPNOTSUPP if the socket type can't listen).
#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd: the listening socket.
  • addr: out-param buffer the kernel fills with the peer's address; may be NULL.
  • addrlen: value-result. On input, the size of *addr; on output, the actual size written. Must be NULL iff addr is NULL.
  • Returns a new non-negative file descriptor for the accepted connection, or -1 with errno set. Common errnos: EINTR (signal — retry), EMFILE/ENFILE (out of descriptors — you are leaking), ECONNABORTED (client bailed during handshake), EAGAIN (only on non-blocking sockets: nothing waiting).
  • You must close() the returned fd when done with that client. The listening sockfd stays open.
  • accept4() (Linux) additionally takes flags like SOCK_NONBLOCK/SOCK_CLOEXEC to set them atomically.

Helpers used below: getsockname() reads the local address the kernel actually assigned (useful when you bind to port 0); inet_ntop() turns a binary address back into a printable string.

Lesson

Two steps to start serving

A server socket becomes usable in two steps: first you tell the kernel to start listening, then you accept connections one at a time.

listen() — start accepting connections

listen(fd, backlog);
  • fd is the bound socket. listen() marks it as ready to accept connections.
  • backlog sets the kernel's queue depth: how many half-finished handshakes (pending SYN/ACK exchanges) the kernel will hold while you are busy.

For labs, a backlog of 32 is fine.

accept() — take one connection

accept(fd, &peer, &peer_len);
  • accept() blocks until a client connects. (Blocking means the call waits and does not return until something happens.)
  • It then returns a new file descriptor for that single connection.
  • The original fd keeps listening for the next client.

So you end up with two sockets: the listening socket and a per-client socket.

Server loop skeleton

listen(srv_fd, 32);
for (;;) {
    struct sockaddr_in peer; socklen_t plen = sizeof peer;
    int client_fd = accept(srv_fd, (struct sockaddr *)&peer, &plen);
    if (client_fd < 0) { perror("accept"); continue; }
    /* talk to client_fd, then close it */
    close(client_fd);
}

The loop accepts one client, handles it, closes its socket, and goes back to wait for the next one.

Code examples

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>

/* Client thread: connects to the port the server chose, sends a line, reads
   the reply, then closes. Runs entirely over the loopback interface. */
static void *client_thread(void *arg) {
    int port = *(int *)arg;
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0) { perror("client socket"); return NULL; }

    struct sockaddr_in srv;
    memset(&srv, 0, sizeof srv);
    srv.sin_family = AF_INET;
    srv.sin_port   = htons((uint16_t)port);
    inet_pton(AF_INET, "127.0.0.1", &srv.sin_addr);

    if (connect(fd, (struct sockaddr *)&srv, sizeof srv) < 0) {
        perror("connect");
        close(fd);
        return NULL;
    }

    const char *msg = "ping\n";
    write(fd, msg, strlen(msg));

    char buf[64];
    ssize_t n = read(fd, buf, sizeof buf - 1);
    if (n > 0) { buf[n] = '\0'; printf("[client] server said: %s", buf); }

    close(fd);
    return NULL;
}

int main(void) {
    /* 1. Create + bind the listening socket on 127.0.0.1, port 0 = "any free
       port". SO_REUSEADDR lets us re-run immediately without TIME_WAIT errors. */
    int srv = socket(AF_INET, SOCK_STREAM, 0);
    if (srv < 0) { perror("socket"); return 1; }

    int yes = 1;
    setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof addr);
    addr.sin_family = AF_INET;
    addr.sin_port   = htons(0);
    inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);

    if (bind(srv, (struct sockaddr *)&addr, sizeof addr) < 0) {
        perror("bind"); close(srv); return 1;
    }

    /* Ask the kernel which port it assigned, so the client can connect. */
    struct sockaddr_in bound;
    socklen_t blen = sizeof bound;
    if (getsockname(srv, (struct sockaddr *)&bound, &blen) < 0) {
        perror("getsockname"); close(srv); return 1;
    }
    int port = ntohs(bound.sin_port);
    printf("[server] listening on 127.0.0.1:%d\n", port);

    /* 2. Move the bound socket into the LISTEN state. */
    if (listen(srv, 32) < 0) { perror("listen"); close(srv); return 1; }

    /* Launch a client so there is something to accept. */
    pthread_t t;
    pthread_create(&t, NULL, client_thread, &port);

    /* 3. accept() blocks until a client connects, then hands back a NEW fd
       that is connected to exactly that one peer. srv keeps listening. */
    struct sockaddr_in peer;
    socklen_t plen = sizeof peer;
    int client = accept(srv, (struct sockaddr *)&peer, &plen);
    if (client < 0) { perror("accept"); close(srv); return 1; }

    char ip[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof ip);
    printf("[server] accepted client from %s:%d (listen fd=%d, client fd=%d)\n",
           ip, ntohs(peer.sin_port), srv, client);

    /* Talk to the per-client socket, then close JUST that one. */
    char buf[64];
    ssize_t n = read(client, buf, sizeof buf - 1);
    if (n > 0) { buf[n] = '\0'; printf("[server] got: %s", buf); }
    write(client, "pong\n", 5);

    close(client);   /* close per-client fd, not the listener */

    pthread_join(t, NULL);
    close(srv);      /* done serving: close the listening socket too */
    return 0;
}

Line by line

  • client_thread(...): a stand-in for a remote client so the demo is hermetic — it creates its own socket, connect()s to 127.0.0.1:<port>, sends ping\n, reads the reply, and closes. It runs concurrently with the server's accept().
  • int srv = socket(...): the future listening socket. SOCK_STREAM = TCP.
  • setsockopt(..., SO_REUSEADDR, ...): lets you re-bind the same port immediately after a previous run, avoiding EADDRINUSE from lingering TIME_WAIT sockets. Good hygiene for any server.
  • addr.sin_port = htons(0): binding to port 0 asks the kernel to pick any free port — perfect for a self-contained test that must not collide with a real service.
  • bind(srv, ...): claims the address (the previous lesson's step).
  • getsockname(srv, ...): because we bound to port 0, we ask the kernel which port it actually assigned, then hand that to the client thread.
  • listen(srv, 32): the first key call. Turns srv into a passive listening socket with an accept queue depth of 32. After this, clients can complete handshakes.
  • pthread_create(..., client_thread, &port): starts the client so there is a connection waiting to be accepted.
  • accept(srv, (struct sockaddr *)&peer, &plen): the second key call. It blocks until a client is in the accept queue, then returns client, a new fd for that one connection, and fills peer with the client's address. Note plen is initialised to sizeof peer first — it is a value-result parameter.
  • inet_ntop(...) + the printf: proves srv (listen fd) and client (per-connection fd) are different descriptors, and shows the peer address the kernel reported.
  • read(client, ...) / write(client, "pong\n", 5): application I/O happens on the per-client fd, never on srv.
  • close(client): releases just this connection's descriptor. If this were a loop, srv would still be listening for the next client.
  • pthread_join(t, NULL) then close(srv): wait for the client to finish, then shut down the listener since the demo is over.

Common mistakes

1. Reading/writing the listening socket instead of the accepted one.

int c = accept(srv, NULL, NULL);
write(srv, "hi\n", 3);   /* WRONG: srv is the listener, not a connection */

Why it breaks: the listening socket has no connected peer; the write fails (ENOTCONN) and the client never gets data.

int c = accept(srv, NULL, NULL);
write(c, "hi\n", 3);     /* write to the per-client fd */

2. Never closing the per-client descriptor (fd leak).

for (;;) {
    int c = accept(srv, NULL, NULL);
    handle(c);
    /* forgot close(c) */
}

Why it breaks: each accepted connection consumes a descriptor forever; after ~1024 (default RLIMIT_NOFILE) accept() fails with EMFILE and the server stops serving.

for (;;) {
    int c = accept(srv, NULL, NULL);
    handle(c);
    close(c);            /* release it every iteration */
}

3. Closing the listening socket inside the loop.

for (;;) {
    int c = accept(srv, NULL, NULL);
    handle(c);
    close(c);
    close(srv);          /* WRONG: kills the listener after one client */
}

Why it breaks: the second iteration calls accept() on a closed fd → EBADF, server dies after one connection. Close srv only when the whole server shuts down.

4. Uninitialised addrlen passed to accept().

struct sockaddr_in peer; socklen_t plen;   /* garbage value */
int c = accept(srv, (struct sockaddr *)&peer, &plen);

Why it breaks: plen is a value-result parameter; if it starts as some tiny/garbage number the kernel may truncate the address or misbehave.

struct sockaddr_in peer; socklen_t plen = sizeof peer;   /* set it first */
int c = accept(srv, (struct sockaddr *)&peer, &plen);

Debugging tips

  • "Connection refused" from the client usually means you bound but never called listen() (or bound the wrong port). Confirm the socket is actually listening: ss -ltnp (Linux) or lsof -iTCP -sTCP:LISTEN -P (macOS) should show your port in LISTEN state.
  • accept() returns and blocks forever — that's normal; it waits for a client. If nothing ever connects, verify the client is hitting the same host/port (a htons/byte-order slip is the classic culprit — a swapped port shows up as a weird number in ss).
  • accept: Too many open files (EMFILE) is the signature of a per-client fd leak. Check ls -l /proc/<pid>/fd | wc -l (Linux) over time; if it climbs monotonically you are missing a close(c). ulimit -n shows the limit.
  • strace -e trace=network -f ./server (Linux) prints every socket/bind/listen/accept with arguments and return values — the fastest way to see exactly which fd each call returns and confirm listener vs client fds.
  • See the queues: ss -ltn shows Recv-Q (current accept-queue length) and Send-Q (the configured backlog / somaxconn cap) for listening sockets. A Recv-Q pinned at the backlog under load means you are accepting too slowly.
  • Check errno on every -1. Wrap accept() failures with perror; distinguishing EINTR (retry), ECONNABORTED (ignore, continue), and EMFILE (real problem) is the whole diagnosis.

Memory safety

  • The sockaddr out-param is a fixed-size buffer. Always set addrlen = sizeof peer before accept(). Using struct sockaddr_storage for peer is the safe general choice because it is large enough for both IPv4 and IPv6; passing a too-small buffer risks the kernel truncating the address.
  • File descriptors are a finite resource, like heap memory. A leaked fd is the socket analogue of a memory leak: close() every accepted connection exactly once. Double-close() is also a bug — after close(c) the integer c is stale and may be reused by the next accept()/open(), so closing it again could clobber an unrelated fd.
  • Threaded servers share the fd table. In the demo the client runs in a pthread; note each thread has its own socket fds here and there is no shared mutable state, so no data race. If you hand an accepted fd to a worker thread, make ownership crystal clear — exactly one thread should close() it, or you get a double-close/use-after-close race.
  • Never read() into a buffer without bounding the length by its size (sizeof buf - 1, leaving room for a NUL). read() does not NUL-terminate; treating socket bytes as a C string without adding '\0' yourself is a classic buffer over-read.
  • read() can return fewer bytes than requested (short read) or 0 (peer closed). Never assume one read() equals one logical message; loop until you have what you need.

Real-world uses

  • Every classic TCP daemon — nginx, Apache, PostgreSQL, Redis, sshd — is an accept loop over listen()/accept() at its core; the differences are in what happens after accept (thread pools, event loops, process pools).
  • inetd/systemd socket activation call listen() for you and pass the ready listening fd into your process, so your program starts directly at accept(). Support this by honoring an inherited fd instead of always creating your own.
  • Load and DoS resilience: production servers tune backlog alongside net.core.somaxconn and enable SYN-cookie protection (net.ipv4.tcp_syncookies) so a SYN flood filling the SYN queue cannot deny service to real clients. Set SO_REUSEADDR (and often SO_REUSEPORT for multi-process accept) as standard practice.
  • Best practice: set SOCK_CLOEXEC on accepted fds (via accept4() on Linux) so descriptors don't leak across exec() into child processes — a real privilege/leak concern in servers that spawn helpers. Always cap concurrent connections and time out idle clients to bound resource use.

Practice tasks

  1. Echo once. Start from the example and change the server so that instead of always replying pong, it echoes back exactly the bytes the client sent (a single read/write round trip). Verify the client prints its own message.
  2. Serve three clients in a loop. Wrap the accept()/handle/close(client) in a for loop that services three connections, then closes srv. Launch three client threads and confirm the listen fd number stays the same while each client fd differs.
  3. Handle errors like a real server. Make the accept loop robust: continue on EINTR and ECONNABORTED, and print a clear message and break on any other error. Prove EINTR handling by sending the process a harmless signal (e.g. install a no-op SIGUSR1 handler) while it blocks in accept().
  4. Report the peer and detect leaks. Print the peer IP:port for every accepted connection using inet_ntop, and add a counter of currently-open client fds. Temporarily remove close(client) and watch the counter (or EMFILE) reveal the leak; then restore the close.
  5. Backlog experiment (lab-only, localhost). Set backlog to 1, and in the client create several connections without the server calling accept() for a moment (e.g. sleep before accept). Observe with ss -ltn how Recv-Q fills and how additional clients are refused/queued. Explain in a comment which queue filled and why.

Summary

  • listen(fd, backlog) converts a bound socket into a passive listening socket and sets the depth of the completed-connection (accept) queue; the number is capped by somaxconn.
  • accept(fd, addr, addrlen) blocks until a client is queued, then returns a new file descriptor for that one connection and (optionally) fills in the peer's address; initialise addrlen to the buffer size first.
  • You always have two socket kinds: the listener (fd factory, never carries data) and one per-client connection socket (does the read/write).
  • Close the per-client fd after each connection to avoid descriptor leaks (EMFILE); keep the listening fd open for the whole server lifetime and close it only at shutdown.
  • Handle accept() errors: retry on EINTR, ignore ECONNABORTED, treat EMFILE as a leak signal. Under load, a full accept queue silently refuses clients — accept faster rather than just enlarging backlog.

Practice with these exercises