C TCP/IP Socket Programming: Build Network Servers and Clients from Scratch

C TCP/IP Socket Programming: Build Network Servers and Clients from Scratch
Table of Contents
- The Network Stack: How TCP Fits In
- The Socket Connection Lifecycle
- Creating a TCP Server: Step by Step
- Creating a TCP Client
- Handling Multiple Clients: select() and poll()
- Non-Blocking Sockets and epoll (Linux)
- Sending and Receiving Complete Messages
- Socket Options: SO_REUSEADDR and TCP_NODELAY
- Building a Complete HTTP Echo Server
- Frequently Asked Questions
- Key Takeaway
The Network Stack: How TCP Fits In
The C socket API operates at the TCP/UDP layer. You don't manage IP routing or Ethernet framing — the OS kernel handles those. You deal with: connect endpoints (IP + port), send/receive byte streams (TCP), and file descriptor management.
The Socket Connection Lifecycle
Creating a TCP Server: Step by Step
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#define PORT 8080
#define BACKLOG 128
#define BUFSIZE 4096
int create_server(uint16_t port) {
// Step 1: Create TCP socket (IPv4, stream)
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) { perror("socket"); return -1; }
// Step 2: Allow port reuse (prevents "Address already in use" after restart)
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// Step 3: Bind to address:port
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(port), // htons: host→network byte order
.sin_addr.s_addr = INADDR_ANY, // Accept connections on any interface
};
if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind"); close(server_fd); return -1;
}
// Step 4: Mark as passive (accept connections, don't initiate)
if (listen(server_fd, BACKLOG) < 0) {
perror("listen"); close(server_fd); return -1;
}
printf("Server listening on port %d\n", port);
return server_fd;
}
int main(void) {
int server_fd = create_server(PORT);
if (server_fd < 0) return 1;
while (1) {
// Step 5: Accept a new connection (blocks until client connects)
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd < 0) { perror("accept"); continue; }
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));
printf("New connection from %s:%d\n", client_ip, ntohs(client_addr.sin_port));
// Step 6: Read data from client
char buffer[BUFSIZE];
ssize_t bytes_received = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
if (bytes_received > 0) {
buffer[bytes_received] = '\0';
printf("Received (%zd bytes): %s\n", bytes_received, buffer);
// Step 7: Send response
const char *response = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello";
send(client_fd, response, strlen(response), 0);
}
close(client_fd); // Step 8: Close the connection
}
close(server_fd);
return 0;
}Creating a TCP Client
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int connect_to(const char *host, uint16_t port) {
// Resolve hostname to IP address
struct hostent *he = gethostbyname(host);
if (!he) { herror("gethostbyname"); return -1; }
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) { perror("socket"); return -1; }
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(port),
};
memcpy(&addr.sin_addr, he->h_addr_list[0], he->h_length);
if (connect(sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("connect"); close(sockfd); return -1;
}
printf("Connected to %s:%d\n", host, port);
return sockfd;
}
int main(void) {
int fd = connect_to("127.0.0.1", 8080);
if (fd < 0) return 1;
const char *request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
send(fd, request, strlen(request), 0);
char response[4096] = {0};
ssize_t n = recv(fd, response, sizeof(response) - 1, 0);
if (n > 0) printf("Response:\n%s\n", response);
close(fd);
return 0;
}Handling Multiple Clients: select() and poll()
The simple server above handles one client at a time. For multiple concurrent clients, use select() or poll():
#include <sys/select.h>
#include <sys/socket.h>
#include <stdio.h>
#include <unistd.h>
void multi_client_server(int server_fd) {
fd_set master_set, read_set;
FD_ZERO(&master_set);
FD_SET(server_fd, &master_set);
int max_fd = server_fd;
while (1) {
read_set = master_set; // select() modifies the set, restore each iteration
// Wait until any fd has data ready to read (no timeout)
if (select(max_fd + 1, &read_set, NULL, NULL, NULL) < 0) {
perror("select"); break;
}
for (int fd = 0; fd <= max_fd; fd++) {
if (!FD_ISSET(fd, &read_set)) continue;
if (fd == server_fd) {
// New connection incoming
int client_fd = accept(server_fd, NULL, NULL);
FD_SET(client_fd, &master_set);
if (client_fd > max_fd) max_fd = client_fd;
printf("New client: fd=%d\n", client_fd);
} else {
// Data from existing client
char buf[4096];
ssize_t n = recv(fd, buf, sizeof(buf) - 1, 0);
if (n <= 0) {
// Client disconnected
close(fd);
FD_CLR(fd, &master_set);
printf("Client fd=%d disconnected\n", fd);
} else {
buf[n] = '\0';
send(fd, buf, n, 0); // Echo back
}
}
}
}
}[!NOTE]
select()has a limit ofFD_SETSIZE(typically 1024) file descriptors. For production servers handling thousands of connections, useepoll(Linux) orkqueue(macOS/BSD).
Non-Blocking Sockets and epoll (Linux)
For high-performance servers (10,000+ concurrent connections), epoll is the standard:
#include <sys/epoll.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
void set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
void epoll_server(int server_fd) {
int epoll_fd = epoll_create1(0);
set_nonblocking(server_fd);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = server_fd };
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev);
struct epoll_event events[64];
while (1) {
int nready = epoll_wait(epoll_fd, events, 64, -1);
for (int i = 0; i < nready; i++) {
int fd = events[i].data.fd;
if (fd == server_fd) {
int client = accept(server_fd, NULL, NULL);
set_nonblocking(client);
struct epoll_event cev = { .events = EPOLLIN | EPOLLET, .data.fd = client };
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client, &cev);
} else {
char buf[4096];
ssize_t n = recv(fd, buf, sizeof(buf), 0);
if (n <= 0) {
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
} else {
send(fd, buf, n, 0);
}
}
}
}
}epoll with edge-triggered mode (EPOLLET) is the foundation of Nginx's event loop and the mechanism enabling its legendary 10,000+ concurrent connection handling.
Sending and Receiving Complete Messages
send() and recv() may not send/receive all bytes in one call for large messages. Production code must loop:
ssize_t send_all(int sockfd, const void *buf, size_t len) {
ssize_t total_sent = 0;
const char *ptr = (const char*)buf;
while (total_sent < (ssize_t)len) {
ssize_t n = send(sockfd, ptr + total_sent, len - total_sent, 0);
if (n <= 0) {
if (n < 0 && errno == EINTR) continue; // Interrupted — retry
return n; // Error or connection closed
}
total_sent += n;
}
return total_sent;
}
ssize_t recv_all(int sockfd, void *buf, size_t len) {
ssize_t total_recv = 0;
char *ptr = (char*)buf;
while (total_recv < (ssize_t)len) {
ssize_t n = recv(sockfd, ptr + total_recv, len - total_recv, 0);
if (n <= 0) {
if (n < 0 && errno == EINTR) continue;
return total_recv == 0 ? n : total_recv;
}
total_recv += n;
}
return total_recv;
}Socket Options: SO_REUSEADDR and TCP_NODELAY
// SO_REUSEADDR: Allow binding to a port that's in TIME_WAIT state
// CRITICAL for servers — prevents "Address already in use" after restart
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// TCP_NODELAY: Disable Nagle's algorithm — send small packets immediately
// Use for real-time protocols (games, trading systems) where low latency > throughput
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));
// SO_KEEPALIVE: Enable TCP keepalive — detect dead connections
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &opt, sizeof(opt));
// SO_RCVTIMEO: Set receive timeout to 5 seconds
struct timeval tv = { .tv_sec = 5, .tv_usec = 0 };
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));Frequently Asked Questions
Why do I need htons() for port numbers?
Different CPU architectures store multi-byte numbers in different byte orders. x86-64 is little-endian; the internet uses big-endian (network byte order). htons() (host-to-network short) converts a 16-bit port number from your CPU's format to big-endian. Always use htons() for ports and htonl() for IP addresses.
What is the difference between send/recv and write/read on sockets?
On POSIX, write(fd, buf, n) and send(fd, buf, n, 0) are functionally identical for TCP sockets (with flags=0). send/recv provide the additional flags parameter for features like MSG_DONTWAIT (non-blocking) and MSG_PEEK (peek without consuming).
How does Nginx handle 10,000 concurrent connections?
Nginx uses a single-threaded event loop with epoll in edge-triggered mode. All sockets are non-blocking. When a socket has data, epoll notifies the event loop; the handler reads what's available and returns immediately (no blocking). One thread serves all connections through multiplexing.
What is the TIME_WAIT state?
After a TCP connection closes, the OS keeps the (source_ip, source_port, dest_ip, dest_port) tuple in TIME_WAIT for 2× Maximum Segment Lifetime (~60-120 seconds). This prevents old delayed packets from a previous connection being misinterpreted as belonging to a new connection on the same ports. SO_REUSEADDR bypasses this for the server's listening socket.
Key Takeaway
TCP socket programming is the Gateway to Networked Systems. The six-step server lifecycle (socket → bind → listen → accept → recv/send → close) is the template behind every web server, database network protocol, and distributed system message passing system ever written in C.
Once you understand blocking I/O with select/poll and non-blocking I/O with epoll, you have the foundation to understand (and contribute to) the Nginx, Redis, and PostgreSQL networking code at its core.
Read next: UDP Datagrams & Broadcast: Fast, Low-Latency Networking →
Part of the C Mastery Course — 30 modules from C basics to expert networking and systems engineering.
