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 senderExpected 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.
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)| Pattern | Best For | Drawback |
|---|---|---|
| Single-threaded (blocking) | Learning, simple scripts | One client at a time |
| Thread-per-client | Small to medium concurrency | High memory per thread at scale |
| Thread pool | CPU-bound tasks | GIL limits true parallelism in Python |
| asyncio | High-concurrency I/O-bound servers | All 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
sslmodule