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-C
sets 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-C
requests 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/htons
convert host byte order → network byte order (big-endian), required for the socket API.
|
|
bind
attaches the address to the socket.listen(..., 128)
starts the passive listen state;128
is the backlog (max queued connections waiting foraccept
).
Main loop (accept → echo → close)
|
|
- Blocking accept: the process sleeps here until a client connects.
- If a
SIGINT
arrives,accept
may fail withEINTR
; code checks that to break cleanly.
|
|
- Formats the client’s IP/port for logs.
ntohs
converts the port back to host order.
Echo loop
|
|
Key points:
n == 0
means EOF: the peer performed an orderly shutdown (e.g., closed the socket).- Handles
EINTR
correctly for bothread
andwrite
. - Handles partial writes: TCP is a byte stream;
write
may write fewer bytes than requested, so it loops until alln
bytes 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
, andwrite
all 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_t
and a minimal signal handler. - Checks
EINTR
everywhere it matters. - Handles partial writes.
- Uses network/host byte-order helpers.
- Logs client IP/port with
inet_ntop
.
Possible extensions
- IPv6 & dual-stack: use
getaddrinfo
and bind both v4/v6 (or v6-only withIPV6_V6ONLY=0
where supported). sigaction
instead ofsignal()
for portable handler semantics (setSA_RESTART
if 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_ntop
failures.
Gotchas & notes
EINTR
vsSA_RESTART
: if syscalls restart automatically, Ctrl‑C won’t stop a blockingaccept()
. Usesigaction
with noSA_RESTART
orsiginterrupt(SIGINT, 1)
.- Partial writes:
write()
may send fewer bytes than requested; loop until you send alln
bytes. 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 -c
to see process cost. - Week‑1 (cont.):
select()
multiplexing, thenepoll
(level‑triggered) for the 10k‑connection problem. - Week‑2:
epoll
(edge‑triggered) + write buffering;TCP_NODELAY
vsTCP_CORK
. - Week‑3: Threads vs small thread‑pool +
SO_REUSEPORT
multi‑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
nc
check + a small load poke (wrk
/ab
) later. - Observability: at least one
strace -c
orperf stat
table. - 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.