6、多进程并发服务器:使用fork()创建子进程处理多客户端连接

讲完了单进程的迭代服务器,你会发现一个很尴尬的问题——一次只能服务一个客户端。第二个客户端想连?等着吧,等第一个处理完再说。这在生产环境里根本没法用。

我记得刚入行那会儿,写了个简单的聊天室demo,用的就是单进程模型。测试的时候开两个客户端,第二个客户端死活连不上,我还以为是代码写错了,查了半天……后来老同事看了一眼说:“你fork一下不就完了?”

嗯,今天我们就来聊聊多进程并发服务器。说白了,就是让父进程负责“接客”,每来一个客户端,就fork一个子进程去专门伺候它。

6.1 多进程模型的核心思想

多进程并发的思路其实很简单:

  • 父进程:只做一件事——accept(),等待新连接
  • 子进程:处理具体的客户端通信,处理完就退出
  • 关键点:子进程要关闭监听socket,父进程要关闭已连接socket

你想想看,如果不关闭会怎样?文件描述符泄漏,资源被白白占用。我在项目中就见过这样的bug——服务器跑了三天,突然连不上新客户端了,一查发现文件描述符用光了。

核心原则: fork之后,父子进程各自关闭不需要的fd。子进程关listen_fd,父进程关conn_fd。

6.2 代码实现:一个完整的多进程并发服务器

直接上代码。我习惯把关键步骤都写在注释里,方便以后回头看。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>

#define PORT 8888
#define BACKLOG 10

// 子进程处理客户端请求
void handle_client(int conn_fd, struct sockaddr_in *client_addr) {
    char buf[1024];
    char client_ip[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &client_addr->sin_addr, client_ip, INET_ADDRSTRLEN);
    
    printf("[子进程 %d] 服务客户端 %s:%d\n", 
           getpid(), client_ip, ntohs(client_addr->sin_port));
    
    while (1) {
        memset(buf, 0, sizeof(buf));
        int n = read(conn_fd, buf, sizeof(buf) - 1);
        
        if (n <= 0) {
            if (n == 0) {
                printf("[子进程 %d] 客户端断开连接\n", getpid());
            } else {
                perror("read error");
            }
            break;
        }
        
        printf("[子进程 %d] 收到: %s", getpid(), buf);
        
        // 回显给客户端
        write(conn_fd, buf, strlen(buf));
    }
    
    close(conn_fd);
    printf("[子进程 %d] 退出\n", getpid());
    exit(0);
}

// SIGCHLD信号处理函数——回收僵尸进程
void sigchld_handler(int sig) {
    while (waitpid(-1, NULL, WNOHANG) > 0);
}

int main() {
    int listen_fd, conn_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    
    // 1. 创建socket
    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        perror("socket error");
        exit(1);
    }
    
    // 2. 设置端口复用
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    // 3. 绑定地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(PORT);
    
    if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind error");
        exit(1);
    }
    
    // 4. 监听
    if (listen(listen_fd, BACKLOG) < 0) {
        perror("listen error");
        exit(1);
    }
    
    // 5. 注册SIGCHLD信号处理
    signal(SIGCHLD, sigchld_handler);
    
    printf("[父进程 %d] 服务器启动,监听端口 %d...\n", getpid(), PORT);
    
    // 6. 主循环:accept + fork
    while (1) {
        conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
        if (conn_fd < 0) {
            if (errno == EINTR) {
                continue;  // 被信号中断,重新accept
            }
            perror("accept error");
            break;
        }
        
        // fork子进程
        pid_t pid = fork();
        
        if (pid < 0) {
            perror("fork error");
            close(conn_fd);
            continue;
        }
        
        if (pid == 0) {
            // 子进程
            close(listen_fd);  // 子进程不需要监听
            handle_client(conn_fd, &client_addr);
        } else {
            // 父进程
            close(conn_fd);  // 父进程不需要处理连接
            printf("[父进程 %d] 创建子进程 %d 处理客户端\n", getpid(), pid);
        }
    }
    
    close(listen_fd);
    return 0;
}

6.3 代码中的几个关键细节

这段代码看起来不长,但坑不少。我一个个说。

6.3.1 端口复用

SO_REUSEADDR 这个选项,我建议你每次都加上。为什么?因为服务器崩溃重启的时候,如果不加这个,bind会失败——端口还被上一个进程占着呢。我曾经在线上环境吃过这个亏,重启服务的时候报错,手忙脚乱地等了两分钟才恢复。

6.3.2 SIGCHLD信号处理

子进程退出时,会向父进程发送SIGCHLD信号。如果父进程不处理,子进程就会变成僵尸进程。僵尸进程虽然不占CPU,但会占用进程表项,进程表满了就fork不出新进程了。

我这里的处理方式是:

  • 注册 sigchld_handler 函数
  • 在handler里用 waitpid(-1, NULL, WNOHANG) 循环回收
  • WNOHANG 是因为可能有多个子进程同时退出,要一次性收完

小技巧: 为什么不用 wait() 而用 waitpid()?因为 wait() 是阻塞的,如果当前没有子进程退出,它会卡住。而 waitpid() 配合 WNOHANG 是非阻塞的,收不到就立即返回。

6.3.3 accept被信号中断

你看代码里有个 if (errno == EINTR) continue;。这是干嘛用的?

当子进程退出时,SIGCHLD信号会打断父进程的accept系统调用。如果不处理这个中断,accept会返回-1,然后父进程就退出了。嗯,我第一次写多进程服务器时就踩了这个坑——客户端一断开,服务器也跟着挂了。

6.4 多进程模型的核心流程图

下面这张图,把整个流程串了一遍。你看完应该就清楚了。

多进程并发服务器流程图 父进程(主循环) 1. socket() 创建监听套接字 2. bind() 绑定地址和端口 3. listen() 开始监听 4. while(1) { accept() } 5. fork() 创建子进程 子进程(处理客户端) fork成功 1. close(listen_fd) 关闭监听 2. handle_client() 处理通信 3. close(conn_fd) 关闭连接 4. exit(0) 子进程退出 SIGCHLD信号 → 父进程回收 waitpid() 回收僵尸进程

6.5 多进程模型的优缺点

任何技术方案都有两面性。多进程并发也不例外。

优点 缺点
编程简单,逻辑清晰 进程创建开销大(fork比较重)
进程间隔离性好,一个子进程崩溃不影响其他 进程数受系统限制(ulimit -u)
充分利用多核CPU(每个进程独立调度) 进程间通信(IPC)复杂
适合计算密集型任务 大量客户端时内存占用高

注意: 多进程模型虽然简单,但不适合高并发场景。如果客户端数量超过几百个,建议考虑多线程或事件驱动模型(如epoll)。我在一个物联网项目中用过多进程,设备量到500左右时,进程切换开销就开始明显了。

6.6 避坑指南

最后,分享几个我踩过的坑:

  • 文件描述符泄漏:每次fork后,父子进程都要记得close不需要的fd。我见过一个同事的代码,父进程没关conn_fd,结果连接数一多,父进程自己先撑不住了。
  • 信号处理要谨慎:printf在信号处理函数里是不安全的。我上面的代码为了演示简单用了printf,生产环境建议用write或记录日志。
  • 子进程退出状态:如果子进程非正常退出(比如段错误),父进程要能感知到。可以用waitpid的status参数检查退出原因。
  • 惊群问题:多个进程同时accept同一个监听socket时,内核会唤醒所有进程,但只有一个能成功。这个问题在Linux 2.6之后有所改善,但高并发场景下还是建议用epoll+非阻塞IO。

我曾经在一个压力测试中,发现服务器CPU使用率异常高。排查了半天,发现是子进程退出太快,父进程频繁处理SIGCHLD信号,导致上下文切换开销巨大。后来加了个简单的进程池,预创建一批子进程,问题就解决了。

嗯,多进程并发服务器就聊到这儿。代码虽然简单,但里面的细节值得你多琢磨琢磨。动手写一遍,跑起来,再压测一下,你会理解得更深。


公众号:蓝海资料掘金营,微信deep3321