Socket Programming

TCP, UDP, Multi-Client Servers, Async Sockets

Introduction

Sockets are the fundamental building blocks of network communication. Every HTTP request, database query, and API call ultimately travels over a socket. Understanding sockets at this level lets you build custom protocols, debug network issues at the lowest level, and understand exactly what every higher-level library does under the hood. This lesson builds TCP servers and clients from scratch, handles multiple connections with threading, and introduces async sockets with asyncio.

TCP Socket: Server & Client

TCP (Transmission Control Protocol) provides reliable, ordered, connection-oriented communication. It's the foundation of HTTP, HTTPS, SSH, and most application protocols.

TCP Echo Server

# tcp_server.py, Simple TCP echo server
import socket

HOST = "127.0.0.1"
PORT = 9000

def run_server():
    # AF_INET = IPv4, SOCK_STREAM = TCP
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_sock:
        # Allow port reuse after restart
        server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

        server_sock.bind((HOST, PORT))
        server_sock.listen(5)    # backlog: max 5 pending connections
        print(f"Server listening on {HOST}:{PORT}")

        while True:
            client_sock, addr = server_sock.accept()
            print(f"Connection from {addr}")

            with client_sock:
                while True:
                    data = client_sock.recv(1024)   # read up to 1024 bytes
                    if not data:
                        break                        # client closed connection
                    print(f"Received: {data.decode().strip()!r}")
                    client_sock.sendall(data)        # echo back

            print(f"Connection closed: {addr}")

if __name__ == "__main__":
    run_server()
Expected Output:
Server listening on 127.0.0.1:9000
Connection from ('127.0.0.1', 54321)
Received: 'Hello, Server!'
Connection closed: ('127.0.0.1', 54321)

TCP Client

# tcp_client.py
import socket

HOST = "127.0.0.1"
PORT = 9000

def send_messages():
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.connect((HOST, PORT))
        print(f"Connected to {HOST}:{PORT}")

        messages = ["Hello, Server!", "Second message", "Goodbye!"]
        for msg in messages:
            sock.sendall(msg.encode("utf-8"))
            response = sock.recv(1024)
            print(f"Sent:     {msg!r}")
            print(f"Received: {response.decode()!r}")

if __name__ == "__main__":
    send_messages()
Expected Output:
Connected to 127.0.0.1:9000
Sent:     'Hello, Server!'
Received: 'Hello, Server!'
Sent:     'Second message'
Received: 'Second message'
Sent:     'Goodbye!'
Received: 'Goodbye!'

UDP Socket: Connectionless Communication

UDP (User Datagram Protocol) is connectionless and does not guarantee delivery or order. It is faster than TCP and used for DNS, video streaming, VoIP, and gaming.

UDP Echo Server

# udp_server.py, UDP echo server
import socket

HOST = "127.0.0.1"
PORT = 9001

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as server:
    server.bind((HOST, PORT))
    print(f"UDP server listening on {HOST}:{PORT}")

    while True:
        data, addr = server.recvfrom(1024)   # no connection needed
        print(f"From {addr}: {data.decode()!r}")
        server.sendto(data, addr)            # echo back to sender
Expected Output:
UDP server listening on 127.0.0.1:9001
From ('127.0.0.1', 54322): 'UDP message 1'
From ('127.0.0.1', 54322): 'UDP message 2'

UDP Client

# udp_client.py
import socket

HOST = "127.0.0.1"
PORT = 9001

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
    # No connect() needed for UDP
    messages = ["UDP message 1", "UDP message 2"]
    for msg in messages:
        client.sendto(msg.encode(), (HOST, PORT))
        data, server_addr = client.recvfrom(1024)
        print(f"Sent: {msg!r}")
        print(f"Echo: {data.decode()!r} from {server_addr}")
Expected Output:
Sent: 'UDP message 1'
Echo: 'UDP message 1' from ('127.0.0.1', 9001)
Sent: 'UDP message 2'
Echo: 'UDP message 2' from ('127.0.0.1', 9001)

Multi-Client Server with Threading

A production server needs to handle multiple clients simultaneously. The simplest approach: spawn a new thread for each client connection.

To test these examples, use the TCP Client from the previous section, point it at PORT = 9002/9003 and open multiple terminal windows to simulate concurrent clients.

Server Setup

# threaded_server.py
import socket
import threading
from datetime import datetime

HOST = "127.0.0.1"
PORT = 9002
client_count = 0
lock = threading.Lock()

def handle_client(conn: socket.socket, addr: tuple, client_id: int):
    """Handle a single client in its own thread."""
    print(f"[{datetime.now().strftime('%H:%M:%S')}] Client {client_id} connected: {addr}")
    try:
        with conn:
            # Send welcome message
            conn.sendall(f"Welcome, Client {client_id}! Type 'quit' to disconnect.\n".encode())

            while True:
                data = conn.recv(1024)
                if not data:
                    break
                message = data.decode().strip()
                if message.lower() == "quit":
                    conn.sendall(b"Goodbye!\n")
                    break
                response = f"[Server → Client {client_id}]: Echo: {message}\n"
                conn.sendall(response.encode())
    except ConnectionResetError:
        pass
    finally:
        print(f"Client {client_id} disconnected")

def run_threaded_server():
    global client_count
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
        server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        server.bind((HOST, PORT))
        server.listen(10)
        print(f"Threaded server on {HOST}:{PORT}")

        while True:
            conn, addr = server.accept()
            with lock:
                client_count += 1
                cid = client_count
            thread = threading.Thread(target=handle_client, args=(conn, addr, cid))
            thread.daemon = True
            thread.start()
            print(f"Active threads: {threading.active_count() - 1}")

if __name__ == "__main__":
    run_threaded_server()

Async Echo Server with asyncio

# async_echo_server.py, Non-blocking server using asyncio
import asyncio

HOST = "127.0.0.1"
PORT = 9003

async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
    addr = writer.get_extra_info("peername")
    print(f"Connection from {addr}")

    writer.write(b"Connected to async echo server\n")
    await writer.drain()

    try:
        while True:
            data = await reader.read(1024)
            if not data:
                break
            message = data.decode().strip()
            print(f"Received from {addr}: {message!r}")

            response = f"Echo: {message}\n"
            writer.write(response.encode())
            await writer.drain()
    except asyncio.IncompleteReadError:
        pass
    finally:
        print(f"Closing connection from {addr}")
        writer.close()
        await writer.wait_closed()

async def main():
    server = await asyncio.start_server(handle_client, HOST, PORT)
    addrs = ", ".join(str(sock.getsockname()) for sock in server.sockets)
    print(f"Async server on {addrs}")

    async with server:
        await server.serve_forever()

if __name__ == "__main__":
    asyncio.run(main())
Expected Output:
Async server on ('127.0.0.1', 9003)
Connection from ('127.0.0.1', 52341)
Received from ('127.0.0.1', 52341): 'hello async'
Closing connection from ('127.0.0.1', 52341)
PatternBest ForDrawback
Single-threaded (blocking)Learning, simple scriptsOne client at a time
Thread-per-clientSmall to medium concurrencyHigh memory per thread at scale
Thread poolCPU-bound tasksGIL limits true parallelism in Python
asyncioHigh-concurrency I/O-bound serversAll code must be async-aware

Key Takeaways

  • TCP provides reliability, connection-oriented with ordering and delivery guarantees; use for most application protocols
  • UDP is faster but unreliable, no guarantees; ideal for DNS, streaming, and latency-sensitive applications
  • SO_REUSEADDR is essential, prevents "address already in use" errors when restarting a server
  • Thread-per-client works for moderate load, but asyncio scales better for thousands of simultaneous connections
  • asyncio is the modern Python answer, non-blocking I/O handles massive concurrency without thread overhead
  • Always use context managers, with socket.socket() ensures the socket is closed even on exceptions
What's Next?

Lesson 7 builds on sockets to implement the HTTP protocol from scratch, parsing requests, building responses, and adding HTTPS with Python's ssl module.

  • HTTP request/response anatomy, headers, body, status codes
  • Raw HTTP client, sending HTTP over a plain socket
  • Minimal HTTP server, serving responses from scratch
  • HTTPS, wrapping sockets with the ssl module