进程、线程、协程:资源单元 vs 执行单元 vs 用户态调度
一句话结论(30s)
进程是资源分配的最小单位、线程是 CPU 调度的最小单位、协程是用户态调度的执行单元,因为三者分别用”隔离性”换取了不同的并发粒度与切换开销。关键设计是协程在用户态主动让出执行权、不经过内核;代价是隔离性从强到无,且协程需协作式让出(遇到阻塞不主动让出会卡死其他协程)。
核心原理(2min)
进程拥有独立虚拟地址空间/文件描述符表,切换需换页表、刷新 TLB;线程共享进程地址空间但各自独立栈,切换只需存寄存器、栈指针、PC;协程遇 IO 阻塞时在用户态让出给其他协程而非让内核挂起线程。Go goroutine、Python asyncio、Java 21 虚拟线程都是协程思想的不同实现——用同步代码风格写异步逻辑。
底层深入(5-10min)
进程:资源分配的基本单位
操作系统为每个进程分配独立的虚拟地址空间、文件描述符表、信号处理器等资源。这些资源是进程的”私有财产”,进程之间完全隔离。
为什么隔离? 如果所有程序共享内存,一个程序的越界写入就能让整个系统崩溃。进程隔离保证了一个程序崩溃不会影响其他程序——Chrome 的多进程架构就是利用这一特性,即使某个 Tab 页面渲染引擎崩溃,其他 Tab 不受影响。
代价:进程切换需要切换页表、刷新 TLB、切换文件描述符表等,开销在微秒到毫秒级别。
思考:进程切换为什么这么贵?切换的到底是什么?
推理:贵的不是「保存几个寄存器」,而是「换地址空间」——换页表、刷新 TLB 会让后续内存访问频繁缺页,一次 TLB 全刷可能带来几十上百次额外的页表走查。所以进程切换的代价绝大部分花在「隔离」上:隔离越强,切换时要做的工作越多,这正对应「用隔离性换开销」这条主线。
线程:CPU 调度的基本单位
线程是进程内的执行流。同一进程的多个线程共享地址空间、文件描述符等资源,但各自拥有独立的栈、寄存器和程序计数器。
为什么需要线程? 进程隔离虽好,但进程间通信(IPC)代价高昂(管道/消息队列需要内核拷贝,共享内存虽然快但同步复杂)。线程在同一地址空间内并发执行,通信只需读写共享变量,配合锁机制即可安全并发。
线程切换为什么比进程快? 线程切换不需要切换页表(共享同一地址空间),只需保存/恢复寄存器、栈指针和 PC,开销约为进程切换的 1/3。
思考:内核到底在调度谁——是进程还是线程?「线程是 CPU 调度的基本单位」在内核里长什么样?
推理:Linux 内核其实没有「进程」和「线程」之分,它只有一个结构体
task_struct作为调度单元。所谓进程和线程,不过是「共享资源的多寡」不同——线程就是多个 task_struct 共享同一份地址空间和文件描述符表。所以内核从不调度”进程”,它调度的永远是 task_struct(线程),下面看源码。
协程:用户态调度的”轻量级线程”
协程的调度完全在用户态完成,不经过内核。当协程遇到 IO 阻塞时,它不是让内核把线程挂起(这会导致系统调用和上下文切换),而是在用户态主动让出执行权给另一个协程。
进程: 内核管理生命周期,切换涉及内核态进出 ✓ 隔离最强 ✗ 最慢
线程: 内核管理调度,共享进程资源 ✓ 共享方便 ✗ 有切换开销
协程: 用户态调度,无内核参与 ✓ 最快 ✗ 需协作式让出
Go 的 goroutine、Python 的 asyncio、Java 21 的虚拟线程,本质上都是协程思想的不同实现。 它们的共同点是让开发者用同步代码风格写异步逻辑,同时享受接近异步的性能。
思考:协程遇到阻塞不主动让出会怎样?它和线程的「抢占式」本质区别在哪?
推理:线程由内核抢占,时间片到了/有更高优先级任务,内核会强制切走,线程自己「身不由己」;协程由用户态协作调度,没人强制你让出,一旦某个协程执行了阻塞调用却不主动 yield,同一个线程上的其他协程会全部被卡死——因为它们共用一个线程、没有第二个执行流。这正是「协程需协作式让出」这条代价的由来:轻量是用「自觉」换来的。
内核源码视角:Linux 调度的是线程(task_struct)
说明:以下源码摘自本仓库
brain/library/linux/。Linux 内核没有「进程 vs 线程」两个概念,只有一个统一的调度单元task_struct;「进程」与「线程」的差别,体现在它们共享资源的多少,而不在调度结构上。
1. 调度单元:task_struct
每个可被调度的实体(无论进程还是线程)都对应一个 task_struct,其中与调度直接相关的字段(include/linux/sched.h):
int prio;
int static_prio;
int normal_prio;
unsigned int rt_priority;
struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl;
struct sched_dl_entity *dl_server;
#ifdef CONFIG_SCHED_CLASS_EXT
struct sched_ext_entity scx;
#endif
const struct sched_class *sched_class;
分析:注意这里没有「进程」也没有「线程」——只有 task_struct。一个”进程”是一个 task_struct,一个”线程”也是一个 task_struct,区别只在 clone 时是否共享 mm(地址空间)和 files(文件描述符表)。sched_class 字段指向这个任务所属的调度类(CFS/RT/DL/IDLE),决定了它按哪种算法被调度。
2. 执行实体:sched_entity
task_struct 里嵌了一个 struct sched_entity,它就是 CFS 用来记账 vruntime、挂红黑树的那个”执行实体”(include/linux/sched.h):
struct sched_entity {
/* For load-balancing: */
struct load_weight load;
struct rb_node run_node;
u64 deadline;
u64 min_vruntime;
u64 min_slice;
u64 max_slice;
struct list_head group_node;
unsigned char on_rq;
unsigned char sched_delayed;
unsigned char rel_deadline;
unsigned char custom_slice;
/* hole */
u64 exec_start;
u64 sum_exec_runtime;
u64 prev_sum_exec_runtime;
u64 vruntime;
...
};
分析:run_node 是红黑树节点(挂进上一篇讲的 cfs_rq->tasks_timeline),vruntime 是虚拟运行时间,load 是权重,exec_start 记录本次运行的起始时刻。这就是「CPU 调度的最小单位」在源码里的真身——它嵌在 task_struct 里,也就是说:内核调度的记账单位是 sched_entity,物理单位是 task_struct(线程),从来不是”进程”这个整体。
3. 调度类:sched_class
「线程按什么算法被调度」由 sched_class 这张”函数指针表”定义,每种算法(公平 CFS、实时 RT、截止期 DL、空闲 IDLE)实现自己的版本(kernel/sched/sched.h):
struct sched_class {
...
void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);
...
bool (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
...
void (*yield_task) (struct rq *rq);
...
void (*wakeup_preempt)(struct rq *rq, struct task_struct *p, int flags);
...
int (*balance)(struct rq *rq, struct rq_flags *rf);
...
struct task_struct *(*pick_task)(struct rq *rq, struct rq_flags *rf);
...
void (*put_prev_task)(struct rq *rq, struct task_struct *p, struct task_struct *next);
void (*set_next_task)(struct rq *rq, struct task_struct *p, bool first);
...
void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);
...
};
分析:注意这些函数指针的形参全是 struct task_struct *——再次印证内核调度的是 task_struct。schedule() 里那句 next = pick_next_task(rq, &rf) 会按 stop > dl > rt > fair > idle 的优先级链逐个询问各调度类的 pick_task,谁先挑出任务就用谁。所以「线程是 CPU 调度的基本单位」翻译成内核语言就是:调度类的一切接口都在操作 task_struct。
总结
| 进程 | 线程 | 协程 | |
|---|---|---|---|
| 资源 | 独立地址空间 | 共享进程地址空间 | 共享线程栈 |
| 调度者 | 内核 | 内核 | 用户态运行时 |
| 切换开销 | 高(μs~ms) | 中(μs) | 低(ns) |
| 隔离性 | 强 | 弱 | 无 |
| 适用规模 | 几十 | 几千 | 百万 |
选择原则:需要强隔离用进程,需要共享内存用线程,需要高并发 + IO 密集用协程。
章末提问
1. 内核调度的是进程还是线程?Linux 里进程和线程在调度上有区别吗?
回答思路:内核只认 task_struct,进程和线程都是它。区别不在调度结构、而在资源共享——线程共享 mm(地址空间)和 files(文件表),进程各自独立。所以「线程是 CPU 调度的基本单位」更准确的说法是「内核调度的单位是 task_struct,线程恰好是共享资源最少的那类 task_struct」。
2. 为什么线程切换比进程切换快?快到什么程度?
回答思路:进程切换要换页表 + 刷新 TLB,导致后续内存访问频繁缺页,这是大头;线程共享地址空间,切换只需保存/恢复寄存器、栈指针、PC,不碰页表。数量级上线程切换约是进程切换的 1/3,且可再往下拆「用户态切换(不陷内核)」和「协程切换(只换栈)」。
3. 协程遇到阻塞不主动让出会怎样?为什么说协程是「协作式」调度?
回答思路:线程是抢占式,时间片到/更高优先级出现时内核强制切走;协程没有内核参与,没人强制你让出。一旦某个协程执行阻塞调用却不主动 yield,同一线程上的其他协程全部卡死——因为它们共用同一个线程、没有第二条执行流。所以协程的「轻量」是用「自觉让出」换来的,这是协作式调度的根本代价。
4. 协程一定比线程快吗?什么场景协程没优势甚至更慢?
回答思路:协程省的是「内核进出 + 上下文切换」的开销,前提是任务本身是 IO 密集、切换频繁。如果是 CPU 密集任务(纯计算),协程让不出执行权也切不动,反而多了运行时开销,可能比线程更慢;单核上协程也不能提升吞吐,只能提升「并发等待」下的资源利用率。
5. 线程共享进程地址空间,为什么还要各自独立的栈?
回答思路:共享的是「地址空间」这个映射关系,不是「执行流」。每个线程有独立的 PC 和栈指针,如果共用同一个栈,两个执行流的函数调用帧会互相覆盖。独立栈保证每个线程的函数调用、局部变量互不干扰——所以线程「共享地址空间、独立栈、独立寄存器/PC」这三句话是一体的。