Linux 34TCP服务器多进程并发
TCP 多进程并发服务器代码讲解:原理、流程与细节
实现了一个 基于 fork() 多进程的 TCP 并发服务器—— 核心能力是:服务器启动后可同时处理多个客户端连接,每个客户端连接由独立的子进程负责数据交互,主进程仅专注于 “接收新连接”,不会被单个客户端的通信阻塞。
核心设计思路
多进程并发的核心逻辑:
主进程(父进程):只做 3 件事 —— 创建监听 socket、绑定端口、监听连接,以及
accept()接收新客户端连接;每接收一个新连接,就fork()一个子进程专门处理该客户端的后续通信;子进程:由主进程
fork()生成,仅与对应的客户端交互(接收数据、发送响应),不参与监听 / 接收新连接;子进程与客户端断开连接后自动退出,释放资源。
优势:实现简单、进程间资源隔离(一个客户端的异常不会影响其他客户端);缺点:进程创建 / 销毁开销略大,适合连接数适中的场景。
代码分段讲解(按执行流程)
1. 头文件与工具函数 sock_init():初始化服务器监听 socket
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h> // socket 核心API头文件
#include <netinet/in.h> // 网络地址结构(sockaddr_in)
#include <arpa/inet.h> // 字节序转换(htons/inet_addr等)
#include <pthread.h> // 此处未用线程,可能是冗余包含// 功能:创建并初始化服务器监听socket(绑定端口、开始监听)
int sock_init()
{// 1. 创建TCP socket(监听用)// 参数1:AF_INET = IPv4地址族;参数2:SOCK_STREAM = TCP类型;参数3:0 = 默认协议(TCP)int sersockfd = socket(AF_INET, SOCK_STREAM, 0); if (sersockfd == -1) // socket创建失败返回-1{perror("socket create failed"); // 建议加perror打印错误原因exit(1); // 直接退出程序(实际开发可优化为返回错误码)}// 2. 初始化服务器地址结构(sockaddr_in)struct sockaddr_in saddr; // 存储服务器IP、端口等信息memset(&saddr, 0, sizeof(saddr)); // 清空结构体(避免垃圾值)saddr.sin_family = AF_INET; // IPv4地址族saddr.sin_port = htons(6000); // 端口号:htons()将主机字节序转为网络字节序(大端)saddr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 绑定本地回环IP(仅本机可连接)// 3. 绑定socket与地址结构(将端口6000绑定到sersockfd)int n = bind(sersockfd, (struct sockaddr *)&saddr, sizeof(saddr));if (n == -1) // 绑定失败(如端口被占用){perror("bind failed");close(sersockfd); // 先关闭已创建的socket,避免资源泄漏exit(1);}// 4. 将socket设为监听状态(等待客户端连接)// 参数2:backlog = 5 → 监听队列最大长度(最多同时有5个未处理的连接请求)n = listen(sersockfd, 5);if (n == -1) // 监听失败{perror("listen failed");close(sersockfd);exit(1);}return sersockfd; // 返回监听socket的文件描述符(主进程用它accept新连接)
}
2. 主进程逻辑:接收新连接 + 创建子进程
int main()
{// 1. 初始化服务器监听socketint sockfd = sock_init(); // sockfd = 监听socket的文件描述符if (sockfd == -1){exit(1);}printf("server start success: listen 127.0.0.1:6000\n");struct sockaddr_in caddr; // 存储客户端的IP、端口信息(accept时填充)while (1) // 主进程无限循环:持续接收新连接{int len = sizeof(caddr);// 2. 接收客户端连接(阻塞调用:直到有新客户端连接才返回)// 参数1:监听socket;参数2:客户端地址结构(输出);参数3:地址长度(输入输出)int cilsockfd = accept(sockfd, (struct sockaddr *)&caddr, (unsigned int *)&len);if (cilsockfd < 0) // accept失败(如被信号中断){perror("accept failed");close(cilsockfd); // 此处cilsockfd是-1,close无效,可省略continue; // 继续等待下一个连接,不退出}// 打印新连接信息:客户端socket、端口(ntohs转为主机字节序)、IP(inet_ntoa转字符串)printf("accept success: cilsockfd=%d, client port=%d, client ip=%s\n", cilsockfd, ntohs(caddr.sin_port), inet_ntoa(caddr.sin_addr));// 3. 创建子进程:处理当前客户端的通信pid_t pid = fork(); // fork()调用后,父进程和子进程同时执行后续代码if (pid == -1) // fork失败(如系统资源不足){perror("fork failed");close(cilsockfd); // 关闭客户端socket,避免资源泄漏continue;}// -------------------------- 子进程逻辑(pid == 0)--------------------------if (pid == 0){// 子进程不需要监听socket,立即关闭(避免端口被占用,且父子进程共享文件描述符)close(sockfd); // 循环与客户端通信(接收数据 + 发送响应)while (1){char buff[128] = {0}; // 存储接收的客户端数据// 接收客户端数据:recv是阻塞调用,直到客户端发送数据或断开连接// 参数1:客户端socket;参数2:接收缓冲区;参数3:缓冲区大小(留1字节存'\0');参数4:0=默认模式int byte = recv(cilsockfd, buff, 127, 0);// 处理recv返回值(关键:判断客户端状态)if (byte <= 0) {// byte == 0 → 客户端主动关闭连接;byte < 0 → 接收失败(如网络异常)printf("client disconnected: cilsockfd=%d\n", cilsockfd);close(cilsockfd); // 关闭客户端socketexit(0); // 子进程完成使命,退出(避免子进程进入主循环accept新连接)}// 打印接收的数据printf("recv from client(%d): %s (bytes=%d)\n", cilsockfd, buff, byte);// 向客户端发送响应("ok")// send默认是阻塞的,返回发送的字节数(此处固定发送2字节:'o'和'k')send(cilsockfd, "ok", 2, 0);}}// -------------------------- 父进程逻辑(pid > 0)--------------------------else{// 父进程不需要与客户端通信,立即关闭客户端socket(父子进程共享文件描述符,子进程仍在使用)close(cilsockfd); // 父进程继续循环,等待下一个客户端连接(accept)continue;}}close(sockfd); // 主进程退出前关闭监听socket(实际不会执行,因主进程在while(1)中)return 0;
}
关键技术点解析
1. fork() 多进程的核心机制
fork()调用后,操作系统会复制当前进程(父进程)的所有资源(代码、数据、文件描述符等),生成一个新进程(子进程);- 父子进程的唯一区别是
fork()的返回值:父进程返回子进程的 PID(>0),子进程返回 0; - 父子进程共享文件描述符(如监听 socket、客户端 socket),因此子进程需关闭监听 socket,父进程需关闭客户端 socket,避免资源泄漏。
2. 字节序转换(htons/ntohs)
- 网络传输使用 “网络字节序”(大端序),而主机(如 x86 架构)使用 “主机字节序”(小端序);
htons(port):将主机字节序的端口号转为网络字节序(用于绑定端口);ntohs(port):将网络字节序的端口号转为主机字节序(用于打印客户端端口)。
3. accept()/recv() 的阻塞特性
accept():主进程调用后会阻塞,直到有新客户端连接,返回客户端 socket 的文件描述符(cilsockfd);recv():子进程调用后会阻塞,直到客户端发送数据(返回接收的字节数)或断开连接(返回 0)或出错(返回 <0)。
4. 资源释放的关键
- 子进程必须关闭监听 socket(
sockfd):子进程不需要监听新连接,关闭后避免端口被占用; - 父进程必须关闭客户端 socket(
cilsockfd):父进程不处理客户端通信,关闭后释放文件描述符资源; - 客户端断开后,子进程必须
exit(0):避免子进程进入主循环的accept(),导致多个进程监听同一个端口。
代码优化点(实际开发必备)
1. 处理僵尸进程(核心优化)
问题:子进程退出后,若父进程未处理其退出状态,子进程会变成 “僵尸进程”(占用系统资源);
解决方法:
- 方法 1:父进程调用
waitpid(-1, NULL, WNOHANG)非阻塞回收僵尸进程(可在主循环中定期调用); - 方法 2:注册
SIGCHLD信号处理函数,子进程退出时触发信号,父进程在信号处理函数中回收。
优化代码示例(添加信号处理):
#include <signal.h> #include <sys/wait.h>// SIGCHLD信号处理函数:回收僵尸进程 void sigchld_handler(int sig) {// waitpid(-1, NULL, WNOHANG):非阻塞回收所有子进程while (waitpid(-1, NULL, WNOHANG) > 0); }int main() {// 注册信号处理函数(在sock_init前)signal(SIGCHLD, sigchld_handler);// ... 其余代码不变 }- 方法 1:父进程调用
2. 增加错误处理的详细信息
- 原代码仅
exit(1),未打印错误原因,建议用perror()或strerror(errno)打印错误信息,方便调试。
3. 优化缓冲区与数据接收逻辑
- 原代码缓冲区固定 128 字节,若客户端发送数据超过 127 字节,会被截断;
- 建议循环
recv()直到接收完所有数据(需约定数据长度或分隔符)。
4. 支持绑定任意 IP(而非仅 127.0.0.1)
- 若需外部主机连接,将
saddr.sin_addr.s_addr = inet_addr("127.0.0.1")改为saddr.sin_addr.s_addr = INADDR_ANY(绑定所有网卡的 IP)。
5. 移除冗余头文件
- 代码中包含
<pthread.h>但未使用线程,可删除,减少编译依赖。
运行与测试步骤
1. 编译运行服务器
2. 客户端连接测试(用 telnet 或 nc)
# 打开多个终端,每个终端执行:
telnet 127.0.0.1 6000
# 或
nc 127.0.0.1 6000# 连接后输入任意字符(如"hello"),服务器会返回"ok",并打印接收的内容
3. 现象观察
- 每个客户端连接会触发服务器创建一个子进程;
- 客户端断开后,子进程自动退出,且不会产生僵尸进程(优化后);
- 多个客户端可同时连接,服务器能分别处理每个客户端的数据。
核心总结
- 多进程并发服务器的核心是 “主进程收连接,子进程处理通信”,通过
fork()实现连接隔离; - 关键细节:父子进程共享文件描述符,需各自关闭不需要的 socket;子进程退出后需回收,避免僵尸进程;
- 适用场景:连接数适中(几百以内)、每个连接的通信逻辑简单,进程隔离能提高稳定性;
- 对比选择:若连接数多(几千上万),建议用 IO 多路复用(
select/poll/epoll)+ 线程池,或异步 IO,减少进程创建开销。
这份代码是 TCP 多进程服务器的 “最小可行版本”,理解其流程后,可基于优化点扩展为生产级服务器(如添加配置文件、日志系统、连接限制等)。
