jongkwan.dev
Development · Essay №031

Socket Programming

From treating sockets as file descriptors, through the bind/listen/accept flow, to I/O multiplexing and concurrency models.

Jongkwan Lee2026년 1월 22일10 min read
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.

python
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.

PropertyTCP (SOCK_STREAM)UDP (SOCK_DGRAM)
Connection modelConnection-orientedConnectionless
ReliabilityGuaranteed by retransmission and ACKNo guarantee (best-effort)
Data boundariesByte stream (no boundaries)Datagram (boundaries preserved)
Header size20-60 bytes8 bytes
Server socketsListening socket + one connected socket per connectionA 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.

FunctionRoleCalled by
socket()Creates a socket, returns a file descriptorServer and client
bind()Assigns an IP and port to the socketServer
listen()Switches to the listening state, sets the backlogServer
accept()Accepts a connection request, returns a new socketServer
connect()Requests a connection to the serverClient
send()/recv()Sends and receives dataBoth
close()Closes the socket, starts the 4-way handshakeBoth

The simplest echo server follows that sequence exactly. It returns the bytes it receives unchanged.

python
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.

Propertyselect / pollepoll / kqueue
Passing fdsThe whole set on every callRegistered once via epoll_ctl
Kernel workScans the whole set, O(n)Returns only ready fds, close to O(1)
fd count limitselect is capped at FD_SETSIZE (usually 1024)Effectively unlimited
TriggeringLevel-triggeredLevel- and edge-triggered
PlatformCommon on POSIXepoll 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).

c
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.

ModelUnit of concurrencyMemoryLimitation
Multi-processA forked process per connectionSeparate address spacesHigh process creation cost
Multi-threadedA thread per connectionShared, requires synchronizationResource use scales with thread count
Event-drivenA single-threaded event loopSharedCallback 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.

python
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.

OptionLevelPurpose
SO_REUSEADDRSOL_SOCKETAllows reuse of a port in TIME_WAIT
SO_KEEPALIVESOL_SOCKETSends periodic probes on idle connections
TCP_NODELAYIPPROTO_TCPDisables the Nagle algorithm
SO_RCVBUF / SO_SNDBUFSOL_SOCKETAdjusts receive and send buffer sizes
SO_REUSEPORTSOL_SOCKETBinds 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.