进程、线程与协程
#系统 · #进程 · #线程 · #协程 · #fork · #task_struct · #调度
从 Linux 内核视角深入理解进程、线程、协程的概念、实现、对比,以及生产系统模型选型分析。
1. 进程
1.1 进程的本质
进程是正在运行的程序实例,提供两个核心抽象:
- 独立逻辑控制流:每个进程好像独占 CPU
- 私有地址空间:每个进程好像独占内存
1.2 Linux 中的进程表示
task_struct 是 Linux 内核中表示进程的核心数据结构:
task_struct
├── pid / tgid (进程ID / 线程组ID)
├── mm_struct *mm (地址空间描述符)
├── fs_struct *fs (文件系统信息)
├── files_struct *files (打开的文件表)
├── signal_struct *signal (信号处理)
├── thread_struct thread (CPU 上下文: 寄存器快照)
├── struct list_head tasks (链表节点)
└── ...1.3 进程的生命周期
mermaid
stateDiagram-v2
[*] --> Created: fork()
Created --> Ready: 调度器选中
Ready --> Running: CPU 分配
Running --> Ready: 时间片耗尽
Running --> Blocked: 等待I/O/信号
Blocked --> Ready: I/O完成/信号到达
Running --> Zombie: exit()
Zombie --> [*]: 父进程 wait()1.4 进程状态(Linux)
| 状态 | 标志 | 含义 |
|---|---|---|
| R | TASK_RUNNING | 正在运行或在运行队列中 |
| S | TASK_INTERRUPTIBLE | 可中断睡眠(等待事件) |
| D | TASK_UNINTERRUPTIBLE | 不可中断睡眠(通常等 I/O) |
| T | TASK_STOPPED | 被暂停(SIGSTOP) |
| Z | EXIT_ZOMBIE | 僵尸进程(已退出但未被回收) |
| X | EXIT_DEAD | 已完全释放 |
1.5 fork() 与 execve()
c
pid_t pid = fork();
if (pid == 0) {
// 子进程
execve("/bin/ls", argv, envp); // 用新程序替换
} else {
// 父进程
waitpid(pid, &status, 0); // 等待子进程
}写时复制(Copy-on-Write, COW):fork() 后父子进程共享物理页面,只有在某方写入时才复制。这是现代 Unix 的重要优化。
c
// fork 代码示例
int main() {
pid_t pid; int x = 1;
pid = fork();
if (pid == 0) { // 子进程
printf("child: x=%d\n", ++x); // x=2
return 0;
}
printf("parent: x=%d\n", --x); // x=0
// 两个进程的 x 是独立的 (COW 隔离)
waitpid(pid, NULL, 0);
return 0;
}1.6 进程调度
CFS(完全公平调度器,Linux 默认):
- 每个进程维护一个
vruntime(虚拟运行时间) - 红黑树按
vruntime排序 - 总是选择
vruntime最小的进程运行 - 目标:小任务优先完成,大任务也获得公平份额
vruntime += 实际运行时间 × (nice权重 / 基准权重)2. 线程
2.1 线程 vs 进程
| 维度 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 独立 | 共享(同一进程内) |
| 文件描述符 | 独立 | 共享 |
| 信号处理 | 独立 | 共享 |
| 创建开销 | 需复制 mm、files 等 | 只需新栈 + 寄存器 |
| 上下文切换 | 切换 mm,刷新 TLB | 切换寄存器即可 |
| 隔离性 | 强(崩溃不影响其他进程) | 弱(崩溃影响整个进程) |
2.2 Linux 线程实现:task_struct 的视角
Linux 不区分进程和线程,两者都是 task_struct。线程通过 clone() 系统调用创建,指定共享哪些资源:
c
// 创建进程 (类似 fork)
clone(SIGCHLD, 0);
// 创建线程 (共享地址空间、文件系统、文件表、信号)
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
// clone 的精细控制:
// 不加 CLONE_VM:独立地址空间 → "轻量级进程"
// 加 CLONE_VM:共享地址空间 → "线程"
// 加 CLONE_THREAD:放入同一线程组 (tgid 相同)内核视角:
pid= 线程 ID(每个 task_struct 唯一)tgid= 线程组 ID(等于主线程的 pid,即用户态看到的进程 ID)
2.3 三种线程模型
| 模型 | 描述 | 示例 |
|---|---|---|
| 1:1 | 每个用户线程对应一个内核线程 | Linux (NPTL)、Windows |
| N:1 | 多个用户线程映射到一个内核线程 | 早期 Java 绿线程、GNU Pth |
| M:N | M 个用户线程映射到 N 个内核线程 | Go 的 GMP、Erlang |
1:1 模型(Linux pthreads):
用户线程1 ─→ 内核线程1
用户线程2 ─→ 内核线程2
用户线程3 ─→ 内核线程3优点:真正的并行、阻塞不互相影响;缺点:创建/切换开销大(8MB 栈)。
M:N 模型(Go GMP):
goroutine1 ─┐
goroutine2 ─┤─→ OS线程1
goroutine3 ─┘
goroutine4 ────→ OS线程2优点:轻量、切换快(~几十 ns);缺点:调度复杂。
3. 协程
3.1 什么是协程
协程(Coroutine)是用户态的协作式调度单元。与线程最大的区别:
- 线程是抢占式调度(由 OS 决定何时切换)
- 协程是协作式调度(自己主动让出控制权)
3.2 有栈协程 vs 无栈协程
| 维度 | 有栈协程 (Stackful) | 无栈协程 (Stackless) |
|---|---|---|
| 代表 | Go goroutine、Boost.Coroutine、Lua coroutine | C++20 coroutine、Kotlin、JavaScript async/await、Rust async |
| 实现方式 | 每个协程有独立栈(通常~2KB起) | 编译器生成状态机(不分配额外栈) |
| 切换点 | 任意位置 | 只在 await 点 |
| 内存开销 | 每个协程几 KB 的栈 | 按需分配的状态结构体 |
| 切换方式 | 切换栈指针和寄存器 | 函数返回 + 状态机跳转 |
| suspend 语义 | 可从任意函数深度挂起 | 只能在 async 函数内挂起 |
| 代码要求 | 无特殊语法 | 需要 async/await 标注 |
有栈协程的栈布局(Go goroutine):
高地址
┌──────────────────────┐
│ goroutine 栈 │
│ (起始 ~2KB, │
│ 按需增长) │
│ ↓ │
│ stackguard (保护页) │
├──────────────────────┤
│ g 结构体 (G 对象) │
└──────────────────────┘
低地址无栈协程(C++20)的状态机:
cpp
// 编译器转换前
Task<int> compute() {
int a = co_await read();
int b = co_await read();
co_return a + b;
}
// 编译器转换后(伪代码)— 每个挂起点是一个状态
struct compute_frame {
int __state; // 0=初始, 1=await1, 2=await2
int a, b;
bool resume() { /* switch(__state) + goto */ }
};3.3 各语言协程对比
| 语言 | 类型 | 调度 | 特点 |
|---|---|---|---|
| Go | 有栈 | GMP (M:N) | 抢占式,栈自动增长 |
| C++20 | 无栈 | 无默认调度器 | 零开销抽象,需配合执行器 |
| Rust | 无栈 | 无默认调度器 | 编译期保证无数据竞争 |
| Kotlin | 无栈 | 结构化并发 | suspend 函数染色 |
| JavaScript | 无栈 | 事件循环 | 单线程,Promise/async-await |
| Python (asyncio) | 无栈 (生成器模拟) | 事件循环 | 基于生成器,单线程 |
| Erlang/Elixir | 有栈 | Actor 模型 | 抢占式,进程隔离 |
| Lua | 有栈 | 协作式 | 非常轻量 |
3.4 协程的适用场景
最适合:高并发 I/O(Web 服务器、数据库代理)、网络爬虫/微服务调用链、实时消息推送
不太适合:CPU 密集型计算(需要多核并行)、对延迟极其敏感的实时系统
4. 生产系统并发模型分析
4.1 主流系统一览
mermaid
graph TD
subgraph 进程模型
N1["Nginx (master+worker)"] --> P1["多进程 + epoll"]
PF["PHP-FPM"] --> P2["进程池(动态/静态)"]
PG["PostgreSQL"] --> P3["每连接一进程"]
end
subgraph 线程模型
MYSQL["MySQL"] --> T1["每连接一线程 + 线程池"]
RS_NEW["Redis 6.0+"] --> T4["IO 线程"]
TOMCAT["Tomcat"] --> T3["线程池"]
end
subgraph 协程/事件循环
GO["Go net/http"] --> C1["goroutine per conn"]
NODE["Node.js"] --> C2["单线程事件循环"]
NGX_LUA["OpenResty"] --> C3["Lua 协程"]
end| 系统 | 模型 | 为什么这样选 |
|---|---|---|
| Nginx | master + N worker 进程,每 worker 单线程 + epoll | 稳定性至上:一个 worker 崩溃不影响其他;单线程 epoll 无锁极致性能 |
| PHP-FPM | 进程池(pm.static/dynamic/ondemand) | PHP 无内置跨请求内存管理,进程天然隔离每个请求的内存;PHP Zend Engine 非线程安全 |
| PostgreSQL | 每连接 fork 一个子进程 | 历史原因(早期无可靠线程),强隔离,崩溃不影响其他连接 |
| MySQL (InnoDB) | 每连接一线程 + 后台 IO 线程 | 大量内存需共享(buffer pool),线程共享地址空间更高效 |
| Redis (早期) | 单线程 epoll 事件循环 | 单线程 = 零锁竞争 + 零上下文切换 + 逻辑简单。每个命令 O(1)/O(logN),瓶颈在网络 IO 而非 CPU |
| Redis 6.0+ | 主线程事件循环 + IO 线程 | TLS 加密的 CPU 开销成为瓶颈,将网络 IO 卸到线程,命令执行仍在主线程(无锁) |
| Go HTTP Server | 每连接一个 goroutine | 百万连接:每个 goroutine 仅 ~2KB,GOMAXPROCS 个 OS 线程承载 |
| Node.js | 单线程 libuv 事件循环 | 统一 JS 异步模型,无锁,回调/Promise 天然异步 |
4.2 演进路线图
mermaid
graph LR
A["1990s: 多进程<br/>(Apache prefork)"] -->|"C10K 问题"| B["2000s: 多路复用<br/>(Nginx, epoll)"]
B -->|"多核时代"| C["2010s: 多线程<br/>(MySQL, 线程池)"]
C -->|"C10M 问题"| D["协程/异步<br/>(Go, Node.js, Rust async)"]4.3 决策框架
| 场景 | 推荐模型 | 原因 |
|---|---|---|
| 强隔离需求(安全、稳定性) | 进程 | 崩溃不互相影响 |
| 大量共享状态 | 线程 | 天然共享地址空间,避免 IPC 开销 |
| 超大规模连接(C10K/C100K) | 协程/事件循环 | 每连接开销极低(几 KB vs 几 MB) |
| CPU 密集型(非 IO) | 线程(1:1) | 真正利用多核 |
| 短连接为主 | 进程池/线程池 | 避免频繁创建销毁 |
| 长连接,IO 密集 | 协程 | 百万连接,每连接成本低 |
| 混合型(微服务) | Go goroutine | 写起来像同步代码,跑起来是异步 |
5. 上下文切换深度分析
5.1 进程上下文切换
用户态 → 内核态 (中断/系统调用)
→ 保存当前进程上下文 (寄存器、PC、栈指针等)
→ 切换页表 (CR3寄存器) ← 最昂贵的操作!
→ 选择下一个进程 (调度器)
→ 恢复目标进程上下文
→ 内核态 → 用户态开销估算(现代 x86):
- 寄存器保存/恢复:~0.1μs
- 调度决策:~0.2-0.5μs
- 切换 CR3 + TLB flush:~0.5-2μs
- 总计:~1-3μs
5.2 线程切换
与进程切换相比,线程切换不切换页表(同一进程的线程共享 mm_struct),开销显著更小:~0.2-0.5μs。
5.3 协程切换(Go 为例)
保存当前 G:仅修改 g.sched (sp, pc, g 等)
切换到目标 G:恢复其 g.sched 中的寄存器状态不涉及:内核态切换、页表切换、系统调用
开销:~几十 ns(用户态几行汇编)。比线程切换快 ~100 倍,比进程切换快 ~500 倍。
6. 并发编程核心问题
6.1 竞态条件 (Race Condition)
两个或多个线程并发访问共享数据,且最终结果依赖于线程的执行时序。
6.2 死锁的四个必要条件
- 互斥:资源不能被共享
- 持有并等待:持有资源的同时等待其他资源
- 不可抢占:资源只能被持有者主动释放
- 循环等待:存在进程/线程间的资源等待环
破坏任一条件即可预防死锁。
go
// 经典死锁: 锁顺序不一致
var mu1, mu2 sync.Mutex
func goroutine1() {
mu1.Lock(); mu2.Lock() // 等 goroutine2 释放 mu2
// ...
mu2.Unlock(); mu1.Unlock()
}
func goroutine2() {
mu2.Lock(); mu1.Lock() // 等 goroutine1 释放 mu1 → 死锁!
// ...
mu1.Unlock(); mu2.Unlock()
}
// 修复: 统一锁顺序 (总是先 mu1 后 mu2)bash
# Linux 线程死锁分析
gdb -p <PID>
(gdb) thread apply all bt # 查看所有线程调用栈
# pthread_mutex_lock 处阻塞 → 可能的死锁6.3 同步原语
| 原语 | 特点 | 适用场景 |
|---|---|---|
| Mutex | 互斥锁,一次只有一个进入 | 临界区保护 |
| RWMutex | 读共享,写互斥 | 读多写少场景 |
| Semaphore | 计数器,用于资源上限 | 限流、连接池 |
| Condition Variable | 等待特定条件 | 生产者-消费者 |
| Channel | 通信为主,同步为辅 | CSP 模型(Go) |
6.4 上下文切换开销量化
理解不同并发原语的切换成本,是选择"进程 vs 线程 vs 协程"的根本依据:
mermaid
flowchart LR
subgraph Costs["上下文切换开销 (~数量级)"]
A["进程切换<br/>VM/TLB/CR3 全换<br/>~1-10 μs"] --> B["线程切换<br/>同进程内, 仅寄存器+栈<br/>~100 ns - 1 μs"]
B --> C["goroutine 切换<br/>用户态, 仅寄存器<br/>~10-50 ns"]
C --> D["函数调用<br/>仅 PC+SP 压栈<br/>~1 ns"]
end| 切换类型 | 延迟 | 操作 | 触发 |
|---|---|---|---|
| 进程切换 | ~1-10 μs | 虚拟地址空间(CR3)、TLB flush、寄存器、内核栈 | 时间片耗尽、I/O 阻塞 |
| 线程切换 (同进程) | ~100 ns - 1 μs | 寄存器、内核栈(不换地址空间) | 锁等待、I/O、时间片 |
| goroutine 切换 | ~10-50 ns | 仅保存/恢复寄存器(用户态完成) | channel 操作、系统调用、时间片 |
| 协程 (Python async) | ~100 ns | 事件循环内切换 | await 表达式 |
为什么 Go 能支撑百万 goroutine:每个 goroutine 初始栈仅 2KB(线程 ~1MB),且切换完全在用户态——100 万 goroutine × 2KB = 2GB 内存可行;而 100 万线程 × 1MB = 1TB 内存荒谬。 | Atomic | 无锁,硬件指令支持 | 简单计数器 |
7. 工程实践:进程/线程问题的排障
7.1 线上常见问题与排查工具
| 问题 | 现象 | 排查工具 | 关键命令 |
|---|---|---|---|
| 线程泄漏 | 线程数持续增长,最终 OOM 或 cannot create thread | /proc/PID/status | `cat /proc/PID/status |
| 进程僵尸 | ps 看到大量 Z 状态进程 | ps aux | `ps aux |
| CPU 100% | 某个线程死循环或自旋 | top -H -p PID | 找到 CPU 最高的线程 TID |
| 上下文切换过多 | 系统整体变慢,cs 值异常高 | vmstat, pidstat | pidstat -w -p PID 1 |
| 死锁 | 进程卡住不响应 | gdb, dlv, pprof | dlv attach PID → goroutines |
| fd 泄漏 | too many open files | /proc/PID/fd | `ls /proc/PID/fd |
7.2 上下文切换的量化诊断
bash
# 查看系统级上下文切换
vmstat 1
# 关注 cs 列(context switches/sec)
# 正常: 数千~数万/s; 异常: 数十万/s
# 查看进程级上下文切换
pidstat -w -p <PID> 1
# cswch/s: 自愿切换(I/O 等待、锁等待)
# nvcswch/s: 非自愿切换(时间片耗尽)
# 自愿切换高 → I/O 密集或锁竞争
# 非自愿切换高 → CPU 密集,P 不够用判断标准:
| 指标 | 正常范围 | 异常信号 |
|---|---|---|
| 系统 cs/s | < 50000 | > 100000 持续 |
| 进程 cswch/s | < 1000 | > 10000 |
| 进程 nvcswch/s | < 100 | > 1000 |
7.3 Go 服务的线程膨胀问题
Go 程序虽然用 goroutine,但底层仍然需要 OS 线程(M)。当 goroutine 执行阻塞系统调用时,Go runtime 会创建新线程:
mermaid
flowchart TD
A["goroutine 执行 syscall<br/>(如 CGO、文件 I/O)"] --> B["当前 M 被阻塞"]
B --> C["runtime 创建新 M<br/>来运行其他 goroutine"]
C --> D{"syscall 返回后<br/>M 是否被复用?"}
D -->|"是"| E["M 回到空闲池"]
D -->|"否 (超过 10000 个 M)"| F["panic: thread exhaustion"]线上案例:
text
场景: Go 服务通过 CGO 调用 C 库做 DNS 解析
→ 每次 DNS 查询阻塞 ~50ms
→ 高并发时大量 goroutine 同时阻塞
→ runtime 不断创建新线程
→ 线程数从 10 涨到 5000+
→ 内存暴涨(每个线程 ~8MB 栈)
→ 最终 OOM
解决:
1. 用纯 Go 的 DNS 解析器(不走 CGO)
2. 或限制并发 DNS 查询数(信号量)
3. 设置 runtime.SetMaxThreads() 提前告警7.4 僵尸进程的产生与处理
text
正常流程:
父进程 fork() → 子进程执行 → 子进程 exit()
→ 子进程变成 zombie (Z) → 父进程 wait() 回收 → 子进程彻底消失
僵尸产生:
父进程没有调用 wait() → 子进程一直是 zombie
→ 占用 PID 和 task_struct(不占内存/CPU)
→ PID 耗尽时无法创建新进程bash
# 查找僵尸进程
ps aux | awk '$8=="Z" {print}'
# 找到僵尸的父进程
ps -o ppid= -p <zombie_pid>
# 解决方案:
# 1. 让父进程正确 wait()
# 2. kill 父进程(僵尸会被 init/systemd 收养并回收)
kill <parent_pid>7.5 fd 泄漏的排查
bash
# 查看进程打开的 fd 数量
ls /proc/<PID>/fd | wc -l
# 查看 fd 类型分布
ls -la /proc/<PID>/fd | awk '{print $NF}' | sort | uniq -c | sort -rn
# 典型输出:
# 3000 socket:[...] ← 大量未关闭的连接
# 500 /tmp/xxx ← 未关闭的临时文件
# 10 /dev/null
# 查看 socket 详情
ss -tnp | grep <PID>
# 查看进程的 fd 上限
cat /proc/<PID>/limits | grep "Max open files"Go 服务 fd 泄漏的常见原因:
| 原因 | 表现 | 修复 |
|---|---|---|
| HTTP Response Body 未 Close | socket 处于 CLOSE_WAIT | defer resp.Body.Close() |
| 文件打开后未关闭 | fd 指向文件 | defer f.Close() |
| goroutine 泄漏持有连接 | goroutine 数持续增长 | 用 context 控制生命周期 |
| 连接池配置不当 | ESTABLISHED 连接过多 | 设置 MaxIdleConns |
参考
- CSAPP 第8章:异常控制流
- CSAPP 第12章:并发编程
- Linux Kernel:
include/linux/sched.h - Go GMP 调度模型
登录后即可发表评论 👇