Pipes and sockets¶
Level 5 · Chapter 4 · ⏱️ ~60 min read · Prerequisites: File descriptors in code, Processes and signals in code, Networking basics
Programs on Linux talk to each other through a handful of inter-process communication (IPC) mechanisms. This chapter covers the ones you'll use most: anonymous pipes, named pipes, Unix domain sockets, and TCP sockets. By the end you'll have written a TCP server that handles many clients at once, three different ways, and you'll know how to test it with nc and ss.
Why it matters¶
A colleague wrote a small Python service that accepts sensor readings over TCP. Locally, with one test client, it worked perfectly. In production, with forty sensors connected, readings from all but one sensor arrived minutes late or not at all.
The server's loop was conn, addr = srv.accept(), then a while True: conn.recv(...) loop for that one client. While it was busy with the first sensor, it never went back to accept(). The other 39 sensors sat in the kernel's queue of pending connections, their data piling up in kernel buffers. A second bug showed up when traffic spiked: readings got glued together or split in half, because the code assumed each recv() returned exactly one message.
Both are classic first-server mistakes, and both come from not knowing what the kernel is doing underneath. After this chapter you'll recognize them on sight: one is a concurrency problem (fixed with threads, selectors, or asyncio), the other a framing problem (TCP is a byte stream, not a message stream).
Concepts¶
The IPC menu¶
Inter-process communication means any way for two processes to exchange data or notifications. Linux offers many. These are the ones that matter in practice:
| Mechanism | Direction | Who can use it | Unit of data | Typical use |
|---|---|---|---|---|
Anonymous pipe (pipe()) |
One way | Related processes (parent and children) that inherit the fds | Byte stream | Shell pipelines; capturing a child's output |
Named pipe / FIFO (mkfifo) |
One way | Any process that can open the path | Byte stream | Simple hand-off between unrelated scripts |
Unix domain socket (AF_UNIX) |
Two way | Any process on the same machine that can access the path | Stream or datagrams | Local daemons: Docker, systemd, PostgreSQL, X11/Wayland |
TCP socket (AF_INET/AF_INET6, SOCK_STREAM) |
Two way | Any process on any reachable machine | Reliable byte stream | Web servers, databases, almost every network service |
UDP socket (SOCK_DGRAM) |
Two way | Any reachable machine | Datagrams; may be lost or reordered | DNS, metrics (StatsD), video, games |
Shared memory (/dev/shm, mmap) |
Both | Processes that map the same region | Raw memory, no copying | Very high-throughput data sharing; needs separate locking |
| Signals | One way | Same user (or root) | A small number, no data | "Stop", "reload config", "child exited" |
Everything except signals and shared memory works through file descriptors, so the same read, write, close, and poll calls from the last two chapters apply. Shared memory is the fastest (no copying through the kernel) but the hardest to use correctly, because you need your own synchronization. Signals carry no data. For most programs, the choice is between a pipe (parent and child), a Unix socket (local services), and TCP (anything that might ever cross a network).
Anonymous pipes¶
os.pipe() asks the kernel for a pipe: a one-way channel with a buffer in kernel memory. It returns two fds: a read end and a write end. Bytes written to the write end come out of the read end in the same order.
A pipe has no name, so the only way for another process to get one of its ends is to inherit it through fork. That's why pipes connect related processes. The pattern:
sequenceDiagram
participant P as Parent
participant K as Kernel pipe buffer
participant C as Child
P->>K: os.pipe() → r=3, w=4
P->>C: os.fork() (child inherits fds 3 and 4)
C->>C: close(r): child only writes
P->>P: close(w): parent only reads
C->>K: write(w, data)
K->>P: read(r) returns data
C->>K: close(w), the last writer is gone
K->>P: read(r) returns b"" (EOF)
Close the ends you don't use. This is the most important pipe rule, and forgetting it causes hangs. The reader only sees EOF when every write end in every process is closed. If the parent keeps its own copy of the write end open, it will wait forever for an EOF that can't come, because a writer (itself) still exists.
Pipe capacity, blocking, and SIGPIPE¶
A pipe's buffer on Linux holds 64 KiB by default (fcntl(fd, F_GETPIPE_SZ) reports it, and an unprivileged process can raise it up to /proc/sys/fs/pipe-max-size, 1 MiB). That limited buffer gives pipes built-in flow control, also called backpressure:
- A writer blocks when the buffer is full, until the reader drains some of it. A fast producer can't run away from a slow consumer.
- A reader blocks when the buffer is empty, until data arrives or every writer closes (EOF).
- A write to a pipe with no readers left makes the kernel send
SIGPIPEto the writer, which kills it by default. If the signal is ignored,writefails withEPIPEinstead.
SIGPIPE is why seq 1000000000 | head -3 finishes instantly instead of generating a billion numbers: once head exits, the next write by seq kills it. Python ignores SIGPIPE, so a Python producer gets a BrokenPipeError exception instead, which you'll see below.
Writes of up to 4096 bytes (PIPE_BUF) to a pipe are atomic: they never interleave with other writers' data. Larger writes can be split up if several processes write to the same pipe.
Named pipes (FIFOs)¶
A named pipe, or FIFO (first in, first out), is a pipe with a name in the filesystem. You create it with mkfifo (the command) or os.mkfifo(). ls -l shows it with type p. Any process with permission can open it by path, so unrelated processes can use it.
The data never touches the disk. The file is just a meeting point. Two behaviors surprise people:
- Opening blocks. Opening a FIFO for reading blocks until someone opens it for writing, and vice versa. That's the hang you traced in chapter 1.
- EOF happens when all writers close. A reader that wants to keep serving several writers in turn must reopen the FIFO after each EOF.
FIFOs are fine for quick plumbing between scripts on one machine. For anything two-way, or with several clients at once, use a Unix domain socket.
Sockets¶
A socket is an endpoint for two-way communication, created with the socket() syscall. It's identified by three choices:
- The address family:
AF_UNIX(a path on this machine),AF_INET(IPv4 address and port), orAF_INET6(IPv6). - The socket type:
SOCK_STREAM(a reliable, ordered, connected byte stream; withAF_INETthat's TCP), orSOCK_DGRAM(separate messages called datagrams; withAF_INETthat's UDP). - The protocol, which is almost always implied by the first two.
After that, a socket is just a file descriptor. It shows up in /proc/PID/fd as socket:[inode], and you can read and write it (Python's socket objects call these recv and send).
Unix domain sockets¶
A Unix domain socket uses a filesystem path as its address, like /run/docker.sock or /var/run/postgresql/.s.PGSQL.5432. It's for communication between processes on the same machine, and it's faster than TCP over loopback because there's no network stack involved: no checksums, no TCP state machine, no packet routing.
Unix sockets have three useful properties that TCP doesn't:
- Access control with file permissions. The socket file has an owner and a mode. If only the
dockergroup can write to/run/docker.sock, only members of that group can talk to Docker. No passwords needed. - Peer credentials. The server can ask the kernel for the connecting process's PID, UID, and GID (
SO_PEERCRED). The kernel fills these in, so the client can't lie. systemd and PostgreSQL's "peer" authentication rely on this. - Passing file descriptors. A process can send an open fd to another process over a Unix socket (
SCM_RIGHTS). This is how systemd hands sockets to services, and how some servers hand off connections between processes.
The path has a length limit of 107 bytes, because it's stored in a fixed 108-byte field in the address structure. Longer paths fail with OSError: AF_UNIX path too long. The socket file also isn't removed automatically when the server exits, and a stale one makes the next bind() fail with EADDRINUSE, so servers usually delete it before binding.
The TCP socket lifecycle¶
A TCP server and client each make a fixed sequence of syscalls. The server prepares a listening socket, and the kernel completes connections in the background. accept() then hands each finished connection to the program as a new socket, with its own fd.
sequenceDiagram
participant S as Server
participant K as Kernel(s)
participant C as Client
S->>K: socket(AF_INET, SOCK_STREAM) → fd 3
S->>K: setsockopt(SO_REUSEADDR)
S->>K: bind(3, 127.0.0.1:9000)
S->>K: listen(3, backlog)
S->>K: accept(3) blocks...
C->>K: socket() → fd 3
C->>K: connect(3, 127.0.0.1:9000)
Note over K: TCP handshake: SYN, SYN-ACK, ACK<br/>(done by the kernels, not your code)
K-->>C: connect() returns
K-->>S: accept() returns NEW fd 4 + client address
C->>K: send(b"hello\n")
K-->>S: recv(4) → b"hello\n"
S->>K: send(4, b"hello\n")
K-->>C: recv() → b"hello\n"
C->>K: close() (sends FIN)
K-->>S: recv(4) → b"" (EOF)
S->>K: close(4)
Note over S: fd 3 is still listening for the next client
Each step, and why it exists:
socket()creates an unconnected endpoint.bind()attaches it to a local address and port. Servers bind to a known port so clients can find them. Clients usually skip this, and the kernel picks an ephemeral port (a temporary high-numbered port, 32768 to 60999 on Linux) at connect time.listen(backlog)marks it as a listening socket. The kernel now completes TCP handshakes on its own and queues finished connections.backlogis roughly how many may wait in that accept queue before new ones get refused or dropped.accept()takes the next finished connection off the queue and returns a new socket for it. The listening socket stays open and keeps listening.connect()on the client starts the handshake and returns once it completes (or fails withECONNREFUSEDif nothing is listening, or times out if packets are dropped).send()/recv()move bytes.recv()returningb""means the other side closed its end: EOF, exactly like a pipe.close()ends the connection, sending a FIN packet ("I'm done sending").
Binding to 127.0.0.1 (loopback) means only programs on this machine can connect. Binding to 0.0.0.0 means "every IPv4 address this machine has", which exposes the port to the network, subject to your firewall (see Firewalls with ufw). For learning and testing, always bind to 127.0.0.1.
TIME_WAIT and SO_REUSEADDR¶
When a TCP connection closes, the side that closed first keeps a small record of it in the TIME_WAIT state for 60 seconds on Linux. This catches stray packets from the old connection that are still in flight, so they can't be mistaken for part of a new connection that reuses the same addresses and ports.
The problem: if your server is the side that closed first (because it crashed, or you stopped it while clients were connected), its port has TIME_WAIT entries. Restarting the server within a minute then fails:
The fix is the SO_REUSEADDR socket option, set before bind(). It tells the kernel "let me bind even if old connections on this port are in TIME_WAIT" (it still refuses if another socket is actively listening there). Every server should set it. Python's socket.create_server() and asyncio.start_server() set it for you on Linux.
There's a Linux subtlety: the option must have been set on the old server's socket too, because each TIME_WAIT entry remembers whether its socket allowed reuse. Restarting a server that never set SO_REUSEADDR can still fail for 60 seconds, even if the new version sets it.
SO_REUSEPORT is a different option. It lets several processes listen on the same port at once, with the kernel spreading connections between them. It's used for multi-process servers, and you don't need it here.
TCP is a byte stream, not a message stream¶
TCP guarantees that bytes arrive in order, without gaps or duplicates. It guarantees nothing about how they're grouped. Three send() calls on one side might arrive as one recv(), or one send() might arrive in five pieces. The kernel splits and merges data based on buffer sizes, packet sizes, and timing.
So recv(4096) returns "whatever bytes have arrived so far, up to 4096". If your protocol has messages, you must mark where each one ends. That's called framing. There are two common schemes:
| Scheme | How | Good for | Watch out for |
|---|---|---|---|
| Delimiter (usually newline) | Each message ends with \n. Read until you see one |
Text protocols: SMTP, Redis, IRC, the capstone | Messages that contain the delimiter must be escaped; cap the line length |
| Length prefix | Send the message length first (e.g. a 4-byte integer), then exactly that many bytes | Binary data, JSON with newlines in it, files | Cap the length so a bad header can't make you allocate 4 GB |
Either way, the receiver keeps a buffer: it appends each recv() result, extracts every complete message, and keeps any leftover partial message for next time.
The same applies to sending. send() may send only part of what you gave it. sendall() loops until everything is sent.
Serving many clients at once¶
A server's main loop spends nearly all its time waiting: for a new connection, or for a client to send something. The question is how to wait for many things at once. There are three common answers.
flowchart TB
subgraph T["Threads: one per client"]
T0["main thread:<br/>accept() loop"] --> T1["thread 1: recv() on client 1"]
T0 --> T2["thread 2: recv() on client 2"]
T0 --> T3["thread N: recv() on client N"]
end
subgraph E["Event loop: one thread, many sockets"]
E0["epoll_wait(all sockets)"] --> E1{"which are ready?"}
E1 -->|"listener"| E2["accept()"]
E1 -->|"client 7"| E3["recv(), handle, send()"]
E2 --> E0
E3 --> E0
end
Threads. The accept() loop starts a new thread per connection, and each thread does simple blocking recv/send. The code reads naturally, top to bottom. Python's GIL is released while a thread waits on I/O, so threads work well for I/O-bound servers. The costs: each thread uses memory (a stack, plus kernel structures), thousands of threads strain the scheduler, and shared data needs locks.
I/O multiplexing with an event loop. One thread asks the kernel "tell me which of these sockets is ready", then handles exactly those, without blocking, and asks again. Linux offers three syscalls for the asking:
| Syscall | How it works | Limits |
|---|---|---|
select() |
Pass bitmasks of fds each call; the kernel scans them all | fds must be below 1024 (FD_SETSIZE); cost grows with the number of fds watched |
poll() |
Pass an array of fds each call | No fd limit, but still scans the whole list every time |
epoll |
Register fds once (epoll_ctl), then epoll_wait returns only the ready ones |
Linux-only; scales to hundreds of thousands of connections |
Python's selectors module wraps these behind one interface. selectors.DefaultSelector() picks the best one available, which is EpollSelector on Linux. Event-loop code is a little less natural, because each handler must return quickly instead of blocking. But one thread can handle thousands of clients with no locks.
asyncio. Python's asyncio module is an event loop (built on selectors, and so on epoll) plus coroutines: functions defined with async def, which can pause at each await while the loop runs other coroutines. You write code that looks sequential (line = await reader.readline()), and the loop multiplexes it underneath. This is the modern default for new network code in Python, and the capstone uses it.
| Threads | selectors |
asyncio |
|
|---|---|---|---|
| Code style | Plain blocking code | Callbacks; manual buffers | Sequential-looking async/await |
| Clients per process | Hundreds to a few thousand | Tens of thousands+ | Tens of thousands+ |
| Shared state | Needs locks | No locks (one thread) | No locks between awaits |
| One slow CPU-bound handler | Others keep running | Blocks everyone | Blocks everyone |
| Good for | Simple servers; calling blocking libraries | Learning how event loops work; tiny dependencies | New network services |
Commands and examples¶
Work in ~/level5. All servers here bind to 127.0.0.1 on high ports, and you stop each one with Ctrl+C or kill when you're done.
A pipe between parent and child¶
import os
r, w = os.pipe() # two fds: read end, write end
print(f"pipe fds: read={r} write={w}")
pid = os.fork()
if pid == 0: # child: the writer
os.close(r) # close the end you don't use
for i in range(3):
os.write(w, f"record {i} from child {os.getpid()}\n".encode())
os.close(w) # EOF for the reader
os._exit(0)
os.close(w) # parent: the reader. MUST close w,
with os.fdopen(r) as reader: # or it would never see EOF
for line in reader:
print("parent got:", line.rstrip())
os.waitpid(pid, 0)
pipe fds: read=3 write=4
parent got: record 0 from child 153290
parent got: record 1 from child 153290
parent got: record 2 from child 153290
os.fdopen(r) wraps the raw fd in a file object, so you can iterate over lines. The for loop ends when read returns 0 bytes, which happens only because both processes closed their copies of w. Comment out the parent's os.close(w) and run it again: it prints the three records and then hangs forever. Press Ctrl+C.
This is what subprocess.run(..., capture_output=True) does internally, with one pipe for stdout and one for stderr.
Measuring the pipe buffer¶
import fcntl, os
r, w = os.pipe()
os.set_blocking(w, False) # don't block when full; raise instead
total = 0
try:
while True:
total += os.write(w, b"x" * 1024)
except BlockingIOError:
print(f"pipe full after {total} bytes ({total // 1024} KiB)")
print("F_GETPIPE_SZ says:", fcntl.fcntl(w, fcntl.F_GETPIPE_SZ))
With a normal (blocking) fd, the 65th write would simply have waited until a reader made room. The non-blocking mode turned that wait into a BlockingIOError (EAGAIN) so you could count. This is the backpressure that keeps fast_producer | slow_consumer from using unlimited memory.
SIGPIPE and BrokenPipeError¶
0
1
2
Traceback (most recent call last):
File "/home/alex/level5/numbers.py", line 2, in <module>
print(i)
BrokenPipeError: [Errno 32] Broken pipe
PIPESTATUS: 1 0
head read three lines and exited. Python's next flush to the pipe failed with EPIPE, because Python ignores SIGPIPE. Compare a classic Unix tool:
seq was killed by SIGPIPE: 128 + 13 = 141. Silent and correct. For Python command-line filters meant to be used in pipelines, restore the classic behavior:
import signal
signal.signal(signal.SIGPIPE, signal.SIG_DFL) # behave like a classic Unix filter
for i in range(1_000_000):
print(i)
Don't do this in servers. A server writing to a socket whose client vanished should get an exception it can handle for that one client, not die.
A named pipe as a job queue¶
import os
FIFO = "jobs.fifo"
if not os.path.exists(FIFO):
os.mkfifo(FIFO, 0o600)
print("waiting for a writer...", flush=True)
while True:
with open(FIFO) as fifo: # blocks until a writer opens it
for line in fifo: # ends when all writers close
print("job:", line.strip(), flush=True)
print("writer closed; reopening", flush=True)
Run the reader in one terminal:
In a second terminal, look at the FIFO and send it work:
ls -l jobs.fifo
echo "resize photo1.jpg" > jobs.fifo
printf 'transcode a.mp4\ntranscode b.mp4\n' > jobs.fifo
The first terminal shows:
waiting for a writer...
job: resize photo1.jpg
writer closed; reopening
job: transcode a.mp4
job: transcode b.mp4
writer closed; reopening
The p at the start of the mode means "pipe", and the size is always 0, because data never goes to disk. Each shell redirection opened the FIFO, wrote, and closed it, which gave the reader EOF, so it reopens and waits for the next writer. Stop the reader with Ctrl+C and delete the FIFO with rm jobs.fifo.
Common mistake
The close-and-reopen loop above has a race. If a new writer opens the FIFO and writes in the moment between the reader seeing EOF and closing its fd, that data is discarded when the reader closes. Under load, lines go missing. Exercise 2 measures it and shows the fix: open the FIFO with os.O_RDWR, so the reader never sees EOF at all.
A Unix domain socket server¶
import os, socket, struct
PATH = "/tmp/level5-demo.sock"
if os.path.exists(PATH):
os.unlink(PATH) # a stale socket file blocks bind()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as srv:
srv.bind(PATH) # creates the socket file
os.chmod(PATH, 0o660) # file permissions = access control
srv.listen()
print("listening on", PATH, flush=True)
conn, _ = srv.accept()
with conn:
creds = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED,
struct.calcsize("3i"))
pid, uid, gid = struct.unpack("3i", creds)
print(f"client pid={pid} uid={uid} gid={gid}", flush=True)
data = conn.recv(1024)
conn.sendall(b"server says: got " + data)
os.unlink(PATH)
Start it, then connect from a second terminal, first with Python and then (after restarting the server) with nc -U:
ls -l /tmp/level5-demo.sock
ss -xl | grep level5
python3 -c '
import socket
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect("/tmp/level5-demo.sock")
s.sendall(b"hello\n"); print(s.recv(1024).decode(), end="")'
srw-rw---- 1 alex alex 0 Oct 2 10:42 /tmp/level5-demo.sock
u_str LISTEN 0 128 /tmp/level5-demo.sock 1940576 * 0
server says: got hello
And the server's terminal:
The s in srw-rw---- marks a socket file. ss -x lists Unix sockets (-l for listening ones). The server learned the client's PID and UID from the kernel without the client sending them. Restart the server and try echo ping | nc -U -q1 /tmp/level5-demo.sock to see it work with nc too.
A TCP echo server and client¶
The simplest possible TCP server handles one client at a time. It's the right place to see every syscall from the lifecycle diagram:
#!/usr/bin/env python3
"""Iterative TCP echo server: one client at a time."""
import socket
HOST, PORT = "127.0.0.1", 9000
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # restart without waiting
srv.bind((HOST, PORT)) # bind()
srv.listen() # listen()
print(f"listening on {HOST}:{PORT}", flush=True)
try:
while True:
conn, addr = srv.accept() # accept() blocks
print(f"connection from {addr[0]}:{addr[1]}", flush=True)
with conn:
while True:
data = conn.recv(4096) # recv() blocks
if not data: # b"" means peer closed
break
conn.sendall(data) # send it all back
print(f"{addr[0]}:{addr[1]} disconnected", flush=True)
except KeyboardInterrupt:
print("\nshutting down", flush=True)
finally:
srv.close()
#!/usr/bin/env python3
import socket
import sys
HOST, PORT = "127.0.0.1", 9000
message = " ".join(sys.argv[1:]) or "hello"
with socket.create_connection((HOST, PORT), timeout=5) as sock: # socket()+connect()
print("local address:", sock.getsockname())
sock.sendall(message.encode() + b"\n")
reply = b""
while not reply.endswith(b"\n"): # TCP is a byte stream: loop until done
chunk = sock.recv(4096)
if not chunk:
break
reply += chunk
print("echoed back:", reply.decode().rstrip())
Start the server in one terminal, then use the client and nc from another:
The server's terminal:
listening on 127.0.0.1:9000
connection from 127.0.0.1:34508
127.0.0.1:34508 disconnected
connection from 127.0.0.1:34512
127.0.0.1:34512 disconnected
Port 34508 is the client's ephemeral port, picked by the kernel. socket.create_connection() is the convenient client-side call: it resolves the host name, tries each address it gets, and applies the timeout to the connect. Always give network clients a timeout. Without one, a silent server can hang your client forever, which is exactly the story at the start of chapter 1.
Now see the limitation. Leave one nc connected:
And from a third terminal, run python3 echo_client.py second. It hangs: the server is stuck in the first client's recv loop and never calls accept() again. The kernel completed the second client's handshake and queued it, which is why connect succeeded, but nobody is reading. Press Ctrl+C in the nc terminal, and the waiting client is served at once.
Watching TIME_WAIT¶
With the iterative server running and an nc connected, stop the server with Ctrl+C first, so the server side closes the connection first. Then:
That's the server side's TIME_WAIT entry, which will last 60 seconds. Because echo_server.py set SO_REUSEADDR, restarting it right away works. A version without that setsockopt line fails during those 60 seconds:
Traceback (most recent call last):
File "/home/alex/level5/echo_noreuse.py", line 9, in <module>
srv.bind((HOST, PORT)) # bind()
^^^^^^^^^^^^^^^^^^^^^^
OSError: [Errno 98] Address already in use
Handling many clients with threads¶
The smallest change that fixes the one-client problem is a thread per connection:
#!/usr/bin/env python3
"""Thread-per-client echo server."""
import socket
import threading
HOST, PORT = "127.0.0.1", 9000
def handle(conn: socket.socket, addr) -> None:
name = f"{addr[0]}:{addr[1]}"
print(f"[{threading.current_thread().name}] {name} connected", flush=True)
with conn:
while data := conn.recv(4096):
conn.sendall(data)
print(f"[{threading.current_thread().name}] {name} disconnected", flush=True)
with socket.create_server((HOST, PORT)) as srv: # sets SO_REUSEADDR for you
print(f"listening on {HOST}:{PORT}", flush=True)
while True:
conn, addr = srv.accept()
threading.Thread(target=handle, args=(conn, addr), daemon=True).start()
daemon=True here is a thread setting (unrelated to daemon processes): Python won't wait for these threads at exit, so Ctrl+C stops the server even with clients connected. The while data := conn.recv(4096) loop uses the "walrus" operator to assign and test in one step. It ends when recv returns b"".
To test concurrency, this script opens many connections at once and interleaves messages across them:
#!/usr/bin/env python3
"""Open N connections at once, interleave messages, check every echo."""
import socket
import sys
import time
N = int(sys.argv[1]) if len(sys.argv) > 1 else 5
socks = [socket.create_connection(("127.0.0.1", 9000)) for _ in range(N)]
t0 = time.monotonic()
for rnd in range(3):
for i, s in enumerate(socks):
s.sendall(f"client {i} round {rnd}\n".encode())
for i, s in enumerate(socks):
buf = b""
while not buf.endswith(b"\n"):
buf += s.recv(4096)
assert buf == f"client {i} round {rnd}\n".encode(), buf
for s in socks:
s.close()
print(f"{N} clients x 3 rounds: all echoes correct in {time.monotonic() - t0:.3f}s")
listening on 127.0.0.1:9000
[Thread-1 (handle)] 127.0.0.1:58338 connected
[Thread-2 (handle)] 127.0.0.1:58342 connected
...
50 clients x 3 rounds: all echoes correct in 0.014s
Against the iterative echo_server.py, the same test would hang on the second client.
Handling many clients with selectors¶
This version uses one thread and the kernel's epoll:
#!/usr/bin/env python3
"""Single-threaded echo server using selectors (epoll on Linux)."""
import selectors
import socket
HOST, PORT = "127.0.0.1", 9000
sel = selectors.DefaultSelector() # EpollSelector on Linux
print("using", type(sel).__name__, flush=True)
def accept(srv: socket.socket) -> None:
conn, addr = srv.accept() # won't block: epoll said it's ready
conn.setblocking(False)
print(f"{addr[0]}:{addr[1]} connected", flush=True)
sel.register(conn, selectors.EVENT_READ, data=echo)
def echo(conn: socket.socket) -> None:
try:
data = conn.recv(4096) # won't block: data is waiting
except ConnectionResetError:
data = b""
if data:
conn.sendall(data) # OK for small replies; see the note
else: # b"" = the client closed
print(f"fd {conn.fileno()} disconnected", flush=True)
sel.unregister(conn)
conn.close()
srv = socket.create_server((HOST, PORT)) # SO_REUSEADDR is set for you
srv.setblocking(False)
sel.register(srv, selectors.EVENT_READ, data=accept)
print(f"listening on {HOST}:{PORT}", flush=True)
try:
while True:
for key, _mask in sel.select(): # sleep until something is ready
callback = key.data
callback(key.fileobj)
except KeyboardInterrupt:
pass
finally:
sel.close()
srv.close()
How it works:
- Each socket is registered with the selector, along with a callback stored in
data. The listening socket's callback isaccept. Each client's isecho. sel.select()blocks until at least one registered socket is ready, then returns only the ready ones. For a listening socket, "ready to read" means a connection is waiting to be accepted.- Every handler does one non-blocking step and returns, so the loop can serve everyone.
The trace shows the event loop in syscalls (trimmed):
bind(4, {sa_family=AF_INET, sin_port=htons(9000), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
listen(4, 128) = 0
epoll_ctl(3, EPOLL_CTL_ADD, 4, {events=EPOLLIN, data={u32=4, u64=130755984359428}}) = 0
epoll_wait(3, [{events=EPOLLIN, data={u32=4, u64=130755984359428}}], 1, -1) = 1
accept4(4, {sa_family=AF_INET, sin_port=htons(36862), sin_addr=inet_addr("127.0.0.1")}, [16], SOCK_CLOEXEC) = 5
epoll_ctl(3, EPOLL_CTL_ADD, 5, {events=EPOLLIN, data={u32=5, u64=130755984359429}}) = 0
epoll_wait(3, [{events=EPOLLIN, data={u32=5, u64=130755984359429}}], 2, -1) = 1
recvfrom(5, "ping\n", 4096, 0, NULL, NULL) = 5
sendto(5, "ping\n", 5, 0, NULL, 0) = 5
epoll_wait(3, [{events=EPOLLIN, data={u32=5, u64=130755984359429}}], 2, -1) = 1
recvfrom(5, "", 4096, 0, NULL, NULL) = 0
epoll_ctl(3, EPOLL_CTL_DEL, 5, 0x7ffd10258be4) = 0
fd 3 is the epoll instance, fd 4 the listener, fd 5 the client (from echo ping | nc -q0 127.0.0.1 9000 in another terminal). The -1 timeout in epoll_wait means "wait forever". The process spends its idle time blocked there, using no CPU.
sendall on a non-blocking socket
sendall() on a non-blocking socket raises BlockingIOError if the kernel's send buffer fills up, for example with a slow client and large replies. It's fine for short echoes. A production-quality selectors server keeps a per-client output buffer, registers for EVENT_WRITE while that buffer is non-empty, and sends more when the socket becomes writable. Handling this kind of detail for you is a big part of what asyncio offers.
Handling many clients with asyncio¶
#!/usr/bin/env python3
"""Echo server with asyncio streams."""
import asyncio
HOST, PORT = "127.0.0.1", 9000
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
host, port = writer.get_extra_info("peername")
print(f"{host}:{port} connected", flush=True)
try:
while line := await reader.readline(): # other clients run while we wait
writer.write(line)
await writer.drain() # wait if the client reads slowly
except ConnectionResetError:
pass
finally:
print(f"{host}:{port} disconnected", flush=True)
writer.close()
await writer.wait_closed()
async def main() -> None:
server = await asyncio.start_server(handle, HOST, PORT)
print(f"listening on {HOST}:{PORT}", flush=True)
async with server:
await server.serve_forever()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
listening on 127.0.0.1:9000
127.0.0.1:59274 connected
127.0.0.1:59290 connected
...
50 clients x 3 rounds: all echoes correct in 0.011s
handle reads like the blocking version, but each await is a point where the coroutine pauses and the event loop runs other clients. asyncio.start_server calls handle in a new task (a scheduled coroutine) for each connection. reader.readline() does the newline framing for you, buffering partial lines across recv calls. writer.drain() waits when the kernel's send buffer is full, which is the backpressure that the selectors version left out.
Common mistake
Calling blocking code inside a coroutine (time.sleep(5), requests.get(...), a slow database driver, heavy computation) freezes every client, because nothing else runs until that call returns. Use the asyncio equivalents (await asyncio.sleep(5)), or push blocking work to a thread with await asyncio.to_thread(func, args).
Framing messages¶
First, proof that TCP doesn't preserve message boundaries. socket.socketpair() returns two connected sockets in one process, which is handy for experiments:
#!/usr/bin/env python3
"""Show that TCP has no message boundaries."""
import socket
import time
a, b = socket.socketpair()
a.sendall(b"SET user alex\n")
a.sendall(b"SET host mint\n")
a.sendall(b"GET us") # a message cut in half
time.sleep(0.1)
print("one recv() got:", b.recv(4096))
a.sendall(b"er\n")
print("next recv() got:", b.recv(4096))
Three sends arrived as one receive, ending in the middle of a command. (socketpair gives Unix stream sockets, but TCP behaves the same way, and over a real network it's even less predictable.) A line-based receiver handles this with a buffer:
buf = b""
while True:
chunk = conn.recv(4096)
if not chunk:
break
buf += chunk
while b"\n" in buf:
line, buf = buf.split(b"\n", 1) # one complete command
handle(line)
if len(buf) > 65536:
raise ValueError("line too long") # protect yourself
For binary data, or payloads that may contain newlines, use a length prefix:
#!/usr/bin/env python3
"""Length-prefixed framing: 4-byte big-endian length, then the payload."""
import json
import socket
import struct
HEADER = struct.Struct("!I") # ! = network byte order, I = uint32
def send_msg(sock: socket.socket, obj) -> None:
payload = json.dumps(obj).encode()
sock.sendall(HEADER.pack(len(payload)) + payload)
def recv_exact(sock: socket.socket, n: int) -> bytes:
buf = bytearray()
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError(f"peer closed after {len(buf)} of {n} bytes")
buf += chunk
return bytes(buf)
def recv_msg(sock: socket.socket):
(length,) = HEADER.unpack(recv_exact(sock, HEADER.size))
if length > 10_000_000:
raise ValueError(f"refusing {length}-byte message")
return json.loads(recv_exact(sock, length))
if __name__ == "__main__":
a, b = socket.socketpair() # two connected sockets, handy for tests
send_msg(a, {"event": "upload", "rows": 1200})
send_msg(a, {"event": "note", "text": "line one\nline two"}) # newline inside: fine
print(recv_msg(b))
print(recv_msg(b))
struct packs the length as 4 bytes in network byte order (big-endian), the convention for binary network protocols, so machines with different CPU byte orders agree. recv_exact is the loop every length-prefixed protocol needs, because even a 4-byte header can arrive in pieces.
Shared memory, briefly¶
For completeness, the one mechanism here that doesn't use file descriptors for the data itself:
import os
from multiprocessing import shared_memory
shm = shared_memory.SharedMemory(name="level5_demo", create=True, size=16)
print("backing file:", os.path.exists("/dev/shm/level5_demo"))
pid = os.fork()
if pid == 0: # child writes...
child = shared_memory.SharedMemory(name="level5_demo")
child.buf[:5] = b"hello"
child.close()
os._exit(0)
os.waitpid(pid, 0)
print("parent reads:", bytes(shm.buf[:5])) # ...parent sees it: no copying
shm.close()
shm.unlink() # remove /dev/shm/level5_demo
Both processes mapped the same memory pages, backed by a file on the /dev/shm tmpfs. The data was never copied through the kernel. Real uses need a lock or another signal to say "the data is ready", which is where the complexity lies. Reach for it only when you've measured that copying through a pipe or socket is your bottleneck.
Testing servers with nc and ss¶
nc (netcat) is a Swiss Army knife for sockets. Mint ships the OpenBSD version. The forms you'll use most:
nc 127.0.0.1 9000 # interactive client: type lines, see replies
echo "STATS" | nc -q1 127.0.0.1 9000 # send, wait 1 s after EOF, then quit
nc -zv 127.0.0.1 9000 # just test whether the port accepts connections
nc -l 127.0.0.1 9001 # be a one-shot server (great for testing clients)
nc -U /tmp/level5-demo.sock # talk to a Unix domain socket
nc -C 127.0.0.1 9000 # send CRLF line endings (like telnet)
nc: connect to 127.0.0.1 port 9000 (tcp) failed: Connection refused
Connection to 127.0.0.1 22 port [tcp/ssh] succeeded!
"Connection refused" means a host answered but nothing is listening on that port (the kernel replied with a TCP reset). A timeout instead would suggest a firewall silently dropping packets.
ss ("socket statistics") shows the kernel's view of all sockets. You met it in Networking basics. With the selectors server running and two nc clients connected:
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 128 127.0.0.1:9000 0.0.0.0:* users:(("python3",pid=228304,fd=4))
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 127.0.0.1:9000 127.0.0.1:55324 users:(("python3",pid=228304,fd=5))
ESTAB 0 0 127.0.0.1:55324 127.0.0.1:9000 users:(("nc",pid=228330,fd=3))
ESTAB 0 0 127.0.0.1:55338 127.0.0.1:9000 users:(("nc",pid=228331,fd=3))
ESTAB 0 0 127.0.0.1:9000 127.0.0.1:55338 users:(("python3",pid=228304,fd=6))
The flags: -t TCP, -l listening only, -n numeric (don't resolve names), -p show the owning process, -x Unix sockets, -a all states. Each connection appears twice because both ends are on this machine. The server has fd 4 (listener) and fds 5 and 6 (one per client), matching the lifecycle diagram.
For a listening socket, Send-Q shows the backlog limit (128 here) and Recv-Q the number of connections waiting in the accept queue. A non-zero Recv-Q on a listener that stays non-zero means the server isn't calling accept() fast enough, which is exactly the bug from this chapter's opening story. For an established connection, Recv-Q is bytes received but not yet read by the application, and Send-Q is bytes sent but not yet acknowledged by the peer.
lsof -i :9000 shows the same information from the process side, and ss -tan state time-wait lists connections in TIME_WAIT.
Exercises¶
Exercise 1: Close the write end (easy)¶
Copy pipe_fork.py, remove the parent's os.close(w), and run it. Describe what happens and why. Then use ls -l /proc/PID/fd on the hung parent (from another terminal) to show the evidence.
Solution
The parent prints the three records and then hangs. The child closed its write end and exited, but the parent still holds its own copy of the write end, so from the kernel's point of view a writer still exists, and read waits for more data instead of returning EOF.
lrwx------ 1 alex alex 64 Oct 2 11:20 0 -> /dev/pts/1
lrwx------ 1 alex alex 64 Oct 2 11:20 1 -> /dev/pts/1
lrwx------ 1 alex alex 64 Oct 2 11:20 2 -> /dev/pts/1
lr-x------ 1 alex alex 64 Oct 2 11:20 3 -> pipe:[2051177]
l-wx------ 1 alex alex 64 Oct 2 11:20 4 -> pipe:[2051177]
The same pipe inode appears twice: read end (lr-x) and write end (l-wx). The process is its own blocked writer. You could also sudo strace -p it and see it waiting in read(3,.
Exercise 2: A FIFO-driven logger (easy)¶
Write fifo_logger.py, which creates events.fifo, reads lines from it forever, and appends each line to events.log with a timestamp. Feed it 100 lines each from two shell loops running at once (echo ... > events.fifo per line). Count the lines in the log. Does any line get lost or mangled? Why?
Solution
A first attempt, using the reopen loop from fifo_reader.py:
import os, time
FIFO, LOG = "events.fifo", "events.log"
if not os.path.exists(FIFO):
os.mkfifo(FIFO, 0o600)
with open(LOG, "a") as log:
while True:
with open(FIFO) as fifo:
for line in fifo:
log.write(f"{time.strftime('%H:%M:%S')} {line}")
log.flush()
python3 fifo_logger.py &
for i in $(seq 100); do echo "shell A event $i" > events.fifo; done &
for i in $(seq 100); do echo "shell B event $i" > events.fifo; done &
wait %2 %3; sleep 0.5
wc -l events.log
kill %1
Lines go missing, and a different number each run (198, 199, 200...). This is the FIFO reopen race: the reader sees EOF when one writer closes, but before it closes its own fd, the next writer opens the FIFO and writes. Then the reader closes, the pipe has no readers left, and the kernel throws away the data sitting in the buffer.
The fix is to open the FIFO read-write. The reader then counts as a writer too, so it never sees EOF and never has to close and reopen:
import os, time
FIFO, LOG = "events.fifo", "events.log"
if not os.path.exists(FIFO):
os.mkfifo(FIFO, 0o600)
# O_RDWR: we count as a writer ourselves, so the pipe never hits EOF
# and we never need to close and reopen it between writers.
fd = os.open(FIFO, os.O_RDWR)
with os.fdopen(fd) as fifo, open(LOG, "a") as log:
for line in fifo:
log.write(f"{time.strftime('%H:%M:%S')} {line}")
log.flush()
Rerun the test (after rm events.log), and add a check for mangled lines:
All 200 lines arrive, and none are mangled. Each echo writes far fewer than 4096 bytes (PIPE_BUF) in a single write, and pipe writes up to that size are atomic, so lines from the two shells never interleave mid-line. (Opening a FIFO with O_RDWR is Linux-specific behavior, but reliable on Linux.)
Exercise 3: A line-based uppercase server (medium)¶
Using asyncio, write upper_server.py on 127.0.0.1:9100: for every line a client sends, it replies with the line in upper case. The command QUIT closes the connection with BYE. Lines longer than 1,024 bytes get ERR too long and a disconnect. Test with nc, and with a Python client that sends a long line.
Solution
import asyncio
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
while True:
try:
raw = await reader.readuntil(b"\n")
except asyncio.IncompleteReadError:
break # client closed
except asyncio.LimitOverrunError:
writer.write(b"ERR too long\n")
break
line = raw.decode(errors="replace").rstrip("\r\n")
if line.upper() == "QUIT":
writer.write(b"BYE\n")
break
writer.write(line.upper().encode() + b"\n")
await writer.drain()
finally:
writer.close()
await writer.wait_closed()
async def main() -> None:
server = await asyncio.start_server(handle, "127.0.0.1", 9100, limit=1024)
async with server:
await server.serve_forever()
asyncio.run(main())
python3 upper_server.py &
printf 'hello mint\nquit\n' | nc -q1 127.0.0.1 9100
python3 -c 'import sys; sys.stdout.write("x" * 5000 + "\n")' | nc -q1 127.0.0.1 9100
kill %1
limit=1024 sets the stream reader's buffer limit. readuntil raises LimitOverrunError when no newline appears within it, so a client can't make the server buffer an endless line.
Exercise 4: Find the bottleneck with ss (medium)¶
Start the iterative echo_server.py. Connect one nc and leave it open. Then start 5 more clients in the background with for i in 1 2 3 4 5; do (echo hi; sleep 30) | nc 127.0.0.1 9000 & done. Use ss to show (a) the listener's accept queue and (b) data sitting unread in kernel buffers. Explain what you see, then fix it by switching to threaded_echo.py.
Solution
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 5 128 127.0.0.1:9000 0.0.0.0:*
State Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB 0 0 127.0.0.1:9000 127.0.0.1:41022
ESTAB 3 0 127.0.0.1:9000 127.0.0.1:41030
ESTAB 3 0 127.0.0.1:9000 127.0.0.1:41034
...
(a) The listener's Recv-Q is 5: five completed connections waiting for accept(). (b) Each of those connections has Recv-Q 3: the bytes hi\n arrived and sit in the kernel's receive buffer because no one has read them. The server is stuck serving the first client. With threaded_echo.py, the listener's Recv-Q drops to 0 and every client gets its echo immediately.
Exercise 5: A chat server with selectors (hard)¶
Write chat_server.py on 127.0.0.1:9200 using selectors only (no threads, no asyncio). Every complete line a client sends is broadcast to all other connected clients, prefixed with the sender's port, like [41022] hello. Use a per-client input buffer for framing. Announce joins and leaves. Test with three nc sessions.
Solution
import selectors, socket
sel = selectors.DefaultSelector()
clients: dict[socket.socket, tuple[int, bytearray]] = {} # socket -> (port, input buffer)
def broadcast(msg: bytes, exclude=None) -> None:
for c in list(clients):
if c is not exclude:
try:
c.sendall(msg)
except OSError:
drop(c)
def drop(conn: socket.socket) -> None:
if conn in clients:
port, _ = clients.pop(conn)
sel.unregister(conn)
conn.close()
broadcast(f"* {port} left\n".encode())
def accept(srv: socket.socket) -> None:
conn, (host, port) = srv.accept()
conn.setblocking(False)
clients[conn] = (port, bytearray())
sel.register(conn, selectors.EVENT_READ, data=read)
broadcast(f"* {port} joined\n".encode(), exclude=conn)
def read(conn: socket.socket) -> None:
try:
data = conn.recv(4096)
except ConnectionResetError:
data = b""
if not data:
drop(conn)
return
port, buf = clients[conn]
buf += data
while (i := buf.find(b"\n")) != -1:
line = bytes(buf[:i]).rstrip(b"\r")
del buf[:i + 1]
broadcast(f"[{port}] ".encode() + line + b"\n", exclude=conn)
if len(buf) > 4096:
drop(conn) # refuse endless lines
srv = socket.create_server(("127.0.0.1", 9200))
srv.setblocking(False)
sel.register(srv, selectors.EVENT_READ, data=accept)
try:
while True:
for key, _ in sel.select():
key.data(key.fileobj)
except KeyboardInterrupt:
pass
Open three terminals and run nc 127.0.0.1 9200 in each. Type in one, and the others see [41022] hello. Close one with Ctrl+C, and the others see * 41022 left.
The client's port is stored at accept time rather than looked up later with getpeername(). A first version did the lookup in drop(), and it crashed the whole server with OSError: [Errno 107] Transport endpoint is not connected when a client vanished with a reset: in an event loop, one unhandled exception takes down every client. The sendall on non-blocking sockets is acceptable for a chat demo. A production server would buffer output per client and use EVENT_WRITE so one slow reader can't stall everyone.
Check yourself¶
-
Why does a pipe reader hang forever if the parent forgets to close its copy of the write end?
Answer
readreturns EOF only when every write end in every process is closed. The parent's own copy of the write end counts as a live writer, so the kernel keeps waiting for data that will never come. -
What happens when a process writes to a pipe whose readers have all exited? How does Python differ from
seqhere?Answer
The kernel sends
SIGPIPE, which kills the writer by default (exit status 141 in the shell). Python ignoresSIGPIPEat startup, so instead the write fails withEPIPEand Python raisesBrokenPipeError. -
Name three things a Unix domain socket can do that a TCP socket on 127.0.0.1 can't.
Answer
Control access with file ownership and permissions on the socket path; let the server learn the client's PID/UID/GID from the kernel (
SO_PEERCRED); and pass open file descriptors between processes (SCM_RIGHTS). It's also faster, since it skips the TCP/IP stack. -
In order, which syscalls does a TCP server make before it can talk to its first client, and what does
accept()return?Answer
socket(),setsockopt(SO_REUSEADDR),bind(),listen(),accept().accept()returns a new socket fd for that one connection, plus the client's address. The listening socket stays open for further clients. -
You stop your server and restart it immediately, and
bind()fails with "Address already in use", though nothing is listening. What's going on, and what's the fix?Answer
The server closed connections first, so the kernel keeps those connections in
TIME_WAITfor 60 seconds, still associated with the port. SetSO_REUSEADDRon the listening socket beforebind()(Python'screate_serverdoes it for you). On Linux the old socket must also have had it set. -
A client sends
"GET a\n"and then"GET b\n"with twosend()calls. The server's singlerecv(4096)returnsb"GET a\nGET b\n". Is something broken?Answer
No. TCP is a byte stream with no message boundaries; sends can be merged or split arbitrarily. The server must frame messages itself: buffer incoming bytes, split on newlines (or use length prefixes), and keep any partial message for the next
recv. -
Why does
epollscale better thanselectfor a server with 10,000 connections?Answer
selecthas a hard limit (fd numbers below 1024) and requires passing and scanning the whole set of fds on every call, so its cost grows with the number of connections. Withepoll, fds are registered once, andepoll_waitreturns only the ones that are ready, so its cost depends on activity rather than the total number of connections. -
What happens to all other clients if an
asynciohandler callstime.sleep(5)?Answer
They all freeze for 5 seconds. asyncio runs everything in one thread, and other coroutines only run when the current one reaches an
await. Useawait asyncio.sleep(5), or move blocking work off the loop withasyncio.to_thread.
Key takeaways¶
- Pipes are one-way byte streams between related processes. Close unused ends, or readers never see EOF. A full pipe blocks the writer, and a pipe with no readers raises
SIGPIPEorEPIPE. - FIFOs give a pipe a filesystem name. Unix domain sockets are the right tool for two-way local IPC, with permission-based access and peer credentials.
- A TCP server goes
socket→bind→listen→accept(new fd per client). SetSO_REUSEADDR, bind to127.0.0.1for local-only services, and give clients timeouts. - TCP is a stream: always frame messages (newline or length prefix) and buffer partial data. Use
sendall. - For many clients, use threads (simple),
selectorsover epoll (one thread, explicit), orasyncio(one thread, sequential-looking code). Never block inside an event loop. - Test with
nc(-zv,-q1,-l,-U) and inspect withss -ltnp/ss -tnp. TheirRecv-Qcolumns show backlogs.
Next¶
You can now build a server that handles many clients. The last step is running it the way real services run: under systemd, with its own user, journald logging, clean stops, and hardening. Continue with Your program as a service.