TCP Client-Server Programming
The previous chapter showed the individual socket calls. Now we wire them into real programs: an echo server that handles multiple clients, a matching client, graceful shutdown, and protocol framing. By the end, you will have a working chat server.
The Echo Server in C
This server accepts connections in a loop, forks a child process for each client, and echoes back everything it receives.
/* echo_server.c -- fork-per-connection echo server */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>
static volatile sig_atomic_t running = 1;
static void handle_sigterm(int sig)
{
(void)sig;
running = 0;
}
static void reap_children(int sig)
{
(void)sig;
while (waitpid(-1, NULL, WNOHANG) > 0)
;
}
static void handle_client(int fd)
{
char buf[4096];
ssize_t n;
while ((n = read(fd, buf, sizeof(buf))) > 0) {
ssize_t written = 0;
while (written < n) {
ssize_t w = write(fd, buf + written, n - written);
if (w <= 0) return;
written += w;
}
}
close(fd);
}
int main(void)
{
struct sigaction sa_term;
memset(&sa_term, 0, sizeof(sa_term));
sa_term.sa_handler = handle_sigterm;
sigaction(SIGTERM, &sa_term, NULL);
sigaction(SIGINT, &sa_term, NULL);
struct sigaction sa_chld;
memset(&sa_chld, 0, sizeof(sa_chld));
sa_chld.sa_handler = reap_children;
sa_chld.sa_flags = SA_RESTART;
sigaction(SIGCHLD, &sa_chld, NULL);
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) { perror("socket"); return 1; }
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(7878);
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind"); return 1;
}
if (listen(listen_fd, 128) < 0) {
perror("listen"); return 1;
}
printf("Echo server listening on port 7878\n");
while (running) {
struct sockaddr_in client;
socklen_t clen = sizeof(client);
int conn_fd = accept(listen_fd, (struct sockaddr *)&client, &clen);
if (conn_fd < 0) {
if (errno == EINTR) continue;
perror("accept");
break;
}
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client.sin_addr, ip, sizeof(ip));
printf("New connection from %s:%d\n", ip, ntohs(client.sin_port));
pid_t pid = fork();
if (pid < 0) {
perror("fork");
close(conn_fd);
} else if (pid == 0) {
/* Child: handle client */
close(listen_fd);
handle_client(conn_fd);
_exit(0);
} else {
/* Parent: close the connected fd, keep listening */
close(conn_fd);
}
}
printf("\nShutting down...\n");
close(listen_fd);
return 0;
}
SIGCHLD handler reaps zombie processes. accept() can return EINTR when interrupted; the loop retries. The child closes the listening socket; the parent closes the connected socket.
Caution:
fork()duplicates the entire process. A thousand simultaneous connections means a thousand processes. We will fix this shortly.
The Matching Client
/* echo_client.c -- send lines from stdin, print echoed replies */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netdb.h>
int main(int argc, char *argv[])
{
const char *host = argc > 1 ? argv[1] : "127.0.0.1";
const char *port = argc > 2 ? argv[2] : "7878";
struct addrinfo hints = {0}, *res;
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
int s = getaddrinfo(host, port, &hints, &res);
if (s != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(s));
return 1;
}
int fd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (fd < 0) { perror("socket"); return 1; }
if (connect(fd, res->ai_addr, res->ai_addrlen) < 0) {
perror("connect"); return 1;
}
freeaddrinfo(res);
printf("Connected. Type lines to echo (Ctrl-D to quit):\n");
char line[1024];
while (fgets(line, sizeof(line), stdin)) {
write(fd, line, strlen(line));
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n <= 0) break;
buf[n] = '\0';
printf("echo: %s", buf);
}
close(fd);
return 0;
}
Try It: Start the server, then open three separate terminals each running the client. Verify that all three sessions echo independently.
Thread-Per-Connection Alternative
Threads share the same address space, so they are cheaper than processes. Replace the fork() block with pthread_create.
/* echo_server_threaded.c -- thread-per-connection echo server */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
static void *client_thread(void *arg)
{
int fd = *(int *)arg;
free(arg);
char buf[4096];
ssize_t n;
while ((n = read(fd, buf, sizeof(buf))) > 0) {
ssize_t w = 0;
while (w < n) {
ssize_t ret = write(fd, buf + w, n - w);
if (ret <= 0) goto done;
w += ret;
}
}
done:
close(fd);
return NULL;
}
int main(void)
{
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) { perror("socket"); return 1; }
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(7878);
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind"); return 1;
}
listen(listen_fd, 128);
printf("Threaded echo server on port 7878\n");
for (;;) {
struct sockaddr_in client;
socklen_t clen = sizeof(client);
int conn_fd = accept(listen_fd, (struct sockaddr *)&client, &clen);
if (conn_fd < 0) { perror("accept"); continue; }
int *fdp = malloc(sizeof(int));
*fdp = conn_fd;
pthread_t tid;
if (pthread_create(&tid, NULL, client_thread, fdp) != 0) {
perror("pthread_create");
close(conn_fd);
free(fdp);
} else {
pthread_detach(tid);
}
}
close(listen_fd);
return 0;
}
Compile with gcc -pthread echo_server_threaded.c -o echo_server_threaded.
Caution: We heap-allocate the fd so each thread gets its own copy. Passing
&conn_fddirectly is a race: the main loop may overwriteconn_fdbefore the thread reads it.
Protocol Framing
TCP is a byte stream. If the client sends two messages quickly, the server might receive them glued together in one read() call. You need a protocol to know where one message ends and the next begins.
Two common approaches: (1) length-prefix -- send 4 bytes of length then the payload; (2) delimiter -- terminate each message with \n. Length-prefix is more robust.
Length-Prefix Framing in C
/* framed_send.c -- send a length-prefixed message */
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdint.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
/* Write exactly n bytes */
static int write_all(int fd, const void *buf, size_t n)
{
const char *p = buf;
while (n > 0) {
ssize_t w = write(fd, p, n);
if (w <= 0) return -1;
p += w;
n -= w;
}
return 0;
}
/* Read exactly n bytes */
static int read_all(int fd, void *buf, size_t n)
{
char *p = buf;
while (n > 0) {
ssize_t r = read(fd, p, n);
if (r <= 0) return -1;
p += r;
n -= r;
}
return 0;
}
int send_message(int fd, const char *msg, size_t len)
{
uint32_t net_len = htonl((uint32_t)len);
if (write_all(fd, &net_len, 4) < 0) return -1;
if (write_all(fd, msg, len) < 0) return -1;
return 0;
}
int recv_message(int fd, char *buf, size_t bufsize, size_t *out_len)
{
uint32_t net_len;
if (read_all(fd, &net_len, 4) < 0) return -1;
uint32_t len = ntohl(net_len);
if (len > bufsize) return -1; /* message too large */
if (read_all(fd, buf, len) < 0) return -1;
*out_len = len;
return 0;
}
Caution: Always validate the length prefix. A malicious client could send
0xFFFFFFFFand trick you into allocating 4 GB of memory. Set a maximum message size.
Try It: Write a small main() that connects to the echo server, sends a framed message, then reads the framed response. Verify that even rapid sends are correctly separated.
Rust TCP Server
// echo_server.rs -- multi-threaded echo server in Rust use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::thread; fn handle_client(mut stream: TcpStream) { let peer = stream.peer_addr().unwrap(); println!("Connection from {}", peer); let mut buf = [0u8; 4096]; loop { match stream.read(&mut buf) { Ok(0) => break, Ok(n) => { if stream.write_all(&buf[..n]).is_err() { break; } } Err(_) => break, } } println!("{} disconnected", peer); } fn main() -> std::io::Result<()> { let listener = TcpListener::bind("0.0.0.0:7878")?; println!("Echo server listening on port 7878"); for stream in listener.incoming() { match stream { Ok(s) => { thread::spawn(move || handle_client(s)); } Err(e) => eprintln!("accept error: {}", e), } } Ok(()) }
Rust Note:
TcpStreamisSend, so moving it into athread::spawnclosure is safe. The compiler ensures no two threads can access the same stream. Nomallocfor the fd pointer, no detach -- ownership transfer handles everything.
Rust: Length-Prefix Framing
// framed.rs -- length-prefix framing over TCP use std::io::{self, Read, Write}; use std::net::TcpStream; fn send_message(stream: &mut TcpStream, msg: &[u8]) -> io::Result<()> { let len = (msg.len() as u32).to_be_bytes(); stream.write_all(&len)?; stream.write_all(msg)?; Ok(()) } fn recv_message(stream: &mut TcpStream) -> io::Result<Vec<u8>> { let mut len_buf = [0u8; 4]; stream.read_exact(&mut len_buf)?; let len = u32::from_be_bytes(len_buf) as usize; if len > 1_000_000 { return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large")); } let mut buf = vec![0u8; len]; stream.read_exact(&mut buf)?; Ok(buf) } fn main() -> io::Result<()> { let mut stream = TcpStream::connect("127.0.0.1:7878")?; send_message(&mut stream, b"Hello, framed world!")?; let reply = recv_message(&mut stream)?; println!("Got: {}", String::from_utf8_lossy(&reply)); Ok(()) }
read_exact loops internally until exactly N bytes are read. This eliminates the manual read_all loop from C.
A Complete Chat Server in C
This is the culmination: a multi-client chat server where messages from one client are broadcast to all others.
/* chat_server.c -- simple broadcast chat (thread-per-connection) */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MAX_CLIENTS 64
#define BUF_SIZE 1024
static pthread_mutex_t clients_lock = PTHREAD_MUTEX_INITIALIZER;
static int client_fds[MAX_CLIENTS];
static int client_count = 0;
static void add_client(int fd)
{
pthread_mutex_lock(&clients_lock);
if (client_count < MAX_CLIENTS) {
client_fds[client_count++] = fd;
}
pthread_mutex_unlock(&clients_lock);
}
static void remove_client(int fd)
{
pthread_mutex_lock(&clients_lock);
for (int i = 0; i < client_count; i++) {
if (client_fds[i] == fd) {
client_fds[i] = client_fds[--client_count];
break;
}
}
pthread_mutex_unlock(&clients_lock);
}
static void broadcast(int sender_fd, const char *msg, size_t len)
{
pthread_mutex_lock(&clients_lock);
for (int i = 0; i < client_count; i++) {
if (client_fds[i] != sender_fd) {
write(client_fds[i], msg, len);
}
}
pthread_mutex_unlock(&clients_lock);
}
static void *client_thread(void *arg)
{
int fd = *(int *)arg;
free(arg);
add_client(fd);
char buf[BUF_SIZE];
ssize_t n;
while ((n = read(fd, buf, sizeof(buf))) > 0) {
broadcast(fd, buf, n);
}
remove_client(fd);
close(fd);
return NULL;
}
int main(void)
{
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(9000);
bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr));
listen(listen_fd, 128);
printf("Chat server on port 9000 (use: nc 127.0.0.1 9000)\n");
for (;;) {
struct sockaddr_in cl;
socklen_t len = sizeof(cl);
int conn = accept(listen_fd, (struct sockaddr *)&cl, &len);
if (conn < 0) continue;
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &cl.sin_addr, ip, sizeof(ip));
printf("%s:%d joined\n", ip, ntohs(cl.sin_port));
int *fdp = malloc(sizeof(int));
*fdp = conn;
pthread_t tid;
pthread_create(&tid, NULL, client_thread, fdp);
pthread_detach(tid);
}
}
Test with multiple nc 127.0.0.1 9000 sessions. Type in one and watch it appear in the others.
Rust Chat Server
// chat_server.rs -- broadcast chat server use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream, SocketAddr}; use std::sync::{Arc, Mutex}; use std::thread; type ClientList = Arc<Mutex<Vec<(SocketAddr, TcpStream)>>>; fn handle_client(stream: TcpStream, clients: ClientList) { let peer = stream.peer_addr().unwrap(); println!("{} joined", peer); { clients.lock().unwrap().push((peer, stream.try_clone().unwrap())); } let reader = BufReader::new(stream); for line in reader.lines().flatten() { let full = format!("{}: {}\n", peer, line); let list = clients.lock().unwrap(); for (addr, mut s) in list.iter().filter(|(a, _)| *a != peer) { let _ = s.write_all(full.as_bytes()); } } { clients.lock().unwrap().retain(|(a, _)| *a != peer); } println!("{} left", peer); } fn main() -> std::io::Result<()> { let listener = TcpListener::bind("0.0.0.0:9000")?; let clients: ClientList = Arc::new(Mutex::new(Vec::new())); println!("Chat server on port 9000"); for stream in listener.incoming() { let stream = stream?; let clients = Arc::clone(&clients); thread::spawn(move || handle_client(stream, clients)); } Ok(()) }
Rust Note:
Arc<Mutex<Vec<TcpStream>>>is the standard pattern for shared mutable state across threads. The compiler refuses to compile the program if you try to share without proper synchronization. No equivalent compile-time guarantee exists in C.
Driver Prep: Kernel network drivers process packets without the luxury of threads-per-connection. The patterns in chapters 48 and 49 (poll, epoll) are what drivers and high-performance servers use instead.
Graceful Shutdown Pattern
The volatile sig_atomic_t running flag (shown in the fork server above) is the standard approach. The signal handler sets it to 0; the main loop checks it. Close the listening socket to unblock accept(), then wait for in-flight clients to finish before exiting.
Knowledge Check
- Why does the fork-based server close
conn_fdin the parent andlisten_fdin the child? - What happens if you omit the mutex around the client list in the chat server?
- How does length-prefix framing solve the TCP message-boundary problem?
Common Pitfalls
- Not handling partial writes --
write()can return fewer bytes than requested. Always loop. - Not handling partial reads -- same issue on the receive side.
read()returns whatever is available, not a complete message. - Zombie processes -- forgetting
SIGCHLDhandler with fork-per-connection fills the process table. - Thread stack overflow -- each thread allocates a stack (typically 2-8 MB). Thousands of threads consume gigabytes of memory.
- Broadcasting while holding the lock too long -- a slow client's
write()can block, stalling all other broadcasts. Consider non-blocking I/O or per-client queues. - Forgetting
SO_REUSEADDR-- restarting the server gives "Address already in use" for up to 60 seconds.