1. 项目概述为什么“fork创建多个子进程”仍是系统编程绕不开的硬核基本功“fork创建多个子进程”——这八个字看似简单实则是Linux/Unix系统编程中一块被反复锤炼、至今仍不可替代的基石。它不是某个新潮框架里的语法糖也不是封装好的API调用而是一次对操作系统内核机制最直接的叩问当父进程调用fork()时内核究竟做了什么子进程如何获得独立地址空间又共享哪些资源父子进程间如何协同、通信、收尸这些问题的答案直接决定了你写的守护进程是否稳定、并发任务是否可控、资源隔离是否彻底。我带过不少刚从Python/Node.js转系统开发的工程师他们第一反应往往是“为什么不用multiprocessing或child_process”——这恰恰暴露了对底层机制理解的断层。fork不是过时技术而是所有高级抽象的源头。它不依赖任何语言运行时不引入额外调度开销一次系统调用就能完成进程克隆实测在主流服务器上平均耗时仅3–8微秒。更重要的是它天然支持COW写时复制内存管理子进程初始阶段几乎零内存开销这对需要快速启动数百个worker的场景如HTTP连接池、日志轮转、批量数据预处理具有不可替代的效率优势。本文面向两类人一是正在啃《APUE》《Linux内核设计与实现》却卡在fork实践环节的开发者二是已用过高阶封装但想穿透抽象、排查诡异僵尸进程、理解SIGCHLD信号丢失根源的实战派。不讲教科书定义只拆解真实代码里每行fork背后的内核动作、每个waitpid参数的取舍逻辑、每种错误码对应的实际现场。接下来的内容全部来自某实验室高并发日志分析系统的重构经历——我们曾因fork后未正确处理文件描述符继承导致子进程意外关闭父进程的监听socket服务中断23分钟也因忽略SIGCHLD信号阻塞积累上千个僵尸进程拖垮整台机器。这些坑我都替你踩过了。2. 核心机制深度拆解fork不是“复制”而是“分叉”与“延迟克隆”2.1 fork()调用的三重语义系统调用、内核动作、用户态表现很多初学者把fork()简单理解为“复制一个进程”这是危险的误解。fork的本质是创建一个与父进程几乎完全相同的子进程但二者共享同一份代码段且内核采用写时复制Copy-on-Write, COW策略管理数据段和堆栈。这个“几乎完全相同”包含五个关键维度进程IDPID不同子进程获得全新PID父进程PID成为其PPID父进程ID。这是区分父子的唯一硬性标识。文件描述符表fd table完全复制子进程拥有与父进程一模一样的fd编号及其指向的内核file结构体。注意不是复制文件内容而是复制对同一内核对象的引用。这意味着若父进程open()打开一个文件fork后父子都能读写该文件且文件偏移量offset是共享的——这是许多竞态问题的根源。信号处理状态独立子进程继承父进程的信号掩码signal mask但信号处理函数handler的注册状态需特别注意若父进程用sigaction()设置了SA_RESTART标志子进程同样继承但若父进程用signal()设置handler子进程可能继承也可能不继承POSIX未强制规定因此生产环境必须显式重置。内存映射mmap区域按策略继承匿名映射如malloc分配的堆内存默认被COW保护文件映射MAP_SHARED在子进程中保持可写并同步更新原文件而MAP_PRIVATE映射在子进程写入时触发COW生成独立副本。进程组IDPGID与会话IDSID相同子进程默认加入父进程所在的进程组这对后续信号广播如kill -TERM -pgid和终端控制至关重要。提示fork返回值是理解整个机制的钥匙。在父进程中返回子进程PID正整数在子进程中返回0出错时返回-1。这个设计让父子进程能立即走上不同执行路径——无需额外判断天然支持分支逻辑。我见过太多新手在fork后忘记检查返回值直接执行后续代码结果父进程和子进程同时执行同一段逻辑造成资源争抢或重复初始化。2.2 写时复制COW的真相不是“不复制”而是“不立刻复制”COW常被神化为“零拷贝”实际并非如此。它的核心是延迟物理内存页复制仅在任一进程尝试修改共享页时内核才为其分配新页并复制内容。这个过程涉及三个关键环节fork时的页表操作内核为子进程创建新页表将父进程所有用户态页表项PTE标记为“只读”并设置COW标志位。此时父子进程的虚拟地址到物理页帧的映射完全一致但所有数据页均被设为只读。第一次写入触发缺页异常当子进程或父进程首次向某共享页写入时CPU触发页故障page fault陷入内核。内核检测到该页有COW标志于是分配一个新物理页帧将原页内容复制到新页更新触发写入的进程的页表项指向新页并清除只读标志恢复进程执行。性能临界点测算COW节省的是物理内存而非CPU时间。复制一页通常4KB耗时约100–300纳秒。若子进程fork后立即execve()加载新程序COW收益极大——因为execve()会清空整个用户空间原有页根本不会被写入但若子进程需大量修改父进程数据如深度克隆一个大哈希表COW反而增加缺页异常开销。实测表明当子进程写入内存占比超过总共享页的15%时纯内存复制非COW可能更优——但这在绝大多数场景下不成立因为fork后execve是主流模式。注意COW不适用于所有内存。栈空间虽被COW保护但子进程函数调用栈增长会频繁触发缺页而通过mmap(MAP_ANONYMOUS)分配的大块内存COW效率极高。某次我们优化一个图像处理服务将原始图像数据存于mmap匿名区fork出16个worker并行处理内存占用比传统malloc方式降低67%正是COW对大块连续内存的极致优化。2.3 fork失败的七种典型原因及诊断方法fork()失败返回-1errno指示具体原因。除常见的ENOMEM内存不足外以下五种情况极易被忽略errno常见场景诊断命令关键线索EAGAIN进程数超限RLIMIT_NPROCulimit -u子shell中fork失败但主进程正常ps -eLf | wc -l查看当前线程/进程总数ENOMEM物理内存swap不足free -h; cat /proc/meminfo | grep -E (MemFreeSwapFree)ENOSYS内核禁用fork极罕见grep -i fork /proc/sys/kernel/*容器环境seccomp策略拦截docker run --security-opt seccomp...EINTR被信号中断已废弃POSIX要求重试strace -e tracefork,clone ./your_programstrace输出显示fork被信号打断后返回-1但现代glibc自动重试EMFILE父进程打开文件描述符超限RLIMIT_NOFILEulimit -n; lsof -p $PID | wc -l子进程无法打开新文件但fork本身失败概率低更多影响后续open()实操心得线上服务fork失败90%源于RLIMIT_NPROC或内存碎片。我们曾遇到一台48核机器ulimit -u设为1024但单个服务启动300个worker后剩余进程槽位不足导致健康检查进程fork失败监控误报服务宕机。解决方案不是盲目调高ulimit而是改用进程池复用机制——fork一次通过pipe传递任务避免高频fork。3. 多子进程创建的四种经典模式与工程选型逻辑3.1 单次fork 循环wait最朴素但最易出错的模式这是教科书式写法适合学习原理但生产环境应规避#include sys/wait.h #include unistd.h #include stdio.h int main() { pid_t pids[5]; for (int i 0; i 5; i) { pids[i] fork(); if (pids[i] 0) { // 子进程 printf(Child %d running\n, i); sleep(2); // 模拟工作 return 0; } else if (pids[i] 0) { perror(fork failed); return 1; } } // 父进程等待所有子进程 for (int i 0; i 5; i) { int status; waitpid(pids[i], status, 0); printf(Child %d exited with %d\n, i, WEXITSTATUS(status)); } return 0; }致命缺陷分析僵尸进程风险若某个子进程提前退出而父进程尚未执行到对应waitpid()该子进程即成zombie直至父进程回收。循环中waitpid顺序固定无法响应子进程实际退出顺序。信号干扰若父进程在waitpid()阻塞时收到SIGCHLD且未设置SA_RESTARTwaitpid可能被中断返回-1errnoEINTR导致后续waitpid跳过。资源泄漏子进程若未正确关闭继承的文件描述符如父进程的监听socket、日志文件句柄会导致父进程资源耗尽。我在某支付网关项目中亲眼见过此模式引发的雪崩父进程fork出100个worker处理支付回调但未关闭继承的数据库连接池fd。当某个worker异常崩溃其持有的数据库连接未释放父进程连接池逐渐枯竭最终所有新请求超时。修复方案是fork后立即调用close()关闭所有非必需fd或使用FD_CLOEXEC标志。3.2 fork SIGCHLD信号处理器异步回收的工业级方案这是生产环境首选核心是将子进程退出事件转化为信号由信号处理器异步调用waitpid非阻塞回收#include signal.h #include sys/wait.h #include unistd.h #include stdio.h #include stdlib.h volatile sig_atomic_t child_exited 0; void sigchld_handler(int sig) { child_exited 1; // 设置标志位避免在信号处理中调用非异步安全函数 } int main() { struct sigaction sa; sa.sa_handler sigchld_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; // 确保系统调用自动重启 sigaction(SIGCHLD, sa, NULL); // 创建5个子进程 for (int i 0; i 5; i) { pid_t pid fork(); if (pid 0) { // 子进程关闭所有继承的fd执行任务 close(0); close(1); close(2); // 关闭标准IO execl(/bin/sleep, sleep, 3, (char*)NULL); exit(127); // exec失败 } } // 主循环检查子进程退出并回收 while (1) { if (child_exited) { child_exited 0; pid_t pid; int status; while ((pid waitpid(-1, status, WNOHANG)) 0) { printf(Child %d exited, status%d\n, pid, WEXITSTATUS(status)); } } sleep(1); // 避免忙等待 } return 0; }关键设计解析WNOHANG标志使waitpid非阻塞立即返回。若无已退出子进程返回0若有则回收一个。循环调用可回收所有已退出子进程。SA_RESTART确保主循环中的sleep()等系统调用在收到SIGCHLD时不被中断避免逻辑错乱。volatile sig_atomic_t信号处理函数中只能使用异步信号安全函数async-signal-safeprintf、malloc等均不安全故仅设置标志位回收逻辑在主循环中执行。注意SIGCHLD信号不排队若多个子进程几乎同时退出内核只发送一次SIGCHLD。因此必须在信号处理器中设置标志然后在主循环中用while (waitpid(-1, ...))循环回收直到返回0。某次我们漏掉这个while循环只调用一次waitpid导致9个子进程退出后仅回收1个其余8个变僵尸持续2小时才被发现。3.3 fork pipe管道通信父子进程双向协作的可靠通道当子进程需向父进程反馈结果而非仅退出状态pipe是轻量级首选#include unistd.h #include sys/wait.h #include stdio.h #include stdlib.h #include string.h int main() { int pipefd[2]; if (pipe(pipefd) -1) { perror(pipe); return 1; } pid_t pid fork(); if (pid 0) { // 子进程 close(pipefd[0]); // 关闭读端 char result[] SUCCESS: processed 1000 records; write(pipefd[1], result, strlen(result)); close(pipefd[1]); exit(0); } else if (pid 0) { // 父进程 close(pipefd[1]); // 关闭写端 char buffer[256]; ssize_t n read(pipefd[0], buffer, sizeof(buffer)-1); if (n 0) { buffer[n] \0; printf(From child: %s\n, buffer); } close(pipefd[0]); waitpid(pid, NULL, 0); } return 0; }工程要点fd关闭时机子进程必须关闭读端pipefd[0]否则父进程read()会因写端未关闭而永远阻塞父进程同理关闭写端。数据边界处理pipe是字节流无消息边界。若需传输多条消息必须约定协议如长度前缀、分隔符。我们在线上系统中采用\0分隔子进程每次write后加\0父进程read到\0即认为一条完整消息。容量限制Linux pipe缓冲区默认64KB可通过/proc/sys/fs/pipe-max-size调整。若子进程write超限且父进程未及时readwrite会阻塞。因此长任务建议用临时文件或共享内存替代。3.4 fork socketpair跨进程全双工通信的终极方案当父子进程需频繁、双向交换结构化数据如JSON、Protobufsocketpair提供TCP语义的可靠连接#include sys/socket.h #include unistd.h #include stdio.h #include string.h int main() { int sv[2]; if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) -1) { perror(socketpair); return 1; } pid_t pid fork(); if (pid 0) { // 子进程 close(sv[0]); // 关闭父端 char msg[] HELLO FROM CHILD; write(sv[1], msg, strlen(msg)); close(sv[1]); exit(0); } else if (pid 0) { // 父进程 close(sv[1]); // 关闭子端 char buffer[256]; ssize_t n read(sv[0], buffer, sizeof(buffer)-1); if (n 0) { buffer[n] \0; printf(Parent received: %s\n, buffer); } close(sv[0]); waitpid(pid, NULL, 0); } return 0; }对比pipe的优势全双工父子可同时读写无需像pipe那样严格分工。SOCK_STREAM语义自动处理粘包/拆包数据按发送顺序到达无需手动解析边界。可设置选项通过setsockopt()启用TCP_NODELAY、SO_RCVTIMEO等精细控制行为。实操经验某实时风控系统要求子进程每秒向父进程上报统计指标。初期用pipe因父子读写节奏不一致父进程read()偶尔读到半条消息解析失败。改用socketpair后配合send()的MSG_NOSIGNAL标志避免SIGPIPE稳定性达99.999%。代价是内存占用略增每个socketpair约4KB内核缓冲区。4. 实战全流程从零构建一个健壮的多子进程工作池4.1 需求定义与架构设计明确边界拒绝过度工程假设我们要构建一个日志分析工作池需求如下父进程监听一个目录当新日志文件出现时fork一个子进程处理每个子进程处理完一个文件后退出不长期驻留最多同时运行8个子进程超出则排队子进程需继承父进程的配置如数据库连接串但不能继承监听socket父进程需实时获知子进程处理进度如“已处理75%”。架构决策进程模型采用“主控进程 工作进程池”非线程模型——因日志解析CPU密集线程受GIL限制若用Python或调度竞争C/C。通信机制子进程通过socketpair向父进程发送JSON进度消息父进程用epoll监听所有socketpair读端实现高并发响应。资源管理父进程为每个子进程设置独立的umask、chroot可选、rlimit确保隔离性。错误恢复子进程崩溃时父进程通过SIGCHLD捕获记录错误日志并根据错误码决定是否重试如OOM Killer杀死则不重试解析错误则重试3次。4.2 核心代码实现逐行注释关键逻辑#include sys/epoll.h #include sys/socket.h #include sys/wait.h #include sys/resource.h #include unistd.h #include stdio.h #include stdlib.h #include string.h #include signal.h #include fcntl.h #include errno.h #include time.h #define MAX_WORKERS 8 #define MAX_EVENTS 64 typedef struct { pid_t pid; int sock_fd; // 与子进程通信的socket fd char filename[256]; int retry_count; } worker_t; worker_t workers[MAX_WORKERS]; int epoll_fd; volatile sig_atomic_t child_exited 0; // 信号处理器仅设标志 void sigchld_handler(int sig) { child_exited 1; } // 初始化epoll int init_epoll() { epoll_fd epoll_create1(0); if (epoll_fd -1) { perror(epoll_create1); return -1; } return 0; } // 启动一个工作进程 int start_worker(const char* filename) { int sv[2]; if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) -1) { perror(socketpair); return -1; } pid_t pid fork(); if (pid 0) { // 子进程 // 关闭所有继承的fd只保留sock_fd[1]和stderr for (int fd 3; fd 1024; fd) { if (fd ! sv[1]) close(fd); } // 设置独立资源限制 struct rlimit rl; rl.rlim_cur rl.rlim_max 1024; // 文件描述符 setrlimit(RLIMIT_NOFILE, rl); // 执行日志解析程序传入socket fd和文件名 char fd_str[16], file_str[256]; snprintf(fd_str, sizeof(fd_str), %d, sv[1]); snprintf(file_str, sizeof(file_str), %s, filename); execl(./log_parser, log_parser, fd_str, file_str, (char*)NULL); exit(127); } else if (pid 0) { // 父进程 // 关闭子端保留父端用于读 close(sv[1]); // 在epoll中监听父端 struct epoll_event ev; ev.events EPOLLIN; ev.data.fd sv[0]; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sv[0], ev) -1) { perror(epoll_ctl add); close(sv[0]); return -1; } // 记录worker信息 for (int i 0; i MAX_WORKERS; i) { if (workers[i].pid 0) { workers[i].pid pid; workers[i].sock_fd sv[0]; strncpy(workers[i].filename, filename, sizeof(workers[i].filename)-1); workers[i].retry_count 0; break; } } } return 0; } // 回收已退出子进程 void reap_workers() { pid_t pid; int status; while ((pid waitpid(-1, status, WNOHANG)) 0) { // 查找对应worker for (int i 0; i MAX_WORKERS; i) { if (workers[i].pid pid) { printf(Worker %d (%s) exited, status%d\n, pid, workers[i].filename, WEXITSTATUS(status)); // 从epoll移除socket epoll_ctl(epoll_fd, EPOLL_CTL_DEL, workers[i].sock_fd, NULL); close(workers[i].sock_fd); // 清空记录 memset(workers[i], 0, sizeof(worker_t)); break; } } } } // 主循环 int main() { // 初始化 if (init_epoll() -1) return 1; struct sigaction sa; sa.sa_handler sigchld_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; sigaction(SIGCHLD, sa, NULL); // 启动初始worker模拟 start_worker(/var/log/app.log); struct epoll_event events[MAX_EVENTS]; while (1) { // 检查子进程退出 if (child_exited) { child_exited 0; reap_workers(); } // 等待socket事件进度消息 int nfds epoll_wait(epoll_fd, events, MAX_EVENTS, 1000); if (nfds -1) { if (errno EINTR) continue; perror(epoll_wait); break; } for (int i 0; i nfds; i) { if (events[i].events EPOLLIN) { char buffer[1024]; ssize_t n read(events[i].data.fd, buffer, sizeof(buffer)-1); if (n 0) { buffer[n] \0; printf(Progress: %s, buffer); } } } } return 0; }4.3 编译与部署Makefile与systemd服务模板MakefileCC gcc CFLAGS -Wall -Wextra -O2 -stdgnu99 TARGET log_worker_pool SOURCES main.c $(TARGET): $(SOURCES) $(CC) $(CFLAGS) -o $ $^ clean: rm -f $(TARGET) .PHONY: cleansystemd服务文件/etc/systemd/system/log-worker-pool.service[Unit] DescriptionLog Analysis Worker Pool Afternetwork.target [Service] Typesimple Userloguser Grouploguser WorkingDirectory/opt/log-analyzer ExecStart/opt/log-analyzer/log_worker_pool Restarton-failure RestartSec10 LimitNOFILE65536 LimitNPROC4096 EnvironmentPATH/usr/local/bin:/usr/bin:/bin [Install] WantedBymulti-user.target部署命令# 编译 make # 创建用户 sudo useradd -r -s /bin/false loguser # 复制二进制和配置 sudo cp log_worker_pool /opt/log-analyzer/ sudo cp log-worker-pool.service /etc/systemd/system/ # 启用服务 sudo systemctl daemon-reload sudo systemctl enable log-worker-pool.service sudo systemctl start log-worker-pool.service # 查看日志 sudo journalctl -u log-worker-pool.service -f注意systemd的LimitNPROC直接控制RLIMIT_NPROC比ulimit更可靠。我们曾将LimitNPROC4096设为硬限制确保即使代码有bug也不会耗尽系统进程槽位。5. 常见问题与排查技巧实录那些年我们踩过的坑5.1 僵尸进程Zombie Process的七种死法与根治方案僵尸进程是子进程已终止但父进程未调用wait()回收其PCB进程控制块的状态。它不占CPU/内存但占用PID和内核PCB表项。当系统PID耗尽默认32768新进程无法创建。现象根本原因快速诊断彻底解决ps aux | grep Z显示大量defunct父进程未处理SIGCHLD或waitpid逻辑有缺陷ps -eo pid,ppid,stat,comm | grep Z查看zombie的PPID定位父进程在父进程中实现可靠的SIGCHLD处理器循环waitpid(-1, ...)top显示%MEM异常高但pmap -x PID内存很小zombie本身不占内存但父进程可能因zombie堆积导致逻辑阻塞间接引发内存泄漏cat /proc/sys/kernel/pid_max查看PID上限ps -eLf | wc -l统计当前进程数重启父进程长期方案重构父进程回收逻辑strace -p PARENT_PID显示wait4(-1,调用阻塞父进程用阻塞式wait()且无子进程退出导致挂起lsof -p PARENT_PID查看父进程打开的fd确认是否有未关闭的pipe/socket改用waitpid(-1, ..., WNOHANG)非阻塞轮询或用epoll监听子进程socketdmesg输出Out of memory: Kill process XXX (PID) score XXX or sacrifice childOOM Killer杀死子进程后父进程未及时回收zombie堆积加剧内存压力grep -i killed process /var/log/kern.log为子进程设置prctl(PR_SET_OOM_SCORE_ADJ, -1000)降低OOM优先级或增加swappstree -p PARENT_PID显示子进程树异常深父进程fork后未正确处理返回值导致子进程再次fork形成进程链pstree -pgrep -E (PARENTlsof -p PARENT_PID显示大量pipe或socket处于0x状态父进程未关闭继承的pipe/socket fd子进程退出后fd仍被父进程持有ls -l /proc/PARENT_PID/fd/ | grep -E (pipesocket)systemctl status SERVICE显示Main process exited, codeexited, status1/FAILURE父进程自身崩溃遗留所有子进程成为孤儿被init收养但未回收journalctl -u SERVICE --since 1 hour ago查看崩溃日志添加core dump分析父进程增加健全性检查如心跳、内存水位实操心得某次线上事故中我们发现zombie数量每小时增长120个持续3天。通过/proc/PID/status查看zombie的PPID定位到一个监控脚本。该脚本用popen()执行命令但未检查pclose()返回值导致子shell退出后未被回收。修复后zombie归零。教训所有fork()调用必须有对应的wait()或waitpid()无论用何种方式。5.2 fork失败的深度排查从内存到内核参数当fork()返回-1errnoENOMEM不要急着加内存。按以下顺序排查检查RLIMIT_AS地址空间限制# 查看当前限制 cat /proc/PID/limits \| grep Max address space # 临时解除仅调试 prlimit --as-1 PID某Java应用因JVM设置-Xmx4g但ulimit -v设为3gfork时因虚拟内存不足失败。检查内存碎片# 查看最低可用内存页单位pages cat /proc/sys/vm/min_free_kbytes # 查看内存碎片指数0无碎片1000严重碎片 cat /proc/buddyinfo若buddyinfo中order-104MB页为0而应用需分配大块内存fork可能失败。解决方案echo 1 /proc/sys/vm/compact_memory触发内存整理。检查cgroup内存限制容器环境# Docker容器内 cat /sys/fs/cgroup/memory/memory.limit_in_bytes cat /sys/fs/cgroup/memory/memory.usage_in_bytes若usage接近limitfork会因内存不足失败。需调整--memory参数或优化应用内存使用。检查内核参数overcommitcat /proc/sys/vm/overcommit_memory # 0启发式1总是允许2严格检查 cat /proc/sys/vm/overcommit_ratio # 仅当overcommit_memory2时生效生产环境推荐overcommit_memory1避免因保守策略拒绝合法fork。注意free -h显示的available内存是内核估算值不等于实际可分配内存。真正权威的是/proc/meminfo中的MemAvailable字段。我们曾因free显示2GB available但fork仍失败最终发现MemAvailable仅剩128MB因内核缓存未及时回收。5.3 子进程继承问题那些悄悄搞垮服务的fd子进程默认继承父进程所有打开的文件描述符这是最大安全隐患。常见问题监听socket被意外关闭父进程fork后子进程若调用close(listen_fd)父进程的监听socket即失效新连接无法接入。日志文件被截断父进程以O_APPEND打开日志fork后父子同时写入因文件偏移量共享导致日志错乱或覆盖。数据库连接池耗尽子进程继承数据库连接fd但未正确关闭父进程连接池满后拒绝新连接。防御性编程四原则fork前设置FD_CLOEXEC对所有非必需fd调用fcntl(fd, F_SETFD, FD_CLOEXEC)确保fork后自动关闭。fork后立即关闭在子进程中用for (int fd 3; fd 1024; fd) close(fd);暴力关闭需先保存必需fd。使用posix_spawn()替代forkexec它内置fd清理逻辑更安全。容器化时启用seccomp禁止子进程调用open()、socket()等敏感系统调用从源头杜绝继承滥用。个人体会在某金融系统中我们曾因子进程继承了父进程的Redis连接fd当子进程崩溃时Redis连接未优雅关闭导致Redis端TIME_WAIT堆积最终连接数超限。修复后所有子进程启动时调用redisAsyncDisconnect()并设置FD_CLOEXEC。这个习惯现在已成为团队代码审查的硬性条款。