Linux 34TCP服务器多进程并发

TCP 多进程并发服务器代码讲解:原理、流程与细节

实现了一个 基于 fork() 多进程的 TCP 并发服务器------ 核心能力是:服务器启动后可同时处理多个客户端连接,每个客户端连接由独立的子进程负责数据交互,主进程仅专注于 "接收新连接",不会被单个客户端的通信阻塞。

核心设计思路

多进程并发的核心逻辑:

  1. 主进程(父进程) :只做 3 件事 ------ 创建监听 socket、绑定端口、监听连接,以及 accept() 接收新客户端连接;每接收一个新连接,就 fork() 一个子进程专门处理该客户端的后续通信;

  2. 子进程 :由主进程 fork() 生成,仅与对应的客户端交互(接收数据、发送响应),不参与监听 / 接收新连接;子进程与客户端断开连接后自动退出,释放资源。

优势:实现简单、进程间资源隔离(一个客户端的异常不会影响其他客户端);缺点:进程创建 / 销毁开销略大,适合连接数适中的场景。

代码分段讲解(按执行流程)

1. 头文件与工具函数 sock_init():初始化服务器监听 socket

cpp 复制代码
#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. 主进程逻辑:接收新连接 + 创建子进程

cpp 复制代码
int main()
{
    // 1. 初始化服务器监听socket
    int 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); // 关闭客户端socket
                    exit(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 信号处理函数,子进程退出时触发信号,父进程在信号处理函数中回收。

    优化代码示例(添加信号处理):

    cpp 复制代码
    #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);
        // ... 其余代码不变
    }

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. 客户端连接测试(用 telnetnc

cpp 复制代码
# 打开多个终端,每个终端执行:
telnet 127.0.0.1 6000
# 或
nc 127.0.0.1 6000

# 连接后输入任意字符(如"hello"),服务器会返回"ok",并打印接收的内容

3. 现象观察

  • 每个客户端连接会触发服务器创建一个子进程;
  • 客户端断开后,子进程自动退出,且不会产生僵尸进程(优化后);
  • 多个客户端可同时连接,服务器能分别处理每个客户端的数据。

核心总结

  1. 多进程并发服务器的核心是 "主进程收连接,子进程处理通信" ,通过 fork() 实现连接隔离;
  2. 关键细节:父子进程共享文件描述符,需各自关闭不需要的 socket;子进程退出后需回收,避免僵尸进程;
  3. 适用场景:连接数适中(几百以内)、每个连接的通信逻辑简单,进程隔离能提高稳定性;
  4. 对比选择:若连接数多(几千上万),建议用 IO 多路复用(select/poll/epoll)+ 线程池,或异步 IO,减少进程创建开销。

这份代码是 TCP 多进程服务器的 "最小可行版本",理解其流程后,可基于优化点扩展为生产级服务器(如添加配置文件、日志系统、连接限制等)。

相关推荐
玉树临风江流儿2 小时前
Linux驱动开发实战指南-中
linux·驱动开发
爱喝矿泉水的猛男2 小时前
单周期Risc-V指令拆分与datapath绘制
运维·服务器·risc-v
科技块儿2 小时前
【IP】公有&私有IP地址?
服务器·网络协议·tcp/ip
灵神翁2 小时前
自建node云函数服务器
运维·服务器
m0_527653903 小时前
NVIDIA Orin NX使用Jetpack安装CUDA、cuDNN、TensorRT、VPI时的error及解决方法
linux·人工智能·jetpack·nvidia orin nx
3***49963 小时前
前端WebSocket教程,实时通信案例
网络·websocket·网络协议
TangDuoduo00053 小时前
【IO模型与并发服务器】
运维·服务器·网络·tcp/ip
FOREVER-Q3 小时前
Windows 下 Docker Desktop 快速入门与镜像管理
运维·服务器·windows·docker·容器
武子康4 小时前
Java-172 Neo4j 访问方式实战:嵌入式 vs 服务器(含 Java 示例与踩坑)
java·服务器·数据库·sql·spring·nosql·neo4j