进程、线程、协程区别
1. 核心结论
进程是资源分配单位,线程是 CPU 调度单位,协程是用户态调度的轻量执行单元。
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 调度者 | OS 内核 | OS 内核 | 用户态运行时或事件循环 |
| 资源隔离 | 强,独立地址空间 | 弱,共享进程地址空间 | 更弱,通常共享线程上下文 |
| 上下文切换 | 最重,涉及地址空间切换 | 中等,涉及内核线程切换 | 最轻,通常不进入内核 |
| 并行能力 | 可多核并行 | 可多核并行 | 单个线程内不能真正并行,多线程运行时可并行 |
| 通信方式 | IPC、管道、Socket、共享内存 | 共享内存、锁、队列 | Channel、await、事件循环、调度器 |
| 典型问题 | IPC 成本高 | 锁竞争、死锁、数据竞争 | 阻塞调用、调度饥饿、回调链路复杂 |
2. 进程
2.1 基础定义
进程是操作系统对一个正在运行程序的抽象。它拥有独立的 虚拟地址空间、文件描述符、信号处理器、权限、内存映射等资源。
可以粗略理解为:
进程 = 资源容器 + 至少一个线程
2.2 核心资源
一个进程通常包含:
-
代码段:程序指令。
-
数据段:全局变量、静态变量。
-
堆:动态分配内存,例如
malloc、new。 -
栈:线程调用栈,注意栈本质上属于线程。
-
文件描述符表:打开的文件、Socket。
-
虚拟地址空间:通过 MMU 映射到物理内存。
-
PCB:Process Control Block,内核维护的进程控制块。
2.3 特点
进程之间默认内存隔离,一个进程崩溃通常不会直接破坏另一个进程的地址空间。
但进程切换成本较高,因为可能涉及:
-
页表切换。
-
TLB 失效。
-
内核态切换。
-
CPU 缓存局部性下降。
3. 线程
3.1 基础定义
线程是进程内部的执行流,也是主流操作系统中实际参与 CPU 调度的基本单位。
多个线程共享同一个进程的:
-
堆内存。
-
全局变量。
-
文件描述符。
-
代码段。
-
进程权限。
但每个线程拥有自己的:
-
栈。
-
寄存器上下文。
-
程序计数器。
-
TCB,Thread Control Block。
3.2 线程切换
线程上下文切换通常需要保存和恢复:
寄存器
程序计数器
栈指针
线程状态
调度优先级
线程切换比进程轻,但仍然需要内核参与,所以成本高于协程。
3.3 线程的主要问题
线程共享内存,因此并发访问必须处理同步问题:
-
数据竞争。
-
锁竞争。
-
死锁。
-
活锁。
-
伪共享。
-
内存可见性问题。
例如 Java 中需要通过 synchronized、volatile、Lock、CAS、AQS 等机制保证线程安全。
class Counter {
private int count = 0;
public synchronized void incr() {
count++; // 注意此处需要加锁,否则多个线程并发写会丢失更新
}
}
4. 协程
4.1 基础定义
协程是用户态的轻量执行单元。它通常不直接由操作系统调度,而是由语言运行时、事件循环或协程调度器调度。
可以粗略理解为:
协程 = 可挂起、可恢复的函数执行上下文
协程的关键能力是:
-
执行到某个点可以 挂起。
-
之后可以从挂起点 恢复。
-
挂起和恢复一般不需要进入内核态。
4.2 协程与线程的关系
协程通常运行在线程之上:
进程
└── 线程
├── 协程 A
├── 协程 B
└── 协程 C
如果只有一个线程承载多个协程,那么这些协程是 并发,不是 并行。
如果运行时使用多个线程承载多个协程,例如 Go 的 GMP 模型,则多个协程可以在多核上并行执行。
4.3 有栈协程与无栈协程
| 类型 | 说明 | 代表 |
|---|---|---|
| 有栈协程 | 每个协程有独立调用栈,可以在深层调用中挂起 | Go goroutine、Lua coroutine |
| 无栈协程 | 编译器把函数改写成状态机,挂起点通常受语法限制 | JavaScript async/await、Rust async、C++20 coroutine、Kotlin coroutine |
4.4 协程为什么轻量
协程轻量主要来自三点:
-
用户态调度:切换不必频繁进入内核。
-
栈更小或按需增长:例如 Go goroutine 初始栈很小,可动态扩容。
-
挂起点明确:通常在
await、yield、Channel 阻塞、IO 等位置切换。
但协程不是银弹。若协程内部执行阻塞系统调用,可能阻塞底层线程,导致整个调度器吞吐下降。
5. 三者关系
5.1 层级模型
操作系统
└── 进程:资源隔离边界
└── 线程:内核调度单位
└── 协程:用户态调度单位
5.2 并发与并行
| 概念 | 含义 |
|---|---|
| 并发 | 多个任务在一段时间内交替推进 |
| 并行 | 多个任务在同一时刻真正同时执行 |
| 异步 | 发起操作后不阻塞当前执行流,结果未来返回 |
| 非阻塞 | 调用立即返回,不等待资源就绪 |
| 多路复用 | 一个线程监听多个 IO 事件,例如 epoll、kqueue、IOCP |
协程通常用于提升 高并发 IO 场景 的吞吐,例如网关、爬虫、RPC 服务、IM 服务。
CPU 密集任务仍然需要多进程或多线程并行利用多核。
6. 调度模型
6.1 1:1 模型
一个用户线程对应一个内核线程。
User Thread 1 -> Kernel Thread 1
User Thread 2 -> Kernel Thread 2
典型代表:
-
Java 传统
Thread。 -
C/C++
pthread。 -
Rust
std::thread。 -
Python
threading,但受GIL影响。
优点是模型简单,可以利用多核。
缺点是线程数量过多时,内核调度成本高。
6.2 N:1 模型
多个用户态协程映射到一个内核线程。
Coroutine 1
Coroutine 2 -> Kernel Thread 1
Coroutine 3
典型代表:
-
JavaScript 主线程事件循环。
-
Python
asyncio默认单线程事件循环。
优点是切换成本低。
缺点是一个阻塞调用可能阻塞整个线程,且单线程内不能真正并行。
6.3 M:N 模型
多个协程映射到多个内核线程。
Coroutine N -> Runtime Scheduler -> Kernel Thread M
典型代表:
-
Go goroutine。
-
Java Virtual Thread,底层由 ForkJoinPool 承载。
-
Erlang lightweight process。
-
部分 Fiber 运行时。
优点是兼顾轻量级和多核并行。
缺点是运行时调度器复杂,需要处理阻塞、抢占、栈管理、IO 事件集成。
7. 不同语言实现思路
7.1 Java
Java 传统 Thread 基本是 1:1 内核线程模型。
Thread t = new Thread(() -> {
System.out.println("run");
});
t.start();
简化调用链:
Thread.start()
-> start0()
-> JVM native
-> OS create thread
传统 Java 线程适合 CPU 密集和普通并发,但大量线程会带来较高内存与调度成本。
Java 21 引入正式版 Virtual Thread,属于轻量线程模型。它不是直接等价于 OS 线程,而是由 JVM 调度到少量 carrier thread 上运行。
Thread.startVirtualThread(() -> {
// 阻塞式写法,底层可由 JVM 挂起虚拟线程
doIo();
});
核心思路:
Virtual Thread -> JVM Scheduler -> Carrier Thread -> OS Thread
它的目标是让开发者继续写阻塞式代码,同时获得接近协程的高并发能力。
7.2 Go
Go 使用 goroutine,核心是 GMP 调度模型。
| 组件 | 含义 |
|---|---|
G |
goroutine,用户态执行单元 |
M |
machine,内核线程 |
P |
processor,调度上下文,持有本地运行队列 |
模型:
G -> P -> M -> CPU
Go 的特点:
-
goroutine 初始栈较小,可动态扩缩。
-
runtime 调度器支持抢占。
-
网络 IO 与 runtime netpoller 集成。
-
Channel 是主要通信机制。
go func() {
// 新建一个 goroutine
fmt.Println("hello")
}()
Go 的实现思路不是简单事件循环,而是运行时层面的 M:N 调度。
7.3 Python
Python 有两套常见并发模型。
第一种是 threading,使用 OS 线程,但 CPython 有 GIL:
Python Thread -> OS Thread
GIL 使得同一时刻通常只有一个线程执行 Python 字节码。因此 Python 多线程适合 IO 密集,不适合纯 Python CPU 密集并行。
第二种是 asyncio,典型无栈协程加事件循环:
import asyncio
async def task():
await asyncio.sleep(1) # 注意此处挂起协程,不阻塞整个线程
asyncio.run(task())
核心思路:
async def -> coroutine object
await -> yield control
event loop -> resume coroutine
7.4 JavaScript
JavaScript 主流运行环境采用 单线程事件循环 + Promise 微任务队列。
async function task() {
await fetch("/api"); // 注意此处挂起 async 函数,控制权返回事件循环
}
核心结构:
Call Stack
Event Loop
Macro Task Queue
Micro Task Queue
Web APIs / libuv
浏览器和 Node.js 都可以通过底层线程池处理部分 IO 或计算,但 JS 主执行栈通常是单线程。
所以 JavaScript 的 async/await 本质上不是创建线程,而是把异步流程改写成 Promise 状态机。
7.5 Kotlin
Kotlin coroutine 是典型的 无栈协程 + 编译器状态机 + Dispatcher 调度。
suspend fun task() {
delay(1000) // 注意此处挂起协程,不阻塞线程
}
核心思路:
suspend function -> Continuation
挂起点 -> 保存状态
Dispatcher -> 选择线程恢复执行
Kotlin 的协程不是 OS 线程,它可以运行在不同的 Dispatcher 上,例如:
-
Dispatchers.Default:CPU 密集。 -
Dispatchers.IO:阻塞 IO。 -
Dispatchers.Main:UI 线程。
7.6 Rust
Rust async 是 无栈协程 + Future + Executor poll 模型。
async fn task() {
do_io().await; // 注意此处生成状态机,由 executor 驱动
}
核心思路:
async fn -> Future
Future::poll()
Poll::Pending -> 挂起
Waker -> 唤醒
Executor -> 再次 poll
Rust 标准库定义了 Future 抽象,但不内置完整运行时。实际常用运行时包括 Tokio、async-std 等。
7.7 C++20
C++20 coroutine 是语言层面的协程机制,但标准库没有提供统一调度器。
核心思路:
coroutine function
-> coroutine frame
-> promise_type
-> co_await / co_yield / co_return
C++20 提供的是底层构建块,真正的事件循环、调度器、IO 集成通常由框架实现。