Socket Programming
From treating sockets as file descriptors, through the bind/listen/accept flow, to I/O multiplexing and concurrency models.
Contents
A socket is an endpoint that abstracts network communication as a file descriptor, and servers and clients establish connections through a fixed sequence of calls.
Sockets and file descriptors
A socket is an endpoint that identifies one end of a network conversation. The operating system hands it back as a file descriptor, an integer handle referring to an open resource. That is why network I/O can be handled with read and write just like a file.
Creating a socket means choosing an address family and a transport type together. IPv4 uses AF_INET, byte-stream TCP uses SOCK_STREAM, and datagram-oriented UDP uses SOCK_DGRAM.
import socket
# Create an IPv4 + TCP socket; the return value is a socket object (a file descriptor underneath)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)TCP and UDP sit on the same socket API but behave differently. The table below sums up the essential differences.
| Property | TCP (SOCK_STREAM) | UDP (SOCK_DGRAM) |
|---|---|---|
| Connection model | Connection-oriented | Connectionless |
| Reliability | Guaranteed by retransmission and ACK | No guarantee (best-effort) |
| Data boundaries | Byte stream (no boundaries) | Datagram (boundaries preserved) |
| Header size | 20-60 bytes | 8 bytes |
| Server sockets | Listening socket + one connected socket per connection | A single socket serves many peers |
The header sizes come from RFC 9293 (TCP) and RFC 768 (UDP). The difference that bites hardest in practice is data boundaries. TCP is a byte stream with no message boundaries, so the application has to impose them itself with a length prefix or a delimiter.
The call sequence that establishes a connection
TCP servers and clients play different roles. The server binds an address, enters a waiting state, and accepts connections; the client dials that address. The diagram below shows the call sequence on both sides.
connect() starts the 3-way handshake, a procedure in which SYN, SYN+ACK, and ACK let both sides confirm the other can receive. Each call plays the following role.
| Function | Role | Called by |
|---|---|---|
| socket() | Creates a socket, returns a file descriptor | Server and client |
| bind() | Assigns an IP and port to the socket | Server |
| listen() | Switches to the listening state, sets the backlog | Server |
| accept() | Accepts a connection request, returns a new socket | Server |
| connect() | Requests a connection to the server | Client |
| send()/recv() | Sends and receives data | Both |
| close() | Closes the socket, starts the 4-way handshake | Both |
The simplest echo server follows that sequence exactly. It returns the bytes it receives unchanged.
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", 9000))
server.listen(128) # backlog 128
while True:
conn, addr = server.accept() # returns the connected socket and the peer address
data = conn.recv(1024) # receive at most 1024 bytes
conn.sendall(data) # send back everything received
conn.close()This code handles one connection at a time, because the next accept() is blocked until recv() and sendall() finish. Handling concurrent connections requires the models described later.
Backlog and connected sockets
The backlog argument to listen() sets the size of the pending connection queue. accept() pulls one completed connection off that queue and returns a new socket descriptor. The original listening socket is not closed and keeps taking new connections.
That is why accept() creates a new socket every time. The listening socket only takes connections, while the actual data transfer belongs to the connected socket. A connected socket is identified by the 4-tuple of (server IP, server port, client IP, client port).
That 4-tuple is what lets one server port talk to many clients at once. As long as the source IP or port differs, the kernel treats it as a distinct connection. On Linux this waiting stage is split into incomplete connections (the SYN queue) and completed connections (the accept queue), per the Linux listen(2) and tcp(7) manual pages.
send, recv, and kernel buffers
send() and recv() do not guarantee the requested number of bytes in one call. recv(1024) returns at most 1024 bytes and may return fewer. send() writes only as much as the kernel send buffer has room for and returns the number of bytes actually transmitted.
Transferring an exact amount therefore requires looping. Python's sendall() loops internally until everything is sent, and on the receiving side you call recv() repeatedly until you have as many bytes as you need. The kernel keeps a send and a receive buffer per socket, sized via SO_SNDBUF and SO_RCVBUF.
This behavior follows from TCP being a byte stream. One send() can arrive split across several recv() calls, and several send() calls can be coalesced into one recv(). Preserving message units requires the application to impose boundaries with a length prefix or a delimiter.
Blocking and I/O multiplexing
With blocking I/O, a recv() call blocks the thread until data arrives. One thread can watch only one socket, so more concurrent connections mean proportionally more threads. Non-blocking I/O returns EAGAIN (or EWOULDBLOCK) immediately when no data is available.
Driving many sockets with non-blocking I/O alone turns into polling over idle sockets and wastes CPU. That is why it is usually combined with I/O multiplexing: one thread watches many sockets with select, poll, or epoll, and processes only the ones that are ready.
| Property | select / poll | epoll / kqueue |
|---|---|---|
| Passing fds | The whole set on every call | Registered once via epoll_ctl |
| Kernel work | Scans the whole set, O(n) | Returns only ready fds, close to O(1) |
| fd count limit | select is capped at FD_SETSIZE (usually 1024) | Effectively unlimited |
| Triggering | Level-triggered | Level- and edge-triggered |
| Platform | Common on POSIX | epoll on Linux, kqueue on BSD and macOS |
The default FD_SETSIZE is usually 1024, per the Linux select(2) manual page. epoll registers fds with the kernel once, after which epoll_wait returns only the fds with pending events. The following is the skeleton of a Linux epoll event loop (some arguments omitted).
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN; // watch for read events
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
struct epoll_event events[MAX_EVENTS];
while (1) {
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == listen_fd) {
int client_fd = accept(listen_fd, NULL, NULL);
ev.events = EPOLLIN | EPOLLET; // edge-triggered
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
} else {
handle_client(events[i].data.fd);
}
}
}Edge triggering (EPOLLET) notifies only at the moment state changes, which cuts down redundant notifications. On macOS and BSD, kqueue plays the same role. Python's asyncio, the Node.js event loop, and Nginx all use epoll or kqueue underneath.
Three models for handling concurrent connections
Approaches to concurrent connections fall into three broad groups. The split is between models that allocate a fresh resource per connection and models that dispatch events on a single thread.
| Model | Unit of concurrency | Memory | Limitation |
|---|---|---|---|
| Multi-process | A forked process per connection | Separate address spaces | High process creation cost |
| Multi-threaded | A thread per connection | Shared, requires synchronization | Resource use scales with thread count |
| Event-driven | A single-threaded event loop | Shared | Callback branching complicates the code |
Multi-process and multi-threaded models create a process or thread per connection, which consumes a lot of resources. The event-driven model combines non-blocking I/O with multiplexing on a single thread and handles tens of thousands of connections. The challenge of sustaining ten thousand concurrent connections is known as the C10K problem, a term coined by Dan Kegel.
The event-driven model rests on the reactor pattern: a single-threaded event loop receives ready events and dispatches them to registered handlers. Python's standard-library selectors module expresses that skeleton compactly.
import selectors
import socket
sel = selectors.DefaultSelector()
def accept_handler(server):
conn, addr = server.accept()
conn.setblocking(False)
sel.register(conn, selectors.EVENT_READ, read_handler)
def read_handler(conn):
data = conn.recv(1024)
if data:
conn.sendall(data)
else: # empty bytes mean the peer called close()
sel.unregister(conn)
conn.close()
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", 9000))
server.listen(100)
server.setblocking(False)
sel.register(server, selectors.EVENT_READ, accept_handler)
while True:
for key, mask in sel.select(timeout=None): # returns only ready fds
callback = key.data
callback(key.fileobj)Here sel.select() picks epoll or kqueue internally. The listening socket and the connected sockets are registered with the same selector, and only sockets with pending events go through a callback. One thread drives a large number of sockets, so the per-connection thread cost disappears.
Socket options you touch in production
Socket options are turned on and off with setsockopt(). The ones adjusted most often in operations are listed below.
| Option | Level | Purpose |
|---|---|---|
| SO_REUSEADDR | SOL_SOCKET | Allows reuse of a port in TIME_WAIT |
| SO_KEEPALIVE | SOL_SOCKET | Sends periodic probes on idle connections |
| TCP_NODELAY | IPPROTO_TCP | Disables the Nagle algorithm |
| SO_RCVBUF / SO_SNDBUF | SOL_SOCKET | Adjusts receive and send buffer sizes |
| SO_REUSEPORT | SOL_SOCKET | Binds multiple sockets to the same port |
SO_REUSEADDR is the option you meet most often. In TCP, the side that actively closes a connection enters TIME_WAIT, which prevents delayed packets from bleeding into a new connection. That window is twice the Maximum Segment Lifetime (MSL), and the Linux implementation fixes it at about 60 seconds (estimated).
Trying to bind() to a port in that state produces an "Address already in use" error. Enabling SO_REUSEADDR reuses the TIME_WAIT port so the server can restart immediately. TCP_NODELAY disables the Nagle algorithm, which batches small packets, and improves responsiveness for latency-sensitive traffic.
Summary
A socket abstracts network communication as a file descriptor; the server establishes connections with bind, listen, and accept, and the client with connect. The connected socket returned by accept() is identified by a 4-tuple, so one port can serve many clients simultaneously. send and recv do not guarantee the requested byte count in one call, so message boundaries have to be handled explicitly on top of the byte stream.
Concurrency can be solved with multi-process, multi-threaded, or event-driven models. At large connection counts the event model built on non-blocking I/O plus epoll or kqueue wins on resource use. Finally, options such as SO_REUSEADDR only behave as intended once you understand the normal TCP behavior, TIME_WAIT included, that they interact with.