TCP Echo Server from Scratch
Goal: Build a TCP echo server in C that accepts multiple clients using epoll. Understand the full socket lifecycle: socket → bind → listen → accept → read/write → close.
Prerequisites: TCP Protocol, Socket Programming, File IO in C, System Calls
What We’re Building
A server on port 8080 that:
- Accepts TCP connections
- Reads whatever the client sends
- Echoes it back
- Handles multiple clients concurrently (with epoll, no threads)
Step 1: Create and Bind the Socket
// echo_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>
#define PORT 8080
#define MAX_EVENTS 64
#define BUF_SIZE 4096
static void die(const char *msg) { perror(msg); exit(1); }
static void set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int create_server(void) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) die("socket");
// Allow port reuse (avoid "Address already in use" on restart)
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &(int){1}, sizeof(int));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(PORT),
.sin_addr.s_addr = INADDR_ANY,
};
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) die("bind");
if (listen(fd, 128) < 0) die("listen");
set_nonblocking(fd);
printf("Listening on :%d\n", PORT);
return fd;
}Why SO_REUSEADDR
After closing a socket, the port stays in TIME_WAIT for ~60 seconds. Without SO_REUSEADDR, restarting the server fails with “Address already in use”. See TCP Protocol — TIME_WAIT prevents stale packets from a previous connection.
Why non-blocking
With blocking sockets, accept() and read() block the entire process. Non-blocking + epoll lets us handle many clients in a single thread.
Step 2: The epoll Event Loop
int main(void) {
int server_fd = create_server();
int epfd = epoll_create1(0);
if (epfd < 0) die("epoll_create1");
// Watch the server socket for incoming connections
struct epoll_event ev = {.events = EPOLLIN, .data.fd = server_fd};
epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);
struct epoll_event events[MAX_EVENTS];
while (1) {
int nready = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (nready < 0) {
if (errno == EINTR) continue; // interrupted by signal
die("epoll_wait");
}
for (int i = 0; i < nready; i++) {
int fd = events[i].data.fd;
if (fd == server_fd) {
// New connection
accept_client(server_fd, epfd);
} else {
// Data from existing client
handle_client(fd, epfd);
}
}
}
close(epfd);
close(server_fd);
return 0;
}How epoll works
- Register file descriptors you care about
epoll_waitblocks until at least one fd is ready- Returns only the ready fds — no scanning the entire set (unlike
select/poll) - O(ready) per call, handles 100k+ connections
Step 3: Accept and Handle Clients
void accept_client(int server_fd, int epfd) {
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
// Accept all pending connections (non-blocking may have multiple)
while (1) {
int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &len);
if (client_fd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK)
break; // no more pending connections
perror("accept");
break;
}
set_nonblocking(client_fd);
struct epoll_event ev = {.events = EPOLLIN, .data.fd = client_fd};
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
printf("Client connected: %s:%d (fd=%d)\n",
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), client_fd);
}
}
void handle_client(int fd, int epfd) {
char buf[BUF_SIZE];
ssize_t n = read(fd, buf, sizeof(buf));
if (n <= 0) {
if (n == 0)
printf("Client disconnected (fd=%d)\n", fd);
else if (errno != EAGAIN)
perror("read");
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
return;
}
// Echo back
write(fd, buf, n);
}Step 4: Build and Test
gcc -Wall -Wextra -g -o echo_server echo_server.c
./echo_serverIn another terminal, test with nc (netcat):
# Terminal 2
nc localhost 8080
hello # type this
hello # server echoes back
world
world
^C # Ctrl+C to disconnect
# Or one-shot test:
echo "ping" | nc localhost 8080Test multiple clients
# Terminal 2
nc localhost 8080 &
nc localhost 8080 &
nc localhost 8080 &
# All three connect simultaneously — single-threaded server handles all of themStep 5: Verify with strace
strace -e trace=socket,bind,listen,accept4,epoll_wait,read,write ./echo_serverYou’ll see the exact syscall sequence: socket → bind → listen → epoll_create → epoll_wait (blocks) → accept4 → read → write → epoll_wait (blocks again).
Architecture Recap
┌──────────┐
Client 1 ──→ │ │
Client 2 ──→ │ epoll │ ──→ single thread handles all events
Client 3 ──→ │ loop │
└──────────┘
│
┌───────┼───────┐
│ │ │
accept read/ close
write
This is the foundation of nginx, Redis, and Node.js — a single-threaded event loop handling thousands of connections.
Exercises
-
Chat server: Instead of echoing back to the sender, broadcast the message to all connected clients. Track clients in an array or linked list.
-
HTTP server: Respond to
GET /with a minimal HTTP response:HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello. Test withcurl http://localhost:8080/. -
Graceful shutdown: Handle
SIGINTto close all client connections, unregister from epoll, and exit cleanly. Usesignalfdor a self-pipe to integrate signals with the event loop. -
Throughput test: Write a client that connects and sends 1 million bytes. Measure throughput (MB/s). Try with and without
TCP_NODELAY.
Next: 06 - Build a Thread Pool in C — handle CPU-bound work alongside the event loop.