资讯动态

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

发布时间:2026/10/26 12:11:58 来源:尧图企业网站定制
很多人对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()失败返回-1pid类型是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导致空转吃满CPUWUNTRACED子进程停止时也返回忘记配合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 traceprocess,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的包装层在每次分配时用简单的魔数校验前后哨兵在并发场景下能更早地抓到越界写。这类工具不追求完备追求的是在问题扩散之前把它钉在发生现场。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑