TCP Socket Programming
TCP Socket Lifecycle
A TCP connection goes through a well-defined sequence of states. Understanding this lifecycle is essential for writing correct server and client code.
stateDiagram-v2
[*] --> CLOSED
CLOSED --> LISTEN: server: listen()
CLOSED --> SYN_SENT: client: connect()
LISTEN --> SYN_RCVD: recv SYN, send SYN+ACK
SYN_SENT --> ESTABLISHED: recv SYN+ACK, send ACK
SYN_RCVD --> ESTABLISHED: recv ACK
ESTABLISHED --> FIN_WAIT_1: close()
ESTABLISHED --> CLOSE_WAIT: recv FIN, send ACK
FIN_WAIT_1 --> FIN_WAIT_2: recv ACK
FIN_WAIT_2 --> TIME_WAIT: recv FIN, send ACK
CLOSE_WAIT --> LAST_ACK: close()
LAST_ACK --> CLOSED: recv ACK
TIME_WAIT --> CLOSED: 2*MSL timeout
Server Side
socket(AF_INET, SOCK_STREAM, 0)— Create a TCP socket (returns a file descriptor)bind(fd, &addr, len)— Associate the socket with an IP address and portlisten(fd, backlog)— Mark the socket as passive, set the pending connection queue sizeaccept(fd, &client_addr, &len)— Block until a client connects; returns a new fd for the connectionrecv()/send()— Exchange data on the connected fdclose(fd)— Terminate the connection
Client Side
socket()— Create a TCP socketconnect(fd, &server_addr, len)— Initiate the three-way handshakesend()/recv()— Exchange dataclose()— Terminate the connection
TCP Server in C
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8080
#define BACKLOG 128
#define BUF_SIZE 4096
int main(void) {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(PORT),
.sin_addr.s_addr = INADDR_ANY
};
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
close(listen_fd);
exit(EXIT_FAILURE);
}
if (listen(listen_fd, BACKLOG) < 0) {
perror("listen");
close(listen_fd);
exit(EXIT_FAILURE);
}
printf("Listening on port %d\n", PORT);
for (;;) {
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
if (conn_fd < 0) {
perror("accept");
continue;
}
char buf[BUF_SIZE];
ssize_t n = recv(conn_fd, buf, sizeof(buf) - 1, 0);
if (n > 0) {
buf[n] = '\0';
printf("Received: %s", buf);
send(conn_fd, buf, n, 0); // Echo back
}
close(conn_fd);
}
close(listen_fd);
return 0;
}
TCP Client in C
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define SERVER_IP "127.0.0.1"
#define PORT 8080
int main(void) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
perror("socket");
return EXIT_FAILURE;
}
struct sockaddr_in server_addr = {
.sin_family = AF_INET,
.sin_port = htons(PORT)
};
inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr);
if (connect(fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("connect");
close(fd);
return EXIT_FAILURE;
}
const char *msg = "Hello, TCP Server!\n";
send(fd, msg, strlen(msg), 0);
char buf[1024];
ssize_t n = recv(fd, buf, sizeof(buf) - 1, 0);
if (n > 0) {
buf[n] = '\0';
printf("Server replied: %s", buf);
}
close(fd);
return 0;
}
Handling Multiple Clients
The server above is iterative—it handles one client at a time. Real servers must handle many clients concurrently.
Fork
Each accept() spawns a child process. The child handles the connection; the parent loops back to accept(). Simple but heavy (each process has its own memory space).
Threads
Each accept() spawns a thread. Lighter than fork (shared address space) but still has per-connection overhead. Typically limited to a few thousand concurrent connections.
select()
Monitor multiple file descriptors in a single thread. Limited to FD_SETSIZE (typically 1024) descriptors. O(n) per call—scans the entire set.
poll()
Similar to select() but uses a dynamic array of struct pollfd. No hard limit on descriptor count. Still O(n).
epoll()
Linux’s scalable I/O event notification mechanism. O(1) for event delivery regardless of the number of monitored descriptors.
epoll in Detail
How epoll Works
flowchart TD
A[epoll_create1] --> B[epoll_ctl: add listen_fd + EPOLLIN]
B --> C[epoll_wait: blocks until events]
C --> D{Event on listen_fd?}
D -->|Yes| E[accept new connection]
E --> F[epoll_ctl: add conn_fd]
F --> C
D -->|No| G{Event on conn_fd?}
G -->|Yes| H[recv/send data]
H --> I{Connection closed?}
I -->|No| C
I -->|Yes| J[epoll_ctl: del conn_fd + close]
J --> C
Level-Triggered vs Edge-Triggered
| Mode | Behavior | When to Use |
|---|---|---|
| Level-triggered (default) | epoll_wait returns as long as the fd is ready. If you only read part of the available data, the next epoll_wait will notify you again. | Simple, correct by default |
Edge-triggered (EPOLLET) | epoll_wait returns only when the state changes from not-ready to ready. You must read/write until EAGAIN. Missed if you don’t drain the buffer. | Higher throughput, lower CPU usage |
Edge-triggered epoll with non-blocking sockets is the pattern used by nginx and redis.
epoll Code Example
#define MAX_EVENTS 64
int listen_fd = /* ... created, bound, listening ... */;
int epfd = epoll_create1(0);
struct epoll_event ev = {
.events = EPOLLIN,
.data.fd = listen_fd
};
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
struct epoll_event events[MAX_EVENTS];
for (;;) {
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == listen_fd) {
// New connection
int conn_fd = accept(listen_fd, NULL, NULL);
// Set non-blocking
int flags = fcntl(conn_fd, F_GETFL, 0);
fcntl(conn_fd, F_SETFL, flags | O_NONBLOCK);
struct epoll_event conn_ev = {
.events = EPOLLIN | EPOLLET, // Edge-triggered
.data.fd = conn_fd
};
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &conn_ev);
} else {
// Data on existing connection
char buf[4096];
for (;;) {
ssize_t n = recv(events[i].data.fd, buf, sizeof(buf), 0);
if (n <= 0) {
if (n == 0 || (errno != EAGAIN && errno != EWOULDBLOCK)) {
epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
close(events[i].data.fd);
}
break;
}
// Process buf[0..n-1]
}
}
}
}
kqueue (BSD/macOS)
kqueue is the BSD/macOS equivalent of epoll. It uses kevent() to register and retrieve events. Like epoll, it is O(1) for event delivery and supports edge-triggered semantics. Key differences:
kqueuecan monitor more event types (file modifications, signals, process events) via filters- Uses
struct keventarrays instead of separate control and wait calls - Portability concern:
kqueueis BSD/macOS,epollis Linux
Connection Options
SO_REUSEADDR
Allows binding to an address/port that is in TIME_WAIT state. Essential for development servers that are frequently restarted.
SO_KEEPALIVE
Enables TCP keepalive probes. If no data is exchanged for a configurable idle period, the kernel sends keepalive packets. Detects half-open connections (peer crashed without sending FIN).
TCP_NODELAY
Disables Nagel’s algorithm. Nagle buffers small writes until an ACK is received or the buffer is full, reducing packet count. TCP_NODELAY forces immediate transmission—critical for low-latency protocols like SSH, Telnet, and interactive games.
Interview Questions
- Explain the TCP state diagram. What is TIME_WAIT and why does the kernel hold it for 2×MSL?
- What is the difference between
select()andepoll()? Why isepollmore scalable? - Explain level-triggered versus edge-triggered epoll. Why must edge-triggered use non-blocking sockets?
- What is the
backlogparameter inlisten()? What happens when it is exceeded? - How would you write a TCP server that handles 100,000 concurrent connections?
- What is Nagle’s algorithm? When would you disable it?
- What does
SO_REUSEADDRdo and why is it important? - Explain the difference between a graceful close (
close()after FIN exchange) and an abortive close (RST). - What is the
C10K problem? How do modern servers solve it? - How does
epolldiffer fromkqueue?