TogetherOS + NetworkingC10KC10MulimitEMFILEfile descriptors

C10K: Ten Thousand Connections, Then a Million

The limits are concrete and countable: threads, descriptors, kernel socket memory, wake-up cost, ephemeral ports, middlebox state — and each has a mechanism that moved it, which is why the number went from 10K to 10M without the laws of physics changing.

ConceptualLinuxSimulated
▶ InteractiveInterview question
Progress

The problem

How can one machine hold ten thousand — today, millions of — concurrent connections? Not by magic: each connection consumes a specific set of resources in the kernel, the process, and the network path, and every one of them has a ceiling. The question is which ceiling you hit first, and what moved it.

The question, and why it was once hard

Conceptual

In 1999 a server that could hold 10,000 simultaneous connections was an engineering achievement; the phrase "C10K problem" named it. The connections were mostly idle — a chat client, a long-poll, a slow download — and the servers of the time gave each one a thread or process and polled with select. Both costs were per connection and both were large: a thread’s scheduling and memory, and select’s O(n) scan on every wake-up. The machine was busy doing nothing.

What moved the number was not faster hardware but two mechanisms: readiness APIs that cost O(ready) instead of O(n) (epoll in Linux 2.5.44, kqueue in FreeBSD 4.1) and architectures that do not park a thread per connection (The Event-Driven Server, and later runtimes with cheap user-level threads). With those, an idle connection costs a few kilobytes of kernel memory and nothing else, and the ceiling moves to the next resource. This lesson walks the ceilings in the order you meet them.

Ceiling 1: threads. Ceiling 2: descriptors

Linux

Thread per connection (Thread per Connection) hits the wall first: at ~50–100 kB resident per idle thread and microseconds of context switch per wake-up, 10,000 is workable and 100,000 is not. Either the architecture stops parking threads on sockets or the runtime makes the threads cheap (goroutines at ~2 kB, Java virtual threads); both are legitimate, and the second is often the right engineering answer because it keeps the code sequential.

Every connection is a file descriptor, and descriptors are limited twice (see File Descriptors). Per process: RLIMIT_NOFILE, shown by ulimit -n, historically 1024 soft — a server that forgets to raise it dies at 1,021 connections with EMFILE: Too many open files, a failure so common it has its own OS challenge. System-wide: fs.file-max (ENFILE), typically hundreds of thousands to millions by default on modern kernels. Raise the soft limit at startup (setrlimit) or in the service unit (LimitNOFILE=), and remember that the descriptor table itself is memory: a million entries is a few megabytes, fine, but each open socket behind it is not free.

Hitting EMFILE in accept() is uniquely nasty: the connection stays in the accept queue, epoll keeps reporting the listen socket readable, and a naive loop spins at 100% CPU failing to accept. The standard trick is to hold one spare descriptor, close it on EMFILE, accept, close the client, reopen the spare — or simply to stop watching the listen socket until a descriptor is freed.

The descriptor ceilings, on Linux
$ ulimit -n                       # per-process soft limit (this shell and its children)
1024
$ cat /proc/$(pidof server)/limits | grep 'open files'
Max open files    1024     524288     files     # soft   hard
$ ls /proc/$(pidof server)/fd | wc -l
1021                                            # three away from EMFILE
$ sysctl fs.file-max fs.file-nr
fs.file-max = 9223372036854775807
fs.file-nr = 18432  0  9223372036854775807      # allocated, free, max (system-wide)

Ceiling 3: memory per connection

Linux

A TCP socket in the kernel is a struct sock and friends — roughly 2–4 kB — plus its buffers. Idle, the buffers are empty and the socket is cheap: on the order of a few kilobytes. Active, each direction can hold up to its buffer limit: with Linux autotuning the receive buffer grows toward tcp_rmem max (6 MB by default) and the send buffer toward tcp_wmem max (4 MB) *if the connection needs it*; a typical active connection sits at tens of kilobytes per direction. net.ipv4.tcp_mem caps total TCP memory system-wide in pages, and when it is exceeded the kernel starts refusing to grow buffers and logging "TCP: out of memory". See The Buffer Chain.

Then the application’s state: a parser, a per-connection object, TLS state (OpenSSL keeps ~20–50 kB per TLS connection, which is why TLS terminators budget it separately), request buffers. A well-built event-driven server keeps total cost per idle connection under ~10 kB and per active one under ~100 kB; at a million connections that is 10 GB idle to 100 GB active. Memory, not CPU, is what a "C10M" box is sized for.

The calculator attached to this lesson lets you vary these per-connection numbers and see which ceiling arrives first. The numbers it uses are simulated defaults; the real ones come from ss -m (per-socket memory), /proc/net/sockstat (TCP memory in pages), and your process’s RSS divided by its connection count.

Where a connection’s bytes live (typical orders of magnitude; measure your own)
ComponentIdleActiveLimit
Kernel socket struct~2–4 kB~2–4 kBRAM
Kernel receive buffer~010 kB – 6 MB (autotuned)tcp_rmem max; tcp_mem total
Kernel send buffer~010 kB – 4 MB (autotuned)tcp_wmem max; tcp_mem total
epoll interest entry~0.1–0.2 kBsameRAM
TLS state (userspace)~20–50 kBsame + record buffersRSS
Application state~1–10 kB10–100 kBRSS
OS thread (if one per conn)~50–100 kBsame + stack growththreads-max, RAM

Ceiling 4: the client’s ports. Ceiling 5: the middleboxes

Linux

A connection is identified by the 4-tuple (source IP, source port, destination IP, destination port). From one client IP to one server IP:port, the only variable is the source port, chosen from the ephemeral range: on Linux net.ipv4.ip_local_port_range is 32768 60999 by default, giving 28,232 connections at once to a given destination. Load generators, proxies that open a new upstream connection per request, and services that talk to one database all hit this. Worse, a closed connection’s tuple is held in TIME_WAIT for 60 s on Linux (fixed), so a proxy that opens and closes 1,000 upstream connections per second needs 60,000 tuples — more than the range. The symptoms are EADDRNOTAVAIL: Cannot assign requested address on connect() and a ss -s with tens of thousands of timewait. See The Connection Lifecycle: Close, Reset, TIME_WAIT, CLOSE_WAIT and Ports: Addressing a Process, Not a Machine; the challenge is Connection errors to a healthy upstream at 400 requests per second.

The fixes are at the right layer: reuse connections (Connection Pooling, Keep-Alive and Connection Reuse), widen the range, add source IPs, or let the kernel reuse TIME_WAIT tuples for outgoing connections (net.ipv4.tcp_tw_reuse, safe with timestamps). Note that the server side has no such limit: a server holds a million connections on one port because the *client* side of each tuple differs.

Between client and server, every stateful device keeps a table entry per connection: a NAT gateway (NAT: Many Private Hosts Behind One Public Address), a stateful firewall (Firewalls), a load balancer (Load Balancers: L4 vs L7), a Linux host running conntrack (nf_conntrack_max; when it fills, nf_conntrack: table full, dropping packet and new connections time out while existing ones work). Cloud NAT gateways publish per-IP connection limits for exactly this reason. C10K on the server is only the beginning; the path has to hold the state too. See the challenge Outbound calls fail from the whole cluster whenever traffic peaks.

Port exhaustion on a client, and a full conntrack table on a middlebox
client$ ss -s | grep -i timewait
TCP:   61034 (estab 2201, closed 58790, orphaned 0, timewait 58790)
client$ sysctl net.ipv4.ip_local_port_range
net.ipv4.ip_local_port_range = 32768	60999          # 28,232 tuples per destination
client$ curl http://api:8080/   → connect: Cannot assign requested address (EADDRNOTAVAIL)

gateway$ dmesg | tail -1
nf_conntrack: table full, dropping packet
gateway$ sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
net.netfilter.nf_conntrack_count = 262144
net.netfilter.nf_conntrack_max = 262144

There is no single right architecture

Conceptual

The event loop is the answer when connections are many and idle. A thread pool is the answer when work per request dominates. A runtime with green threads over a netpoller — Go, Erlang, Java virtual threads — gets close to event-loop cost with thread-style code, and for most teams that is the best trade available. WhatsApp famously held two million connections per box on Erlang; nginx does it with epoll in C; a Go service does it with goroutines. Each is thread-per-connection *from the programmer’s view* or event-driven *from the kernel’s view*, and the choice between them is about the code you want to maintain.

What every C10K-scale system shares is not an architecture but a discipline: no per-connection OS thread parked on a socket, no O(n) scan per event, raised and monitored descriptor limits, bounded buffers per connection, connection reuse on the client side, and knowledge of every stateful device on the path. Get those right and the architecture is a matter of taste; get one wrong and no architecture saves you.

  • Idle-heavy fan-out (chat, push, long-poll, proxies): event loop or green threads.
  • Work-heavy, few connections (batch APIs, compute services): thread pool, possibly behind an event-driven proxy.
  • Most services: a runtime that hides the choice, plus the discipline above.

Key points

  • A connection costs threads (if you park one), a descriptor, kernel socket memory, buffers when active, application and TLS state, a client-side ephemeral port, and a table entry in every stateful middlebox.
  • Readiness APIs (epoll, kqueue) and non-parking architectures moved the ceiling from thousands to millions; hardware did not.
  • Descriptors are limited per process (ulimit -n, EMFILE) and system-wide (fs.file-max, ENFILE); raise the soft limit at startup and handle EMFILE in accept() without spinning.
  • Idle sockets cost kilobytes; active ones cost their buffers (tens of kB typical, MBs at the autotuning limit). tcp_mem caps the total; memory, not CPU, sizes a C10M box.
  • Clients, not servers, run out of ports: ~28K ephemeral ports per destination on Linux and 60 s of TIME_WAIT. Reuse connections.
  • NAT, firewalls, load balancers and conntrack all hold per-connection state with their own limits.
  • Event loop, thread pool and cheap-thread runtime are all legitimate; the discipline is shared, the choice is about code.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why was 10,000 ever hard?

Because the two mechanisms of the time charged per connection whether or not it was active: a thread’s memory and switching, and select’s O(n) scan. The fix was to make idle connections cost nothing on the CPU — readiness lists in the kernel and no parked thread — after which memory became the limit.

Why can a server hold a million connections on one port but a client cannot open 70,000 to one server?

The 4-tuple. On the server the peer IP and port vary, so one local port serves everyone. On the client the only free variable is the local ephemeral port, and there are ~28,000–64,000 of them, minus those parked in TIME_WAIT.

Why do I need to think about middleboxes if my server is fine?

Because every NAT, stateful firewall and load balancer on the path keeps an entry per connection with a table size and a timeout. When the table is full, new connections vanish while old ones work — a failure that looks like the network and lives in a device nobody monitors.

C10K calculator

C10K calculator
How many threads, how much memory, how many descriptors — and which limit you hit first. The arithmetic is an educational model.
Concurrency model
Threads needed
10K
Descriptors needed
10K / 66K
Context switches/s
10K
Kernel socket buffers
937.5 MB
Stacks (touched)
625.0 MB
App memory
78.1 MB
Total memory vs 16 GB1,641 MB
Descriptors vs ulimit -n10,064
First limit you hit: Scheduler: 10K context switches/s across 10K threads. Fix: Bound the thread count (pool) or let epoll/kqueue/IOCP wake only the threads that have work.
  • EMFILE: 10K descriptors > ulimit -n 66K
  • Ephemeral ports: 10K outbound connections to one destination exceed the ~28K default range
  • Memory: 1.6 GB > 16 GB RAM
  • Scheduler: 10K context switches/s across 10K threads
No architecture is universally correct: thread-per-connection is fine at 100 connections, and an event loop with a blocking handler is worse than any of them.Educational model

How it fails

What the failure looks like from inside real software.

  • ulimit -n 1024 left at default in production: the server dies at ~1,020 connections with EMFILE; if the accept loop does not handle it, it spins at 100% CPU. See EMFILE after thirty hours of uptime and "Too many open files" — and then the health check passes.
  • Descriptor leak in an error path (a socket not closed on exception): connection count in ss stays flat while /proc/<pid>/fd climbs until EMFILE, hours later.
  • Proxy with a new upstream connection per request at 1,000 rps: 60,000 TIME_WAIT tuples, EADDRNOTAVAIL on connect(), and 502s from the proxy while the upstream is idle.
  • tcp_mem exceeded on a box with 500,000 active connections: "TCP: out of memory -- consider tuning tcp_mem" in dmesg, receive windows collapse, throughput drops across every connection.
  • Cloud NAT gateway connection limit reached: new outbound connections from a whole subnet time out; the servers, the DNS and the destination are all healthy.
  • TLS terminator sized by socket cost alone: OpenSSL state at ~40 kB per session makes 200,000 connections need 8 GB the plan did not include; the box swaps.