03|线程与并发
进程是资源分配的单位,线程是调度的单位。理解线程,是写好并发程序的第一步。本章将带你从进程内部的执行流出发,掌握线程模型、协程以及并发与并行的核心区别。
📋 开篇自测:你已经知道多少?
- 线程和进程的核心区别是什么?为什么说”线程是轻量级的进程”?
- 用户态线程和内核态线程各自的优缺点是什么?
- 并发和并行有什么区别?单核 CPU 上能实现并行吗?
一、为什么需要线程
1.1 进程的局限
上一章我们学了进程,进程很好,但它有一个本质性的”重”——每个进程都有独立的地址空间。
想象一个 Web 服务器,每来一个请求就 fork() 一个子进程来处理。如果同时有 1000 个请求,就需要 1000 个进程。每个进程都有自己的页表、文件描述符表、信号处理表…内存开销巨大,进程间切换也需要切换整个地址空间。
而且,同一个程序的多个进程之间共享数据非常麻烦——因为地址空间隔离,它们必须通过管道、共享内存、消息队列等 IPC 机制来通信(第 4 章详述)。
1.2 线程:共享地址空间的执行流
线程的出现就是为了解决这个问题。同一进程内的多个线程共享地址空间和大部分资源,每个线程只需要维护自己的:
进程的资源(所有线程共享) 每个线程私有的
+----------------------------+ +-------------------+
| 代码段 (Text) | | 线程 ID (TID) |
| 数据段 (Data/BSS) | | 栈 (Stack) |
| 堆 (Heap) | | 寄存器组 |
| 打开的文件描述符 | | 程序计数器 (PC) |
| 信号处理设置 | | 栈指针 (SP) |
| 当前工作目录 | | 线程局部存储 (TLS) |
| 用户 ID / 组 ID | | errno 值 |
+----------------------------+ +-------------------+
用图来看更直观:
进程 P
+----------------------------------------------+
| |
| 共享地址空间 |
| +--------+--------+--------+---------+ |
| | 代码段 | 数据段 | 堆 | 文件表 | |
| +--------+--------+--------+---------+ |
| |
| 线程1 线程2 线程3 |
| +------+ +------+ +------+ |
| | 栈1 | | 栈2 | | 栈3 | |
| | PC1 | | PC2 | | PC3 | |
| | 寄存 | | 寄存 | | 寄存 | |
| | 器1 | | 器2 | | 器3 | |
| +------+ +------+ +------+ |
| |
+----------------------------------------------+
1.3 进程 vs 线程:成本对比
| 维度 | 进程 | 线程 |
|---|---|---|
| 创建成本 | 高(复制页表等结构) | 低(只需分配栈和少量元数据) |
| 切换成本 | 高(切换地址空间、刷新 TLB) | 低(共享地址空间,无需切换页表) |
| 通信成本 | 高(需 IPC 机制) | 低(直接读写共享内存) |
| 隔离性 | 强(一个崩溃不影响其他) | 弱(一个线程崩溃整个进程退出) |
| 内存开销 | 大(独立地址空间) | 小(共享地址空间) |
| 编程难度 | 较低 | 较高(需处理同步问题) |
线程的”轻”是有代价的——共享意味着需要同步。如果两个线程同时修改同一个全局变量而没有任何保护,就会产生数据竞争(Data Race),导致不可预测的结果。
🤔 想一想 既然线程比进程轻量,为什么 Chrome 浏览器选择用多进程而不是多线程来隔离标签页?
二、线程的实现模型
线程的实现方式决定了它的性能和灵活性。历史上出现了三种主要的线程模型。
2.1 用户态线程(多对一模型,N:1)
线程完全由用户空间的线程库管理,内核对线程的存在毫不知情,在内核看来只有一个进程。
用户空间
+------------------+
| 线程库 |
| +----+ +----+ |
| | T1 | | T2 | |
| +----+ +----+ |
| +----+ +----+ |
| | T3 | | T4 | |
| +----+ +----+ |
+--------+---------+
|
| N:1 映射
|
+--------+---------+
| 内核线程 (1个) |
+------------------+
内核空间
优势:
- 线程切换不需要进入内核,极快(通常只需几十纳秒)
- 可以在不支持线程的操作系统上实现
- 线程调度策略可以自定义
劣势:
- 一个线程阻塞(如 I/O),整个进程都会阻塞
- 无法利用多核 CPU(因为内核只看到一个执行实体)
2.2 内核态线程(一对一模型,1:1)
每个用户线程对应一个内核线程,线程的创建、调度、同步全部由内核完成。
用户空间
+------------------+
| +----+ +----+ |
| | T1 | | T2 | |
| +----+ +----+ |
| +----+ +----+ |
| | T3 | | T4 | |
| +--+---+--+--+ |
| | | |
| 1:1映射 |
| | | |
+--+-+---+--+--+---+
| KT1 KT2 KT3 KT4 |
+--------------------+
内核空间
优势:
- 一个线程阻塞不影响其他线程
- 可以在多核 CPU 上真正并行执行
劣势:
- 线程创建和切换需要系统调用,开销较大
- 内核线程数量有限制(Linux 默认数万个)
Linux 采用的就是 1:1 模型。Linux 中的线程其实就是共享地址空间的”轻量级进程”——内核用 clone() 系统调用创建,和进程共享同一个 task_struct 数据结构。
2.3 混合模型(多对多模型,M:N)
将 N 个用户线程映射到 M 个内核线程(M <= N),兼顾两者的优势。
用户空间
+------------------------+
| +----+ +----+ +----+ |
| | T1 | | T2 | | T3 | |
| +----+ +----+ +----+ |
| +----+ +----+ +----+ |
| | T4 | | T5 | | T6 | |
| +--+--+-+--+-+--+---+ |
| \ | |/ | |
| M:N 映射 |
| / | |\ | |
+--+-+--+-+--+-+-+--+--+
| KT1 KT2 KT3 |
+------------------------+
内核空间
Go 语言的 goroutine 运行时就是 M:N 模型的典型实现——数千个 goroutine 被调度到少量的操作系统线程上执行。
2.4 Linux 中的 NPTL
Linux 现代的线程实现叫做 NPTL(Native POSIX Threads Library)。它是 1:1 模型,每个 pthread 对应一个内核 task_struct。
#include <pthread.h>
#include <stdio.h>
void* thread_func(void* arg) {
int id = *(int*)arg;
printf("Thread %d running, TID = %ld\n", id, pthread_self());
return NULL;
}
int main() {
pthread_t threads[4];
int ids[4] = {0, 1, 2, 3};
for (int i = 0; i < 4; i++) {
pthread_create(&threads[i], NULL, thread_func, &ids[i]);
}
for (int i = 0; i < 4; i++) {
pthread_join(threads[i], NULL);
}
printf("All threads finished.\n");
return 0;
}
// 编译: gcc -pthread thread_demo.c -o thread_demo
# 查看一个进程的线程
ps -T -p <pid>
# 或者在 /proc 文件系统中查看
ls /proc/<pid>/task/
# 每个子目录代表一个线程,目录名就是线程 ID
三、线程的生命周期
3.1 线程状态
线程的状态转换和进程类似,但更加简单:
pthread_create()
|
v
+----------+
| 就绪态 |<---------+
+----+-----+ |
| |
| 获得CPU | 时间片用完/被抢占
v |
+----------+ |
| 运行态 |----------+
+----+-----+
| \
| \ pthread_exit() / return
等待事件 \
| v
v +-----------+
+----------+| 终止态 |
| 阻塞态 |+-----------+
+----------+
|
| 事件完成
v
回到就绪态
3.2 线程的创建与终止
pthread_create() 创建线程
pthread_exit() 线程主动退出
pthread_cancel() 请求取消另一个线程
pthread_join() 等待指定线程结束 (类似进程的 wait)
pthread_detach() 将线程设为分离状态 (结束后自动回收资源)
关于 join 和 detach 的选择:
方式一: join (汇合)
主线程 子线程
| |
| create() |
|-------------------->|
| | 执行任务...
| join() 阻塞等待 |
| ... |
| | exit()
|<--------------------+
| 获得返回值
v
方式二: detach (分离)
主线程 子线程
| |
| create() |
|-------------------->|
| detach() | 执行任务...
| |
| 继续做其他事 |
| | exit()
v | (资源自动回收)
🤔 想一想 如果一个线程既不 join 也不 detach,会发生什么?这和进程中的僵尸进程有什么相似之处?
四、协程:比线程更轻的执行流
4.1 线程的瓶颈
虽然线程比进程轻量,但内核态线程仍然有不小的开销:
- 默认每个线程栈 8MB(Linux glibc 默认值,实际取决于
ulimit -s配置) - 创建/销毁需要系统调用
- 上下文切换需要进入内核(约几微秒)
- 系统能支持的线程数有限(通常几万个)
当你的服务器需要同时处理十万甚至百万级别的连接时,一个连接一个线程的模型就不可行了——这就是经典的 C10K 问题(如何在单机上处理一万个并发连接)。
4.2 协程的概念
**协程(Coroutine)**是用户态的轻量级执行流,由程序自身(而非内核)调度。
线程 vs 协程:
内核线程:
+---------+ +---------+ +---------+
| Thread1 | --> | Thread2 | --> | Thread3 | 由内核调度
+---------+ +---------+ +---------+ 每个栈 8MB (glibc 默认)
系统调用切换
协程:
+---------+ +---------+ +---------+ +---------+
|Corout.1 | |Corout.2 | |Corout.3 | |Corout.4 | 由运行时调度
+---------+ +---------+ +---------+ +---------+ 每个栈 2-8KB
\ | / / 用户态切换
\ | / /
v v v v
+---------------------------------+
| 操作系统线程 (少量, 如4个) |
+---------------------------------+
4.3 协程的核心特征
| 特征 | 说明 |
|---|---|
| 用户态调度 | 不需要系统调用,切换开销极小(几十纳秒) |
| 栈极小 | 通常只有 KB 级别,可动态增长 |
| 协作式调度 | 协程主动让出执行权(yield),而非被强制抢占 |
| 数量庞大 | 一个进程中可以创建数百万个协程 |
4.4 主流语言的协程实现
语言 协程名称 调度模型 栈大小
Go goroutine M:N (GMP) 2KB (可增长)
Python async/await 单线程事件循环 无栈协程 (受GIL限制,无法利用多核并行;Python 3.13+ 引入实验性 free-threaded 模式可选去除 GIL)
JavaScript async/await 单线程事件循环 无栈协程
Rust async/await 基于 Future 无栈协程
Java (21+) 虚拟线程 M:N 动态
Kotlin coroutine 用户态调度 无栈协程
注意:Linux 的 io_uring 是内核异步 I/O 框架,不是协程实现。它可以与协程配合使用(如 C++20 coroutines + io_uring),但本身不属于协程机制。
4.5 Go 的 GMP 模型
Go 的 goroutine 是协程最成功的工业实现之一。它的调度模型叫做 GMP:
G = Goroutine (协程,可以有数百万个)
M = Machine (操作系统线程,通常和 CPU 核数相当)
P = Processor (逻辑处理器,管理 G 的本地队列)
全局队列 (Global Queue)
[G7] [G8] [G9] ...
|
+----------+-----------+
| |
+----+----+ +----+----+
| P0 | | P1 |
| 本地队列 | | 本地队列 |
| [G1][G2]| | [G4][G5]|
+----+----+ +----+----+
| |
+----+----+ +----+----+
| M0 | | M1 |
| (OS线程)| | (OS线程)|
| 正在执行 | | 正在执行 |
| G0 | | G3 |
+---------+ +---------+
| |
+----+----+ +----+----+
| CPU核0 | | CPU核1 |
+---------+ +---------+
当一个 goroutine 发起系统调用(如文件 I/O)阻塞时,Go 运行时会将当前 M 与 P 解绑,把 P 交给一个空闲的 M 继续执行其他 goroutine。这样就避免了因为一个 goroutine 阻塞导致整个线程被占用的问题。
五、并发 vs 并行
5.1 概念辨析
这两个词经常被混用,但它们的含义完全不同:
并发(Concurrency):多个任务在时间上交替执行,看起来像是同时进行的。重点是”结构”——程序被组织成可以独立推进的多个部分。
并行(Parallelism):多个任务在同一时刻真正同时执行。需要多个 CPU 核心的物理支持。
并发(单核CPU):
CPU: |--T1--|--T2--|--T1--|--T3--|--T2--|--T1--|-->
时间轴: -------->
多个任务快速切换,宏观上"同时"进行
并行(多核CPU):
CPU0: |----T1----|----T1----|----T1----|-->
CPU1: |----T2----|----T2----|----T2----|-->
CPU2: |----T3----|----T3----|----T3----|-->
时间轴: -------->
多个任务物理上同一时刻执行
Rob Pike(Go 语言之父)有一句经典的话:
“Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.” “并发是关于同时处理多件事。并行是关于同时做多件事。“
5.2 并发不一定需要并行
一个单核 CPU 上的 Web 服务器,可以通过并发模型同时处理数千个请求——当一个请求在等待数据库响应时,CPU 切换去处理另一个请求。虽然任何时刻只有一个请求在执行,但宏观上看所有请求都在推进。
而一个视频编码程序,把视频帧分到 8 个 CPU 核上同时编码,这才是并行。
5.3 四种并发/并行的组合
并行
No Yes
+------------+------------+
并 | 单线程同步 | 多核但 |
发 No | 程序 | 无并发结构 |
| (顺序执行) | (少见) |
+------------+------------+
| 单核多线程 | 多核多线程 |
Yes| 或事件循环 | 真正并发 |
| (并发无并行) | + 并行 |
+------------+------------+
5.4 并发编程模型
当前主流的并发编程模型有几种:
共享内存模型:多个线程通过读写共享变量来通信,需要锁来保护(Java, C/C++, Python threading)。
Thread1 Thread2
| |
| lock(mutex) |
| counter++ | lock(mutex) -- 阻塞等待
| unlock(mutex) |
| | counter++
| | unlock(mutex)
消息传递模型:执行实体之间通过发送消息来通信,不共享内存(Go channels, Erlang, Actor model)。
Goroutine1 Goroutine2
| |
| data := process() |
| ch <- data 发送 | result := <-ch 接收
| | use(result)
事件驱动模型:单线程通过事件循环处理所有任务,遇到 I/O 就注册回调然后继续处理下一个事件(Node.js, nginx)。
事件循环:
while true:
event = poll_events() # 获取下一个就绪事件
handler = lookup(event) # 查找对应的处理函数
handler(event) # 执行处理(必须是非阻塞的)
🤔 想一想 Node.js 是单线程的,但它能处理高并发。Nginx 也是少量 worker 进程就能处理数万连接。它们靠的是什么?
六、多线程编程的陷阱
6.1 数据竞争
当两个线程同时访问同一个变量,且至少有一个是写操作,如果没有同步机制,就会发生数据竞争:
// 危险的代码!
int counter = 0;
void* increment(void* arg) {
for (int i = 0; i < 1000000; i++) {
counter++; // 不是原子操作!
}
return NULL;
}
// 两个线程同时运行 increment
// 预期结果: 2000000
// 实际结果: 可能是 1234567 或其他随机值
counter++ 看起来是一条语句,但在 CPU 层面它分为三步:
1. 从内存读取 counter 到寄存器 (LOAD)
2. 寄存器值加 1 (ADD)
3. 将结果写回内存 (STORE)
线程A: LOAD(0) -> ADD(1) -> STORE(1)
线程B: LOAD(0) -> ADD(1) -> STORE(1)
结果: counter = 1 (丢失了一次加法!)
6.2 死锁的四个必要条件
死锁是多线程编程中最棘手的问题之一:
线程A: 线程B:
lock(mutex1) lock(mutex2)
... ...
lock(mutex2) -- 等待B释放 lock(mutex1) -- 等待A释放
... ...
unlock(mutex2) unlock(mutex1)
unlock(mutex1) unlock(mutex2)
--> 永远等下去,死锁!
死锁发生的四个必要条件(第 4 章详细讨论):
- 互斥:资源不能被共享
- 持有并等待:持有资源的同时等待其他资源
- 非抢占:资源只能被持有者主动释放
- 循环等待:存在一个进程/线程的环形等待链
6.3 线程安全的思路
- 不共享:每个线程使用自己的局部变量
- 不可变:共享的数据设为只读
- 同步保护:使用互斥锁、读写锁、原子操作等(第 4 章详细展开)
- 消息传递:通过 channel 而非共享内存通信
七、动手实验
7.1 观察线程
# 查看一个 Java 程序的线程数
ps -T -p $(pgrep java) | wc -l
# 查看线程详情
top -H -p $(pgrep java)
# 查看系统允许的最大线程数
cat /proc/sys/kernel/threads-max
# 查看单个进程允许的最大线程数
ulimit -u
7.2 线程 vs 进程创建性能对比
# 用 time 命令测试创建 10000 个进程的耗时
time python3 -c "
import os
for i in range(10000):
pid = os.fork()
if pid == 0:
os._exit(0)
os.wait()
"
# 对比创建 10000 个线程的耗时
time python3 -c "
import threading
threads = []
for i in range(10000):
t = threading.Thread(target=lambda: None)
t.start()
threads.append(t)
for t in threads:
t.join()
"
你会发现线程的创建速度明显快于进程。
八、本章总结
+----------------------------------------------------+
| 线程与并发核心知识 |
+----------------------------------------------------+
| |
| 线程 = 共享地址空间的执行流 |
| 共享: 代码段、数据段、堆、文件表 |
| 私有: 栈、寄存器、PC、TLS |
| |
| 实现模型: |
| N:1 用户态线程 (快但不能利用多核) |
| 1:1 内核线程 (Linux NPTL, 可多核) |
| M:N 混合模型 (Go goroutine) |
| |
| 协程 = 用户态调度 + 极小栈 + 协作式 |
| |
| 并发 != 并行 |
| 并发: 结构上可交替执行 |
| 并行: 物理上同时执行 |
| |
| 并发模型: 共享内存 | 消息传递 | 事件驱动 |
| |
+----------------------------------------------------+
📝 结尾自测:检验你的收获
- 同一进程内的线程共享哪些资源?各自独有哪些资源?
- 用户态线程、内核态线程、混合线程模型各自的优缺点是什么?
- 协程和线程的核心区别是什么?为什么协程可以创建数百万个?
- 并发和并行的区别是什么?画图说明单核并发和多核并行的不同。
- 为什么 counter++ 在多线程环境下不安全?它在 CPU 层面分为哪几步?
购买课程解锁全部内容
系统底层入门:10 章掌握操作系统核心
¥29.90