Goal: a tiny, correct, blocking TCP echo server in C you can use as a baseline for later labs (forked/threaded models, select, and epoll). The point isn’t the feature list; it’s the control and measurement you’ll apply to everything that follows in posts to come shortly…
This series is a practical backbone for systems roles and lays foundations for deep infra: evented I/O, perf thinking, and OS hygiene.
Why start with an echo server?
An echo server sends back whatever it receives. That sounds trivial, but it’s ideal for:
- Learning sockets and I/O models without protocol noise.
- Plumbing checks (NAT, firewalls, proxies, Ingress).
- Perf experiments (throughput/latency, syscalls, 10k-connections problem).
- Debug repros (half-closes,
TCP_NODELAY, buffer sizes).
Security note: The historical Echo Protocol (TCP/UDP port 7) is “trivial to abuse” for reflection and feedback loops (e.g., Echo↔Chargen). Don’t expose an echo server publicly unless you lock it down (TCP-only, allowlists, rate limits, timeouts, buffer caps, no verbose payload logs).
What is a blocking server?
A blocking server uses I/O calls that wait until they complete:
accept()blocks until a client connects.read()blocks until data arrives (or peer closes).write()can block if the socket’s send buffer is full.
In this baseline, the main thread does:
=> One client at a time; others queue in the listen backlog. It’s simple and a great reference point, but it won’t scale. That’s the point: a clean baseline to compare against threads/processes and then select/epoll.
The Code – Build, run, test
Project layout for Day‑1:
linux-net-labs/01-echo-io-models/blocking/
The Day‑1 code is at linux-net-labs/01-echo-io-models/blocking/.
|
|
Exercise: Try opening a second or third terminal and connecting to it simultaneously with netcat as above. IT WON’T WORK!!
|
|
How It Works
This document explains how the provided server_blocking.c program works, line by line, and why it is structured the way it is.
High-level behavior
It’s a single-process, single-threaded blocking echo server over IPv4/TCP. It:
- Opens a listening socket on a port (default
8080). - Waits (blocks) in
accept()for a client. - For that one client, repeatedly
read()s bytes and immediatelywrite()s them back (echo). - When the client closes (or an error happens), it closes the connection and goes back to
accept(). Ctrl-Csets a flag that cleanly breaks theaccept()loop and exits.
Because it’s blocking and handles one connection at a time, other clients queue in the listen backlog until the current client disconnects.
Includes and globals
<sys/socket.h>,<netinet/in.h>,<arpa/inet.h>: sockets, IPv4 structs, byte-order helpers.<signal.h>: for catchingSIGINT.static volatile sig_atomic_t g_stop = 0;
A signal-safe flag the handler can set asynchronously.
|
|
The handler only sets a flag (no I/O), which is the correct pattern for signal safety.
Setup
|
|
- Port comes from argv or defaults to 8080.
- Registers the SIGINT handler so
Ctrl-Crequests a graceful shutdown.
|
|
Creates a TCP socket (IPv4, stream).
|
|
Allows quick rebinding to the same port after restarts (avoids “address already in use” when a previous socket is in TIME_WAIT).
|
|
- Binds to all local interfaces (
0.0.0.0) onport. htonl/htonsconvert host byte order → network byte order (big-endian), required for the socket API.
|
|
bindattaches the address to the socket.listen(..., 128)starts the passive listen state;128is the backlog (max queued connections waiting foraccept).
Main loop (accept → echo → close)
|
|
- Blocking accept: the process sleeps here until a client connects.
- If a
SIGINTarrives,acceptmay fail withEINTR; code checks that to break cleanly.
|
|
- Formats the client’s IP/port for logs.
ntohsconverts the port back to host order.
Echo loop
|
|
Key points:
n == 0means EOF: the peer performed an orderly shutdown (e.g., closed the socket).- Handles
EINTRcorrectly for bothreadandwrite. - Handles partial writes: TCP is a byte stream;
writemay write fewer bytes than requested, so it loops until allnbytes are sent.
After the client session ends, it closes cfd and logs “client disconnected”.
Shutdown path
When you press Ctrl-C, the signal handler sets g_stop = 1. If accept is blocked, it is interrupted (EINTR), the code sees g_stop and breaks the loop, then:
|
|
Clean exit, releasing the port.
Why it’s called “blocking”
- Blocking I/O:
accept,read, andwriteall sleep the thread until progress can be made. - Single concurrency slot: While serving one client, the server is not calling
accept, so others queue in the backlog. It’s simple and fine for demos or very low traffic; for many clients you’d fork/thread/event-loop.
Subtle correctness wins in this code
- Uses
volatile sig_atomic_tand a minimal signal handler. - Checks
EINTReverywhere it matters. - Handles partial writes.
- Uses network/host byte-order helpers.
- Logs client IP/port with
inet_ntop.
Possible extensions
- IPv6 & dual-stack: use
getaddrinfoand bind both v4/v6 (or v6-only withIPV6_V6ONLY=0where supported). sigactioninstead ofsignal()for portable handler semantics (setSA_RESTARTif you prefer syscalls to auto-retry).- Concurrency: fork per connection, thread per connection, or switch to non-blocking +
select/poll/epoll/kqueue. - Backpressure/limits: timeouts with
SO_RCVTIMEO/SO_SNDTIMEO, connection caps, etc. - Error paths: more precise logging; handle
setsockopt/inet_ntopfailures.
Gotchas & notes
EINTRvsSA_RESTART: if syscalls restart automatically, Ctrl‑C won’t stop a blockingaccept(). Usesigactionwith noSA_RESTARTorsiginterrupt(SIGINT, 1).- Partial writes:
write()may send fewer bytes than requested; loop until you send allnbytes. SO_REUSEADDR: helps quick restarts while sockets linger in TIME_WAIT; behavior differs across OSes.SIGPIPE: writing to a dead peer can raiseSIGPIPE(default: terminate). Common hardening:signal(SIGPIPE, SIG_IGN)orsend(..., MSG_NOSIGNAL).- Limits: This baseline serves one client at a time. That’s deliberate—to highlight improvements later.
TL;DR
It’s a clean, correct example of a blocking, single-client echo server:
set up socket → bind/listen → loop on accept → for each client, read → write back the same bytes → close → repeat; exit gracefully on Ctrl-C.
Roadmap (what’s next)
- Day‑2: forked multi‑client server (one child per connection). Quick
strace -f -cto see process cost. - Week‑1 (cont.):
select()multiplexing, thenepoll(level‑triggered) for the 10k‑connection problem. - Week‑2:
epoll(edge‑triggered) + write buffering;TCP_NODELAYvsTCP_CORK. - Week‑3: Threads vs small thread‑pool +
SO_REUSEPORTmulti‑acceptors; CPU affinity. - Week‑4: Static HTTP with
sendfile()vsread/write; measure syscalls and throughput. - Week‑5: cgroups v2 limits (CPU/memory) on your server; show throttling graphs.
- Week‑6: Hardening: seccomp‑bpf, capabilities, user/net namespaces; systemd units & health checks.
Definition of Done (for each lab)
- Build: one command (
make/./run.sh). - Tests: quick
nccheck + a small load poke (wrk/ab) later. - Observability: at least one
strace -corperf stattable. - Limits & failure modes documented.
- Short write‑up with results/graphs in
results/.
Appendix: Windows differences (curiosity)
- No
fork()on Windows; process-per-connection isn’t idiomatic. Use threads or IOCP (I/O Completion Ports) with overlapped I/O. - Winsock vs POSIX:
recv/send/closesocket,WSAStartup,ioctlsocket(FIONBIO)for nonblocking. For this series we focus on Linux because the container/cloud/Kubernetes ecosystem and most infra tooling are optimized for it.
If you’re following along in the repo: the Day‑1 code is at linux-net-labs/01-echo-io-models/blocking/.
See also: Day 2: Multi-Client Forking Echo Server.