当我们讨论进程与线程时,究竟在讨论什么
「进程 / 线程 / 协程」常被当成三个并列的名词背,但它们其实是同一条演化链上、被不同矛盾逼出来的三层调度单元:多道并存逼出进程(资源定界)→ 进程内要多执行流又省 IPC,逼出线程(同地址空间的调度实体)→ 线程切换进内核、栈又重,逼出协程(用户态自己调度的轻量执行流)。这篇不停在抽象动机,而是落到现实标准回答「它们到底是什么实体」:在 Linux 上,进程和线程都是
task_struct,区别只在clone()时共享了哪些资源;协程则根本不进内核,是运行时在用户态切换的栈 / 状态机。
1. 三层单元,是被三个矛盾依次逼出来的
每一层都不是「设计者想加就加」,而是上一层留下一个具体痛点、下一层才必然出现。把矛盾说清,名词自然就排成了链。
1.1 单任务:还不需要「进程」
最早一段程序独占整台机器直到结束。内存里只有一份上下文,谈不上「这块内存归谁、这个文件归谁」——没有并存,就不需要定界,也就没有进程抽象的位置。
1.2 多道 / 分时 → 进程:给资源划界
矛盾:CPU 远快于外设,一次只跑一道,等 I/O 时 CPU 空转。 办法:内存同时驻留多道程序,一道阻塞就切去跑另一道。
一旦多道并存,内核必须能回答三个「归谁」:这段内存和页表归谁、打开的文件和凭证归谁、下次调度该换回谁的寄存器与内核栈。把「地址空间 + 文件表 + 凭证 + 至少一条执行轨迹」打包成一个可命名、可切换的单位——这就是进程。它解决的是资源与保护边界。
1.3 进程内多执行流 → 线程:省掉 IPC
矛盾:只有进程这一种粗粒度边界,隔离性好,但同一个程序里要并发处理多件事时,共享可变状态只能走 IPC(管道、共享内存、消息),又慢又繁。 办法:在同一地址空间里放多条可独立调度的执行流,共享数据直接走指针 + 锁。
于是进程管「谁的资源边界」,线程管「同一边界里几条执行通道怎么叠着跑」。代价是把隔离换成了同步——数据竞争、死锁、内存序从此进场。
1.4 线程仍嫌重 → 协程:把调度搬进用户态
矛盾:1:1 线程每次切换要陷入内核(保存 / 恢复内核栈、走调度器),栈还默认按 MB 级预留;要开「几十万条并发执行流」时,内核线程的切换开销 + 内存占用都扛不住。 办法:在用户态自己维护一批轻量执行流,切换不进内核、栈按需增长——这就是协程(也叫用户态线程 / 纤程)。它不取代线程,而是多路复用在少量线程之上。
- 这条链是「定界 → 共享 → 轻量化」:每一层都在上一层基础上换取某种东西,也付出某种代价。
- 注意它不是「后者淘汰前者」:进程、线程、协程在现代系统里同时存在、分层叠用。
2. 进程的真实实体:以 Linux 为现实标准
教材说「进程是资源容器」,落到 Linux 就是一个
task_struct通过指针挂着的一组资源结构。所谓「容器」不是比喻,是一组可被共享或复制的具体内核对象。
Linux 里每个调度单元都是一个 struct task_struct。一个「进程」由它 + 它指向的资源结构组成:
| 资源 | 内核结构 | 装什么 |
|---|---|---|
| 地址空间 | mm_struct | 页表、代码 / 数据 / 堆 / 栈 / mmap 区 |
| 打开的文件 | files_struct | fd → file 对象的映射表 |
| 文件系统上下文 | fs_struct | cwd、root、umask |
| 信号处理 | signal_struct / sighand_struct | 信号处理函数表、待决信号 |
| 凭证 | cred | uid / gid / capabilities |
两个关键系统调用决定这组资源怎么来:
fork():复制一份task_struct和上述资源。地址空间用 COW(写时复制)——父子先共享物理页,谁写谁才真正复制,避免开销。exec():不创建新进程,而是把当前进程的mm_struct整个换掉,装入新程序的代码 / 数据,fd 表默认保留(于是 shell 的重定向能传给子程序)。
「fork + exec」分开正是 Unix 的经典设计:fork 后、exec 前那段窗口里,子进程还在原地址空间,可以从容地改 fd、设权限,再换上新程序。
3. 线程的真实实体:Linux 根本不单独区分线程
这是最容易被教材带偏的一点:Linux 内核里没有独立的「线程」对象。线程就是一个和别的
task_struct共享了地址空间 / fd 表 / 信号表的普通task_struct。进程 vs 线程的全部差别,浓缩在clone()的几个共享标志位里。
fork、pthread_create 在内核里都走同一个 clone(),差别只在传哪些 CLONE_*:
| 标志 | 含义 | fork(建进程) | pthread_create(建线程) |
|---|---|---|---|
CLONE_VM | 共享地址空间 mm_struct | 否(COW 复制) | 是 |
CLONE_FILES | 共享 fd 表 | 否 | 是 |
CLONE_FS | 共享 cwd / root / umask | 否 | 是 |
CLONE_SIGHAND | 共享信号处理表 | 否 | 是 |
CLONE_THREAD | 归入同一线程组 | 否 | 是 |
- 「线程共享内存」不是一个特殊机制,而是
CLONE_VM让两个task_struct的mm指针指向同一个mm_struct的直接结果。 - 进程之间默认不共享,正是因为
fork不带这些标志。
两套 ID 别混(这是 ps / top 里看着别扭的根源):
- TGID(线程组 ID):用户态
getpid()返回的「进程号」。一个进程内所有线程共享同一个 TGID。 - TID(内核任务 ID):
gettid()返回,每个task_struct唯一,是内核真正用来调度的 ID。 - 主线程的 TID == 进程的 TGID,所以单线程程序里两者看起来是一回事。
现代 glibc 用 NPTL(Native POSIX Thread Library) 实现 pthread,是严格的 1:1 模型:一个 pthread_t 对应一个内核 task_struct,调度完全交给内核。(早期的 LinuxThreads 靠一个 manager 线程,信号语义不符合 POSIX,已被淘汰。)
对照:Windows 把进程和线程做成两种独立内核对象——
EPROCESS(不可调度,只是资源容器)和ETHREAD(真正的调度单位)。「进程不被调度、线程才被调度」在 Windows 上是显式的两层对象;Linux 则把它们统一成「带不同共享标志的 task」。两种现实标准,同一套抽象。
4. 用户线程到内核实体的三种映射
「我开了一个线程 / 协程」到底对应内核里几个可调度实体?答案取决于映射模型。这是把「运行时怎么说」和「内核怎么调度」对齐的关键一列。
| 模型 | 用户执行流 ↔ 内核实体 | 谁来调度 | 一个阻塞会怎样 | 代表 |
|---|---|---|---|---|
| 1:1 | 一条用户线程 = 一个内核 task | 内核 | 只阻塞这一条,内核切别的 | NPTL pthread、std::thread |
| N:1 | N 条用户线程 = 1 个内核 task | 纯用户态 | 一条阻塞,全部卡死(内核只见一条) | 早期 green thread |
| M:N | M 条用户线程复用 N 个内核 task | 用户态 + 内核协作 | runtime 设法把阻塞的那条挪开 | Go runtime、各类协程库 |
- 1:1 最简单、调度交给内核,但每条都是重量级内核线程,开不了太多。
- N:1 切换最便宜,但只要一条陷入阻塞系统调用,内核以为整个 task 睡了,其余全部停摆——这是 N:1 的致命伤。
- M:N 是协程追求的形态:多数切换在用户态(便宜),又不至于一条阻塞拖垮全部(runtime 兜底)。代价是 runtime 复杂度陡增。
5. 协程:把调度搬到用户态,但没消除阻塞
协程的本质是「用户态可挂起 / 恢复的执行流」:切换不进内核、栈可小可增长,所以能开几十万条。但它不是魔法——阻塞只是被换了地方,没有凭空消失。
5.1 有栈 vs 无栈:两种实现路线
| 维度 | 有栈协程 (stackful) | 无栈协程 (stackless) |
|---|---|---|
| 怎么保存现场 | 每条协程一个独立栈,切换时换栈指针 | 编译器把函数改写成状态机,挂起点的局部变量存进堆上的状态对象 |
| 能否在任意深度挂起 | 能(嵌套函数里也能 yield) | 不能,只能在标了挂起点的函数里 |
| 内存 | 一个栈(可按需增长,初始可小至 KB 级) | 一个状态对象(往往更省) |
| 代表 | goroutine、ucontext、boost.context、libco | C++20 协程、Python async/await、Rust async |
- 有栈协程对业务代码透明——普通函数照写,调度库负责换栈;代价是栈管理成本和迁移限制。
- 无栈协程侵入语法(要写
co_await/async),但不需要为每条协程预留栈,编译期就能把状态压到最小。
5.2 阻塞点仍会「占死」底层线程
协程跑在少量 OS 线程之上(M:N)。如果一条协程调用了同步阻塞的系统调用(比如阻塞 read),内核只看得到承载它的那条 OS 线程睡了——这条线程上其余协程全被连坐,退化成 N:1 的灾难。
成熟 runtime 用两招破解(以 Go 的 GMP 为例):
- 网络 I/O 异步化:
net包底层走 epoll/kqueue(netpoller),协程「阻塞」时其实是挂起,OS 线程被释放去跑别的协程。 - 系统调用时解绑:协程真要陷入阻塞 syscall 时,runtime 把它所在的 OS 线程(M)与逻辑处理器(P)解绑,让 P 带着其余协程换一条线程继续跑。
一句话:协程把「廉价并发」交给了用户态调度器,但阻塞语义的正确性依赖 runtime 是否把每个阻塞点都异步化了。脱离 runtime 直接调阻塞 API,协程的优势立刻归零。这也呼应那条链——协程不是消灭了线程,而是多路复用在线程之上。
6. 同一组词,四套观察角度
「线程」这个词在不同语境指不同东西。开口前先对齐自己站在哪一列,否则各说各的。
| 角度 | 实体落点 | 典型争论 |
|---|---|---|
| 内核 | task_struct、clone、TGID/TID、futex | 谁先跑、优先级、亲和性、唤醒路径 |
| 运行时 | pthread / std::thread、协程库、线程池 | 「十万任务」内核眼里是几条 runnable |
| 语言抽象 | goroutine、async fn、co_await | 有栈还是无栈、哪些点能挂起 |
| 硬件观测 | 核数、perf、上下文切换、缓存行 / 伪共享 | 跑不满是 I/O、锁还是调度延迟 |
多核只改变最底一行:同一时刻真正同时在不同核上推进的执行流,上界约等于可用核数;其余仍在分时排队。「开很多线程 / 协程」≠「物理上同时跑很多条」——这是「并发」与「并行」最常被混掉的坎,但它不改变上面三层的实体定义。
7. 小结
- 进程:资源与保护的定界单位。Linux 里 =
task_struct+ 各自的mm_struct/files_struct/cred,由fork(COW)创建。 - 线程:同地址空间里的调度实体。Linux 里也是
task_struct,只是clone时带上CLONE_VM/FILES/SIGHAND/THREAD共享了资源;NPTL 是 1:1 模型,TID 才是内核调度 ID。 - 协程:用户态可挂起的执行流,M:N 复用在线程上。有栈(goroutine)对业务透明、无栈(C++20 / async)省内存;阻塞没被消除,只是靠 runtime 异步化挪开。
- 多核:给「同时推进」一个硬上界,不改变前三层的实体;性能问题归到调度、锁、内存序、profiling 去谈。
一句话说全:进程、线程、协程不是三个并列名词,而是「定界 → 共享 → 轻量化」一条链上的三层调度单元;落到现实标准,Linux 用「带不同共享标志的 task_struct」统一了进程与线程,再用用户态 runtime 把协程多路复用在线程之上。
版权所有
版权归属:Pray0