☰
进程和线程到底差在哪?一文讲透Linux下的并发模型
2026/10/15 7:30:45 网站建设 项目流程

1. 从一次“诡异”的调试说起:进程和线程到底差在哪

有一回我在优化一个常驻服务,日志里偶发出现内存错乱,数据对不上,进程却没崩。我一度怀疑是线程同步写坏了共享结构,就和搭档把pthread_mutex加了个遍,甚至用valgrind跑了几轮,问题依旧。后来偶然在/proc下看线程状态,才发现出事的是“父子进程”不是“线程”,那两块地址空间各自为政,互斥锁根本管不到对方。那一刻我意识到,很多开发者在“父子进程、进程内线程、不同进程、不同线程”这几组概念上其实是模糊的。

这篇文章就想把这几组关系一次性讲透。我基于Linux环境,结合C语言的fork()、pthread系列接口,从系统视角、内存视角、崩溃隔离、锁竞争、调度开销等维度做对比。内容适合正在学操作系统原理的学生、写并发服务的开发者,也适合那些用Python/Go但偶尔需要理解底层模型的人。读完你至少能回答这几个问题:fork()出来的子进程和父进程是什么关系?同一进程里的多个线程共享什么?不同进程的线程和同一进程的线程,差别到底有多大?以及:我该用进程还是线程?

这不是一篇照搬man手册的教程。我会从实际可观测的行为出发,给出验证代码,也讲清每个关键选择背后的原因。你不需要看完所有源码,只需要跟着思路在终端里跑几个小程序,就能建立一套属于你自己的“进程线程心智模型”。

2. 先建立底层模型:进程是“资源容器”,线程是“执行流”

2.1 Linux如何组织一个运行中的程序

在Linux里,进程不是一个“正在运行的程序”这么简单。现代Linux内核中,一个进程对应一个task_struct,它承载的是地址空间、打开的文件描述符、信号处理器、环境变量、挂起的信号、资源限制等一大堆状态。换句话说,进程更像一个“资源容器”,它本身并不负责具体执行,真正被CPU调度运行的是线程。

同一个进程内的多个线程,共享这个容器里的绝大部分资源:同一份代码段、同一片数据段、同一个堆,以及相同的文件描述符表。但它们各自拥有独立的栈、独立的寄存器上下文、独立的线程ID和调度优先级。内核调度的最小单位是线程,不是进程。这个区分至关重要,因为很多并发编程里的错误认知,源头就是把“进程”和“线程”混为一谈。

拿一个具体例子说:你启动一个服务,内核创建一个进程,里面默认只有一个主线程。主线程执行main()时,调用pthread_create()又创建了三个工作线程,于是这个进程名下就有四个执行流。它们看到同一个全局变量地址,但每个线程的局部变量、函数调用栈是完全分开的。

2.2 四个概念的精确区分

  • 父子进程:由某个进程通过fork()创建出的新进程。父进程和子进程各自拥有独立地址空间、独立文件描述符表,在写时复制(Copy-on-Write)机制下,初始内存内容相同,但修改互不可见。
  • 进程中的线程:同一个进程内通过pthread_create()(或clone相关机制)创建的多个执行流,共享进程的地址空间、堆、全局变量和文件描述符,各自保存独立的栈。
  • 不同的进程:系统里没有直接血缘关系的两个进程,资源完全隔离,彼此只能通过进程间通信(管道、消息队列、共享内存、socket等)交换数据。
  • 不同的线程:这里有两层含义。一层是同一进程内的不同线程,另一层是分属不同进程的线程。后者本质上是进程隔离加线程并发,既要考虑锁的粒度,也要考虑跨进程通信。

为了更直观,我给你画一张“视角图”:

进程A(容器) ├── 主线程(执行流1) ├── 工作线程(执行流2) └── 工作线程(执行流3) 共享:进程A的地址空间、堆、全局变量、fd表 独立:栈、寄存器上下文、线程ID 进程B(由进程A fork而来,独立容器) └── 主线程(执行流1) 拥有独立地址空间、独立fd表副本 与进程A之间通过管道/共享内存等进行IPC

2.3 为什么说“线程是轻量级进程”有时候是个陷阱

很多资料把线程称为“轻量级进程”,这句话在早期Linux实现里有一定道理,因为那时线程本质上是通过clone()创建的、共享了部分资源的进程。但现在再这么理解,就容易踩坑:线程的“轻量”主要体现在创建和切换时不复制完整地址空间,上下文切换成本更低。但线程的同步复杂性、崩溃传播风险、调试难度,一点也不“轻”。

我在实际项目里见过不少类似的误判,比如一个服务为了追求并发,把十几个线程都往一个vector里塞,结果偶发崩溃,排查很久才发现是多个线程在没有加锁的情况下同时扩容,属于数据竞争,而不是逻辑顺序问题。用进程就不会出现这种情况,因为各自地址空间不共享。但用进程又要面对IPC的复杂度。所以这两者之间是“开销”和“隔离”的权衡,不是简单的“线程一定比进程好”。

3. 父子进程的真实面貌:fork()之后发生了什么

3.1 一个只跑一遍却有两个返回值的函数

先写一段最经典的验证代码:

#include <stdio.h> #include <unistd.h> int main(void) { printf("before fork, pid=%d, ppid=%d\n", getpid(), getppid()); pid_t pid = fork(); if (pid < 0) { perror("fork"); return 1; } if (pid == 0) { printf("child: pid=%d, ppid=%d, return=%d, addr=%p\n", getpid(), getppid(), pid, &pid); } else { printf("parent: pid=%d, child_pid=%d, return=%d, addr=%p\n", getpid(), pid, pid, &pid); } printf("after fork, pid=%d\n", getpid()); return 0; }

编译后运行,你会看到类似这样的输出:

before fork, pid=10001, ppid=9999 parent: pid=10001, child_pid=10002, return=10002, addr=0x7ffe... child: pid=10002, ppid=10001, return=0, addr=0x7ffe... after fork, pid=10001 after fork, pid=10002

注意几个细节:fork()调用一次,却有两个返回值——父进程返回子进程PID,子进程返回0。两个进程打印出的&pid地址一模一样,但这是各自独立地址空间中的同一虚拟地址,不是共享的同一块物理内存。变量是在栈上的,而栈本身被复制了,因此两个进程各有一份。

那为什么地址相同?因为虚拟地址空间布局一致,逻辑上好像是同一个位置。但由于页表不同,这个地址映射到的物理页面在写入前还是共享的,一旦有一方修改,就会触发写时复制,拆分出独立物理页。这就是很多人误解“地址相同等于共享数据”的根源。

3.2 子进程和父进程的“分家”细节

  • 子进程继承父进程的打开文件描述符、环境变量、信号设置、当前工作目录、用户ID等。
  • 子进程拥有独立的内存空间,写入互不干扰。
  • 子进程不继承父进程的内存锁、文件锁、异步I/O操作等。
  • 父进程和子进程的执行顺序不确定,由调度器决定,不要假设“先父后子”或“先子后父”。
  • fork()失败通常是因为进程数达到上限或内存不足,返回值是-1,并设置errno。

在实际工程中,子进程经常配合exec族函数使用。fork()先复制出一个和父进程几乎一样的子进程,然后子进程调用execve()把新的程序镜像装载进来,替换掉自己的地址空间。比如system()、shell命令执行、守护进程的二次启动,本质都是fork()加exec。

但也有不少程序只用fork(),不调用exec,希望父子进程走不同的业务分支。这种模式下,父子进程维持“同源但分家”的关系。一个经典的例子是网络服务:父进程监听端口,每来一个连接就fork()一个子进程处理;或者父进程负责守护业务,子进程被重启、被监控。此时要格外小心资源重复释放问题,比如父子进程都去close()同一个socket描述符,虽然系统层面有引用计数,不会立刻出错,但逻辑上很容易混乱。

3.3 僵尸进程和wait():为什么子进程退出了还占资源

fork()之后,如果父进程不调用wait()或waitpid(),子进程退出时会变成僵尸进程。它不再占用CPU和内存主页,但内核为了保留退出状态,仍然保留task_struct和少量资源信息。如果父进程一直不回收,僵尸进程会不断累积,最终可能导致系统无法创建新进程。

我见过一个监控脚本,每轮循环fork()一个子进程去抓数据,但父进程只负责打印结果,没有wait()。运行一周后,进程表被僵尸占满,ps里一片<defunct>,排查时才发现问题不在业务逻辑,而在资源回收。

正确做法是:在父进程里写信号处理函数捕获SIGCHLD,或在循环里调用waitpid(-1, &status, WNOHANG)批量收尸。对不关心退出结果的场景,也可以显式忽略SIGCHLD,让子进程退出时被内核自动回收,前提是确认不需要子进程的退出状态。还有一个容易忽视的点:如果父子进程都通过管道读写,子进程退出后,父进程再读管道会得到EOF,这很正常;但如果父进程不关闭写端,就会造成管道永远等不到EOF,从而卡住。

3.4 写时复用:父进程和子进程的内存为什么不写不复制

Linux的fork()在复制地址空间时使用了写时复制技术。具体来说,父子进程一开始共享所有的物理内存页,页表项被标记为只读。任意一方尝试写入,CPU会触发页错误,内核随即复制该页,并将共享页变成各自私有的可写页。这样做的好处是,fork()本身非常快,大部分页面不会真的复制。

这对性能有直接影响。如果一个进程内存占用很大(比如几十GB),fork()依然能在毫秒级别返回,归功于COW。但代价是:父进程或子进程频繁写内存时,会不断触发页面复制,造成页错误开销和额外内存消耗。对于超大内存的服务,频繁fork()+大量写入可能比预想更慢。有的框架在fork()之后立刻exec(),能最大化COW的收益,因为新程序镜像会整体替换,旧页面不需要逐一复制。

另外一个常见误区是:父子进程都持有同一个文件描述符,读写同一个文件时,文件偏移是共享的。因为文件描述符表虽然复制了一份,但每一项都指向同一个文件对象,文件对象的f_pos是共享的。如果不加控制,父子进程同时write()文件,可能互相覆盖。要真正独立写文件,需要各自open()一次。

4. 进程内的线程:共享的“家”,独立的“床”

4.1pthread_create创建了什么

线程创建接口最常用的是pthread_create():

#include <pthread.h> #include <stdio.h> #include <unistd.h> void *worker(void *arg) { int id = *(int *)arg; printf("thread-%d start, pid=%d, tid=%lu\n", id, getpid(), pthread_self()); sleep(2); printf("thread-%d stop\n", id); return NULL; } int main(void) { pthread_t tids[4]; printf("main thread, pid=%d, tid=%lu\n", getpid(), pthread_self()); int ids[4] = {0, 1, 2, 3}; for (int i = 0; i < 4; i++) { pthread_create(&tids[i], NULL, worker, &ids[i]); } for (int i = 0; i < 4; i++) { pthread_join(tids[i], NULL); } return 0; }

编译时一定要加-pthread,否则链接会报错:

gcc -O2 -g -o thread_demo thread_demo.c -pthread

运行时你会发现四件事:

  1. 所有线程的getpid()相同。
  2. 每个线程的pthread_self()不同。
  3. worker函数里可以访问main函数中定义的局部数组ids,因为线程共享整个地址空间,包括栈上的某些区域——准确说,是main栈帧里的数据,在生命周期内对所有线程可见。
  4. 子线程之间的栈是互相独立的:一个线程的局部变量在另一个线程里不可见,即使能看到对方的tid,也无法直接访问别人的栈。

从内核角度看,每个线程都有独立的task_struct,只是它们之间通过mm_struct、files_struct等结构共享资源。所以在ps -eLf输出里,你会看到同一进程的多个线程都对应独立行,且LWP各不相同。ps -T -p PID也能列出线程信息,top -H则按线程显示CPU占用。这些都是排查线程问题时的常用手段。

4.2 线程局部的边界:栈、寄存器、线程局部存储

线程之间共享的是进程级资源,不共享的包括:

  • 调用栈:每个线程有独立栈空间,通常8MB左右,可通过ulimit -s查看或调整。栈溢出会导致段错误,而且崩溃点常常不在栈溢出的线程本身,而是在其他线程的随机位置上,极难排查。
  • 寄存器上下文:包括PC指针、栈指针等,调度器切换线程时会保存和恢复。
  • 线程局部存储(TLS):用__thread修饰的变量,每个线程各有独立实例。这在多线程环境中很常见,比如errno在Linux的glibc实现中就是线程局部的,因此每个线程的errno不会互相干扰。
  • 信号掩码:pthread_sigmask()可以设置每个线程独立的信号阻塞集合,但信号处理函数是整个进程共享的。

有段代码可以验证“共享地址空间”:

#include <pthread.h> #include <stdio.h> int global_counter = 0; void *inc(void *arg) { for (int i = 0; i < 1000000; i++) { global_counter++; } return NULL; } int main(void) { pthread_t t1, t2; pthread_create(&t1, NULL, inc, NULL); pthread_create(&t2, NULL, inc, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("counter = %d\n", global_counter); return 0; }

你多跑几遍就会发现,global_counter的值几乎不会稳定等于2000000。原因在于global_counter++不是原子操作,它包含读取、加一、写回三步。两个线程可能在读取到相同旧值后各自加一,最终只加了一次。这就是数据竞争。解决方案不是“尽量少用全局变量”,而是用_Atomic、互斥锁或修改变量为线程局部。

4.3 线程和fork()的糟糕组合:一个容易炸的雷

如果在一个多线程进程里调用fork(),子进程只会保留调用fork()的那个线程。其他线程在子进程中直接消失,但它们持有过的锁、内存状态却可能仍然处于锁定状态。这会导致一个极端头疼的问题:子进程里新起的线程尝试加一个已经被“不存在线程”持有的锁,就会永久死锁。

这并非理论场景。和服务端用的多线程框架里,如果在某个业务线程中调用了system()或fork(),父进程的其他线程可能正握着malloc的锁,而fork()出来的子进程随后调用printf、malloc,就可能和已经“消失”的锁状态纠缠。按POSIX标准,fork()之后,子进程中只能调用异步信号安全函数,直到它调用exec为止。

所以我的建议是:多线程程序里尽量避免直接使用fork()。如果确实需要与外部程序交互,优先用posix_spawn()或完整的多进程架构设计。如果必须用,也要在fork()后立刻在子进程里执行exec,不要做多余操作。

5. 不同进程和不同线程的隔离与协作:一场关于平衡的权衡

5.1 进程隔离:崩溃不传染,但通信成本高

进程之间天然被地址空间隔离。一个进程里写坏指针,只会影响自己,最多触发它自己退出,不会污染其他进程的页表,更不会让另一个进程的数据错乱。这种隔离是稳定性的一大优势。比如浏览器采用多进程架构,某个标签页崩溃,浏览器主界面和其他标签页还能继续工作,靠的就是进程级隔离。

代价是进程之间不能直接访问对方的变量。要交换数据,常用的方式有以下几种:

  • 管道:单向字节流,适合父子进程或亲缘进程间通信。
  • socket:本地或网络,双向通信,适合任意进程。
  • 共享内存:最接近“共享变量”的方式,但必须配合同步机制,通常用信号量或互斥锁保护。
  • 消息队列:传递结构化消息,有系统限制,但使用简单。

这些方式里面,共享内存的性能最高,因为数据不经过内核缓冲区拷贝,直接映射到用户态。但它也最危险:一旦同步没做好,多个进程同时写共享内存,数据会撕裂,而且你很难从崩溃现场判断是谁先动的手。

5.2 线程隔离:共享高效,但崩溃传染、调试困难

同一进程里的线程在崩溃隔离和内存保护上几乎没有任何屏障。只要一个线程越界写,把堆或栈踩坏了,整个进程就可能在任何一个线程的任意位置崩溃。哪怕那个线程什么都没干,也可能在检查一块“完全正常”的数据时察觉到异常,因为内存已经被别的线程改写了。

调试上也有困难。gdb虽然支持多线程调试,但线程崩溃时经常需要结合core dump、address sanitizer、helgrind等工具。我自己遇到过一个非常“阴间”的bug:主线程正常退出,工作线程还在后台跑,程序返回main之后,静态对象的析构函数把工作线程正在用的资源给释放了,结果崩溃点飘忽不定,后来靠-fsanitize=address才定位到是生命周期问题。

下表是我对进程和线程在多个维度上的对比:

维度多进程多线程
地址空间独立,天然隔离共享,无内置隔离
创建开销较高,但COW优化后尚可较低,创建快
上下文切换切换地址空间,开销更大同一地址空间,开销更小
通信方式管道、IPC、共享内存直接读写共享内存/全局变量
同步复杂性跨进程同步,成本高锁竞争和死锁风险集中
崩溃影响进程独立,其他进程可继续整个进程崩溃
调试难度相对容易定位数据竞争难复现、难排查
扩展模型适合多实例、分布式部署适合单机高并发I/O

5.3 不同进程的线程:跨进程线程同步的场景

再说“不同的线程”。有些场景下,线程属于不同进程,但需要相互协作,比如多个消费者进程各自开线程去消费同一个共享内存队列。这时你会同时面对进程间隔离和线程间竞争两类问题。

我见过一个比较典型的设计:主进程创建多个工作进程,每个工作进程里面又开了多个线程处理网络请求。工作进程之间用共享内存传递数据,共享内存里放了一个队列,队列前面挂了信号量。每个生产者线程入队前先sem_wait(),每个消费者线程出队后sem_post()。这套设计把进程隔离和线程并发同时揉了进去,好处是既能享受进程级崩溃隔离,又能靠多线程提升单进程内的并行能力。但一旦信号量初值、队列容量、锁顺序没配对,调试起来非常痛苦。

这种跨进程线程协调,最需要警惕的是“乐观假设”:你觉得sem_wait()一定先于对方sem_post(),或者你觉得共享内存中的某个字段在同一时刻只有一个写入者,但现实是调度器可能在任意时刻切换线程,你的假设随时会被打破。正确方式是用内核同步原语,比如futex、sem_t、共享内存上的pthread_mutex(需设置PTHREAD_PROCESS_SHARED属性),不要自己写自旋锁。

5.4 同一个地址空间里,锁是给你自己的,不是给对方的

如果线程分属不同进程,同一把锁能不能同时管住两边的线程?可以,但必须使用进程共享属性。Linux的pthread_mutexattr_setpshared()可以把互斥锁设置为PTHREAD_PROCESS_SHARED,然后把这把锁放到共享内存区域里。这样进程A的线程和进程B的线程都会遵守这把锁。如果不设置pshared,默认是进程私有锁,另一个进程里的线程根本不知道这把锁的存在,也不会被阻塞。

很多人在跨进程共享内存的场景中直接用普通pthread_mutex_t,结果发现另一个进程完全不买账。原因就是属性设置不对。同理,条件变量也有pshared属性。设计跨进程序通信时,建议大家先把同步点定清楚:哪个队列、哪个标志位、哪把锁、哪些线程是竞争参与者,然后统一用共享内存承载这些同步对象。

6. 选择进程还是线程:按场景定夺,而不是按“潮流”定夺

6.1 什么时候无脑用多线程

如果工作负载是同一进程内的CPU密集任务、或者是大量I/O等待,而且各任务之间需要频繁共享状态,多线程明显更合适。典型场景包括:

  • 在线Web服务器:同一个进程接受无数并发连接,线程池统一管理。
  • 图像处理:把一张大图分成多块,各线程处理自己的区域,最后合并。
  • 解析、计算密集型应用:利用多核并行,共享输入数据和输出结果。

线程最大的好处是数据搬运成本低。全局变量、指针、对象直接传递,不需要序列化。但要管理好锁的粒度和持有时间,否则性能会烂在锁竞争上。一个经验法则是:尽量让锁内的代码路径短,能不用共享变量就不用。哪怕用原子变量,也要意识到底层可能是CAS重试,而不是“绝对无锁”。

6.2 什么时候优先上多进程

当崩溃隔离、安全边界、模块独立性是第一优先级时,进程是更好的选择。把一个大服务拆成多个小进程,互相之间通过IPC通信,单个进程挂掉后可以由父进程拉起,整体可用性显著提高。

另外,如果某个模块要跑第三方代码或资源占用极大,进程隔离能减少“一崩全崩”的风险。比如浏览器插件、JVM里执行自定义脚本、外部命令行工具,都适合放到独立进程中。

有两个工程实践值得参考:

  • 需要弹性扩缩容时,进程模型更接近“多实例”,可以按机器负载启动或下线进程。
  • 需要权限隔离时,进程是天然的边界。子进程可以降权运行,即使被攻破,也拿不到父进程的敏感数据。

不过多进程也有明显的坑:进程创建成本表面上低,但如果服务频繁创建/销毁进程且每轮都要加载大量配置、初始化连接池,整体开销就不可忽略;进程间通信多了之后,要么代码被消息协议占满,要么共享内存管理复杂到没人敢动。

我一直建议团队在架构阶段就把这个问题想清楚,而不是上线后再“进程转线程”或“线程转进程”。真到了重构那一步,成本比一开始选错高得不是一点半点。如果可能,也可以采用混合模型:主进程负责生命周期管理,工作进程内部用多线程分担负载。不过这需要更成熟的监控、重启、升级机制,别在早期就引入多余复杂度。

6.3 可复现的实测:一个简单的进程/线程对比实验

为了让你亲手感受到差异,我给出一个可跑的实验方案。

实验目标:对比“父子进程”和“进程内线程”在写共享变量时的行为差异。

步骤:

  1. 写一个父进程,fork()一个子进程,双方各自对同一个全局变量累加100万次,结束后在各自进程内打印结果。
  2. 再写一个多线程程序,两个线程对同一个全局变量累加100万次,打印结果。

第一段代码(父子进程版):

#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int global_counter = 0; int main(void) { pid_t pid = fork(); if (pid < 0) return 1; if (pid == 0) { for (int i = 0; i < 1000000; i++) { global_counter++; } printf("child sees counter = %d\n", global_counter); _exit(0); } else { for (int i = 0; i < 1000000; i++) { global_counter++; } wait(NULL); printf("parent sees counter = %d\n", global_counter); } return 0; }

第二段代码把fork()换成pthread_create(),如前面第4.2节的示例。

运行后你会发现:父子进程版,父子各打印1000000,互不影响;线程版,结果几乎都不是2000000,而是随机在1000000到2000000之间。

这个对比把本文最重要的差异浓缩成了一句话:不同进程的“变量”是两份独立的副本,不同线程的“变量”是同一块内存上的竞争点。理解了这一点,很多并发问题都能定位到方向。

7. 我踩过的几个具体坑,也是文末最想叮嘱的事

写这篇文章时,我把自己项目里和进程线程相关的几个深刻教训重新梳理了一遍。有一个印象最深:多线程程序里某个线程持有锁期间调用了fork(),子进程里再有线程去加同一把锁,直接卡死,排查了很久才想到是这个原因。从那以后,我的代码规范里明确加了一条:多线程进程禁止直接fork(),必须用进程或exec模式。

另一个教训是跨进程共享内存里的锁没有设pshared。当时两个进程互相“抢”一个队列,偶尔出现数据错乱,所有人都觉得是队列逻辑问题,反复调生产者消费者代码。后来发现共享内存里放的锁是PTHREAD_PROCESS_SHARED没配置,导致第二个进程完全无视这把锁。配置好进程共享属性之后,问题一次解决。

还有一个经验是关于调试工具的:多进程崩溃用gdb时,follow-fork-mode设置为child或parent要看具体需求,但多线程崩溃建议优先开AddressSanitizer,很多“玄学崩溃”其实是栈越界或堆越界。把这三个坑写出来,是想让你少走一点弯路。

最后分享一个小技巧:在排查线程和进程相关问题时,先用strace -f跟踪系统调用,再用/proc/<pid>/status看线程数,再用valgrind或ASan排查内存错误,不要一上来就怀疑锁或者调度。从系统视角往下钻,比从业务代码往上猜要快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询