Talking to the Kernel Directly

Every HTTP client, database driver and message broker ends up calling the same small API: the Berkeley sockets interface. Python's socket module is a thin wrapper around it, so what you learn here carries over to C, Go, Rust and Java almost unchanged. Writing your own client and server is also the fastest way to understand why TCP behaves the way it does, and why so much networking code is subtly broken.

The Lifecycle of a TCP Connection in Code

A server needs one listening socket plus one connected socket per client. The calls map onto the handshake:

Call Side What the kernel does
socket(AF_INET, SOCK_STREAM) both allocates a socket; nothing on the wire
bind((host, port)) server claims an address and port; fails with Address already in use if taken
listen(backlog) server starts answering SYNs; completed handshakes queue up
connect((host, port)) client picks an ephemeral source port, sends SYN, returns after the SYN-ACK
accept() server takes one completed connection off the queue, returns a new socket
send / recv both copy bytes into and out of kernel buffers
shutdown(SHUT_WR) either sends FIN: "I will send nothing more", but can still receive
close() either releases the socket; FIN (or RST if unread data remains)

Two points trip people up. The handshake is completed by the kernel, not by accept(): a client can connect successfully to a server that never calls accept(), until the backlog fills. And send and recv do not talk to the network at all. They move bytes between your program and kernel buffers; TCP decides when and how those bytes travel.

Python provides two helpers that do the boilerplate correctly: socket.create_server() (socket, SO_REUSEADDR on POSIX, bind, listen) and socket.create_connection() (resolves the name, tries each returned address including IPv6, applies a timeout).

A Threaded Server with Real Framing

TCP is a byte stream, so a protocol must define where each message ends. The TCP vs UDP lesson shows length-prefixed framing; here is the other common choice, newline-delimited JSON, with the defensive details a real server needs:

import json, socket, threading

MAX_LINE = 64 * 1024                                  # refuse lines longer than this

def handle(conn, addr):
    conn.settimeout(30)                               # an idle client cannot hold a thread forever
    with conn, conn.makefile("rb") as reader:
        while True:
            line = reader.readline(MAX_LINE + 1)      # returns one complete line, or less at EOF
            if not line:                              # b"": the client closed its side
                break
            if not line.endswith(b"\n"):              # too long, or cut off by EOF
                conn.sendall(b'{"error": "bad frame"}\n')
                break
            try:
                reply = {"echo": json.loads(line), "peer": addr[1]}
            except json.JSONDecodeError:
                reply = {"error": "bad json"}
            conn.sendall(json.dumps(reply).encode() + b"\n")

def serve(listener):
    while True:
        conn, addr = listener.accept()                # blocks until a handshake completes
        threading.Thread(target=handle, args=(conn, addr), daemon=True).start()

listener = socket.create_server(("127.0.0.1", 0))     # bind + listen; port 0 = any free port
port = listener.getsockname()[1]
threading.Thread(target=serve, args=(listener,), daemon=True).start()

with socket.create_connection(("127.0.0.1", port), timeout=5) as client:
    client.sendall(b'{"n": 1}\n{"n": 2}\n{"n"')       # two whole messages and part of a third
    client.sendall(b': 3}\nnot json\n')               # the rest arrives in a later write
    client.shutdown(socket.SHUT_WR)                   # half-close: "I have nothing more to send"
    with client.makefile("rb") as replies:
        for line in replies:
            print(line.decode().rstrip())

Output (the port number varies):

{"echo": {"n": 1}, "peer": 63327}
{"echo": {"n": 2}, "peer": 63327}
{"echo": {"n": 3}, "peer": 63327}
{"error": "bad json"}

What each non-obvious line buys you:

  • makefile("rb") plus readline() puts a buffer in front of the socket. It keeps calling recv until it has a full line and holds any extra bytes for the next call, which is exactly the reassembly the client's split write requires.
  • The MAX_LINE + 1 limit. A bare for line in reader would happily buffer gigabytes from a client that never sends a newline. Every framing scheme needs a maximum message size, including length prefixes, where a hostile 4-byte header can claim a 4 GB body.
  • if not line. recv returning an empty bytes object is the only way you learn that the peer closed the connection. Forgetting this check produces a loop that spins at 100% CPU.
  • sendall instead of send. send may accept only part of the data and returns how many bytes it took. sendall loops until everything is queued.
  • shutdown(SHUT_WR) sends a FIN without closing the socket, so the server sees EOF and the client can still read the replies. Closing instead would discard them.

One thread per client is fine for dozens or low hundreds of connections; beyond that, non-blocking I/O wins.

Timeouts: Blocking Forever Is the Default

A fresh socket blocks indefinitely: a recv whose peer vanished without a FIN (pulled cable, expired NAT state) waits for hours. Set a timeout on every socket:

  • create_connection(addr, timeout=5) bounds the connect and sets the same timeout on later operations.
  • sock.settimeout(30) applies per call, not per message. A client that dribbles one byte every 29 seconds keeps a readline alive indefinitely, so servers that care also enforce an overall deadline per request.
  • A timeout raises TimeoutError (socket.timeout is an alias since Python 3.10). After one, treat the connection as unusable: you do not know how much of a message was consumed.

For long-lived idle connections, add an application-level ping (or TCP keepalive via SO_KEEPALIVE) so dead peers are detected.

Non-Blocking Sockets

In non-blocking mode, a call that cannot complete immediately raises instead of waiting:

import socket

listener = socket.create_server(("127.0.0.1", 0))
client = socket.create_connection(listener.getsockname())
server_side, _ = listener.accept()           # accepted, but this side never reads

client.setblocking(False)
try:
    client.recv(1024)                         # nothing has arrived yet
except BlockingIOError as e:
    print("recv:", repr(e))

total = 0
chunk = b"x" * 65536
try:
    while True:
        total += client.send(chunk)           # returns how many bytes the kernel took
except BlockingIOError:
    print(f"send: buffers full after {total:,} bytes")

On Windows:

recv: BlockingIOError(10035, 'A non-blocking socket operation could not be completed immediately', None, 10035, None)
send: buffers full after 458,752 bytes

On Linux the error reads [Errno 11] Resource temporarily unavailable, and the byte count depends on buffer sizes. The second result is backpressure: when a receiver stops reading, its receive buffer fills, then the sender's send buffer, and writes stall. A program that queues the excess in its own memory instead will eventually run out.

Nobody writes non-blocking servers by catching BlockingIOError in loops. You register sockets with an OS readiness API (epoll on Linux, kqueue on BSD and macOS, wrapped by Python's selectors module) and act only on sockets that are ready. That event loop is exactly what asyncio provides.

The Same Server with asyncio Streams

import asyncio

async def handle(reader, writer):
    peer = writer.get_extra_info("peername")
    try:
        while True:
            line = await asyncio.wait_for(reader.readline(), timeout=30)
            if not line:                                   # EOF
                break
            writer.write(line.upper())
            await writer.drain()                           # wait if the send buffer is full
    except (asyncio.TimeoutError, ValueError, ConnectionError) as e:
        print(f"dropping {peer}: {type(e).__name__}")      # ValueError = line over the limit
    finally:
        writer.close()
        await writer.wait_closed()

async def client(port, i):
    reader, writer = await asyncio.open_connection("127.0.0.1", port)
    writer.write(f"hello from client {i}\n".encode())
    await writer.drain()
    reply = await asyncio.wait_for(reader.readline(), timeout=5)
    writer.close()
    await writer.wait_closed()
    return reply.decode().strip()

async def main():
    server = await asyncio.start_server(handle, "127.0.0.1", 0, limit=64 * 1024)
    port = server.sockets[0].getsockname()[1]
    async with server:
        print(await asyncio.gather(*(client(port, i) for i in range(3))))

asyncio.run(main())
['HELLO FROM CLIENT 0', 'HELLO FROM CLIENT 1', 'HELLO FROM CLIENT 2']

One thread now serves thousands of connections. The details that matter: limit= caps the buffer, and readline() raises ValueError when a line exceeds it; readexactly(n) is the tool for length-prefixed protocols; wait_for supplies the timeout that streams lack by default; and await writer.drain() is how backpressure reaches your code. Calling write() in a loop without drain() buffers without bound. Any blocking call inside a handler (time.sleep, a synchronous database driver, requests) freezes every connection at once.

A note on UDP. UDP sockets (SOCK_DGRAM, covered in TCP vs UDP) have no listen or accept. One extra trick: connect() on a UDP socket sends nothing, but fixes the peer address so ICMP errors surface as exceptions.

Common Bugs and Their Symptoms

Bug Symptom Fix
Assuming one recv equals one message works on localhost, corrupts data over real links framing: delimiter or length prefix, with a buffer
send instead of sendall truncated messages under load sendall, or drain() in asyncio
Ignoring recv() == b"" busy loop at 100% CPU after the client leaves treat empty read as EOF and close
No timeouts threads or tasks pile up; ss shows stuck ESTABLISHED sockets timeouts on connect and every read
Not closing on error paths CLOSE-WAIT count grows with blocks or try/finally
Binding to 127.0.0.1 works locally, "connection refused" from other hosts bind 0.0.0.0 / :: deliberately, behind a firewall
Binding 0.0.0.0 only IPv6 clients refused create_server(("", port), family=socket.AF_INET6, dualstack_ipv6=True)
Restart fails with Address already in use old connections in TIME_WAIT SO_REUSEADDR (create_server sets it on POSIX); on Windows it permits port hijacking, so leave it off there

Verifying from the Outside

Test your server with tools that do not share your bugs:

ss -ltnp | grep 8000                 # is it listening, on which address, which process?
printf '{"n": 1}\n' | nc -q1 127.0.0.1 8000    # send one framed message (-q1: GNU/Debian netcat)
sudo tcpdump -n -i lo 'tcp port 8000' -A        # watch the bytes and the FINs

If ss shows the socket bound to 127.0.0.1:8000, it is unreachable from other machines no matter what the firewall says. For which ports are conventional and how to see who owns them, see Common Ports and Protocols.

When to Use Raw Sockets at All

Use them to learn, for a small custom protocol between systems you control, or for a protocol with no library. For anything standard, use the library, which already handles framing, pooling and TLS. If your own protocol needs encryption, wrap the socket with ssl.create_default_context().wrap_socket(...) as shown in The TLS Handshake.

Practice

Change the threaded server to use a 4-byte length prefix instead of newlines, enforce a 1 MB maximum, and write a client that sends a message one byte per send call with a short sleep in between. Then connect with nc, type nothing, and confirm that your timeout closes the connection and ss -tan shows no leftover sockets.