计算机系统基础-08:并发
并发编程
并发
并发指多个逻辑控制流在时间上重叠执行。它出现在硬件异常处理、进程、Unix signal handler、线程和网络服务器等场景中。
应用层并发的常见用途:
- 响应异步事件。
- 在多处理器上并行计算。
- 访问慢速 I/O 设备时隐藏等待时间。
- 与用户交互。
- 同时服务多个网络 client。
并发程序常见实现方式:
| 方式 | 特点 |
|---|---|
| Process | 地址空间隔离,通信需要 IPC |
| Thread | 共享同一进程地址空间,通信方便但容易产生 race |
| I/O multiplexing | 单线程管理多个 I/O 事件,本部分不展开 |
并发程序的主要难点是需要考虑多种可能的执行交错。常见错误包括 data race、deadlock、livelock 和 starvation。
Iterative Server
Iterative echo server 一次只服务一个 client:
1 | int main(int argc, char **argv) |
流程:
- Server 在
accept中等待连接。 - Client 连接成功后,server 调用
echo(connfd)。 echo持续处理该 client,直到 client 关闭连接。- Server 才能回到
accept接收下一个 client。

问题:若 client 1 已连接但长时间不发送数据,server 会阻塞在读取 client 1 的数据上,client 2 即使发起连接也无法被及时服务。
解决思路:使用 concurrent server,为不同 client 创建不同控制流,使多个连接可以重叠处理。
基于 Process 的并发服务器
基于 process 的并发服务器为每个 client fork 一个 child process:
1 | void sigchld_handler(int sig) |
执行流程:
- Parent process 在
accept中等待连接。 - 某个 client 连接后,
accept返回connfd。 - Parent 调用
fork创建 child。 - Child 关闭自己的
listenfd,使用connfd服务该 client。 - Parent 关闭自己的
connfd,继续回到accept。

为什么 parent 必须关闭
connfd?
fork后,parent 和 child 都持有同一个 connected socket 的引用。若 parent 不关闭自己的connfd,即使 child 结束并关闭connfd,内核中的 socket 引用计数仍不为 0,连接无法按预期释放。
1
2
3
4
5 After fork:
refcnt(connfd) = 2
child close(connfd) -> refcnt = 1
parent close(connfd) -> refcnt = 0,连接可关闭
三个
Close是否都必须保留?Process-based concurrent server 中通常有三个
Close:
1
2
3
4
5
6
7
8
9
10 if (Fork() == 0) {
/* 子进程不接受新连接,关闭继承的监听描述符。 */
Close(listenfd);
echo(connfd);
/* 子进程完成服务后关闭自己的连接描述符副本。 */
Close(connfd);
exit(0);
}
/* 父进程不处理该连接,关闭自己的 connfd 副本。 */
Close(connfd);解析:
- Child 中的
Close(listenfd):不是功能正确性的必要条件。Child 即使保留listenfd,只要不调用accept,就不会接收新连接;并且 childexit时 OS 会关闭其描述符。但关闭它更清晰,可避免无关 descriptor 留在 child 的 descriptor table 中。- Child 中的
Close(connfd):若后面立即exit(0),从功能上也可由 OS 在进程退出时关闭。但显式关闭能表达连接服务结束,也避免未来修改代码时形成泄漏。- Parent 中的
Close(connfd):必须保留。Parent 是 long-running server,不会很快退出;若不关闭自己的connfd,每次连接都会在 parent descriptor table 中留下一个无用引用,使 descriptor 和内核 socket 资源长期无法释放。因此,前两个
Close主要是清理 child 不需要的资源;parent 的Close(connfd)是长期运行 server 中避免 resource leak 的关键。
第一次
accept为什么常返回connfd = 4?Unix descriptor 按进程维护,通常从最小空闲编号分配。普通进程启动后,
0/1/2分别是stdin/stdout/stderr;open_listenfd创建 listening socket 后通常占用3。第一次accept会创建新的 connected socket,因此常返回最低空闲 descriptor4。
fork会复制 parent 的 descriptor table。复制后 parent 和 child 的listenfd、connfd指向相同的内核 file/socket object,对应引用计数增加。Close只删除当前进程 descriptor table 中的一项,并使对应内核对象引用计数减一;引用计数降到 0 时,内核对象才会释放。
为什么需要回收 child?
Child 退出后会成为 zombie。Parent 需要处理
SIGCHLD并调用waitpid回收,否则 zombie 会保留内核资源。
waitpid(-1, 0, WNOHANG)的含义:
-1:回收任意 child。WNOHANG:没有可回收 child 时立即返回。while:一次 signal 可能对应多个 child 退出,需要循环回收。
Process 方案的优缺点:
优点:
- 可同时处理多个连接。
- 地址空间隔离,默认不共享全局变量,错误隔离较清晰。
- 编程模型直接。
缺点:
- Process 创建和回收开销较高。
- 进程间共享数据不方便,需要 IPC,例如 FIFO、System V shared memory 等。
- 若要统计所有 client 的总接收字节数,不能直接用普通全局变量完成。
Thread 模型
传统视角下,process 包含 process context、code、data 和 stack。另一种视角下,process 可看作 thread 加上 code、data 和 kernel context。

一个 process 可以包含多个 thread。每个 thread 有:
- 独立逻辑控制流。
- 独立 thread context,包括 TID、寄存器、栈指针、程序计数器、条件码。
- 独立 thread stack。
同一 process 内的 thread 共享:
- code segment。
- data segment。
- heap。
- shared libraries。
- open files。
- installed signal handlers。
- kernel context 中的地址空间与描述符表。

同一个进程中的线程像是一个“线程池”里的同级成员;processes 通常形成父子层级。

Pthreads 基本接口
Pthreads 是 POSIX 线程接口,常用函数包括:
| 函数 | 作用 |
|---|---|
pthread_create |
创建 thread |
pthread_join |
等待 joinable thread 结束并回收 |
pthread_self |
获取当前 thread ID |
pthread_cancel |
请求取消某个 thread |
pthread_exit |
结束当前 thread |
exit |
结束整个 process,包括所有 threads |
Hello world 示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 void *thread(void *vargp)
{
/* thread 入口函数必须返回 void *。 */
printf("Hello, world!\n");
return NULL;
}
int main()
{
pthread_t tid;
/* 创建一个新线程执行 thread(NULL)。 */
Pthread_create(&tid, NULL, thread, NULL);
/* 主线程等待 tid 对应线程结束。 */
Pthread_join(tid, NULL);
exit(0);
}
流程:
- Main thread 调用
Pthread_create创建 peer thread。- Peer thread 执行
thread。- Main thread 调用
Pthread_join等待 peer thread 结束。- Peer thread
return NULL等价于隐式pthread_exit(NULL)。Pthread_join返回后,main thread 调用exit结束进程。
基于 Thread 的并发服务器
Thread-per-connection server 为每个连接创建一个 thread:
1 | int main(int argc, char **argv) |
关键点:
connfdp需要动态分配,确保每个 thread 得到独立参数。- Thread routine 复制
connfd后立即Free(vargp)。 - Thread 调用
pthread_detach(pthread_self()),结束时自动释放资源。
为什么 server thread 要 detached?
Thread 有两种状态:
- joinable:其他 thread 可通过
pthread_join回收;若不 join,会造成资源未释放。- detached:结束后资源自动回收,不能被 join。
Server 通常不逐个 join worker thread,因此 worker 应设为 detached。
错误示例:
1
2
3
4
5 while (1) {
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
/* 错误:每个线程都拿到同一个栈变量 connfd 的地址。 */
Pthread_create(&tid, NULL, thread, &connfd);
}
connfd是 main thread 栈上的一个变量,循环下一次accept可能覆盖它。多个 worker thread 可能读到同一个地址中的新值,导致连接描述符混乱。正确做法:为每个连接分配独立
int,或用能安全承载整数的intptr_t做值传递。直接把int强转为void *在 64-bit 平台上不可移植。
为什么传
&connfd会产生 data race?
connfd是 main thread 栈上的同一个局部变量。Main thread 第一次accept后可能得到4,创建 worker thread 后立刻进入下一轮accept,并把同一内存位置覆盖为5。若第一个 worker 还没来得及读取*connfdp,它也可能读到5。该错误不是因为 descriptor 本身不能共享,而是因为多个 thread 共享了同一个参数存放位置。动态分配
int的作用是让每个 worker 拥有不同地址;worker 复制出connfd后立即Free(vargp),即可避免参数位置被下一轮循环覆盖。
如何比较
pthread_create和fork的开销?可以分别循环创建并回收大量 thread/process,记录总时间并除以次数。测量时应注意:
- 每轮都要包含回收成本,例如
pthread_join或waitpid。- 子任务内容应尽量为空,避免业务逻辑掩盖创建成本。
- 需要预热和重复测量,观察均值和波动。
- 创建开销只是维度之一;process 还提供地址空间隔离,thread 则提供更低成本的数据共享。
Threads & Processes
相同点:
- 都有独立逻辑控制流。
- 都可并发执行,也可能运行在不同 core 上。
- 都会发生 context switch。
不同点:
| 对比项 | Process | Thread |
|---|---|---|
| 地址空间 | 通常独立 | 同一 process 内共享 |
| 数据共享 | 需要 IPC | 可直接共享全局数据和 heap |
| 创建与回收开销 | 较高 | 较低 |
| 错误隔离 | 较强 | 较弱 |
Thread 方案的优点是共享数据方便、开销较低;缺点是 unintended sharing 可能引入难以复现的错误。
Thread Memory Model
概念模型:
- 每个 thread 运行在同一个 process context 中。
- 每个 thread 有自己的 thread context。
- 所有 threads 共享 process virtual address space 中除 thread stack 以外的区域。
实际模型中,寄存器值是隔离的,但一个 thread 可以读写另一个 thread 的 stack,只要它获得相应地址。因此“每个 thread 的 stack 私有”是编程约定,不是硬件强制隔离。
Shared Variable 判定
变量实例与内存位置有关:
| 变量类型 | 实例位置 | 实例数量 |
|---|---|---|
| Global variable | data segment | 每个 process 一个实例 |
| Local automatic variable | thread stack | 每次调用、每个 thread 各自实例 |
| Local static variable | data segment | 每个 process 一个实例 |
定义:变量 x 是 shared variable,当且仅当多个 thread 引用了 x 的至少同一个实例。
示例:
1 |
|
共享性分析:
| 变量实例 | main thread | peer thread 0 | peer thread 1 | 是否共享 |
|---|---|---|---|---|
ptr |
是 | 是 | 是 | 是 |
cnt |
否 | 是 | 是 | 是 |
i in main |
是 | 否 | 否 | 否 |
msgs in main |
是 | 是 | 是 | 是 |
myid in thread 0 |
否 | 是 | 否 | 否 |
myid in thread 1 |
否 | 否 | 是 | 否 |
其中 cnt 是 local static variable,两个 peer threads 共享同一个实例。++cnt 若不加同步,会发生 data race。
Data Race 示例:
cnt++两个 thread 各执行
NITERS次cnt++:
1
2
3
4
5
6
7
8
9
10
11
unsigned int cnt = 0;
void *count(void *arg)
{
int i;
for (i = 0; i < NITERS; i++)
/* cnt++ 不是原子操作,会被拆成读、加、写。 */
cnt++;
return NULL;
}期望结果是
2 * NITERS。实际可能小于期望值:
1
2 BOOM! cnt=198841183
BOOM! cnt=198261801原因:
cnt++不是单条原子操作。它通常被编译为 load、update、store:
1
2
3 L: load cnt -> register
U: update register + 1
S: store register -> cnt错误交错示例:
1
2
3
4
5
6
7
8 初始 cnt = 0
Thread 1: L1 读 cnt = 0
Thread 1: U1 得到 1
Thread 2: L2 读 cnt = 0
Thread 1: S1 写 cnt = 1
Thread 2: U2 得到 1
Thread 2: S2 写 cnt = 1两个 thread 都完成一次 increment,但最终
cnt只增加 1。
Progress Graph
Progress graph 用于描述两个并发 thread 的离散执行状态空间:
-
每个轴对应一个 thread 的指令顺序。
-
每个点表示一个执行状态,例如
(L1, S2)表示 thread 1 已完成L1,thread 2 已完成S2。

-
一条 trajectory 表示一种合法执行交错。

-
对共享变量
cnt,L、U、S构成 critical section。两个 thread 的 critical section 不应交错。会导致交错的状态集合称为 unsafe region。
结论:
- trajectory 不接触 unsafe region,则称为 safe。
- 对
cnt++来说,trajectory 正确当且仅当它是 safe trajectory。

Semaphore
Semaphore 是非负整数同步变量。Dijkstra 的 P / V 操作:
1 | P(s): while (s == 0) wait(); s--; |
操作系统保证 P 和 V 中修改 semaphore 的部分不可分割:
- 同一时刻只有一个
P或V能修改s。 - 当
P的等待结束时,只有该P能执行s--。 V可唤醒被阻塞的P。- Semaphore 不保证唤醒顺序。
POSIX semaphore 接口:
1 |
|
CSAPP wrapper:
1 | /* CSAPP 对 sem_wait 的包装,失败时统一报错。 */ |
将 semaphore 初始化为 1,可作为 mutex 使用:
1 | unsigned int cnt; |
P(&sem) 与 V(&sem) 把 critical section 包围起来。Progress graph 中,这相当于增加 forbidden region,使任何合法 trajectory 都不能进入 unsafe region。

Producer-Consumer
单元素 buffer 使用两个 semaphore。一个进程负责“生产数据”,另一个进程负责“消费数据”。两个进程通过一个共享缓冲区交接数据,其中 Producer 不能在缓冲区还没被消费时继续覆盖写入;Consumer 不能在缓冲区还没有数据时读取。所以这里用两个 semaphore 来协调顺序:
empty:空槽数量,初始为 1。full:已有 item 数量,初始为 0。
1 |
|
Producer:
1 | void *producer(void *arg) |
Consumer:
1 | void *consumer(void *arg) |
流程解析:
- 初始
empty = 1, full = 0。 - Consumer 若先运行,会阻塞在
P(full)。 - Producer 执行
P(empty)后写入buf,再V(full)唤醒 consumer。 - Consumer 执行
P(full)后读取buf,再V(empty)允许 producer 写入下一项。
Readers-Writers Problem
定义
- Reader 只读共享对象。
- Writer 修改共享对象。
- Writer 必须独占访问对象。
- 多个 reader 可同时访问对象。
First Readers-Writers Problem
First readers-writers problem 偏向 reader:除非 writer 已经获得对象,否则 reader 不应等待。若 writer 在等待期间仍有新 reader 到达,新 reader 可继续优先进入,因此 writer 可能 starvation。
实现:
1 | int readcnt; /* initially 0 */ |
冲突情况:
- 如何避免 W-W conflict? 所有 writer 都必须持有
w,因此同一时刻只有一个 writer 能写。- 如何避免 R-W conflict? 第一个 reader 执行
P(&w),最后一个 reader 执行V(&w)。只要有 reader 在读,writer 无法获得w。- 如何支持 R-R concurrency? Reader 只在修改
readcnt时短暂持有mutex,实际 reading 阶段不持有mutex,多个 reader 可并发读。- 如何实现 reader first? 当已有 reader 在读时,
readcnt > 0,新 reader 只需更新readcnt,不会等待 writer。
Second Readers-Writers Problem
Second readers-writers problem 偏向 writer:一旦 writer 准备写,后续到达的 reader 必须等待,直到 writer 完成。该策略减少 writer starvation,但 reader 可能 starvation。
实现思路通常需要额外状态,例如:
readcnt:当前 reader 数量。writecnt:正在等待或写入的 writer 数量。rmutex/wmutex:保护计数器。readTry:writer 等待时阻止新 reader 进入。resource:保护共享对象。
核心约束是:writer 到达后先关闭 reader 入口;已有 reader 退出后,writer 尽快获得资源。
一种等价的实现方式是使用 lock 和 condition variable 维护状态:
readers:正在读的 reader 数量。writers:正在写的 writer 数量,取值通常为 0 或 1。waiting_writers:正在等待写入的 writer 数量。ok_to_read/ok_to_write:分别用于唤醒 reader 和 writer。
Reader 获取读锁时,需要在 writers > 0 或 waiting_writers > 0 时等待;这表示只要有 writer 正在写或已经排队,新的 reader 就不能插队。Writer 获取写锁时,先增加 waiting_writers,然后等待 readers == 0 && writers == 0,满足后减少 waiting_writers 并设置 writers = 1。
释放规则:
- Reader 释放读锁后,若
readers == 0且存在等待 writer,应 signalok_to_write。 - Writer 释放写锁后,若仍有等待 writer,优先 signal 下一个 writer;否则 broadcast 给等待的 readers。
为什么该实现偏向 writer?
关键在于 reader 获取读锁时检查
waiting_writers。若某个 writer 已经到达并等待,即使当前还有 reader 在读,后续新 reader 也必须等待。这样已有 reader 退出后,writer 能尽快获得资源,避免 reader 连续到达导致 writer 长期 starvation。
Prethreaded Concurrent Server
Prethreaded server 在启动时创建 worker thread pool。Master thread 只负责 accept 连接并把 connfd 插入共享 buffer;worker threads 从 buffer 中取出 connfd 并服务 client。

优势:
- 避免每次连接都创建新 thread。
- 可限制并发 worker 数量。
- 适合高频短连接或 IPC 服务。
worker thread 数量如何设置?
取决于 workload:
- CPU-bound:通常接近 core 数,避免过多 context switch。
- I/O-bound:可高于 core 数,用更多 worker 覆盖 I/O 等待。
- 受内存、栈空间、调度开销和 tail latency 约束。
需要通过压测确定。固定经验值不能替代实际负载下的测量。
shared buffer 大小如何设置?
Buffer 太小会使 master thread 频繁阻塞在插入
connfd上,无法及时accept后续连接;buffer 太大会占用更多内存,并可能增加排队时间,使 client 连接已经建立但迟迟得不到 worker 处理。该大小应结合连接到达速率、平均服务时间、worker 数量和延迟目标确定。可以用压测观察队列长度、拒绝率和 tail latency,再调整 buffer 容量。
Android / HarmonyOS 中 prethreading 可用于什么?
可用于 Binder IPC 等服务线程池。系统服务提前准备线程处理 IPC 请求,避免每次请求临时创建线程,并通过线程池上限控制资源使用。
Threads for Parallelism
并发程序不一定并行。Parallel program 是 concurrent program 的子集,要求多个任务同时在不同硬件执行单元上运行。
相对加速比定义为 Speedup = T1 / Tp,其中 T1 是单线程时间,Tp 是 p 个线程时间。
并行求和:mutex 版本
1 | long gsum; |
问题:每次累加都进入 critical section,同步开销占比高,线程越多竞争越强。
优化原则:
- 若能避免共享写,就避免共享写。
- 若不能避免同步,应增加每次同步之间的有效计算量,摊薄同步成本。
为什么 mutex 版本可能越并行越慢?
每次循环只做一次简单加法,但都要执行
P/V。同步操作需要原子修改、阻塞和唤醒等机制,开销远高于一次加法。单线程时
P通常立即成功,V也不需要唤醒其他 thread;多线程时会出现真实竞争,等待者可能阻塞,释放者可能唤醒其他 thread。若机器有 4 个 core,从 1 个 thread 增加到 4 个 thread 时,更多 thread 同时竞争同一把锁,contention 增强,运行时间可能上升。超过 core 数后,更多 thread 处于 run queue 中,性能变化还会受到调度、唤醒和缓存状态影响,不一定呈单调趋势。
并行求和:数组部分和
1 | void *sum_array(void *vargp) |
每个 thread 写自己的 psum[myid],main thread 在 join 后合并。该方法减少了锁竞争,但循环中仍持续写共享数组位置,可能有 cache 影响。
并行求和:local sum
1 | void *sum_local(void *vargp) |
该版本在 local variable 中累加,最后写一次 psum[myid],减少共享写和 cache coherence 流量。
并行求和的性能应如何核对?
若每个 thread 处理独立区间,且循环主体几乎没有同步,理想情况下
p个 thread 的时间接近T1 / p。实际性能会在 core 数附近到达平台瓶颈;例如 4-core 机器上,4 个 thread 后继续增加到 8 或 16 个 thread 通常不会继续线性加速。若测量结果不符合预期,应检查:是否仍有共享写、是否存在 false sharing、任务划分是否均匀、计时是否包含 thread 创建和 join、是否受内存带宽或其他后台任务影响。
Thread-Safe 与 Reentrant
Thread-safe function:被多个 concurrent threads 反复调用时,总能产生正确结果。
常见 thread-unsafe 函数类别:
| 类别 | 原因 | 修复方向 |
|---|---|---|
| Class 1 | 未保护共享变量 | 使用锁或 semaphore |
| Class 2 | 依赖跨调用持久状态 | 把状态作为参数传入 |
| Class 3 | 返回 static variable 指针 | 调用者提供 buffer,或 lock-and-copy |
| Class 4 | 调用 thread-unsafe 函数 | 改用 thread-safe 版本 |
Class 2 示例:
rand
1
2
3
4
5
6
7
8
9
10
11
12
13
14 unsigned int next = 1;
int rand(void)
{
/* next 是全局状态,多个线程同时更新会产生数据竞争。 */
next = next * 110351524 + 12345;
return (unsigned int)((next / 65536) % 32768);
}
void srand(unsigned int seed)
{
/* 修改同一个全局 next,会影响所有线程后续 rand 结果。 */
next = seed;
}
next是共享持久状态,多个 thread 会互相干扰。改为传入状态指针:
1
2
3
4
5
6 int rand_r(int *nextp)
{
/* 调用者传入私有状态指针,从而避免共享全局 next。 */
*nextp = *nextp * 110351524 + 12345;
return (unsigned int)((*nextp / 65536) % 32768);
}
Class 3 示例:
ctime
1
2
3
4
5
6
7 char *ctime(const time_t *timep)
{
static char *p;
/* convert time to string */
/* 返回 static buffer 指针,多个线程会共享同一存储。 */
return p;
}多个 thread 调用时会共享同一个 static buffer。可改为调用者传入 private buffer,或使用 lock-and-copy:
1
2
3
4
5
6
7
8
9
10
11
12 char *ctime_ts(const time_t *timep, char *privatep)
{
char *sharedp;
/* 用互斥锁保护对 thread-unsafe ctime 的调用。 */
P(&mutex);
sharedp = ctime(timep);
/* 把共享缓冲区内容复制到调用者提供的私有缓冲区。 */
strcpy(privatep, sharedp);
V(&mutex);
return privatep;
}Reentrant function 是 thread-safe function 的重要子集。若函数在多线程调用时不访问任何共享变量,则它是 reentrant。Reentrant function 不需要同步。
为什么 signal handler 中不能使用
printf?
printf不是 async-signal-safe。它可能使用内部锁和共享缓冲区。若 signal 在主程序持有printf内部锁时打断执行,handler 再调用printf,可能发生 deadlock 或破坏内部状态。
Thread Local Storage
Thread local storage(TLS)为每个 thread 提供独立变量实例,适合“逻辑上全局、实际应按 thread 隔离”的数据。
示例:
1 | thread_local int i = 0; |
若 main 中创建 id 为 1、2、3 的三个 thread,每个 thread 的 i 独立:
- thread 1 输出
2。 - thread 2 输出
3。 - thread 3 输出
4。 - main thread 的
i仍为0。
可能输出为 2340、3240、4230、4320、2430、3420 等排列,但最后一位总是 main thread 输出的 0。
TLS 应用:
- Random number generator:每个 thread 维护独立 seed。
errno:每个 thread 维护独立错误码,避免互相覆盖。
同一条指令如何访问不同 thread 的 TLS 实例?
多个 thread 共享同一份 code,不能依赖 PC-relative addressing 区分 TLS 实例,因为执行到同一条指令时 PC 相同。常见实现是为每个 thread 维护 TLS base address,并把它放在专用寄存器或线程控制块中。
编译器把 TLS 变量访问转换为“TLS base + 固定 offset”。Offset 在编译或链接阶段确定;TLS base 在 thread 创建时由运行时/内核设置。于是同一条机器指令在不同 thread 中使用不同 TLS base,最终访问不同内存位置。
例如
errno逻辑上像全局变量,但每个 thread 都有独立实例。一个 thread 的系统调用失败不应覆盖另一个 thread 看到的errno。
Race 与 Deadlock 示例:
Race 的本质:程序正确性依赖“某个 thread 先到达控制点 x,另一个 thread 后到达控制点 y”。
错误示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int main()
{
pthread_t tid[N];
int i;
for (i = 0; i < N; i++)
/* 错误:传入的是循环变量 i 的同一个地址。 */
pthread_create(&tid[i], NULL, thread, &i);
for (i = 0; i < N; i++)
pthread_join(tid[i], NULL);
}
void *thread(void *vargp)
{
/* 读取时 i 可能已经被主线程改成其他值。 */
int myid = *((int *)vargp);
printf("Hello from th. %d\n", myid);
return NULL;
}问题:所有 thread 都获得同一个
&i。Thread 读取i的时刻不确定,可能输出重复 id 或越界 id。应为每个 thread 准备独立参数,例如myid[i] = i后传&myid[i]。Deadlock 示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 sem_t mutex[2];
void *count(void *vargp)
{
int id = (int)vargp;
/* 两个线程可能分别持有 mutex[0] 和 mutex[1]。 */
P(&mutex[id]);
/* 再请求对方持有的锁时形成循环等待。 */
P(&mutex[1 - id]);
cnt++;
V(&mutex[id]);
V(&mutex[1 - id]);
return NULL;
}若 thread 0 获得
mutex[0],thread 1 获得mutex[1],随后二者都等待对方持有的 semaphore,就进入 deadlock。避免方法:所有 thread 按同一全局顺序获取锁,例如总是先获取编号较小的锁,再获取编号较大的锁。
Pthread Mutex
POSIX mutex 提供 mutual exclusion:
1 | pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; |
常见扩展接口:
1 | /* 非阻塞加锁;若锁已被持有,立即返回错误码。 */ |
trylock:若锁已被持有,立即失败返回。timedlock:获得锁或超时后返回。
评价一个 lock 主要看:
- mutual exclusion:能否保证互斥。
- fairness:等待者是否可能长期得不到锁。
- performance:低竞争和高竞争下开销是否可接受。
为什么锁的性能也重要?
Lock 本身不产生业务结果,它只是保护 critical section。若 critical section 中的有效计算很短,而加锁、等待、唤醒或 cache coherence 的开销很高,整体程序可能比单线程更慢。评价锁时要同时看正确性、公平性和开销:正确性保证互斥,公平性避免 starvation,性能决定并发是否真正带来收益。
构造 Lock
关闭中断
单处理器系统可在 critical section 中关闭中断:
1 | void lock() { |
问题:
- 允许普通 thread 执行特权操作,依赖调用者不滥用。
- 多处理器上无效,因为其他 CPU 仍可执行。
- 长时间关闭中断会影响系统响应。
- 只适合内核在特定场景保护自己的共享数据结构。
简单 Flag 的错误
尝试用普通变量 flag 表示锁状态:
1 | typedef struct { |
正确性问题:
1 | Thread 1: while(flag == 1) // sees 0 |
两个 thread 都认为自己获得锁,互斥失效。
性能问题:等待者一直 spin-wait,占用 CPU。单处理器上若持锁 thread 被抢占,其他 thread 自旋不会推进锁释放。
Test-and-Set Spin Lock
Test-and-Set 原子地读取旧值并写入新值。
1 | int TestAndSet(int *old_ptr, int new) |
基于 Test-and-Set 的 spin lock:
1 | typedef struct { |
特点:
- 正确性:Test-and-Set 原子化了“检查并设置”。
- 公平性:不保证先等待者先获得锁,可能 starvation。
- 性能:低竞争时开销小;高竞争或单 CPU 上浪费 CPU。
CAS & LL/SC & Ticket Lock
Compare-And-Swap(CAS)原子地比较旧值并按条件写入:
1 | int CompareAndSwap(int *ptr, int expected, int new) |
Load-Linked / Store-Conditional(LL/SC)常见于 ARM:
1 | int LoadLinked(int *ptr) { |
Ticket lock 使用 Fetch-and-Add 保证公平性:
1 | typedef struct { |
每个 thread 获得递增 ticket,只能在 turn == myturn 时进入 critical section,因此不会 starvation。
Test-and-Set + Yield
1 | void lock() |
若只有两个 thread 且单 CPU,yield 可让出 CPU 给持锁 thread。若有很多等待 thread,一个持锁 thread 被抢占后,其他等待者会轮流运行、发现锁被持有、再 yield,仍有调度开销。
Queue + Park/Unpark
改进思路:显式维护等待队列,把不能获得锁的 thread sleep,释放锁时唤醒队列中的一个 thread。
1 | typedef struct { |
flag:真实锁状态。guard:保护 lock 内部状态的短期 spin lock。q:等待队列。
Lock 逻辑:
1 | void lock(lock_t *lock) |
Unlock 逻辑:
1 | void unlock(lock_t *lock) |
为什么不能在
park()后再释放guard?若 thread 在持有
guard时调用park(),它会睡眠且不释放guard。其他 thread 在unlock中需要获得guard才能唤醒等待者,于是系统 deadlock。
1
2 waiting thread: holds guard -> park()
unlocking thread: waits for guard
为什么唤醒等待者时不把
flag置为 0?若等待队列非空,
unlock直接把锁的所有权交给被唤醒 thread。该 thread 从park()返回后不会重新执行flag = 1。因此flag应保持为 1,表示锁仍被持有,只是持有者即将切换。
Wakeup/Waiting Race
存在丢失唤醒风险:
1 | Thread 1: |
unpark 发生在 park 之前,Thread 1 之后仍进入睡眠,唤醒丢失。
Solaris 的 setpark() 解决该问题:
1 | /* setpark 先声明自己即将 park,避免 unpark 早于 park 导致丢失唤醒。 */ |
若 setpark() 后已经发生 unpark(),后续 park() 会立即返回,不会睡眠。
Futex & Two-Stage Lock
Linux futex 与某个用户态内存地址关联,并在内核中维护每个 futex 的等待队列。大部分锁操作在用户态完成;只有真的需要睡眠或唤醒时,才进入内核。
如果发现锁一直被占用,就通过 futex_wait 进入内核睡眠:
1 | /* 仅当 address 当前值仍为 expected 时,线程才进入睡眠。 */ |
若 *address == expected,调用 thread 睡眠;否则立即返回。
1 | /* 唤醒等待在 address 上的一个或多个线程。 */ |
唤醒在该地址队列上等待的一个 thread。
Linux mutex 常使用 two-stage 设计:
- 先在用户态用原子操作尝试获得锁,必要时短暂 spin。
- 若竞争持续,再通过 futex 进入内核睡眠。
优点:
- 低竞争时无需进入内核,开销低。
- 高竞争时等待者睡眠,减少 CPU 浪费。
Condition Variable
定义
Lock 只能保证互斥;有时 thread 需要等待某个条件成立。例如 parent 需要等待 child 执行完毕后再继续。
自旋等待:
1 | volatile int done = 0; |
该方法会浪费 CPU。Condition variable 允许 thread 在条件不满足时进入等待队列,由改变条件的 thread 唤醒。
POSIX 接口:
1 | pthread_cond_t c; |
pthread_cond_wait 的语义:
- 调用时 mutex 必须已加锁。
- 原子地释放 mutex 并让当前 thread 睡眠。
- 被唤醒后,先重新获得 mutex,再从
pthread_cond_wait返回。
Condition Variable 示例:Join
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 int done = 0;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t c = PTHREAD_COND_INITIALIZER;
void thr_exit()
{
Pthread_mutex_lock(&m);
/* 状态变量记录 child 已经结束。 */
done = 1;
/* 唤醒等待 done 变为 1 的线程。 */
Pthread_cond_signal(&c);
Pthread_mutex_unlock(&m);
}
void *child(void *arg)
{
printf("child\n");
thr_exit();
return NULL;
}
void thr_join()
{
Pthread_mutex_lock(&m);
while (done == 0)
/* wait 会原子释放 m 并睡眠;醒来后重新获得 m。 */
Pthread_cond_wait(&c, &m);
Pthread_mutex_unlock(&m);
}两种执行顺序:
- Parent 先执行
thr_join:看到done == 0,释放锁并睡眠;child 设置done = 1后 signal,parent 醒来。- Child 先执行
thr_exit:设置done = 1;parent 后执行thr_join时不会睡眠。错误实现:没有状态变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 void thr_exit()
{
Pthread_mutex_lock(&m);
/* 错误:没有修改状态变量,signal 不能被后来的 wait 观察到。 */
Pthread_cond_signal(&c);
Pthread_mutex_unlock(&m);
}
void thr_join()
{
Pthread_mutex_lock(&m);
/* 错误:如果 signal 已经发生,这里会永久等待。 */
Pthread_cond_wait(&c, &m);
Pthread_mutex_unlock(&m);
}若 child 在 parent
wait前 signal,该 signal 不会被保存。Parent 之后调用wait会永久睡眠。Condition variable 必须和状态变量配合使用。错误实现:没有锁
1
2
3
4
5
6
7
8
9
10
11
12
13 void thr_exit()
{
/* 错误:修改 done 时没有持有锁,和 thr_join 的检查存在竞态。 */
done = 1;
Pthread_cond_signal(&c);
}
void thr_join()
{
if (done == 0)
/* 错误:wait 没有与互斥锁配合,无法保证检查和睡眠的原子性。 */
Pthread_cond_wait(&c);
}问题:检查
done和进入等待不是原子操作。可能出现:
1
2
3 parent: if (done == 0) 成立
child: done = 1; signal
parent: waitSignal 丢失,parent 睡眠。Mutex 用于保护“检查条件”和“进入等待”的原子性。
Condition Variable & Producer-Consumer
基础 buffer:
1 | int buffer; |
仅使用 lock 不够,因为当 buffer 满或空时,thread 需要等待条件改变,而不是持锁循环。
错误版本:用 if
1 | void *consumer(void *arg) |
多 consumer 场景可能出错:
Tc1发现count == 0,进入 wait。- Producer 放入 item,signal 唤醒
Tc1。 Tc1还未运行,Tc2先获得锁并消费 item,使count = 0。Tc1醒来后从wait返回,若只用if,不会重新检查count,直接get(),断言失败。
结论:pthread_cond_wait 返回后,条件不一定仍成立。必须用 while 重新检查条件。
为什么一个 condition variable 仍然不够?
即使用
while,若 producer 和 consumer 共用同一个cond,多 producer / consumer 场景仍可能唤醒错误角色。错误流程:
Tc1和Tc2都因count == 0等待。- Producer 放入 item,signal 唤醒
Tc1。- Producer 下一轮发现
count == 1,进入 wait。Tc1消费 item 后 signal,但可能唤醒Tc2。Tc2醒来发现count == 0,继续 wait。- Producer 仍在 wait,所有相关 thread 都睡眠。
问题在于同一个
cond同时表示“非空”和“非满”,signal 可能唤醒不满足条件的一类 thread。
正确版本:两个 condition variables
使用两个条件变量:
empty:buffer 非满,producer 等待它。fill:buffer 非空,consumer 等待它。
1 | cond_t empty, fill; |
要点:
- 等待条件必须写成
while。 - Producer 只 signal
fill。 - Consumer 只 signal
empty。 - 对
count、buffer的访问必须在mutex保护下进行。
多槽 Buffer
多槽 circular buffer:
1 | int buffer[MAX]; |
条件改为:
- Producer 等待
count == MAX变为 false。 - Consumer 等待
count == 0变为 false。
signal 与 broadcast
若多个等待者需要不同大小的资源,只 signal 一个 thread 可能唤醒不合适的等待者。
内存分配示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 int bytesLeft = MAX_HEAP_SIZE;
cond_t c;
mutex_t m;
void *allocate(int size)
{
Pthread_mutex_lock(&m);
while (bytesLeft < size)
/* 可用字节不足时等待 free 增加空间。 */
Pthread_cond_wait(&c, &m);
void *ptr = ...;
/* 分配成功后扣减剩余空间。 */
bytesLeft -= size;
Pthread_mutex_unlock(&m);
return ptr;
}
void free(void *ptr, int size)
{
Pthread_mutex_lock(&m);
/* 释放空间后增加剩余字节数。 */
bytesLeft += size;
/* 唤醒所有等待者,让不同 size 的请求都重新检查条件。 */
Pthread_cond_broadcast(&c);
Pthread_mutex_unlock(&m);
}使用
broadcast唤醒所有等待者,让它们各自重新检查bytesLeft < size。代价是可能造成更多 context switch,但语义更稳妥。
Dining Philosophers
五个 philosophers 和五把 forks 构成环。若每个 philosopher 都先拿左边 fork,再拿右边 fork:
1 | void get_forks(int p) |
可能 deadlock:
1 | P0 holds f0, waits f1 |
一种修复:破坏环形等待,让一个 philosopher 反向拿 fork:
1 | void get_forks(int p) |
一般原则:避免 circular wait,可规定全局资源顺序,并要求所有 thread 按相同顺序申请资源。
用 Lock + CV 实现 Semaphore
可用 mutex 和 condition variable 实现 semaphore:
1 | typedef struct { |
解析:
lock保护value。wait中必须用while,因为被唤醒后条件可能已变化。post增加value后 signal 一个等待者。



