在Linux网络编程中,多进程服务器是最经典的并发模型之一。它的核心思想是通过fork()系统调用创建子进程,每个子进程独立处理一个客户端连接。这种架构的优势在于隔离性好——某个客户端连接出现异常不会影响其他连接,同时编程模型相对简单直观。
典型的多进程服务器遵循以下工作流程:
这种设计的关键点在于文件描述符的继承与关闭策略。fork()后,子进程会继承父进程的所有文件描述符,因此需要明确哪些描述符应该在哪个进程中关闭,避免资源泄漏。
提示:在多进程服务器中,文件描述符的管理尤为重要。父进程和子进程必须各自关闭不需要的描述符,否则可能导致连接无法正常关闭或资源泄漏。
子进程退出后如果不及时回收,会变成僵尸进程占用系统资源。服务器程序中通常采用以下两种方式处理:
在我们的示例代码中,通过设置信号处理来避免僵尸进程:
c复制signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信号,内核自动回收子进程
这种方式最简单有效,但缺点是父进程无法获取子进程的退出状态。如果需要更精细的控制,应该使用waitpid()在信号处理函数中显式回收。
服务器启动的第一步是创建监听套接字并绑定到指定端口:
c复制// 创建IPv4的TCP套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1) {
perror("socket error");
exit(1);
}
// 设置地址结构
struct sockaddr_in addr;
addr.sin_family = AF_INET; // IPv4协议
addr.sin_port = htons(PORT); // 端口号转网络字节序
addr.sin_addr.s_addr = INADDR_ANY; // 绑定所有可用IP (0.0.0.0)
// 绑定套接字
int ret = bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
if(ret == -1) {
perror("bind error");
close(lfd);
exit(1);
}
几个关键点需要注意:
AF_INET指定使用IPv4协议族SOCK_STREAM表示面向连接的TCP套接字INADDR_ANY表示绑定到本机所有网络接口htons()将主机字节序的端口号转换为网络字节序绑定成功后,服务器需要设置监听队列并开始接受客户端连接:
c复制// 设置监听队列长度
ret = listen(lfd, MAX_LISTEN);
if(ret == -1) {
perror("listen error");
close(lfd);
exit(1);
}
// 主循环接受新连接
while(1) {
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
// 接受新连接(阻塞调用)
int cfd = accept(lfd, (struct sockaddr*)&client_addr, &client_len);
if(cfd == -1) {
perror("accept error");
continue;
}
// 创建子进程处理连接
pid_t pid = fork();
if(pid == 0) { // 子进程
close(lfd); // 子进程不需要监听套接字
// 处理客户端通信
working(cfd);
// 通信结束,退出子进程
exit(0);
} else if(pid > 0) { // 父进程
close(cfd); // 父进程不需要通信套接字
} else {
perror("fork error");
close(cfd);
}
}
这里有几个关键细节:
listen()的第二个参数指定了连接请求队列的最大长度accept()是阻塞调用,会一直等待直到有新连接到达子进程中的working()函数负责与客户端进行数据交换:
c复制void working(int cfd) {
char buf[BUFFER_SIZE];
while(1) {
memset(buf, 0, sizeof(buf));
// 接收客户端数据
int len = read(cfd, buf, sizeof(buf));
if(len == 0) {
printf("客户端断开了连接...\n");
break;
} else if(len == -1) {
perror("read error");
break;
} else {
printf("接收到客户端数据: %s\n", buf);
// 原样返回数据
write(cfd, buf, len);
}
}
close(cfd);
}
这个简单的echo服务器实现了最基本的读写操作:
服务器程序在崩溃后重启时,经常会遇到"Address already in use"错误。这是因为TCP连接的TIME_WAIT状态会保持一段时间。通过设置SO_REUSEADDR选项可以解决这个问题:
c复制int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
这个选项应该在bind()之前设置,它允许绑定处于TIME_WAIT状态的地址,使服务器能够快速重启。
原始的fork()模型为每个连接创建一个新进程,当连接数很大时会产生显著的性能开销。更高效的方案是预创建一组工作进程(进程池),通过IPC机制分配连接:
这种设计避免了频繁创建销毁进程的开销,适合高并发场景。
服务器程序应该正确处理各种信号,特别是:
c复制// 忽略SIGPIPE信号,防止写关闭的套接字导致进程退出
signal(SIGPIPE, SIG_IGN);
// 处理SIGTERM等终止信号,实现优雅退出
void handle_signal(int sig) {
// 清理资源并退出
exit(0);
}
signal(SIGTERM, handle_signal);
signal(SIGINT, handle_signal);
当客户端无法连接到服务器时,可以按照以下步骤排查:
ps aux | grep 服务器程序名netstat -tulnp | grep 端口号iptables -L -ntelnet 127.0.0.1 端口号多进程服务器在高并发下可能遇到性能瓶颈,可以考虑:
ulimit -ubash复制echo 1024 > /proc/sys/net/core/somaxconn
echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse
虽然子进程退出时会自动释放资源,但仍需注意:
bash复制valgrind --leak-check=full ./server
./serverbash复制telnet 127.0.0.1 9999
bash复制nc 127.0.0.1 9999
bash复制for i in {1..10}; do nc 127.0.0.1 9999 & done
使用专业的压力测试工具评估服务器性能:
bash复制ab -c 100 -n 10000 http://127.0.0.1:9999/
bash复制wrk -t4 -c100 -d30s http://127.0.0.1:9999/
测试过程中监控系统资源使用情况:
ps -ef | grep server | wc -ltop -p 服务器PIDnetstat -an | grep 9999 | wc -luptime在实际项目中,我曾经遇到过服务器在约300个并发连接时性能急剧下降的情况。通过分析发现是默认的文件描述符限制太低,通过调整ulimit -n 65535解决了问题。这也提醒我们,生产环境中的服务器程序必须考虑各种系统限制和优化参数。