TCP 服务器并发:fork、僵尸进程与双重 fork

看到 defunct 或 ps 中 STAT 为 Z 时,先别急着 kill 僵尸进程:子进程已经退出,缺的是父进程的回收动作。需要记录退出码和终止信号,应使用 SIGCHLD + waitpid();只在 Linux 运行且完全不关心退出状态,才可以把 SIGCHLD 显式设为 SIG_IGN。双重 fork 改变的是进程收养关系,不能替代父进程对直接子进程的 waitpid()。

最常见的 TCP 并发结构是父进程持有监听套接字并调用 accept(),每接入一个客户端就 fork(),由子进程处理已连接套接字,父进程关闭该连接描述符后立即回到 accept()。经典 socket 示例也是这种“父进程监听、子进程服务”的分工;并发能否成立,关键不在于有没有调用 fork(),而在于父进程能否及时回到循环,以及父子进程有没有关闭各自不应持有的套接字。

先从进程树确认问题落在哪一层。下面尖括号中的 PID 需要替换为实际值,否则会被 shell 当成重定向符号:

# 找出 STAT 以 Z 开头的进程
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/'

# 根据上一条结果中的 PPID 检查父进程
ps -o pid,ppid,stat,cmd -p <PPID>

# fork 或 accept 因资源限制失败时检查
ulimit -n
ulimit -u

如果僵尸进程的父进程是业务服务,重点检查它有没有回收直接子进程;如果 PPID 是 1,则要继续确认 PID 1 是谁、是否具备回收孤儿进程的能力。仅仅重启或杀掉僵尸进程本身没有意义,因为僵尸已经不存在可执行实体。

父进程监听、子进程服务:明确描述符归属

阻塞式服务器如果直接在 accept() 后处理业务,第一个客户端只要保持连接不放,服务就会一直被占住,后面已经完成握手的客户端只能等待。这里的“并发”并不保证多个进程同时占用多个 CPU 核,它首先解决的是一个慢客户端不能独占整个服务的问题。

典型的职责划分如下:

父进程:socket → bind → listen → accept → fork → close(conn) → 继续 accept
子进程:close(listen_fd) → 处理 conn → close(conn) → _exit

fork() 后,父子进程都会继承监听套接字和已连接套接字。父进程如果不关闭 conn,就可能和子进程同时读写同一个客户端连接,造成响应串线;如果子进程不关闭 listen_fd,监听描述符就会一直留在这个子进程中。正确做法是父进程关闭 conn,子进程关闭 listen_fd,双方只保留自己负责的描述符。

下面是一个只保留关键分支的骨架:

#include <errno.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>

static int serve(int fd)
{
    /* 收发数据并处理业务 */
    return 0;
}

for (;;) {
    int conn = accept(listen_fd, NULL, NULL);
    if (conn < 0) {
        if (errno == EINTR) {
            /* 配合 SIGCHLD 回收子进程后继续 */
            continue;
        }

        if (errno == ECONNABORTED) {
            /* 客户端在建立连接期间中止,通常可以重试 */
            continue;
        }

        if (errno == EMFILE || errno == ENFILE) {
            /* 记录 errno,按部署策略退避后重试 */
            continue;
        }

        /* 其他错误需要记录 errno,并决定退出还是退避重试 */
        break;
    }

    pid_t pid = fork();
    if (pid == 0) {
        close(listen_fd);

        int rc = serve(conn);
        close(conn);

        /* 避免刷新从父进程复制来的 stdio 缓冲区 */
        _exit(rc == 0 ? EXIT_SUCCESS : EXIT_FAILURE);
    }

    if (pid > 0) {
        /* 父进程不持有这个客户端连接 */
        close(conn);
    } else {
        int saved_errno = errno;
        close(conn);

        /* 记录 saved_errno;本次连接没有创建子进程 */
    }
}

fork() 成功时,子进程返回 0,父进程返回子进程 PID,失败返回 -1 并设置 errno。父子进程的地址空间彼此独立,普通内存写入采用写时复制,不能把子进程对变量的修改当成父子共享状态;需要共享业务状态时,要明确选择管道、共享内存、消息队列等 IPC 机制。父子进程之后谁先运行则由调度器决定。

如果只有第一个客户端表现正常,优先检查是否在父进程中调用 serve(conn),直到它返回后才重新 accept();如果后续客户端能够连接但迟迟没有数据,检查父进程是否仍在循环、conn 是否被错误地留在父进程,以及相关文件描述符是否耗尽;如果 fork() 突然失败,则先区分“临时错误”和“资源已经耗尽”,不要无条件快速重试。

Linux 上可先检查当前 shell 的限制:

ulimit -Sn
ulimit -Hn
ulimit -u

ulimit -n 是文件描述符限制,ulimit -u 是用户进程数量限制,但它们不一定等于容器或系统实际生效的限制。cgroup v2 常见的进程控制文件位于:

cat /sys/fs/cgroup/pids.current
cat /sys/fs/cgroup/pids.max

cgroup v1、宿主机挂载方式和容器内的实际路径可能不同,需要查看当前环境。fork() 返回 EAGAIN 或 ENOMEM 时,应继续检查进程数、内存和 cgroup 限制;accept() 返回 EMFILE、ENFILE 时,则更像文件描述符压力。EAGAIN 或 EWOULDBLOCK 只应按非阻塞套接字的语义处理,不能把阻塞式 accept() 的永久错误也当成普通重试。

看到 defunct / Z:父进程回收退出状态

defunct 不是仍在运行的客户端,而是已经退出、但父进程尚未取走退出状态的进程表记录。子进程退出后,内核会保留 PID、退出码或终止信号,直到父进程执行一次成功的 wait() 或 waitpid()。此时再向僵尸 PID 发送 SIGKILL 没有作用,因为它已经退出,真正缺失的是父进程的回收动作。

回收方式的选择取决于服务器是否需要子进程的退出结果:

方式 能否取得退出状态 适用场景 主要边界
SIGCHLD handler + waitpid() 能 需要记录退出码、终止信号或排查异常退出 必须和 accept() 或事件循环正确配合
Linux 下显式设置 SIGCHLD 为 SIG_IGN 否 只需要自动回收,不关心退出状态 Linux 特有简化,不能再依赖 waitpid() 取得状态
阻塞式 wait() 能 父进程不需要继续接收新连接 放在 accept() 主循环中可能停止接入新客户端

资料中的经典做法是处理 SIGCHLD 并调用 wait() 回收,或者直接忽略 SIGCHLD。 在 Linux 上,显式把 SIGCHLD 设为 SIG_IGN 后,子进程退出时会被自动回收,退出状态则被丢弃。这里要特别区分“没有安装 SIGCHLD handler”和“显式把处置设为 SIG_IGN”:前者的默认动作不会自动回收子进程,后者在 Linux 上才会采用自动回收语义。

需要退出状态时,更稳妥的做法是让信号处理函数只设置标志,主循环或事件循环负责调用 waitpid():

#include <errno.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>

static volatile sig_atomic_t reap_pending;

static void on_sigchld(int signo)
{
    (void)signo;
    reap_pending = 1;
}

static void reap_children(void)
{
    for (;;) {
        int status;
        pid_t pid = waitpid(-1, &status, WNOHANG);

        if (pid > 0) {
            /* 在正常上下文记录 pid、退出码或终止信号 */
            continue;
        }

        if (pid < 0 && errno == EINTR) {
            continue;
        }

        break;
    }
}

在以 EINTR 唤醒 accept() 的简化模型中,可以这样驱动回收:

do {
    reap_pending = 0;
    reap_children();
} while (reap_pending);

这里必须循环调用 waitpid(-1, ..., WNOHANG)。多个子进程退出时,SIGCHLD 作为普通信号可能合并到达;收到一次信号不代表只有一个子进程需要回收,但各子进程的退出状态仍保留在内核中,可以由 waitpid() 依次取走。因此,信号合并本身不会让退出状态丢失,真正丢弃状态的是显式 SIG_IGN。

如果 sigaction() 设置了 SA_RESTART,阻塞中的 accept() 可能被系统调用自动重启,不能只依赖它返回 EINTR。事件循环可以用自管道或 Linux 的 signalfd 唤醒主循环,再在循环中执行 waitpid()。直接调用阻塞式 wait() 也不合适:只要已有子进程尚未退出,accept() 循环就会停下来,新客户端即使已经完成握手也得不到及时服务。

还要避免把几个不同状态混在一起:

Z:已经退出,等待父进程回收
D:通常处于不可中断的 I/O 等待,并不是僵尸进程
孤儿进程:父进程已经退出或发生重新安置,不等于僵尸进程

PPID 变成 1 只说明进程被收养,不能据此判断它已经僵死。普通 Linux 环境中,孤儿进程通常由 PID 1 或配置的 subreaper 接管;容器里则取决于 PID 1 的实际程序和运行时配置。Docker 文档明确说明,使用 --init 时会通过 tini 处理信号和回收孤儿进程;没有单独 init 进程时,应用自身的 PID 1 需要承担这些责任。

双重 fork、线程与 epoll:按负载和隔离要求选择

双重 fork 调整的是父子关系,而不是并发模型本身。典型过程如下:

P(服务器) ──fork──> A ──fork──> B(实际服务进程)
                   └─立即退出

P:回收 A
B:成为孤儿进程,被 PID 1 或配置的 subreaper 接管

对应的关键骨架如下:

pid_t a = fork();
if (a < 0) {
    /* 处理 fork 失败 */
}

if (a == 0) {
    pid_t b = fork();

    if (b == 0) {
        if (setsid() == -1) {
            _exit(EXIT_FAILURE);
        }

        run_daemon();
        _exit(EXIT_SUCCESS);
    }

    _exit(b < 0 ? EXIT_FAILURE : EXIT_SUCCESS);
}

/* P 仍然是 A 的直接父进程,必须回收 A */
waitpid(a, NULL, 0);

这段代码涉及三层回收责任。第一层子进程 A 仍然是 P 的直接子进程,P 不调用 waitpid(A),A 就会成为 P 的僵尸进程;双重 fork 并不会自动替代等待。第二层子进程 B 在 A 退出后成为孤儿进程,由最近的 subreaper 或 PID 1 收养;收养者不是 P 时,P 无法直接取得 B 的退出状态。第三,B 将来退出时,收养它的进程必须负责回收;如果原服务器自己被配置为 subreaper,收养者可能就是原服务器,双重 fork 并没有把责任移走。

这套结构适用于守护化、服务进程需要脱离原父进程,或明确需要改变收养关系的场景。实际守护化通常还要配合 setsid()、关闭或重定向继承的文件描述符、重定向标准输出和错误日志,并通过管道或 Unix socket 通知就绪状态。单独做两次 fork() 并不是完整的守护化方案。

它不适合作为普通每连接模型的默认补丁:它不能降低每个连接创建进程的开销,不能代替关闭多余套接字,也不能消除 P 对直接子进程 A 的回收责任。相反,它会让退出状态、日志关联和进程树排查更复杂。容器中的 PID 1 是否自动回收 B,也必须按具体运行时验证,不能仅凭“双重 fork 后是孤儿进程”得出安全结论。

选择并发模型时,应按连接特征判断。串行阻塞、每连接一个进程、每连接一个线程、预创建进程池和 IO 复用解决的问题不同,资料中的取舍也集中在隔离性、共享状态、创建成本和连接数量之间的平衡。

模型 主要优势 主要代价 更适合的情况
串行阻塞 accept() 实现最简单 同一时间只处理一个请求 学习、小流量或请求极短
每连接一个 fork() 故障隔离直观,进程间共享少 进程创建开销、僵尸回收、进程数限制 连接数量可控、强调隔离
每连接一个线程 共享内存方便,创建通常比进程轻 线程与连接接近 1:1,连接多时栈和切换开销明显 中等并发、需要共享状态
预创建进程池 摊薄连接到来时的 fork() 成本 需要维护池大小,多 worker 竞争监听描述符时可能遭遇惊群 连接突发明显、进程隔离重要
epoll 等 IO 复用 少量进程可管理大量等待中的连接 事件循环和并发状态管理更复杂,CPU 密集任务仍需额外调度 大量连接以网络 IO 等待为主

如果连接数量可控、重视故障隔离,而且需要记录每个子进程的退出状态,每连接一个 fork() 配合 SIGCHLD + waitpid() 仍然清楚直接。如果只运行在 Linux 上且完全不关心退出状态,可以评估显式忽略 SIGCHLD,但要接受状态不可观测这一边界。大量长连接主要等待网络数据时,应优先评估 epoll;连接持续时间短、突发明显时,可以评估预创建进程池。线程模型则更适合需要共享状态、连接规模中等且能够控制线程数量的场景。

没有压测数据时,不能给出“达到多少连接必须切换”的统一数字。连接持续时间、业务是否阻塞、共享状态需求、故障隔离要求和进程或线程预算,比单一连接数更能决定模型。排查时可以先运行前面的 ps 命令找到 Z 进程及其 PPID,确认直接子进程的回收路径,再根据连接数量、持续时间和隔离要求决定是补上 waitpid(),还是调整整个并发架构。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容