Loading learning content...
The socket API provides primitives; building applications requires patterns. The most fundamental pattern in network programming is the client/server model, where server processes wait for connection requests and client processes initiate contact.
This seemingly simple model has profound architectural implications. How does a server handle multiple clients? Should it dedicate a thread per connection or use event-driven I/O? How do clients handle slow or unresponsive servers? These questions have shaped decades of software architecture, from Apache's prefork model to NGINX's event-driven design to modern async/await patterns.
Mastering client/server socket programming means understanding not just the code, but the architectural tradeoffs that determine scalability, latency, and resource utilization.
By the end of this page, you will understand the client and server socket lifecycles, iterative and concurrent server models, the roles of listening and connected sockets, and how to design for scalability. You'll be equipped to architect network services for any scale.
The client/server model is an asymmetric communication pattern where:
Server:
Client:
This model decouples service providers from consumers, enabling the distributed systems architecture that powers the modern internet.
Socket Lifecycle Comparison:
| Phase | Server | Client |
|---|---|---|
| Creation | socket() | socket() |
| Address Assignment | bind() to known port | Automatic or bind() to ephemeral |
| Role Designation | listen() → passive mode | — |
| Connection Phase | accept() → blocks/waits | connect() → initiates |
| Data Transfer | recv()/send() on accepted socket | recv()/send() on connected socket |
| Termination | close() client socket; keep listening | close() socket |
| Lifespan | Long-running (daemon) | Short-lived (session) |
A key insight: servers have TWO types of sockets. The LISTENING socket (created once, used for accept()) and CONNECTED sockets (one per client, used for data transfer). The listening socket never sends or receives application data—it only exists to accept new connections.
Client sockets follow a straightforward pattern: connect, communicate, disconnect. However, production clients require careful error handling, timeout management, and connection reuse.
Basic Client Pattern:
1. Create socket: socket()
2. Connect to server: connect()
3. Send request: send()
4. Receive response: recv() (loop until complete)
5. Close connection: close()
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
#include <sys/socket.h>#include <netdb.h>#include <string.h>#include <stdio.h>#include <unistd.h>#include <errno.h> // Production-quality TCP clienttypedef struct { int sockfd; char host[256]; char port[16]; int connected;} Client; int client_connect(Client *c, const char *host, const char *port) { struct addrinfo hints = {0}, *res, *p; hints.ai_family = AF_UNSPEC; // IPv4 or IPv6 hints.ai_socktype = SOCK_STREAM; // TCP int err = getaddrinfo(host, port, &hints, &res); if (err != 0) { fprintf(stderr, "Resolution failed: %s\n", gai_strerror(err)); return -1; } // Try each resolved address for (p = res; p != NULL; p = p->ai_next) { c->sockfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol); if (c->sockfd == -1) continue; if (connect(c->sockfd, p->ai_addr, p->ai_addrlen) == 0) { break; // Success } close(c->sockfd); c->sockfd = -1; } freeaddrinfo(res); if (p == NULL) { fprintf(stderr, "Failed to connect to %s:%s\n", host, port); return -1; } strncpy(c->host, host, sizeof(c->host) - 1); strncpy(c->port, port, sizeof(c->port) - 1); c->connected = 1; return 0;} ssize_t client_send(Client *c, const void *data, size_t len) { if (!c->connected) return -1; size_t sent = 0; while (sent < len) { ssize_t n = send(c->sockfd, (char*)data + sent, len - sent, MSG_NOSIGNAL); if (n == -1) { if (errno == EINTR) continue; c->connected = 0; return -1; } sent += n; } return sent;} ssize_t client_recv(Client *c, void *buf, size_t len) { if (!c->connected) return -1; ssize_t n = recv(c->sockfd, buf, len, 0); if (n <= 0) { c->connected = 0; } return n;} void client_close(Client *c) { if (c->sockfd >= 0) { close(c->sockfd); c->sockfd = -1; } c->connected = 0;} // Example usage: simple HTTP requestvoid example_http_request() { Client c = {.sockfd = -1}; if (client_connect(&c, "example.com", "80") != 0) { return; } const char *request = "GET / HTTP/1.1\r\n" "Host: example.com\r\n" "Connection: close\r\n" "\r\n"; client_send(&c, request, strlen(request)); char buf[4096]; ssize_t n; while ((n = client_recv(&c, buf, sizeof(buf) - 1)) > 0) { buf[n] = '\0'; printf("%s", buf); } client_close(&c);}A blocking client hangs indefinitely if the server stops responding. Production clients must implement timeouts using: (1) non-blocking sockets with select/poll/epoll, (2) SO_RCVTIMEO/SO_SNDTIMEO socket options, or (3) application-level timer threads. No network call should block indefinitely.
The simplest server model handles one client at a time: accept a connection, process the entire request, close the connection, then accept the next. This is the iterative server.
Characteristics:
Limitations:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
#include <sys/socket.h>#include <netinet/in.h>#include <string.h>#include <stdio.h>#include <unistd.h>#include <signal.h> static volatile int running = 1; void handle_sigint(int sig) { running = 0;} // Handle a single client connectionvoid handle_client(int client_fd) { char buf[4096]; ssize_t n; // Simple echo server: read and echo back while ((n = recv(client_fd, buf, sizeof(buf), 0)) > 0) { // Echo the data back ssize_t sent = 0; while (sent < n) { ssize_t s = send(client_fd, buf + sent, n - sent, MSG_NOSIGNAL); if (s <= 0) return; // Error or closed sent += s; } }} // Iterative server main loopint run_iterative_server(uint16_t port) { signal(SIGINT, handle_sigint); signal(SIGPIPE, SIG_IGN); // Ignore broken pipe // Create listening socket int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd == -1) { perror("socket"); return -1; } // Allow address reuse int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // Bind struct sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(port); if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) { perror("bind"); close(listen_fd); return -1; } // Listen if (listen(listen_fd, 128) == -1) { perror("listen"); close(listen_fd); return -1; } printf("Iterative server listening on port %d\n", port); // Main loop: accept and handle one at a time while (running) { struct sockaddr_in client_addr; socklen_t addr_len = sizeof(client_addr); int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len); if (client_fd == -1) { if (errno == EINTR) continue; // Interrupted, retry perror("accept"); continue; } printf("Client connected (fd=%d)\n", client_fd); // Handle this client completely before accepting another handle_client(client_fd); printf("Client disconnected (fd=%d)\n", client_fd); close(client_fd); } close(listen_fd); printf("Server shutdown\n"); return 0;}When to Use Iterative Servers:
| Scenario | Suitable? | Reason |
|---|---|---|
| Simple echo server | ✓ | Fast processing, no blocking |
| DNS server (simple) | ✓ | Quick queries, stateless |
| High-traffic web server | ✗ | Clients queue behind slow requests |
| Database server | ✗ | Queries may take seconds |
| Development/testing | ✓ | Easy debugging, reproducible behavior |
| Embedded systems | ✓ | Minimal resource usage |
In iterative servers, a single slow client blocks all others. If Client A sends a request that takes 10 seconds to process, Clients B, C, D must all wait—even if their requests would complete in milliseconds. This head-of-line blocking is the fundamental limitation that motivates concurrent server designs.
To handle multiple clients simultaneously, servers employ concurrency. Several models exist, each with distinct tradeoffs:
1. Process-Per-Connection (fork):
2. Thread-Per-Connection:
3. Thread Pool:
4. Event-Driven (Single-Threaded):
5. Hybrid (Event-Driven + Thread Pool):
| Model | Connections | Memory | Latency | Complexity |
|---|---|---|---|---|
| Iterative | 1 (blocking) | Minimal | High for waiting clients | Simple |
| Fork per connection | ~1,000 | High (per-process) | Good | Simple |
| Thread per connection | ~10,000 | Moderate (per-thread) | Good | Moderate |
| Thread pool | ~10,000 | Bounded | Good | Moderate |
| Event-driven | ~100,000+ | Minimal | Excellent | Complex |
| Hybrid | ~100,000+ | Bounded | Excellent | Complex |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
#include <sys/socket.h>#include <pthread.h>#include <stdlib.h>#include <stdio.h>#include <unistd.h> // ========================================// MODEL 1: FORK PER CONNECTION// ========================================void fork_server_loop(int listen_fd) { while (1) { int client_fd = accept(listen_fd, NULL, NULL); if (client_fd == -1) continue; pid_t pid = fork(); if (pid == 0) { // Child process close(listen_fd); // Child doesn't need listening socket handle_client(client_fd); close(client_fd); _exit(0); } else if (pid > 0) { // Parent process close(client_fd); // Parent doesn't need client socket // Also: handle SIGCHLD to reap children, or use waitpid() } else { // Fork failed close(client_fd); } }} // ========================================// MODEL 2: THREAD PER CONNECTION// ========================================void *thread_handler(void *arg) { int client_fd = *(int*)arg; free(arg); handle_client(client_fd); close(client_fd); return NULL;} void thread_per_connection_loop(int listen_fd) { while (1) { int client_fd = accept(listen_fd, NULL, NULL); if (client_fd == -1) continue; int *fd_ptr = malloc(sizeof(int)); *fd_ptr = client_fd; pthread_t tid; if (pthread_create(&tid, NULL, thread_handler, fd_ptr) != 0) { free(fd_ptr); close(client_fd); continue; } pthread_detach(tid); // Auto-cleanup when thread exits }} // ========================================// MODEL 3: THREAD POOL// ========================================typedef struct { int *queue; int head, tail, size, capacity; pthread_mutex_t mutex; pthread_cond_t not_empty; pthread_cond_t not_full; int shutdown;} WorkQueue; void *worker_thread(void *arg) { WorkQueue *q = arg; while (1) { pthread_mutex_lock(&q->mutex); while (q->size == 0 && !q->shutdown) { pthread_cond_wait(&q->not_empty, &q->mutex); } if (q->shutdown && q->size == 0) { pthread_mutex_unlock(&q->mutex); break; } int client_fd = q->queue[q->head]; q->head = (q->head + 1) % q->capacity; q->size--; pthread_cond_signal(&q->not_full); pthread_mutex_unlock(&q->mutex); // Handle client outside the lock handle_client(client_fd); close(client_fd); } return NULL;} void thread_pool_loop(int listen_fd, WorkQueue *q) { while (1) { int client_fd = accept(listen_fd, NULL, NULL); if (client_fd == -1) continue; pthread_mutex_lock(&q->mutex); while (q->size == q->capacity) { pthread_cond_wait(&q->not_full, &q->mutex); } q->queue[q->tail] = client_fd; q->tail = (q->tail + 1) % q->capacity; q->size++; pthread_cond_signal(&q->not_empty); pthread_mutex_unlock(&q->mutex); }}The 'C10K problem' (handling 10,000+ concurrent connections) drove the development of event-driven architectures. With thread-per-connection, 10K threads consume gigabytes of stack memory and cause excessive context switching. Event-driven servers like NGINX handle millions of connections with fixed thread count, fundamentally changing server architecture.
Event-driven servers use I/O multiplexing to monitor multiple sockets simultaneously with a single thread. When any socket has data available, the server processes it without blocking on others.
I/O Multiplexing APIs:
| API | Platform | Scalability | Notes |
|---|---|---|---|
| select() | POSIX (universal) | ~1024 fds | FD_SETSIZE limit; O(n) scanning |
| poll() | POSIX | No limit | Still O(n) every call |
| epoll | Linux | Very high | O(1) operations; edge/level triggered |
| kqueue | BSD/macOS | Very high | Similar to epoll, different API |
| IOCP | Windows | Very high | Completion-based (different model) |
For any serious server, epoll (Linux) or kqueue (BSD/Mac) is essential.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
#include <sys/epoll.h>#include <sys/socket.h>#include <netinet/in.h>#include <fcntl.h>#include <stdio.h>#include <unistd.h>#include <errno.h> #define MAX_EVENTS 1024 // Set socket to non-blocking modeint set_nonblocking(int fd) { int flags = fcntl(fd, F_GETFL, 0); if (flags == -1) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK);} // Event-driven server using epollint run_epoll_server(uint16_t port) { // Create listening socket int listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); if (listen_fd == -1) return -1; int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_ANY), .sin_port = htons(port) }; if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) { close(listen_fd); return -1; } if (listen(listen_fd, 128) == -1) { close(listen_fd); return -1; } // Create epoll instance int epoll_fd = epoll_create1(0); if (epoll_fd == -1) { close(listen_fd); return -1; } // Add listening socket to epoll struct epoll_event ev = { .events = EPOLLIN, .data.fd = listen_fd }; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev); struct epoll_event events[MAX_EVENTS]; printf("Event-driven server listening on port %d\n", port); while (1) { int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); for (int i = 0; i < nfds; i++) { int fd = events[i].data.fd; if (fd == listen_fd) { // New connection(s) available while (1) { int client_fd = accept(listen_fd, NULL, NULL); if (client_fd == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) { break; // No more pending connections } perror("accept"); break; } set_nonblocking(client_fd); struct epoll_event cev = { .events = EPOLLIN | EPOLLET, // Edge-triggered .data.fd = client_fd }; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &cev); printf("New connection (fd=%d)\n", client_fd); } } else { // Data available on client socket if (events[i].events & (EPOLLHUP | EPOLLERR)) { // Error or hangup close(fd); continue; } if (events[i].events & EPOLLIN) { char buf[4096]; while (1) { ssize_t n = recv(fd, buf, sizeof(buf), 0); if (n == -1) { if (errno == EAGAIN) break; // No more data close(fd); break; } if (n == 0) { // Connection closed printf("Connection closed (fd=%d)\n", fd); close(fd); break; } // Echo back (simplified; real code handles partial sends) send(fd, buf, n, MSG_NOSIGNAL); } } } } } close(epoll_fd); close(listen_fd); return 0;}Event-driven programming inverts control flow—instead of reading in sequence, you respond to events. This makes logic harder to follow and debug. Libraries like libuv, libevent, and libev abstract this complexity. Modern languages provide async/await syntax that looks sequential but executes event-driven.
UDP sockets have inherently simpler patterns due to their connectionless nature:
Server:
Client:
No listen() or accept() needed—there's no connection establishment.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
#include <sys/socket.h>#include <netinet/in.h>#include <string.h>#include <stdio.h>#include <unistd.h> // UDP Echo Serverint run_udp_echo_server(uint16_t port) { int sockfd = socket(AF_INET, SOCK_DGRAM, 0); if (sockfd == -1) return -1; struct sockaddr_in addr = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_ANY), .sin_port = htons(port) }; if (bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) == -1) { close(sockfd); return -1; } printf("UDP server listening on port %d\n", port); char buf[65536]; // Max UDP payload ~65KB struct sockaddr_in client_addr; socklen_t addr_len; while (1) { addr_len = sizeof(client_addr); ssize_t n = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr*)&client_addr, &addr_len); if (n == -1) { perror("recvfrom"); continue; } // Echo back to the sender sendto(sockfd, buf, n, 0, (struct sockaddr*)&client_addr, addr_len); } close(sockfd); return 0;} // UDP Clientint udp_client_send_recv(const char *host, uint16_t port, const void *request, size_t req_len, void *response, size_t resp_len) { int sockfd = socket(AF_INET, SOCK_DGRAM, 0); if (sockfd == -1) return -1; // Set receive timeout (important for UDP!) struct timeval tv = { .tv_sec = 5, .tv_usec = 0 }; setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); struct sockaddr_in server_addr = { .sin_family = AF_INET, .sin_port = htons(port) }; inet_pton(AF_INET, host, &server_addr.sin_addr); // Send request if (sendto(sockfd, request, req_len, 0, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) { close(sockfd); return -1; } // Receive response ssize_t n = recvfrom(sockfd, response, resp_len, 0, NULL, NULL); close(sockfd); return n;} // Connected UDP socket (alternative pattern)int connected_udp_client(const char *host, uint16_t port) { int sockfd = socket(AF_INET, SOCK_DGRAM, 0); if (sockfd == -1) return -1; struct sockaddr_in server_addr = { .sin_family = AF_INET, .sin_port = htons(port) }; inet_pton(AF_INET, host, &server_addr.sin_addr); // "Connect" UDP socket (sets default destination) if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) { close(sockfd); return -1; } // Now can use send()/recv() instead of sendto()/recvfrom() // Also enables ICMP error reporting for this destination send(sockfd, "Hello", 5, 0); char buf[1024]; ssize_t n = recv(sockfd, buf, sizeof(buf), 0); // n == -1 with errno == ECONNREFUSED if port unreachable close(sockfd); return 0;}Calling connect() on a UDP socket doesn't establish a connection—it sets a default destination address. Benefits: use send()/recv() instead of sendto()/recvfrom(), receive ICMP errors (connection refused), and filter incoming datagrams to only the connected peer. Useful for request/response protocols to a single server.
UDP vs. TCP Client/Server Differences:
| Aspect | TCP | UDP |
|---|---|---|
| Connection setup | Required (3-way handshake) | None |
| Accept/listen | Required for server | Not applicable |
| Per-client socket | Yes (from accept()) | No (one socket, many clients) |
| Message boundaries | Not preserved (stream) | Preserved (datagrams) |
| Reliability | Guaranteed | Application responsibility |
| Ordering | Guaranteed | Application responsibility |
| NAT traversal | Easier (connection state) | Harder (stateless) |
Building production-quality client/server applications requires careful attention to several cross-cutting concerns:
Connection Lifecycle Management:
Connections go through distinct phases: setup, active, draining, closed. Each phase requires appropriate handling:
Resource Bounds:
Production servers must protect themselves from resource exhaustion:
Protocol Design:
The application protocol layered over sockets significantly impacts implementation:
| Protocol Pattern | Description | Example |
|---|---|---|
| Request-Response | Client sends request, waits for response | HTTP/1.1 |
| Streaming | Data flows continuously in one/both directions | WebSocket, gRPC streaming |
| Pipelining | Client sends multiple requests without waiting | HTTP/1.1 pipelining |
| Multiplexing | Multiple logical streams over one connection | HTTP/2, QUIC |
Keep-Alive vs. Connection-Per-Request:
Keep-alive connections amortize TCP handshake overhead across multiple requests:
For internal services with many requests between the same endpoints, connection pooling provides the best of both worlds.
When multiple threads/processes wait on the same listening socket, an incoming connection can wake ALL of them—but only one can accept it. This 'thundering herd' wastes CPU on spurious wakeups. Solutions: SO_REUSEPORT (each process has its own listening socket), EPOLLEXCLUSIVE (Linux 4.5+), or application-level connection distribution.
We've explored the architectural patterns for socket-based network applications. Let's consolidate the key insights:
What's Next:
With architectural patterns understood, we're ready for practical application. The next page covers Socket Programming—putting everything together with complete, working examples that demonstrate best practices for building robust network applications.
You now understand client/server socket architecture at a production level—from simple iterative designs to highly scalable event-driven servers. These patterns form the foundation of every network service, from simple utilities to platforms serving millions of users.