1. 先搞清楚:进程和线程,到底谁“重”谁“轻”
我经常跟刚接触 Linux 的同学聊一个话题:你天天写代码,启动一个程序、开一个线程,但你有没有想过,进程和线程在 Linux 内核里,本质上都是同一个东西——task_struct?
对,你没看错。Linux 内核从来不区分“这是进程”“这是线程”,它只认“任务”(task)。你通过fork()创建的是任务,通过pthread_create()创建的也是任务。区别只是它们对资源的可见范围不一样:有的任务有自己的独立地址空间,有的任务共享别人的地址空间。
这个理解特别关键。很多面试题上问“进程和线程的区别”,标准答案张口就来:进程是资源分配的最小单位,线程是 CPU 调度的最小单位。这么说没错,但你要是真在 Linux 下用ps -eLf看过,你会发现线程和进程都有 PID、都有状态、都有优先级,看起来一模一样。你甚至可以用kill单独杀掉一个线程(tgkill系统调用干的就是这事)。所以你要是只背教科书答案,实际排查问题时会很懵。
我平时排查线上问题,最常用的三个命令组合是ps -eLf、top -H -p <pid>、/proc/<pid>/status。看什么?看主进程和它的子线程各自占了多少 CPU、处于什么状态、缺不缺少线程。但这里有个坑:top -H默认只展示线程的“轻量级进程号”(LWP),你不会直接看到线程名对应的主进程是谁。你得自己知道这个进程组的关系,再去/proc/<pid>/task/目录下面翻每个线程的状态。
这篇我就把“父子进程、进程中的线程、不同进程、不同的线程”这四组关系一次讲透。包括 fork 写的时复制到底怎么回事、为什么多线程程序里你千万别随便 fork 之后再调用非 async-signal-safe 的函数、线程之间怎么同步最靠谱、跨进程通信到底选哪套方案。最后我会分享几个实战里踩过的坑,每个都是能直接拿去用的经验。
2. 父子进程之间:恩断义绝的复制品
2.1 fork() 到底复制了什么
先看一个最基础的场景:你在 Linux 写了个 C 程序,调一次fork(),内核返回两次,一次在父进程返回子进程 PID,一次在子进程返回 0。是不是觉得特别魔法?其实本质上就是复制当前任务,子进程刚出生时,跟父进程一模一样:相同的地址空间内容、相同的打开文件描述符表、相同的环境变量、相同的信号处理器设置。
但这里有个绝对误区:复制不等于共享。父进程后来改了自己的变量、关闭了自己的文件描述符,子进程完全感知不到。反过来也一样。因为它们俩各有各的地址空间,各有各的文件描述符表。
所以“父子进程”的关系,用一句大白话概括就是:曾经双胞胎,出生即分家,此后两家人。而且分家分得很彻底——内存页都给你抠成两份(实际上有写时复制优化,见下节),文件描述符表也独立了。它们之间唯一的联系就是:子进程的 PPID(父进程 ID)指向父进程,以及一些残留的资源(比如父进程退出后,子进程变成孤儿,被 init 收养)。
我还记得第一次用fork()写并发服务器时的困惑:我在父进程里 accept 了一个连接,fork 出子进程去处理。子进程里我关掉监听 socket,只保留连接 socket,这段操作没问题。但我一个同事在子进程里写日志时,发现日志文件的行 offset 总是跟不上,原因是父子进程共享了同一个 file 结构体的 offset(因为 fork 复制了文件描述符表,但 file 结构体是共享的,引用计数加了 1)。如果你在子进程里用 C 标准库的fwrite写日志,又没调用fsync,父进程的偏移量都不会变。
这就是著名的“fork 之后文件偏移共享”陷阱。你想让子进程从文件当前位置继续写,它确实是继续写;但父进程再写,两边就可能互相覆盖,因为它们的用户态缓冲区是独立的,内核里 file 结构体的 f_pos 也是同一个。解决办法是什么?要么子进程重新打开文件,各写各的 fd;要么在 fork 之前把所有该写的写完。
2.2 写时复制(COW)和它带来的性能真相
Linux 的fork()之所以没有成为性能灾难,全靠写时复制(Copy-On-Write)。fork()刚创建子进程时,并不会把父进程整个地址空间拷贝一份,而是让父子进程共享同一批物理内存页,并且把这些页统统标记为只读。任何一方想要往内存页里写数据,CPU 就会触发缺页异常,内核这才把这一页复制一份,改好页表权限,让那个写操作落在新页上。
听哥们儿一句话,理解 COW 是看懂 fork 性能曲线的钥匙。一个典型的 fork + exec 场景(比如 shell 执行一个外部命令),父进程内存哪怕有 1 GB,fork 也只花不到 1 ms,因为 exec 马上就要把子进程地址空间整个换掉,那些共享页根本派不上用场。你如果真把内存从 1 GB 扩大到 8 GB,fork 的性能差不多还是不变的。真正让它变慢的,是页面表本身的复制和缓存刷新的开销,而不是内存内容拷贝的开销。
不过 COW 也带来一个坑:如果你 fork 之后,子进程和父进程都疯狂对同一大片内存区域写数据,每个页都要复制一次,那开销就非常爆炸。实际经验是:不要 fork 之后让两边在高频共享内存上同时写。有这需求,就该用 mmap 共享内存或换多线程模型。
另外一个 fork 相关的老生长谈问题是:多线程程序里调用 fork 极容易出幺蛾子(glibc 2.25 之后有所缓解)。比如你在一个多线程进程里,线程 A 持有某个锁,然后线程 B 调用fork()。子进程出生时,其他线程没了,但锁状态还是“被持有着”的——子进程里没有任何线程能释放这个锁,后面谁再拿这把锁就死锁。标准做法是什么?在fork()的子进程分支里,只做_exit()或者调用exec(),里面别再用任何复杂的库。如果你必须用锁,就调用pthread_atfork()注册回调,在 fork 之前把锁全锁上、fork 之后在子进程里全解锁。道理虽然简单,实际项目里不少人就是在这里翻车。
2.3 父子进程的同步:wait/僵尸进程
进程间通信轮不到父子进程之间秀什么花活,但父子进程之间的“同步”有一件非常朴素的事:父进程要等子进程结束。
如果你fork()出了子进程,从不在子进程上调用waitpid(),子进程正常结束后,它的 task_struct 和退出码还必须留在内核里,等着父进程来“收尸”。这个状态的进程就是僵尸进程(Zombie)。僵尸进程不占 CPU,但占一个 PID 槽位,也占一小块内存。积累多了,系统可用的 PID 就变少,最终表现是 fork 返回错误 “cannot fork”。
我见过一个实际案例:有个服务,主进程每接收一个任务就 fork 一个子进程去处理,处理完子进程退出。运维反馈系统偶尔会有一批僵尸进程,持续一两天。排查时发现主进程里根本没有 waitpid 循环,而是注册了 SIGCHLD 信号处理器。诡异的是,用signal()注册 SIGCHLD,子进程退出的信号来了,处理器也执行了waitpid(-1, &status, WNOHANG),但之后又发现很多子进程“死而无尸”。后来查了 glibc 文档才发现:用signal()注册的 SIGCHLD 在某些发行版上是带 SA_RESTART 的,但waitpid在 EINTR 时可能被信号处理器打断。最后改成sigaction()显式设置 SA_RESTART,并循环调用 waitpid,问题就没了。
所以给一个非常实用的建议:fork 之后,主进程一定要及时 waitpid 回收每一个子进程,除非子进程里的逻辑简单到可以忽略退出码。用信号配合 waitpid 时,循环加WNOHANG,保证把队列里所有僵尸都收干净。
3. 进程中的线程:同居室友与资源博弈
3.1 线程到底共享了什么
同一个进程内部的多个线程,共享的东西远比你想象得多:地址空间、打开的文件描述符表、当前工作目录、信号处理器、用户 ID/组 ID。不共享的东西,其实归结为“执行上下文”:每个线程有自己的栈、寄存器现场、线程局部存储(TLS)、信号掩码。
这里有个好处:线程之间通信根本不需要任何“跨进程机制”。你定义一个全局变量,线程 A 改了,线程 B 下次读的时候自然就是新值。性能上这个通信代价为零级。但代价来了——数据竞争。你要是读过一个没有加锁的全局变量,在开启-fsanitize=thread的编译器下跑一遍测试,会在意想不到的角落发现一堆 data race 警告。线程之间的“同步”,核心工作永远是围绕在共享数据上排队的。
线程的本质是 _clone() 系统调用加一堆共享参数的产物。pthread_create()底层调用 clone 时,带上 CLONE_VM、CLONE_FS、CLONE_FILES、CLONE_SIGHAND 这类标志,表示新任务要共享地址空间、文件系统信息、文件描述符表、信号处理器表。所以线程也被叫做“轻量级进程”(LWP)。但从内核调度视角看,它又确确实实是一个独立的 task,独立参与调度、独立占 CPU 时间。
去/proc/<pid>/status看线程数,你会看到“Threads: N”这一行,那里统计的就是该进程下所有线程的数目。如果你跑一个 Java 应用,里面线程池开了 200 个线程,那你/proc/<pid>/status的 Threads 就是 200 左右,别惊讶,这是正常的。
3.2 线程之间为什么必须锁:一个不加锁的教训
我记得有次帮人排查一个日志系统。逻辑很简单:多线程每处理一条日志,就把一个全局计数器加 1,最后落盘。最开始用的代码是:
// 全局变量 unsigned long g_counter = 0; void onEvent(void) { g_counter++; // 这里没有同步 }在单线程下没有任何问题,但多线程一跑,计数就对不上,甚至出现小于真实值的情况。原因很简单:g_counter++不是原子操作,它底层是“从内存读值、寄存器加一、写回内存”三步。线程 A 和线程 B 同时读到旧值,各自加一,写回时后写回的覆盖先写回的,计数就少了。
正确的姿势是至少用 C11 的atomic_fetch_add,或者用互斥锁保护起来,或者干脆用 GCC 内置的__atomic_add_fetch(&g_counter, 1, __ATOMIC_SEQ_CST)。看起来只是一个小问题,但线上日志量一大,错误的计数直接会导致告警误报和报表不准。
我给团队一条死规定:所有跨线程共享的变量,要么加锁,要么声明为原子变量,不允许裸写裸读。代码 Review 时看到任何私有的非 const 全局变量,必须能说清楚被谁读、被谁写、有没有同步机制。这条规则看似严格,但它实实在在减少了线上 bug。
3.3 线程之间的同步:三大件(锁、条件变量、原子操作)
线程之间除了共享,更重要的是有序协作。锁只是互斥——保护临界区,不让两个线程同时写。但更常见的情形是:一个线程等另一个线程完成某件事,例如生产者-消费者模型,消费者要等队列非空才能取数据。
这里推荐用互斥锁 + 条件变量,而不是自旋锁硬忙等。典型代码结构:
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond = PTHREAD_COND_INITIALIZER; unsigned int queue_len = 0; // 生产者 void produce(void) { pthread_mutex_lock(&mtx); queue_len++; pthread_cond_signal(&cond); pthread_mutex_unlock(&mtx); } // 消费者 void consume(void) { pthread_mutex_lock(&mtx); while (queue_len == 0) { pthread_cond_wait(&cond, &mtx); } queue_len--; pthread_mutex_unlock(&mtx); }关键点有三个:
- 条件变量 wait 时要配 while 而不是 if,防止虚假唤醒。
pthread_cond_signal最好在持有锁的时候调用,免得丢唤醒。- 等待条件的线程醒来之后,会自动重新获取互斥锁,所以排队逻辑都在锁内完成。
原子操作则适合更简单的场景——一个计数器、一个标志位。atomic_compare_exchange可以做到无锁编程里的状态转移,比如 CAS 循环更新链表头。但我的建议是:别轻易在大型共享结构上自造无锁算法,你以为是内存屏障的万能,实际线上一跑,ABA、内存序、缓存行伪共享全来了。真要无锁,用现成库,比如 liburcu、boost.lockfree,它们的坑都被削平了。
3.4 线程“调度的公平性”问题
不同线程之间,听起来大家都在同一个进程里,应该风平浪静。但实际上,CFS 调度器(完全公平调度器)是对 task 调度的,不是对“进程”调度的。线程和进程在调度器眼里都是 task。如果一个进程里开了 8 个线程,另一个进程只有 1 个线程,那前者能拿到更多 CPU 时间吗?默认的 CFS 有一个“nice 值”分摊机制,大体上多线程进程会占便宜。不过,如果你开了 CPU 亲和性设置(taskset或sched_setaffinity),线程被固定到某些 CPU 核心上之后,调度策略又不一样了。
还有一个和调度相关的经典问题:线程优先级反转。低优先级线程持有锁,高优先级线程在等锁,中优先级线程一直抢跑,导致高优先级线程迟迟拿不到 CPU。严格说这是实时系统话题,但在普通 Linux 上也有影响。解决办法通常是pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT),给互斥锁设优先级继承,让持有锁的低优先级线程暂时提高优先级。项目里要是做音视频处理这类延迟敏感的服务,这一条是必须考虑的。
4. 不同进程之间:通信靠 IPC,内存各占一亩三分地
4.1 进程间为什么非要 IPC
进程地址空间完全隔离,这是操作系统安全性和稳定性的基石。A 进程崩了,B 进程不该跟着崩。但业务上进程之间需要通信:Nginx 的 worker 进程从 master 进程读配置、数据库进程通过网络和客户端进程交换数据……所以就得有通信机制。
Linux 下 IPC 的选择非常多,我列一个实用对照表:
| 通信方式 | 特点 | 典型场景 | 性能量级 |
|---|---|---|---|
| 管道(pipe/FIFO) | 简单单向,适合流式数据 | shell 命令串联、父子进程传数据 | 内存拷贝,中等 |
| 共享内存(shm/mmap) | 最快,但要自己同步 | 高吞吐、低延迟数据交换 | 近乎零拷贝,最快 |
| 消息队列(POSIX mq) | 带优先级、持久化,但系统调用开销 | 生产者消费者解耦 | 中等偏慢 |
| Socket(Unix domain/TCP) | 语义丰富,跨网络可用 | 分布式服务、多机通信 | 来回拷贝,较慢 |
| 信号 | 控制面,不适合传数据 | 通知退出、中断 | 极轻,不传数据 |
你去看微服务里的进程间通信,绝大多数走的是 TCP 或者 Unix domain socket。本地进程通信用 Unix domain socket 比 TCP 省去协议栈开销,性能能提升 30% 上下。不过你说到底高吞吐场景,比如 Redis Cluster 节点间同步数据,还是会考虑共享内存方式。
4.2 共享内存 + 信号量:Linux 最快的内核 IPC 组合
如果你在做数据采集、中间件开发,进程之间要传几个 GB 的吞吐,共享内存 + POSIX 信号量是你的第一选择。
做法很简单:
- 用
shm_open创建或打开一个 POSIX 共享内存对象,然后ftruncate设置大小,再mmap映射到进程地址空间。 - 用
sem_open创建或打开一个信号量,抢到信号量的进程操作共享内存,操作完释放。 - 用
munmap映射解除,最后shm_unlink清理对象。
我调过的一版代码大致是:
// 进程A int fd = shm_open("/myshm", O_CREAT | O_RDWR, 0666); ftruncate(fd, 4096); void* addr = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); sem_t* sem = sem_open("/mysem", O_CREAT, 0666, 1); // 写入前拿锁 sem_wait(sem); sprintf((char*)addr, "hello from A"); sem_post(sem);这样的模式能拿到单机原生速度:一条 20 字节消息,走共享内存从进程 A 到进程 B,往返延迟可能不到 1 微秒;同一条走 Unix domain socket,大概要 3~5 微秒;走 TCP loopback,可能就要 20 微秒以上。差距非常直观。
但共享内存有个补丁要打:你要小心翼翼地设计环形缓冲区,否则生产者消费者会互相踩。最简单的是做一个“单生产者单消费者”的无锁环形队列,用两个内存屏障实现。如果要多个生产者多个消费者,那还是要上信号量或自旋锁。
4.3 fork 之后的 exec 和子进程独立性的联系
刚才说的“父子进程各过各的”,其实还有一个终止路径:exec()。fork()出来跑到一半,进程可以调用exec()系列函数,把当前进程的地址空间全部替换成新程序。这个时候,进程的 PID 不变(巧了,exec 不创建新进程),但它的代码段、数据段、堆栈全部换成新程序的。这解释了为什么 shell 执行外部命令时,先 fork 一个子进程、再在子进程里 exec 目标命令,父 shell 还在原地等着。
exec 和父子进程有什么关系?对“子进程”这个身份来说,exec 是它摆脱父亲代码逻辑、变成全新程序的办法。fork + exec 这对组合是现代 Unix 派生新程序的法宝。底层的系统调用是execve(),shell 里的bash -c、编程语言里的subprocess.Popen('/bin/ls'),走的全是这一套路。
这时候子进程和父进程之间,除了 PID/PPID 这层血缘,基本就没有任何代码级共享了。
4.4 守护进程、孤儿进程和会话的关系
长时间跑的进程,比如后台服务,一般都会 fork 一次、setsid、再 fork 一次,把自己变成守护进程。
第一次 fork 可以让未来会话首进程不再拥有控制终端;setsid()创建新会话,脱离原来的进程组和会话;第二次 fork 确保不再次成为会话首进程,避免重新获取控制终端。这是经典的“双 fork 守护进程法”。
线程有没有类似的“会话”?线程本身没有 session 概念,只有进程有。当你说“杀掉一个进程组”的时候,kill(-pgid, SIGTERM)会把所有属于该进程组的进程都杀掉,但不会自动把某个进程里的线程也杀掉——不过 SIGTERM 给进程的默认动作是终止整个进程,所以该进程的线程也确实会一起消失。
这里有个容易混淆的点:线程死了,进程不一定死;进程死了,线程必死无疑。因为进程是线程的容器,内核销毁进程时,会把它所有线程一并销毁。所以你排查问题时,看到某个线程崩溃导致 core dump,实际挂掉的是整个进程。
5. 不同线程之间:互斥、死锁和调试的艺术
5.1 同进程线程之间不需要 IPC,但需要纪律
线程之间因为是共享内存,通信基本靠读写变量,根本没有“IPC”这套概念。但这也带来了一个隐患:共享变量的同步纪律必须自己管。内核不会替你检查,编译器不会替你纠错,只有人管人。
写多线程程序的先决条件,就是想清楚“状态归属”。哪些状态属于具体某个线程(放栈上、放线程局部存储里),哪些状态属于进程全局(加锁保护),哪些状态是不可变的只读(安全共享)。我见过很多出 bug 的项目,都是把所有东西一锅端声明成全局变量。
实际项目里更常见的其实不是“控制共享”而是“并发调度”:线程池大小怎么定、任务队列怎么喂、线程怎么优雅退出。这些不是纯语言层面的东西,但要会设计。
5.2 死锁四条件与破解思路
线程同步里最经典的老难题是死锁。要死锁必须同时满足四个条件:
- 互斥:资源同一时刻只能被一个线程占用。
- 持有并等待:线程占着一个资源,又等着别的资源。
- 不可剥夺:资源只能主动释放。
- 循环等待:线程 A 等线程 B 的资源,线程 B 等线程 A 的资源。
破解思路基本就是破坏其中一个条件。最简单的是锁顺序约定:所有线程加锁的顺序必须一致,比如先锁 A 再锁 B。这样只要有一个人拿到 A,它一定会再去拿 B,不会出现 A 等待 B、B 等待 A 的循环。
实践上的陷阱在于:代码一多,锁的层级关系经常被破坏。我用过一个工具叫lockdep(Linux 内核里也有同名机制,是死锁检测的神器)。用户态可以跑 Thread Sanitizer 或者 Helgrind,它们可以发现运行路径上的潜在死锁。不过静态分析工具能覆盖的场景有限,关键还是设计时别用多把锁交叉保护同一批资源。
5.3 原子指令和内存序:无锁编程的底线
如果你做高并发计数器、状态机这类“窄临界区”,完全可以用原子操作替代锁。
GCC 提供的__atomic_*内置函数、C11 的<stdatomic.h>、C++11 的<atomic>,都是映射到 CPU 的原子指令,比如 x86 的LOCK CMPXCHG。原子操作的好处是不阻塞线程,不会因为锁而让线程睡眠和唤醒,延迟最低。
但原子操作一旦涉及顺序,就得小心内存序(memory order)。默认的memory_order_seq_cst可以保证全局顺序,但开销略高;memory_order_acquire/release则适合“我写完数据,你再读”的场景:release 写保证之前的所有普通写操作同步到其他线程,acquire 读保证之后的所有普通读操作不会越过这个读。
举个例子:
std::atomic<bool> ready{false}; std::string payload; // 线程1 payload = "hello"; ready.store(true, std::memory_order_release); // 线程2 if (ready.load(std::memory_order_acquire)) { // 这里读 payload 一定是 "hello" }如果你乱用 relaxed 内存序,在某些架构(比如 ARM)上,线程 2 有可能在ready变成 true 之后,读到payload的旧值。这种 bug 很难复现,也难定位。所以我常用的经验是:非极端性能需求,默认全用 seq_cst;要优化了,才换 acquire/release;relaxed 只用在“只统计不依赖顺序”的场景。
5.4 线程调试三板斧:strace / gdb / gstack
多线程 bug 排查时,下面这几个工具我都是常驻现场的:
strace -f -p <pid>:跟踪进程所有线程的系统调用,看哪个线程卡在哪个 syscall 上。gdb -p <pid>:直接 attach 正在跑的进程,thread apply all bt看所有线程的调用栈。pstack/gstack(gstack更推荐)快速打印所有线程的栈,比 gdb attach 更省事。perf top、perf record:看各线程的 CPU 占比和热点函数。
现场排查线程问题的核心思路:先定位是哪个线程出问题,再判断它挂在什么调用上,最后查上下文。
有一次一个服务响应卡住,top -H里看不出异常,CPU 也不高。我用gstack一看,发现一堆线程都卡在__lll_lock_wait上,进一步看发现拿到锁的线程堵在了一个磁盘 I/O 的read上,磁盘慢导致锁一直不释放。最后我们给磁盘加缓存,把 I/O 挪到独立线程池,问题解决。这类问题只有结合“锁等待 + 系统调用”一起看才能定位。
6. 实战排查:四个经典案例复盘
6.1 场景一:多线程进程频繁 fork 导致锁残留
背景是个监控 agent,它有一个主线程负责采集指标,另一个线程定时执行用户命令。命令执行走的是fork + exec。监控线程用的是多线程库,内部有自己的锁。上线一周后,agent 频繁出现 exec 出来的子进程启动到一半卡死,CPU 正常,但任务不推进。
排查过程:用strace -f挂子进程,发现子进程卡在futex等待一个锁上,但没有任何其他线程持有。后来查代码,确认是 fork 在 exec 前那一刻,其他线程的某个锁恰好处于持锁状态,而子进程除了调用 fork 的线程以外,其他线程都没有了,于是直接死锁。
修复方案:给 fork 调用准备pthread_atfork,在 prepare 阶段把进程内比较关键的库锁都锁上,在 parent 阶段解锁,child 阶段重置锁。更重要的是,后来我们把采集功能改成不用多线程,而是用主循环 + 非阻塞 I/O,彻底规避 fork 加锁的问题。结论:多线程程序别轻易 fork,除非你非常清楚哪些锁在 fork 瞬间是干净的。
6.2 场景二:线程池里有线程“消失”了
另一回,一个 C++ 网关服务日志显示客户端连接数在涨,但处理线程池可用线程数在降。top -H -p <pid>一看,线程总数确实少了,说明有线程退出后没有被重新创建。
看代码,线程池的 worker 循环里,某个异常导致线程直接退出,而线程池的“补充线程”逻辑没有覆盖这种退出路径。修复很简单:worker 函数外面包一层循环,子线程因异常退出后重新投递一个任务到线程池,或者干脆设置一个监控线程定期检查线程数并补足。
教训:线程池必须定义“线程意外死亡”后的恢复策略,否则你再多的哨兵监控也白搭。
6.3 场景三:僵尸进程堆积导致 PID 耗尽
经典案例前面提过:主进程 fork 了子进程,但没有 waitpid,子进程退出后变成僵尸。僵尸进程多了,PID 空间被占满,后面 fork 直接返回EAGAIN。
排查命令就三招:
# 数一数僵尸进程 ps -eo stat,ppid,pid,comm | awk '$1=="Z" {print $2}' | sort | uniq -c # 看是不是有父进程忘了回收 ps -eo pid,ppid,stat,cmd | grep defunct # 如果是 init 的僵尸,多半是孤儿,不用慌张修复就一行:父进程加 waitpid 循环,或者直接忽略 SIGCHLD(signal(SIGCHLD, SIG_IGN)),这样子进程退出后内核会把它自动回收。对很多“不关心子进程状态码”的后台程序,忽略 SIGCHLD 是最省心的一招。
6.4 场景四:线程里全局变量计数不准
前面讲过了,但这里补一个实际操作经验:如果你用gcc编译直接裸跑g_counter++,在默认优化级别下,编译出的汇编确实是普通内存读改写;但如果你开-O2,编译器有可能把它优化成寄存器操作再写回。不管怎么优化,多核下都会丢更新。
修的最终版是用 C11 原子操作:
#include <stdatomic.h> atomic_ulong g_counter; void onEvent(void) { atomic_fetch_add_explicit(&g_counter, 1, memory_order_relaxed); }这里用 relaxed 就够,因为计数只做统计、不依赖顺序,relaxed 的性能最好。把内存序吃透以后,你就能知道哪里该严、哪里可以松。
7. 面试考点与自我检测清单
这篇文章本身就是 Linux 面试高频区。我按自己招人时喜欢问的方向,给你整理几个必须能答上来的点:
- 进程和线程在内核里的本质区别:都是 task,区别在资源可见范围。
- fork 之后父子进程共享什么不共享什么:共享文件偏移、打开的文件表,不共享地址空间、锁状态。
- 线程共享什么不共享什么:共享地址空间、文件描述符表、信号处理器,不共享栈、寄存器现场、线程局部存储。
- 僵尸进程怎么产生、怎么处理:子进程退出无人回收,父进程 waitpid 或忽略 SIGCHLD。
- 线程间同步手段:互斥锁、条件变量、原子操作、读写锁、信号量。
- 进程间通信手段:管道、共享内存、消息队列、Socket、信号。
- 写时复制原理:fork 共享只读页,写时分裂页表。
- 多线程进程 fork 风险:锁残留导致死锁,需要 pthread_atfork 或避免 fork。
- 死锁四条件、破解思路、如何验证是否死锁:4 个条件同时满足才会死锁,破解任一条件即可。
- 原子操作和锁的性能差异:前者无阻塞,后者可能睡眠;窄临界区用原子,宽临界区用锁。
- CFS 如何调度线程:对 task 调度,多线程进程会相对多占 CPU,但受负载均衡影响。
- 守护进程怎么创建:fork + setsid + 再 fork + 改工作目录 + 重定向 fd。
- 孤儿进程怎么办:被 init(PID 1)收养。
- 线程池和进程池怎么设计:任务队列、工作线程循环、优雅退出。
每一条背后都有真实工程场景支撑,能结合案例讲清楚,绝对比死背概念强得多。面试官问“进程和线程的区别”,你直接抛内核 task_struct 的角度,马上就跟普通候选人的答案拉开了层次。
8. 个人经验:我最终怎么看待这组关系
Linux 上这些概念的掌握,不在于背熟定义,而在于调试时能不能第一时间反应出问题属于哪个层面。
比如服务卡了,你第一反应是看进程状态还是线程状态?如果现象是整个服务回复变慢,多半是锁竞争或者磁盘 I/O;如果现象是一个请求卡住,另一些请求正常,多半是某个线程死锁或者等待条件变量。如果ps -ef看到一堆 defunct,说明是 fork 生命周期管理出了问题。如果 shm 服务异常重启,问题多半是共享内存映射、权限、或者信号量残留。
我在实际项目里体会最深的一件事:用“进程/线程”这种二元思维理解 Linux,永远差着一层。更准确的模型是“资源容器 + 执行单元”。进程是资源的容器,线程是容器里跑的执行单元。fork 创建了一个新的容器,clone 创建了一个新的执行单元,可以把它放到别人的容器里,也可以放到新容器里。理解了这层,面试题、线上排查、系统设计全都通了。
另外一个小技巧:多线程程序的线程名,在 Linux 下是可以自定义的,用pthread_setname_np()设置后,top -H或htop里看起来一目了然。项目进到一个阶段,别只停留在“会写并发代码”,要学会“让代码自己在进程列表里说话”。
最后再补一个细节:如果你用 Java,JVM 里的用户线程和内核线程是 1:1 映射的,所以 Java 线程池的线程数,在系统层面就是等量 LWP。调优时别光看 JVM 指标,也该看系统线程状态。Python 的 GIL 又是另一种故事,但原理还是同一套:共享资源要同步,多线程不一定更快,多进程也未必更稳,关键看你的瓶颈在哪里。