ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unix进程与线程模型实战:fork、exec、僵尸进程与互斥锁

Unix进程与线程模型实战:fork、exec、僵尸进程与互斥锁

很多人对Unix进程模型的第一层理解停在“进程是资源分配单位、线程是调度单位”这句口诀上,背得很熟,真到写代码还是会在fork的返回值上卡半天。前面那部分把进程和线程的概念边界铺开了,这一部分换个角度:不聊定义,直接扒开系统调用看真实发生了什么——fork到底复制了哪些东西、exec把什么抹干净了、僵尸进程卡在内核的哪个环节、pthread_create开出来的线程为什么共享堆却各自有栈、互斥锁保护的究竟是变量还是不变量。这些问题的答案不在教科书的名词解释里,而在你写出一段能跑、又能在压力下不出事的代码时才会真正浮现。适合已经能写多进程或多线程Demo、但对自己的代码在高并发下是否可靠心里没底的开发者,也适合准备系统编程面试、想把“知道”变成“讲得清”的人。下面这些结论大多来自实际调试和被线上问题教育过的经历,代码能直接拿去改。

1. fork复制的是资源目录,不是程序正文

1.1 三个返回值分支为什么必须这么设计

fork被调用一次,却在两个执行流里各返回一次,这是Unix进程模型里最反直觉的设计。父进程拿到子进程的PID,子进程拿到0,出错时只有调用方拿到-1。这个不对称不是为了炫技,而是让两个执行流能立刻分开走:子进程手里没有任何可用的标识符能指回自己,给它0就是明确宣布“你是新产生的那一个”;父进程必须拿到PID,否则它没法wait、没法发信号、没法做任何后续管理。错误只在父进程侧出现,因为子进程压根没出生。

pid_t pid = fork(); if (pid < 0) { perror("fork"); /* 只有父进程会走到这里 */ } else if (pid == 0) { /* 子进程:拿到 0,通常紧接着 exec */ } else { /* 父进程:拿到子进程 PID */ }

新手最容易犯的错是把pid == 0和pid != 0写反,然后在父进程里执行了本该子进程干的活儿。还有一个隐蔽的坑:fork()失败返回-1,pid类型是pid_t,在某些平台上是有符号整型,用if (pid)判断“是否父进程”会把出错分支也吞进父进程逻辑里,导致父进程以为子进程创建成功、随后wait一个不存在的PID,卡在ECHILD上。我见过有代码这么写,测试环境一直没报错,因为fork几乎不失败,直到进程数打满才炸。

1.2 写时复制:看着像全量拷贝,实际只拷了一张页表

“fork会复制父进程的整个地址空间”——这句话对,但它会让人误以为fork很贵。实际实现是写时复制(Copy-On-Write):内核把父进程的页表复制一份给子进程,所有页表项都指向同一批物理页,同时把这些页在两边都标记为只读。父子任何一方写入某页时触发缺页异常,内核才真正分配一页新内存、拷贝内容、恢复可写。

这意味着fork的实际成本主要和地址空间大小相关,而不是和已用内存大小相关——因为要复制的页表项数量取决于虚拟地址空间被映射了多少。一个进程即使只用了50MB内存,如果它映射了几个GB的文件或者有大量mmap区域,fork的页表拷贝依然可观。反过来说,一个占了几百MB但映射很规整的进程,fork可能相当快。

这个机制对写代码有两个非常实际的推论。第一,fork之后尽快exec的程序(比如shell启动命令),几乎不承担内存拷贝成本,因为子进程在写入之前就换掉了整个地址空间,那些共享页直接释放。第二,fork之后如果子进程要写大量内存,会连续触发缺页异常,性能表现和前期的“廉价fork”完全不同——你以为fork很便宜,其实账单是在第一次写的时候才寄到。

提示:如果程序在fork之后立刻遍历并修改一大片内存,性能会比预期差很多。想规避,可以在fork之后马上exec,或者改用posix_spawn这类明确为此优化的接口。

1.3 父子共享文件偏移量:一个只在压力下暴露的坑

fork复制的文件描述符表里,每个表项指向的是内核中同一个“打开文件描述”(open file description),里面装着文件偏移量和打开标志。父子进程各自持有一份描述符表,但指向的是同一个偏移量。

后果是:父进程打开日志文件、fork出若干子进程,所有子进程往同一个描述符写,偏移量是共享的,写入不会互相覆盖,看起来“很美好”。但只要有人调用了lseek,全体偏移一起跳,其他进程的追加就跑到别处去了。更麻烦的是如果用了O_APPEND,追加语义在大多数实现里是原子的,但如果你自己lseek(fd, 0, SEEK_END)再write,父子并发时必然出现覆盖。

真正安全的做法是每个子进程自己open一次文件,得到独立的打开文件描述、独立的偏移量;或者干脆在父进程统一收日志,子进程通过管道把内容送过来由父进程串行落盘。第二种做法在多进程日志场景里更常见,代价是需要设计一个不会因为管道写满而阻塞业务逻辑的缓冲策略。

场景是否共享偏移量是否共享文件状态标志典型风险
fork后继承同一fd是是并发写互相干扰偏移
各自open同一路径否否无,但要多占fd
通过fd传递(SCM_RIGHTS)是是与fork继承等价
dup出的多个fd是是同上

2. fork与exec之间那段窄缝,能做的事比你想的少

2.1 为什么这段区间只能调异步信号安全函数

fork之后、exec之前,子进程处在一个非常尴尬的状态:它可能继承了父进程里某些锁处于“已加锁”的中间态。比如父进程是多线程的,某个线程正在调用malloc持有堆锁,此时另一个线程fork出了子进程,子进程的堆锁状态是“被锁定”,但持锁的线程在子进程里根本不存在,永远不会解锁。子进程一旦调malloc,直接死锁。

这就是POSIX规定这段区间只能调用“异步信号安全函数”的原因。安全名单很短:read、write、close、_exit、execve、open、dup2等少量系统调用,以及signal这类。printf、malloc、pthread_mutex_lock都不在名单里。我见过有人在子进程里立刻调printf("child\n")调试,然后抱怨“有时候能打印有时候卡死”——这就是踩在这条线上了。

实践中的写法是把这段区间压到最短,只做三件事:重定向标准输入输出、重置需要的信号处理、执行execve。判断逻辑应该在fork之前就算好,通过闭包变量带进去。

2.2 多线程进程里fork,唯一能安全执行的只有exec

如果父进程是多线程的,fork出来的子进程只包含调用fork的那个线程的副本,其他线程全部消失。但其他线程持有的锁、正在修改的数据结构、队列里的部分状态都还在内存里,只是没人推进了。这种“半截状态”是无数诡异问题的来源。

标准做法只有一条:多线程程序里fork之后,子进程唯一能做的就是立刻execve,任何其他操作都有概率踩到不一致状态。如果确实需要在子进程里做点别的(比如记录日志),用posix_spawn更合适——它内部会处理好这些,或者干脆用fork之前就准备好的管道把信息交回父进程。

2.3 exec之后,哪些东西跟着走、哪些被清零

这个清单我建议直接背下来,面试和排错都用得上。

属性fork后exec后
进程PID新的保留
父进程不变不变
进程组、会话、控制终端继承保留
真实/有效UID、GID继承保留(除非setuid程序)
当前工作目录、根目录继承保留
环境变量继承保留(可替换)
打开的文件描述符继承保留,除非设了FD_CLOEXEC
信号处理函数继承重置为默认
被忽略的信号继承保持忽略
信号掩码继承保留
地址空间、堆栈COW共享完全替换
线程只有调用fork的线程只剩一个主线程
文件锁(fcntl)不继承与父进程相关,需注意
内存映射COW共享全部撤销
定时器(alarm)继承,计时保留保留剩余时间

两个地方最容易翻车。第一是“被忽略的信号在exec后仍然被忽略”,如果你在子进程里忽略了SIGPIPE然后exec一个需要默认行为的程序,它就不会按默认行为退出,这个差异极难排查。第二是FD_CLOEXEC没设,导致子进程exec后把父进程打开的监听socket、数据库连接、临时文件描述符全部带跑,造成连接泄漏或者端口无法重新bind。规则很简单:除了0、1、2,其他fd一律加上FD_CLOEXEC,用open(O_CLOEXEC)或者fcntl(fd, F_SETFD, FD_CLOEXEC),能省掉一个隐蔽的泄漏面。

/* 正确姿势:创建时就带 CLOEXEC,避免竞态 */ int fd = open(path, O_RDONLY | O_CLOEXEC); /* 旧的设 flags 方式: */ int flags = fcntl(fd, F_GETFD); fcntl(fd, F_SETFD, flags | FD_CLOEXEC);

3. 进程退出后的收尾账:僵尸、孤儿、waitpid的细枝末节

3.1 僵尸进程的内核本质是“退出状态没被取走”

进程调用exit之后,它占用的地址空间、打开的文件、内存映射全部释放,但内核里那个进程控制块(PCB)不销毁,因为里面存着退出码、终止信号、资源使用统计——这些信息父进程还没来取。这个“已死但记录还在”的状态就是僵尸(Z)。

换句话说,僵尸进程本身不占内存、不占CPU,它只占一个PID和一点内核结构。危险在于PID是有限资源,成千上万个僵尸会把PID耗尽,导致新进程创建失败。真正要防的不是僵尸本身,而是它背后那个“从不wait的父进程”。

排查手段很直接:ps -eo pid,ppid,stat,cmd | grep '^ *[0-9]* .* Z',或者看stat列里带Z的行。找到父进程PID后,判断这个父进程是否长期运行、是否在逻辑里漏掉了wait。最容易被忽略的漏点是在信号处理函数里写wait(NULL)——如果信号处理函数注册时没设SA_RESTART,或者处理过程中被打断,就可能漏掉某个子进程的回收。

3.2 孤儿进程不是故障,是设计里的正常路径

父进程先于子进程退出,子进程就变成孤儿,被init(或者现代系统里的进程管理守护进程)接管。接管后它的父进程PID变成1,后续退出由1号进程负责wait回收,不会再变成僵尸。

这个机制给了我们一个很有用的技巧:想让某个后台任务彻底脱离父进程的生命周期,就在fork之后让父进程立刻退出,子进程被收养,然后setsid建立新会话,再重定向标准输入输出到/dev/null。这就是常说的“双fork”守护进程化套路。第一次fork后让父进程退出,目的是确保子进程不是进程组组长;setsid需要非组长身份才能成功;第二次fork是为了防止之后重新获得控制终端。

值得强调的是,双fork在现代容器化环境里反而经常是错的——容器的主进程必须前台运行,PID 1的职责是转发信号和回收子进程,双fork会让容器误以为主进程已退出而重启。这个坑我在真实部署里吃过一次,排查了半天才发现是启动脚本里躺着一个老派的守护进程化逻辑。

3.3 waitpid的选项组合和EINTR,写错的概率比想象中高

waitpid的返回值有四种情况:大于0表示回收了某个子进程,等于0只在使用WNOHANG且没有子进程可回收时出现,等于-1表示失败(errno为ECHILD说明没有子进程了,属于正常终止条件)。

选项作用常见误用
WNOHANG不阻塞,没有可回收的子进程就返回0循环里不检查0,导致空转吃满CPU
WUNTRACED子进程停止时也返回忘记配合WIFSTOPPED判断
WCONTINUED子进程恢复时也返回同上
0阻塞等待任一子进程退出在事件循环里用,把整个线程挂死

最典型的错误写法是:

/* 错误示范:在信号处理函数里裸调 wait */ void sigchld_handler(int sig) { wait(NULL); /* 一次只回收一个,多个子进程同时退出会漏 */ }

信号不排队,两个子进程几乎同时退出时,SIGCHLD可能只送达一次,而处理函数只调用了一次wait,剩下的僵尸会一直挂着。正确做法是循环调用直到没有可回收的:

void sigchld_handler(int sig) { int saved_errno = errno; pid_t pid; while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) { /* 逐个回收 */ } errno = saved_errno; /* 信号处理函数必须保存并恢复 errno */ }

还有一个细节:信号处理函数里必须保存和恢复errno,因为处理函数可能在任意时刻打断主流程,而waitpid会覆盖errno,导致主流程读到一个被篡改的错误码。这一行代码我在代码评审里见一次提一次。

如果不想用信号,还有个更稳的路子:用sigwaitinfo把SIGCHLD同步化,或者用signalfd把它变成可读事件塞进epoll/kqueue里,这样回收逻辑就在主事件循环里,不用处理异步重入问题。事件驱动型的服务我更倾向这一套,因为逻辑集中、状态可见。

4. 线程的私有财产和公共财产,边界比很多人以为的窄

4.1 一张对照表说清共享与独占

线程共享的东西比大多数人想的多,独占的东西比大多数人想的少。搞错这个边界,就会写出“两个线程都在改同一个变量却以为各自独立”的代码。

资源同进程内多个线程说明
地址空间(代码段、数据段、堆)共享堆是共享的,所以malloc需要内部加锁
打开的文件描述符共享一个线程close,其他线程的fd直接失效
当前工作目录共享一个线程chdir,全体受影响
信号处理函数共享signal是进程级设置
信号掩码各自独立pthread_sigmask只影响当前线程
环境变量(通过environ)共享任意线程setenv影响全体
线程ID独占pthread_self()
寄存器组与程序计数器独占决定执行流
栈独占局部变量、返回地址都在这里
errno独占每线程一份,但注意只是“值”是独立的
浮点环境与舍入模式独占fesetround只影响当前线程
调度优先级独占pthread_setschedparam

表格里最值得单独拎出来的是errno。它是线程局部的,所以你可以放心在多个线程里同时出错、各自读各自的错误码。但反过来说,这也意味着跨线程传递错误必须显式传值,不能依赖errno——主线程读到的errno和子线程里发生的错误毫无关系。

另一个高频误解是“局部变量在线程间绝对安全”。局部变量确实在线程自己的栈上,但如果你把它的地址传了出去,其他线程照样能改。这个问题在pthread_create的传参场景里几乎必然出现。

4.2 pthread_create传参的两个经典错误

第一个错误是传栈变量的地址:

/* 错误示范:所有线程都拿到同一个地址,读到同一个(或已销毁的)值 */ for (int i = 0; i < 4; i++) { pthread_t t; pthread_create(&t, NULL, worker, &i); }

&i只有一个,四个线程都拿到它,读到的值取决于调度时机,而且循环结束后i就失效了。第三种情况更糟:主线程传了一个局部结构体地址,然后主线程函数返回,线程再去读那块已经回收的栈。

正确做法是每个线程一份独立的参数,用堆分配,或者用数组的独立下标:

typedef struct { int idx; char name[32]; } task_arg; for (int i = 0; i < 4; i++) { task_arg *a = malloc(sizeof(task_arg)); a->idx = i; /* 值拷贝,不是地址共享 */ pthread_create(&t, NULL, worker, a); } /* worker 里负责 free(a) ,或者由主线程 join 之后统一释放 */

所有权要写清楚:谁分配、谁释放。我的习惯是“谁传谁释放”或者“线程自己释放”二选一,并在创建处写注释。混着来一定会有泄漏或者double free。

第二个错误是参数里传了一个大结构体的值,然后在pthread_create那一行看着没问题,实际因为C的形参是void*,如果直接写(void*)some_struct会按指针解释,运行时解引用野指针。想传值就必须传地址,或者把结构体打包分配。

注意:pthread_create成功返回0,失败返回错误号。它不会设置errno,用perror调试是无效的,必须用返回值配合strerror(rc)。

4.3 栈大小不是玄学,是实打实的虚拟内存账

多数主流实现里,线程默认栈大小是8MB(虚拟内存,按需提交)。这意味着一万个线程就是80GB的虚拟地址空间。在64位系统上这还撑得住,但线程一多,光是栈的虚拟地址占位就可能撞上ulimit -v的限制,或者让内存统计看起来非常可怕。

真实开销取决于实际触碰的页。一个线程常用的栈可能只有几十KB,但只要有递归深、有大局部数组,就可能触发栈溢出。栈溢出在单线程里表现为段错误,在多线程里会以SIGSEGV的形式让整个进程崩掉——因为线程崩溃没有隔离。

pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setstacksize(&attr, 256 * 1024); /* 256KB,够用就压 */ pthread_t t; pthread_create(&t, &attr, worker, arg); pthread_attr_destroy(&attr);

我一般会在以下场景主动调小栈:线程数上千的IO密集型服务、每个线程只处理短连接的服务器。调到256KB甚至128KB,虚拟内存账马上好看很多。但调小之前先确认没有深递归、没有alloca、没有大的栈上数组、没有那些会在栈上开几MB缓冲区的库函数——尤其是某些正则库和序列化库,它们会悄悄地用大栈。

相反,如果线程里要做深层递归(比如解析嵌套极深的文档),默认8MB可能还不够,得往上调。用ulimit -s看到的是主线程栈的限制,对pthread_create创建的线程不生效,这点很多人混淆过。

5. 互斥锁和条件变量:语法都会,逻辑常错

5.1 锁保护的从来不是变量,是那个“不变量”

新手写锁的思路通常是“这块内存会被多个线程写,所以加锁”。这个表述不够精确,导致锁的粒度要么太大要么不够。正确的判断依据是:锁保护的是数据必须始终满足的那个条件,也就是不变量。

举个例子:一个任务队列,内部用head、tail两个指针。真正的不变量是“当head == tail时队列为空;tail->next始终可以安全解引用”。所以入队操作必须在“读tail、写tail->next、更新tail”这三步之间持有锁,否则别的线程可能看到tail->next还是垃圾值。而出队操作只需要保护“读head、取head->next、释放”这一段。

一旦你开始用“不变量”来思考,就会发现锁的边界很自然:什么时候不变量会被临时破坏,锁就覆盖到什么时候。相反,如果按“这个变量被多线程访问”来加锁,你会把整个函数都包进去,锁粒度变粗,吞吐掉下来,然后开始怀疑是不是该换无锁结构——其实是锁的划分没想清楚。

5.2 条件变量的等待必须放在while里,这不是风格问题

/* 错误示范:用 if 判断条件 */ pthread_mutex_lock(&m); if (queue_empty()) { pthread_cond_wait(&cond, &m); } task = pop(); pthread_mutex_unlock(&m);

这段代码在单生产者单消费者、且只有一个等待者时能跑通,看起来完全正常。问题出在两个地方。

第一是虚假唤醒。pthread_cond_wait在某些实现里可能在没有收到信号的情况下返回(信号被别的线程抢走、或者实现自身的唤醒策略)。用if判断,wait返回后就直接往下走,此时队列可能还是空的,pop就解引用了非法内存。

第二是多消费者时的惊群。广播唤醒三个消费者,只有一个能拿到任务,另外两个用if判断就直接去pop,同样出问题。

标准写法只有一个:

pthread_mutex_lock(&m); while (queue_empty() && !should_stop) { pthread_cond_wait(&cond, &m); /* 内部会原子地释放锁并睡眠 */ } if (!should_stop) { task = pop(); } pthread_mutex_unlock(&m);

while在这里不是防御性编程,是逻辑必需:唤醒只代表“条件可能变了”,不代表“条件成立”。所以每次醒来都要重新检查。

还有一个常被忽略的点:条件变量必须和一把互斥锁配对使用,pthread_cond_wait的第一个动作是释放锁、最后一步是重新获得锁,这两个动作对调用者而言是原子的。这就是为什么生产者修改队列之后要持锁发信号——如果你在解锁之后再signal,可能出现消费者在中间挤进来、发现队列空、又睡过去,而信号已经发完了,于是没人再唤醒它。

5.3 死锁在真实代码里的四种样子

教科书列了死锁的四个条件,工程里出问题的形态其实就那么几种:

第一种,加锁顺序不一致。线程A先锁m1再锁m2,线程B反过来。这个最容易在代码演进中产生——两个函数各自加锁顺序没问题,被嵌套调用时就打架了。对策是给所有锁定义一个全局序,任何路径都按序加。

第二种,锁的重入。非递归互斥锁被同一线程重复加,直接自锁。常见于“内部函数假定调用方已加锁,外部函数也加了一次”的重构残留。对策是明确锁的所有权约定,或者干脆用pthread_mutexattr_settype(PTHREAD_MUTEX_ERRORCHECK)在调试期暴露这个问题。

第三种,在持锁期间做阻塞操作。持锁调read、调网络、调数据库、甚至调记录日志的函数(日志函数内部有锁)。这类死锁的表现很怪:平时不出现,IO一卡就全线挂住。规则是持锁区间内只碰内存。

第四种,信号处理函数里加锁。信号可能打断一个正在持锁的线程,处理函数再去加同一把锁,直接死锁。这就是前面说的“信号处理函数只能用异步信号安全函数”的另一个理由。

排查死锁,用gdb挂上去thread apply all bt看每个线程卡在哪一行是最快的。如果是长期运行的服务,我习惯在锁的获取处加一个带超时的版本做保底,超时后打印当前持有者的调用栈,这样线上出问题不至于完全抓瞎。

6. 进程还是线程,拿指标说话

6.1 上下文切换成本:自己测一遍比背结论强

“线程切换比进程切换快”这句话对,但快多少、在什么场景下重要,得看数字。切换开销主要来自两块:一是内核里的状态保存与恢复,二是切换后TLB和缓存失效带来的间接成本。进程切换因为地址空间变了,TLB要刷(有PCID/ASID时部分保留),缓存局部性损失更明显;同进程的线程切换地址空间不变,TLB不需要整体失效。

粗测方法很简单,用两个线程/两个进程做高频交接(比如通过管道或原子变量乒乓),跑几百万次,算平均每次的耗时。实测数量级通常是:线程交接在亚微秒到微秒之间,进程间通过管道交接要贵出几倍到十几倍。但请注意这只是交接的成本,不含真正的数据搬运。如果你的多进程方案每次通信都要拷几MB数据,那讨论切换开销就没意义了,通信成本早就压过一切。

真正该关心的判断是:你的任务粒度有多大。如果每个任务的计算量远超切换开销(比如每次处理几十毫秒的请求),那进程和线程在切换成本上的差异根本不影响整体性能,此时应该按隔离性和开发复杂度来选。如果任务粒度极细(每次几百纳秒),那切换开销就是主要成本,此时该考虑的是减少并发单元数量或者用事件驱动,而不是纠结进程还是线程。

6.2 隔离性:崩一个还是崩一片

这是进程相对线程最大的优势,也是最难事后弥补的。线程共享地址空间,任何一个线程野指针写坏了堆,其他线程会在完全无关的地方出错,排查时看到的崩溃点往往离真凶很远。多进程模型下,一个子进程崩了,父进程能感知到退出信号、能重启它,其他进程完全不受影响。

代价是内存复用变差。N个进程各自持有一份解释器、一份连接池、一份缓存,内存占用是N倍。线程模型下这些东西共享一份,内存效率高得多。

有个中间路线值得提:用多进程做隔离边界,进程内部再用线程池做并发。对外呈现为一组工作进程,每个进程内若干线程。这样单次崩溃的爆炸半径被限制在一个进程内,同时每个进程内部的并发又不至于浪费太多切换成本。很多成熟的服务器程序就是这么组织的。

6.3 通信成本:共享内存不是免费的

线程之间通过共享内存通信,看起来零拷贝、成本最低。但“零拷贝”不等于“零成本”——你需要加锁或用原子操作来协调访问,锁竞争激烈时成本会急剧上升,而且调试难度是非线性的。共享内存的并发bug往往只在特定时序下复现,用压力测试跑一万次可能一次都不出。

进程间通信需要显式拷贝(管道、套接字)或者映射共享内存(mmap匿名共享、shm_open)。显式拷贝的成本是确定的、可估算的;共享内存方案要面对和线程一样的内存可见性和同步问题,只是多了跨进程的复杂度。

维度多线程多进程
切换开销低较高
内存占用共享一份每进程独立
崩溃隔离无,全挂有,可重启
数据共享直接读写,需同步需IPC或共享内存映射
调试难度高(时序相关)中(可单独attach)
权限控制无独立权限可降权、可沙箱
热升级基本不可能可逐个替换

我个人的取舍原则很朴素:如果模块之间的数据交换量小、又希望某个模块出问题不拖垮全局,就用进程;如果模块之间需要高频共享大量状态、且能接受同步带来的复杂度,就用线程。拿不准的时候先按进程切,因为把进程改成线程比反过来容易——前者是把隔离去掉,后者是要补上一整套故障隔离机制。

7. 关于gdb和strace在这两类问题上的用法

调试进程问题,strace -f -e trace=process,desc能直接把fork、exec、wait、exit这一串调用按时间顺序打出来,父子PID的对应关系一目了然。我排查过一个问题:服务启动后偶发卡死,strace一看发现某个子进程卡在wait4上,而它等的那个PID在日志里根本找不到创建记录——最后定位到是启动脚本里一个fork之后没有exec的分支,子进程跑完逻辑后return而不是_exit,把父进程的栈一路返回下去,行为完全不可预期。这个坑的教训是:fork出来的子进程,退出必须用_exit而不是exit,因为后者会刷新父进程继承来的stdio缓冲区,导致输出重复甚至二次释放。

调试线程问题,gdb的info threads加thread apply all bt是基本操作,配合set scheduler-locking on可以冻结其他线程,观察某个线程的状态。pstack能快速抓一次全线程栈,适合线上做几次采样看热点卡在哪。

还有个容易忽略的手段:在关键路径上记录线程ID和进程ID。日志里带上getpid()和pthread_self(),事后分析并发问题时能立刻判断“这两条日志是两个线程交叉输出还是一个线程顺序输出”,这个信息在排查乱序 bug 时价值极高。我现在的项目模板里,日志格式默认就带这两个字段,加上去之后排查效率的提升远超预期。

最后再分享一个小技巧:写多线程代码时,先在单线程模式下把所有逻辑跑通,再逐层加上并发。更狠一点,可以写一个malloc/free的包装层,在每次分配时用简单的魔数校验前后哨兵,在并发场景下能更早地抓到越界写。这类工具不追求完备,追求的是在问题扩散之前把它钉在发生现场。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进