第5章 TCP客户/服务器程序示例
实例分析
一、导读
5.1 本章学习目标
第4章掌握了TCP数据传输的基本操作后,本章通过完整的客户/服务器示例,将前面学到的知识串联起来,构建真实可用的网络应用。本章的重点不仅是"怎么写",更是"怎么写好"——如何设计并发模型、如何处理错误、如何应对各种边界情况。
本章的核心学习目标包括:
- 理解迭代服务器与并发服务器的区别:各自的优缺点和适用场景。
- 掌握fork创建并发服务器的方法:父子进程如何共享文件描述符?如何正确关闭不需要的描述符?
- 学会处理SIGCHLD信号:如何回收已终止的子进程,避免僵尸进程?
- 理解并发服务器的性能特征:每连接一个进程的模型有什么局限性?
- 掌握多线程服务器的实现:与多进程相比有什么优劣?
- 学会编写健壮的网络代码:错误处理、资源清理、异常情况的应对。
5.2 为什么本章如此重要
从简单的echo程序到生产级的网络服务器,中间隔着巨大的工程鸿沟。本章正是跨越这道鸿沟的桥梁。Stevens通过逐步改进一个回显服务器——从迭代版本到并发版本,从多进程到多线程——展示了网络服务器设计的演进过程和每种方案的权衡。
理解这些内容,你就能回答以下关键问题:
- 为什么Apache HTTP Server早期使用fork模型,后来改用线程模型?
- 为什么Nginx使用事件驱动模型而不是每连接一个线程?
- 当你的服务器需要同时处理10000个连接时,应该选择什么架构?
5.3 核心问题引导
在阅读本章时,请带着以下问题思考:
迭代服务器在什么情况下会"卡住"?一个慢客户如何影响其他客户?
fork() 后父子进程的文件描述符引用计数如何变化?为什么子进程必须关闭 listenfd?
为什么回收子进程时要用 while (waitpid(-1, NULL, WNOHANG) > 0) 而不是 wait(NULL)?
如果一个服务器同时有100个客户连接,使用fork模型需要多少系统资源?
多线程服务器中,多个线程共享哪些资源?需要注意什么同步问题?
什么是"C10K问题"?为什么传统的并发模型无法解决它?
二、核心概念详解
5.1 迭代服务器
5.1.1 基本模型
迭代服务器是最简单的服务器模型——一次只处理一个客户请求,处理完一个才处理下一个。
服务器启动 → accept() → 处理请求 → 关闭连接 → accept() → 处理请求 → ...// 迭代echo服务器
int main(void) {
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
// ... bind, listen ...
for (;;) {
int connfd = accept(listenfd, NULL, NULL);
// 处理当前客户(阻塞在这里,直到客户关闭连接)
char buf[1024];
ssize_t n;
while ((n = read(connfd, buf, sizeof(buf))) > 0) {
write(connfd, buf, n);
}
close(connfd);
// 回到accept,等待下一个客户
}
}5.1.2 适用场景
迭代服务器只适用于以下场景:
- 每次请求的处理时间很短(毫秒级)
- 不需要同时服务多个客户
- 请求是独立的,没有长连接
典型应用:DNS服务器、DHCP服务器、简单的时间服务器。
5.1.3 局限性
时间线:
客户A: ──连接──[=====处理5秒=====]──关闭──
客户B: ──连接──[等待5秒...][处理]──关闭──
客户C: ──连接──[等待10秒...][处理]──关闭──
客户B和C必须等待客户A处理完毕才能被服务!如果一个客户的处理时间很长(如大文件下载),或者客户连接后不发送数据(慢速客户),整个服务器就会被阻塞,其他客户无法得到服务。
5.2 并发服务器:fork模型
5.2.1 基本模型
并发服务器为每个客户创建一个子进程,子进程专门处理该客户的请求,父进程继续接受新的连接。
父进程: accept() → fork() → accept() → fork() → ...
│ │
子进程1: └── 处理客户1 ── close()
子进程2: └── 处理客户2 ── close()// 并发echo服务器(fork模型)
#include <signal.h>
#include <sys/wait.h>
void sigchld_handler(int sig) {
// 回收所有已终止的子进程
while (waitpid(-1, NULL, WNOHANG) > 0)
;
}
int main(void) {
signal(SIGCHLD, sigchld_handler);
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
// ... bind, listen ...
for (;;) {
struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
int connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
if (connfd < 0) {
if (errno == EINTR) continue; // 被SIGCHLD中断
perror("accept error");
continue;
}
pid_t pid = fork();
if (pid == 0) {
// 子进程
close(listenfd); // 子进程不需要监听套接字
str_echo(connfd); // 处理客户请求
close(connfd);
_exit(0);
}
// 父进程
close(connfd); // 父进程不需要已连接套接字
}
}5.2.2 fork后的文件描述符
fork() 创建子进程时,子进程继承父进程的所有文件描述符。但父子进程共享的是同一个文件表项(内核中的 struct file),引用计数增加。
fork前:
listenfd → [内核文件表项A] (ref=1)
connfd → [内核文件表项B] (ref=1)
fork后:
父进程: listenfd → [内核文件表项A] (ref=2)
connfd → [内核文件表项B] (ref=2)
子进程: listenfd → [内核文件表项A] ↗
connfd → [内核文件表项B] ↗
子进程close(connfd):
connfd → [内核文件表项B] (ref=1) ← 连接不关闭!
父进程close(connfd):
connfd → [内核文件表项B] (ref=0) ← 连接关闭,发送FIN为什么子进程必须close(listenfd)?
- 子进程不需要接受新连接,listenfd对它无用
- 如果不关闭,listenfd的引用计数不会减到0,即使父进程退出,内核也不会释放监听套接字
- 更严重的是,如果父进程崩溃重启,旧的子进程持有的listenfd会阻止新服务器bind同一端口
为什么父进程必须close(connfd)?
- 父进程只负责接受新连接,不处理客户数据
- 如果不关闭,connfd的引用计数不会减到0,客户关闭连接后连接不会真正关闭
- 大量未关闭的connfd会耗尽文件描述符
5.2.3 SIGCHLD与僵尸进程
当子进程终止时,内核将其变为"僵尸进程"(zombie),保留其进程控制块(PCB)直到父进程回收。如果父进程不回收,僵尸进程会一直存在,浪费系统资源。
// 错误做法:使用wait()
void sigchld_handler(int sig) {
wait(NULL); // 只回收一个子进程
// 如果同时有多个子进程终止,只会回收一个!
// 因为信号不排队,多个SIGCHLD可能合并为一个
}
// 正确做法:使用waitpid循环
void sigchld_handler(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0)
; // 回收所有已终止的子进程
}
// waitpid参数说明:
// -1: 等待任何子进程
// NULL: 不关心退出状态
// WNOHANG: 非阻塞,没有已终止的子进程立即返回0信号不排队的问题:
如果同时有3个子进程终止,内核只发送一个SIGCHLD信号(信号不排队)。如果信号处理函数只调用一次 wait(),只会回收一个子进程,另外两个变成僵尸进程。因此必须用 while 循环回收所有已终止的子进程。
5.2.4 accept被信号中断
当SIGCHLD信号到达时,如果父进程正在 accept() 中阻塞,accept 会被中断,返回-1,errno 设为 EINTR。
// 正确处理accept被信号中断
int connfd;
for (;;) {
connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
if (connfd < 0) {
if (errno == EINTR) {
continue; // 被SIGCHLD中断,重新accept
}
perror("accept error");
continue;
}
// 处理连接...
}5.3 并发服务器:线程模型
5.3.1 基本模型
与fork模型类似,但使用线程而不是进程。每个客户连接由一个线程处理。
#include <pthread.h>
void *thread_handler(void *arg) {
int connfd = *(int *)arg;
free(arg);
str_echo(connfd);
close(connfd);
return NULL;
}
int main(void) {
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
// ... bind, listen ...
for (;;) {
int connfd = accept(listenfd, NULL, NULL);
int *arg = malloc(sizeof(int));
*arg = connfd;
pthread_t tid;
pthread_create(&tid, NULL, thread_handler, arg);
pthread_detach(tid); // 分离线程,自动回收资源
close(connfd); // 主线程不需要connfd
}
}5.3.2 线程 vs 进程
| 特性 | 多进程(fork) | 多线程(pthread) |
|---|---|---|
| 资源开销 | 大(独立地址空间) | 小(共享地址空间) |
| 创建速度 | 慢 | 快 |
| 内存占用 | 高(每个进程独立) | 低(共享内存) |
| 隔离性 | 强(一个崩溃不影响其他) | 弱(一个崩溃全部崩溃) |
| 同步需求 | 低(独立地址空间) | 高(共享数据需加锁) |
| 可移植性 | 好(POSIX标准) | 好(POSIX标准) |
| 适用场景 | 需要强隔离 | 高性能、大量连接 |
5.3.3 线程池模型
创建和销毁线程有开销。线程池预先创建一组线程,从任务队列中取出连接处理。
#include <pthread.h>
#define THREAD_POOL_SIZE 10
typedef struct {
int connfd;
} client_task;
typedef struct {
client_task tasks[100];
int head, tail, count;
pthread_mutex_t mutex;
pthread_cond_t cond;
} task_queue;
task_queue queue;
void *worker_thread(void *arg) {
for (;;) {
pthread_mutex_lock(&queue.mutex);
while (queue.count == 0) {
pthread_cond_wait(&queue.cond, &queue.mutex);
}
client_task task = queue.tasks[queue.head];
queue.head = (queue.head + 1) % 100;
queue.count--;
pthread_mutex_unlock(&queue.mutex);
// 处理客户
str_echo(task.connfd);
close(task.connfd);
}
}
int main(void) {
// 初始化队列
queue.head = queue.tail = queue.count = 0;
pthread_mutex_init(&queue.mutex, NULL);
pthread_cond_init(&queue.cond, NULL);
// 创建线程池
pthread_t threads[THREAD_POOL_SIZE];
for (int i = 0; i < THREAD_POOL_SIZE; i++) {
pthread_create(&threads[i], NULL, worker_thread, NULL);
}
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
// ... bind, listen ...
for (;;) {
int connfd = accept(listenfd, NULL, NULL);
pthread_mutex_lock(&queue.mutex);
queue.tasks[queue.tail].connfd = connfd;
queue.tail = (queue.tail + 1) % 100;
queue.count++;
pthread_cond_signal(&queue.cond);
pthread_mutex_unlock(&queue.mutex);
}
}5.4 并发模型比较
5.4.1 各种模型的性能特征
| 模型 | 最大连接数 | 内存开销 | CPU开销 | 编程复杂度 |
|---|---|---|---|---|
| 迭代 | 1 | 最低 | 最低 | 最简单 |
| 每连接一进程 | ~1000 | 高 | 高(上下文切换) | 中等 |
| 每连接一线程 | ~5000 | 中 | 中 | 中等(需同步) |
| 线程池 | 受线程数限制 | 中 | 中 | 较高 |
| I/O多路复用 | ~100000 | 低 | 低 | 高 |
5.4.2 C10K问题
C10K问题是指同时处理10000个并发连接的问题。传统的每连接一进程/线程模型面临以下瓶颈:
- 内存:10000个线程,每个线程栈8MB → 80GB内存
- CPU:10000个线程的上下文切换开销巨大
- 文件描述符:需要调整系统限制
解决方案:I/O多路复用(select/poll/epoll),一个线程处理多个连接。这是第6章的主题。
5.5 错误处理与健壮性
5.5.1 常见的网络错误
// 错误处理模板
int ret = some_network_call(...);
if (ret < 0) {
switch (errno) {
case EINTR:
// 被信号中断,通常可以重试
break;
case ECONNRESET:
// 连接被对端重置
// 通常意味着对端进程崩溃或主动发送RST
break;
case ECONNREFUSED:
// 连接被拒绝
// 通常意味着对端没有进程监听
break;
case ETIMEDOUT:
// 连接超时
// 通常意味着网络不可达或防火墙丢弃
break;
case EPIPE:
// 管道破裂
// 向已关闭的连接写入
break;
default:
perror("network error");
break;
}
}5.5.2 资源清理
// 确保资源被正确释放
void handle_client(int connfd) {
char *buf = malloc(BUF_SIZE);
if (!buf) {
close(connfd);
return;
}
// 使用goto统一清理(Linux内核常用风格)
int ret = 0;
ret = read_request(connfd, buf, BUF_SIZE);
if (ret < 0) goto cleanup;
ret = process_request(buf);
if (ret < 0) goto cleanup;
ret = send_response(connfd, buf, ret);
if (ret < 0) goto cleanup;
cleanup:
free(buf);
close(connfd);
}三、实战案例
5.1 完整的并发回显服务器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <signal.h>
#include <sys/wait.h>
#include <errno.h>
#define MAXLINE 4096
#define LISTENQ 128
#define SERV_PORT 8080
void sigchld_handler(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0)
;
}
void str_echo(int connfd) {
ssize_t n;
char line[MAXLINE];
while ((n = read(connfd, line, MAXLINE)) > 0) {
if (write(connfd, line, n) != n) {
perror("write error");
return;
}
}
if (n < 0) {
perror("read error");
}
}
int main(int argc, char **argv) {
int listenfd, connfd;
struct sockaddr_in servaddr, cliaddr;
socklen_t clilen;
int port = SERV_PORT;
if (argc > 1) {
port = atoi(argv[1]);
}
// 设置SIGCHLD处理器
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGCHLD, &sa, NULL);
// 忽略SIGPIPE
signal(SIGPIPE, SIG_IGN);
// 创建监听套接字
listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd < 0) {
perror("socket error");
exit(1);
}
// 设置SO_REUSEADDR
int optval = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
// 绑定
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(port);
if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
perror("bind error");
exit(1);
}
// 监听
if (listen(listenfd, LISTENQ) < 0) {
perror("listen error");
exit(1);
}
printf("Concurrent echo server on port %d\n", port);
// 主循环
for (;;) {
clilen = sizeof(cliaddr);
connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
if (connfd < 0) {
if (errno == EINTR) {
continue;
}
perror("accept error");
continue;
}
// 打印客户信息
char cli_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &cliaddr.sin_addr, cli_ip, sizeof(cli_ip));
printf("Connection from %s:%d\n", cli_ip, ntohs(cliaddr.sin_port));
pid_t pid = fork();
if (pid == 0) {
// 子进程
close(listenfd);
str_echo(connfd);
close(connfd);
_exit(0);
}
// 父进程
close(connfd);
}
return 0;
}5.2 多线程版本
#include <pthread.h>
typedef struct {
int connfd;
struct sockaddr_in cliaddr;
} thread_args;
void *client_handler(void *arg) {
thread_args *args = (thread_args *)arg;
int connfd = args->connfd;
char cli_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &args->cliaddr.sin_addr, cli_ip, sizeof(cli_ip));
printf("[Thread %lu] Connection from %s:%d\n",
(unsigned long)pthread_self(), cli_ip, ntohs(args->cliaddr.sin_port));
free(args);
str_echo(connfd);
close(connfd);
return NULL;
}
int main(int argc, char **argv) {
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
// ... bind, listen (同上) ...
for (;;) {
thread_args *args = malloc(sizeof(thread_args));
socklen_t clilen = sizeof(args->cliaddr);
args->connfd = accept(listenfd, (struct sockaddr *)&args->cliaddr, &clilen);
if (args->connfd < 0) {
free(args);
if (errno == EINTR) continue;
perror("accept error");
continue;
}
pthread_t tid;
if (pthread_create(&tid, NULL, client_handler, args) != 0) {
perror("pthread_create");
close(args->connfd);
free(args);
continue;
}
pthread_detach(tid);
}
}四、本章小结
本章通过完整的示例展示了TCP客户/服务器的设计模式,核心要点回顾:
迭代服务器:一次处理一个客户,简单但效率低,只适合短连接、快请求。
fork并发服务器:每连接一个子进程。注意文件描述符的引用计数——子进程close(listenfd),父进程close(connfd)。使用 waitpid(-1, NULL, WNOHANG) 循环回收子进程,避免僵尸进程。
线程并发服务器:每连接一个线程,资源开销小于进程模型。但需要注意线程同步问题,一个线程崩溃会影响整个进程。
线程池:预先创建固定数量的线程,从任务队列中取任务。避免了频繁创建/销毁线程的开销。
C10K问题:传统模型无法高效处理大量并发连接。解决方案是I/O多路复用(下一章的主题)。
错误处理:网络编程必须检查每个系统调用的返回值,区分EINTR、ECONNRESET、EPIPE等不同错误,采取相应的处理策略。
五、思考与练习
思考题
如果fork后子进程没有close(listenfd),当父进程崩溃重启时,会发生什么?
为什么信号处理函数中不能使用printf?在sigchld_handler中应该避免哪些操作?
线程池的大小应该如何选择?太大和太小分别有什么问题?
如果一个客户连接后不发送任何数据,fork模型和线程模型分别会怎样?
为什么fork模型中子进程使用 _exit(0) 而不是 exit(0)?
编程练习
并发聊天服务器:实现一个支持多个客户同时连接的聊天服务器。每个客户发送的消息被广播给所有其他客户。使用fork模型。
带超时的服务器:修改echo服务器,如果客户在30秒内没有发送数据,服务器主动关闭连接。提示:使用 setsockopt 设置 SO_RCVTIMEO。
连接计数器:实现一个服务器,记录当前活跃连接数和总连接数。每当新客户连接或旧客户断开时,打印统计信息。