Skip to content

进程、线程与协程 ​

#系统 · #进程 · #线程 · #协程 · #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) ​

状态标志含义
RTASK_RUNNING正在运行或在运行队列中
STASK_INTERRUPTIBLE可中断睡眠(等待事件)
DTASK_UNINTERRUPTIBLE不可中断睡眠(通常等 I/O)
TTASK_STOPPED被暂停(SIGSTOP)
ZEXIT_ZOMBIE僵尸进程(已退出但未被回收)
XEXIT_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:NM 个用户线程映射到 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 coroutineC++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
系统模型为什么这样选
Nginxmaster + 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 死锁的四个必要条件 ​

  1. 互斥:资源不能被共享
  2. 持有并等待:持有资源的同时等待其他资源
  3. 不可抢占:资源只能被持有者主动释放
  4. 循环等待:存在进程/线程间的资源等待环

破坏任一条件即可预防死锁。

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, pidstatpidstat -w -p PID 1
死锁进程卡住不响应gdb, dlv, pprofdlv 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 未 Closesocket 处于 CLOSE_WAITdefer resp.Body.Close()
文件打开后未关闭fd 指向文件defer f.Close()
goroutine 泄漏持有连接goroutine 数持续增长用 context 控制生命周期
连接池配置不当ESTABLISHED 连接过多设置 MaxIdleConns

参考 ​

  • CSAPP 第8章:异常控制流
  • CSAPP 第12章:并发编程
  • Linux Kernel: include/linux/sched.h
  • Go GMP 调度模型
批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记