Lecture 22: 同步:基础 (Synchronization: Basic)
Lecture 22: 同步:基础 (Synchronization: Basic)
讲义对应:CMU 15-213 Lecture 22 — Synchronization: Basics(素材:
F25-22-sync-basic.txt);配套 recitation:F25-rec12_slides.txt教材对应:CS:APP3e 第 12 章 12.4(共享变量与信号量)、12.5.1–12.5.3(互斥、顺序、生产者-消费者、读者-写者) 关联 Lab:L8 SFS Lab(tiny 文件系统的并发访问)/ L7 Proxy Lab(缓存对象的读者-写者锁)
22.1 概述
Lecture 21 告诉我们”线程共享一切”,本讲回答它的必然后果:既然共享,如何保证正确? 讲义先用一个 20,000 次计数的例子把”数据竞态(data race)”钉在墙上,再用进度图(progress graph)把”任意交错”这个模糊直觉变成可证明的几何对象,最后给出两把武器:互斥量(mutex)与信号量(semaphore)。核心结论是:加锁不是”防君子”的礼貌,而是用一条不可违反的不变量把不安全区域从状态空间中物理隔离。它为 Lecture 23 的条件变量、死锁避免、并发 bug 分类铺路,也是 SFS Lab 中 tiny 文件系统被多线程服务器调用时必须先想清楚的问题。
22.2 核心概念与底层机制图解
22.2.1 共享变量与线程内存模型(Shared Variables and the Threads Memory Model)
- 定义与目的:讲义拒绝”全局变量共享、栈变量私有”这种粗糙说法,给出精确定义——变量 $x$ 是共享的,当且仅当它的某个实例被多于一个线程引用(a variable $x$ is shared iff multiple threads reference some instance of $x$)。判定分三步:确定线程内存模型 → 确定变量实例在内存中的位置 → 数清有多少线程可能引用该实例。
- 直观解释:把线程想象成同一间办公室里的人:每人有自己的草稿本(寄存器 + 栈),但白板、文件柜(代码段、数据段、堆、共享库)公用。你的笔记不锁起来别人也能翻——这就是”实际模型”与”概念模型”的错位。
- 底层机制图解:线程把进程虚拟地址空间一分为二。
共享(所有线程可见)
+-------------------------------------------+
高地址 | stack 2 (thread 2 私有,但别人可读写) |
| stack 1 (main thread 私有,same) |
| shared libraries (只读映射) |
| run-time heap (malloc 出来的对象全部共享) |
| read/write data (全局变量 + 局部 static) |
| read-only code/data (.text, .rodata) |
+-------------------------------------------+
低地址 | 0
+-------------------------------------------+
每个线程独占:TID、寄存器组、PC、条件码、SP、自己的栈
全部线程共享:代码段、数据段、堆、共享库、打开的文件、信号处理器
- 与机器码/硬件的对应:寄存器上下文真正隔离(
%rax、%rsp、%rip随线程切换保存/恢复),但地址空间共用同一套页表——因此线程 2 完全可以通过指针读写线程 1 的栈,这正是”概念模型”与”实际模型”错位的根源。 - 补充说明:
errno是特例——源码里是全局变量,但 glibc 用线程局部存储(TLS)让每线程各有一份实例,故不是共享变量。
22.2.2 进度图:把”任意交错”变成几何(Progress Graphs)
- 定义与目的:进度图描绘并发线程的离散执行状态空间:每个轴对应一个线程的指令序列,平面上每个点是一个可能的执行状态 $(Inst_1, Inst_2)$,例如 $(L_1, S_2)$ 表示”线程 1 已完成 $L_1$ 且线程 2 已完成 $S_2$”。轨迹(trajectory)是合法的状态转移序列,即一次可能的并发执行。
- 直观解释:就像两张地铁线路图并排,横轴 2 号线、纵轴 1 号线;一次并发执行就是”从原点出发只向右和向上走的路线”,任何单调右上折线都可能发生。
- 底层机制图解:把
cnt++编译出的三条指令记为 $L_i$(load)、$U_i$(update)、$S_i$(store),临界区由 $H_i$(header)、$L_iU_iS_i$、$T_i$(tail)围成。不安全区域是两线程临界区指令交错的状态集合(下图中###):
Thread 2 →
H2 L2 U2 S2 T2
+-----+-----+-----+-----+-----+
T1 | | | | | |
+-----+-----+-----+-----+-----+
S1 | | ### | ### | ### | |
+-----+-----+-----+-----+-----+
U1 | | ### | ### | ### | | <- 不安全区域
+-----+-----+-----+-----+-----+ (unsafe region)
L1 | | ### | ### | ### | |
+-----+-----+-----+-----+-----+
H1 | | | | | |
+-----+-----+-----+-----+-----+
Thread 1
↓
定义:轨迹 safe ⟺ 从不进入任何不安全区域
断言:轨迹对 cnt 而言是正确的 ⟺ 它是 safe 的
- 对角线 vs 阶梯线:本讲最易被忽略的几何直觉。
- 对角线(沿 $45°$ 斜走):两线程进度大致相等地推进,临界区在时间上重叠。只要重叠,轨迹必然穿过中央
###,于是L1读到旧值、S2又写回,出现”两次自增得到 1”的经典错误。 - 阶梯线(先竖走完一段再横走一段):即”让线程 1 把整个临界区跑完,再放线程 2 进来”,本质是把并发串行化。轨迹始终贴着不安全区域上方或下方绕行即可证明 safe。
- 结论:只要把所有不安全区域排除在轨迹之外,就保证了互斥。mutex 的作用正是制造更外圈的禁止区域(forbidden region),把 unsafe region 整个包住。
- 对角线(沿 $45°$ 斜走):两线程进度大致相等地推进,临界区在时间上重叠。只要重叠,轨迹必然穿过中央
- 与硬件/机器码的对应:不安全区域之所以存在,是因为
cnt++;被编译成三条独立指令(见 22.3.1),指令之间可被调度器/中断任意切开;若硬件能把它变成一条不可分割的 read-modify-write(如lock addq),不安全区域就消失了——这就是 22.3.5 原子操作的几何意义。
22.2.3 信号量(Semaphore)
- 定义与目的:Dijkstra 提出的经典同步原语,是互斥量的推广:一个非负整数,创建时给定初值。mutex 是”只能取 0/1 且必须 P 先于 V”的退化情形;信号量允许 $s>1$ 且无 P/V 顺序要求,因此能表达”计数”与”顺序”,而不只是”互斥”。
- 直观解释:信号量像停车场的电子计数牌:
sem_wait是”抬杆进车”——有空位就减 1 开进去,为 0 就在门口排队;sem_post是”出场”——牌子加 1 并叫醒一个排队者。互斥量则像只有单把钥匙的洗手间。 - 两个操作:
- $P(s)$(
sem_wait,”Prolaag/Proberen”,荷兰语”尝试减少”):若 $s=0$ 则等待某个 $V$ 操作发生;然后 $s \mathrel{-}= 1$ 并返回。 - $V(s)$(
sem_post,”Verhogen”,荷兰语”增加”):$s \mathrel{+}= 1$;若有线程正阻塞在 $P$ 中,唤醒其中一个。
- $P(s)$(
- 原子性:
sem_wait/sem_post内部原子——sem_wait的”判断 $s>0$”与”$s\mathrel{-}=1$”之间不可能被插入。讲义给出的部分实现正是用 CPU 原子指令:
sem_wait:
mov $-1, %edx # decrement
lock xadd %edx, SEM_COUNT(%rdi) # 原子读-改-写;%edx 得到旧值
test %edx, %edx
jle .Lclosed # 旧值 <= 0 => 减成负数了,必须睡
ret # 信号量原本是开的,直接返回
.Lclosed:
# 睡到别的线程调用 sem_post 为止(30 多条指令 + 一次系统调用)
讲义点评:”Suspiciously similar to a mutex, huh?”。关键区别在语义:sem_wait 可在任何时刻调用(初值甚至为 0),而 unlock 只能由持有者调用。
sem_* 与 pthread_mutex_* 接口对照表
| 语义 | POSIX 信号量(<semaphore.h>) | 互斥量(<pthread.h>) |
|---|---|---|
| 声明类型 | sem_t s; | pthread_mutex_t m; |
| 初始化(动态) | sem_init(&s, pshared, val);pshared=0 表示线程间共享 | pthread_mutex_init(&m, NULL) |
| 初始化(静态) | 无(必须先 sem_init) | pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; |
| 加锁 / P 操作 | sem_wait(&s) —— $s>0$ 则 $s{=}s-1$,否则阻塞 | pthread_mutex_lock(&m) —— 空闲则锁住,否则阻塞重试 |
| 解锁 / V 操作 | sem_post(&s) —— $s{=}s+1$,唤醒一个等待者 | pthread_mutex_unlock(&m) —— 只能由持锁者调用 |
| 取值(调试用) | sem_getvalue(&s, &v)(非标准语义,仅作参考) | 无对应接口(不透明对象) |
| 销毁 | sem_destroy(&s) | pthread_mutex_destroy(&m) |
| 初值范围 | 任意 $val \ge 0$(计数信号量) | 只能 0/1(二元) |
| 调用约束 | P/V 可以乱序、可跨线程配对 | unlock 必须与 lock 同线程配对 |
| 典型用途 | 互斥、计数、顺序/调度 | 仅互斥 |
| 相对开销 | 更慢(讲义实测 27.6 s vs mutex 15 s) | 较快 |
22.2.4 用信号量实现互斥与顺序(Mutex and Ordering)
- 互斥(mutual exclusion):信号量初值设为 1,临界区前后包上
sem_wait/sem_post,它就退化成”二元信号量(binary semaphore)”。不变量是”$s$ 只可能是 0 或 1”,禁止区域恰好包住不安全区域。 - 顺序 / 调度(scheduling / ordering):这是信号量无法用 mutex 替代的能力——强制”线程 A 先做某事,线程 B 后做”。手法是把初值设为 0,让先到的线程阻塞:
线程 A(生产者) 线程 B(消费者)
--------------------------- ---------------------------
生产一个 item sem_wait(&items) <-- 初值 0,先阻塞
sem_post(&items) ----------> (被唤醒) 消费该 item
| ^
+------- 信号量充当"事件已发生"的标志 -------+
讲义在 Supplemental 里点明:信号量的真正价值是”wait on event”,这正与之前用 sigsuspend 解决”等待某个事件”是同一思想(详见 Lecture 17)。生产者-消费者里的 slots/items 就是这一用法的教科书范例。
22.2.5 生产者-消费者问题(Producer-Consumer / Bounded Buffer)
- 定义与目的:生产者产生 item 放进有界缓冲区(bounded buffer),消费者取出。这是”SFS Lab 服务器线程”与”Proxy Lab 缓存淘汰”的共同抽象,约束有两条:绝不在满缓冲区插入、绝不在空缓冲区取出。
- 底层机制图解:缓冲区是循环数组,三个信号量各司其职:
sbuf_t +--------------------------+
+-----------------+ | sem_t mutex; init = 1 | 保护 buf/front/rear
| int buf[N]; | <-- 数据 ---- | sem_t slots; init = N | 空槽位计数
| int n; | | sem_t items; init = 0 | 已填 item 计数
| int front; | +--------------------------+
| int rear; |
| sem_t mutex; | 生产者: wait(slots) -> wait(mutex) -> 存 -> post(mutex) -> post(items)
| sem_t slots; | 消费者: wait(items) -> wait(mutex) -> 取 -> post(mutex) -> post(slots)
| sem_t items; |
+-----------------+ slots + items + 缓冲区占用 = N (不变量)
|
v N = 4 的循环缓冲区(rear/front 都按 mod N 前进)
+------+------+------+------+
| [0] | [1] | [2] | [3] |
+------+------+------+------+
^front=2 ^rear=0
已取出 下一个插入位置
占用 = (rear - front) mod N = 2 => items = 2, slots = 2
- 为什么
mutex必须存在?slots与items只保证计数语义,不保证对buf、front、rear的访问互斥。两个生产者都成功sem_wait(&slots)拿到空位后,会同时执行sp->buf[(++sp->rear) % n] = item;——++sp->rear是非原子的读-改-写,两者可能算出同一下标,一个 item 被覆盖、另一个消失;同理两个消费者会重复取出同一槽位。slots/items管”能不能进”,mutex管”进去以后别撞车”——职责正交,缺一不可。 sem_wait的顺序陷阱(本讲最重要的 bug):如果写成
sem_wait(&sp->mutex); /* ❌ 先拿锁 */
sem_wait(&sp->slots); /* ❌ 再等空位 */
会死锁。机制:生产者握着 mutex 去等 slots;而唯一能增加 slots 的人是消费者(取出 item 后 sem_post(&slots)),消费者却必须先 sem_wait(&mutex)——mutex 正被生产者攥着。于是形成持有并等待(hold-and-wait)+ 循环等待(circular wait):
生产者: [持有 mutex] --等待--> (slots > 0)
^
| 只有消费者能释放
消费者: [等待 mutex] <--需要------ (取出 item 后 post(slots))
^
+-- mutex 被生产者持有 => 谁也动不了 => 死锁
正确顺序的铁律:先等资源(slots/items),后拿锁(mutex);先放锁(mutex),后通知(items/slots)。这样”持锁等待”的边被彻底消除,循环等待无从形成。22.3.4 用真实程序演示了这个卡死。
22.2.6 读者-写者问题(Readers-Writers)
- 定义与目的:两类线程共享一个对象:读者只读、写者只写。约束是”任意时刻允许多个读者,或至多一个写者“。这是 Proxy Lab 缓存锁的原型。
- 第一类:读者优先(readers preference),写者可能饥饿。两个信号量:
mutex保护readcnt,w作为写者锁。
读者进入 写者进入
sem_wait(&mutex); sem_wait(&w);
readcnt++; ... 写 ...
if (readcnt == 1) sem_wait(&w); <- 第一个读者拦住写者
sem_post(&mutex); sem_post(&w);
... 读 ...
sem_wait(&mutex); 状态机(w 的取值):
readcnt--; w = 1 : 无人持有(无读者、无写者)
if (readcnt == 0) sem_post(&w); <- 最后一个读者放行写者 <--+
sem_post(&mutex); w = 0 : 有写者写,或 readcnt > 0
|
多个读者可同时持有"读权限"(w 保持 0) |
新读者在 readcnt>0 时不会被阻塞 ----------------------+
=> 只要读者源源不断到达,写者永远等不到 w => 写者饥饿
- 第二类:写者优先(writers preference),读者可能饥饿。经典做法是加一个旋转门(turnstile)信号量:写者一到就”关门”挡住后续读者,写完再”开门”。注意:网上流传的”在读者入口直接
sem_wait(&w)、出口sem_post(&w)“写法虽然能挡住新读者,却把读者之间也串行化了(每个读者都独占w),丢掉了”多读者并发”这一核心优势——它是一个”能跑但对性能有害”的写法,22.3.3 给出了正确的 turnstile 版本。 - Proxy Lab 的缓存就是读者-写者锁:多个请求线程同时查缓存(读者),只有插入/淘汰才需写者权限。单一 mutex 会让所有请求串行化;读者-写者锁把”读多写少”变成真正的并行。
- 讨论:应用真有读者-写者语义吗? 有些对象读者之间也会改变状态——带 LRU 淘汰的缓存,读者命中后要更新”最近使用”顺序,这就是一次写;带引用计数的对象,读者要
refcount++。此时”多读者并发”的假设不成立,必须退化为互斥(或更细粒度的锁)。判据是:这次读操作是否修改了共享状态。
22.2.7 信号量的常见陷阱(Pitfalls)
sem_wait与sem_post不配对:少一个post→ 其他线程永久阻塞;少一个wait→ 计数错乱、缓冲区越界。- 忘记
sem_init:未初始化的sem_t是垃圾字节,行为未定义(UB),通常表现为立刻死锁或随机唤醒。 - 用
mutex+if而不是while:这是条件变量的典型陷阱(Lecture 23 详述),此处提前埋点——条件判断必须重新检查,不能只信一次。 - 加锁顺序不一致 → 死锁:线程 A 按 $R_1 \to R_2$、线程 B 按 $R_2 \to R_1$ 加锁,必然可能循环等待。铁律:所有线程按同一全序加锁。
- 锁的粒度太粗 → 性能崩塌:recitation 里用一把锁保护整棵 BST,所有插入完全串行;改成”每结点一把锁”后即可并发。但细粒度锁会引入新的加锁顺序问题。
- 持有锁时调用可能阻塞的函数(
malloc、printf、read、write):临界区被无谓拉长,甚至与库内部锁形成新的循环等待。
22.3 代码示例与底层机制分析
22.3.1 数据竞态与信号量修复:badcnt.c vs goodcnt.c
代码 (C)——先看错误的版本:
/* badcnt.c : 编译 gcc -g -Wall -std=c11 badcnt.c -o badcnt -lpthread */
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
static volatile unsigned long cnt = 0; /* 共享全局变量 */
void *incr_thread(void *arg)
{
unsigned long i, niters = (unsigned long)arg;
for (i = 0; i < niters; i++)
cnt++; /* 非原子:L=load, U=update, S=store */
return NULL;
}
int main(int argc, char **argv)
{
unsigned long niters = strtoul(argv[1], NULL, 10);
pthread_t t1, t2;
pthread_create(&t1, NULL, incr_thread, (void *)niters);
pthread_create(&t2, NULL, incr_thread, (void *)niters);
pthread_join(t1, NULL); pthread_join(t2, NULL);
if (cnt != 2 * niters) { printf("FAIL: cnt=%lu (expected %lu)\n", cnt, 2*niters); return 1; }
printf("OK: cnt=%lu\n", cnt);
return 0;
}
修复版只改三行——把 cnt++ 包进二元信号量:
/* goodcnt.c : 编译 gcc -g -Wall -std=c11 goodcnt.c -o goodcnt -lpthread */
#include <semaphore.h>
static sem_t mutex; /* 二元信号量,初值 1 */
void *incr_thread(void *arg)
{
unsigned long i, niters = (unsigned long)arg;
for (i = 0; i < niters; i++) {
sem_wait(&mutex); /* P(S):进入临界区 */
cnt++; /* 临界区 wrt cnt */
sem_post(&mutex); /* V(S):离开临界区 */
}
return NULL;
}
int main(int argc, char **argv) {
/* ... 同上 ... */
sem_init(&mutex, 0, 1); /* pshared=0,线程间共享;初值 1 */
/* ... create / join ... */
sem_destroy(&mutex);
}
【代码做什么?】
- 主线程把
niters强转成void *传给两个线程(讲义认可的”cast of int/long”传参法)。 - 两个
incr_thread各循环niters次读-改-写cnt。 main用pthread_join等待结束,再检查cnt == 2*niters。badcnt中三条指令可被任意切开;goodcnt中sem_wait/sem_post把整段包成一个”禁止区域”。
【底层机制透视】 sem_wait 内部是一条 lock xadd(原子读-改-写,锁住缓存行并广播失效),”判断 $s>0$”与”$s-1$”之间不存在窗口;而 cnt++ 的 movq/addq/movq 之间存在窗口——另一线程可在窗口中把 cnt 从 0 改到 1,而本线程 %rax 仍是 0,最后把 0 写回,丢失一次自增。注意 volatile 只阻止编译器优化,不提供原子性,badcnt 里加它是为了不让编译器把循环优化掉。
【与汇编 / 硬件的对应】 真实的 gcc -O0 -S 输出(普通 vs _Atomic):
# 普通 unsigned long: # _Atomic unsigned long:
.L3: .L6:
movq cnt(%rip), %rax movl $1, -20(%rbp)
addq $1, %rax movl -20(%rbp), %eax
movq %rax, cnt(%rip) cltq
addq $1, -8(%rbp) lock xaddq %rax, acnt(%rip)
cmpq $2, -8(%rbp) movq %rax, -16(%rbp)
jle .L3 addq $1, -8(%rbp)
jle .L6
# 3 条指令 = 不安全区域的几何来源 # 1 条带 lock 前缀的指令 = 不安全区域消失
【实测验证】(两台命令均真实执行,-lpthread 链接;本机 128 核):
$ ./badcnt 100000
FAIL: cnt=127014 (expected 200000)
$ ./badcnt 100000
FAIL: cnt=188915 (expected 200000)
$ ./badcnt 100000
FAIL: cnt=145590 (expected 200000)
$ ./badcnt 100000
FAIL: cnt=159203 (expected 200000)
$ ./goodcnt 100000
OK: cnt=200000
$ ./goodcnt 100000
OK: cnt=200000
$ ./goodcnt 100000
OK: cnt=200000
$ ./goodcnt 100000
OK: cnt=200000
即使加 -O2,badcnt 依然会失败(实测 cnt=163023、cnt=170219 交替出现,偶尔”碰巧”等于 200000)——竞态的不确定性正是它最难调试的地方:程序可能 100 次都对,第 101 次错。
22.3.2 有界缓冲区生产者-消费者(多生产者 + 多消费者)
代码 (C):
/* sbuf_pc.c : 编译 gcc -g -Wall -std=c11 sbuf_pc.c -o sbuf_pc -lpthread */
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
#define N 4 /* 缓冲区槽位数 */
#define NITERS 5 /* 每个生产者/消费者的次数 */
typedef struct {
int buf[N];
int n, front, rear;
sem_t mutex; /* 保护 buf/front/rear */
sem_t slots; /* 空槽位数,初值 n */
sem_t items; /* 已填 item 数,初值 0 */
} sbuf_t;
static sbuf_t sbuf;
static void sbuf_init(sbuf_t *sp, int n) {
sp->n = n; sp->front = sp->rear = 0;
sem_init(&sp->mutex, 0, 1);
sem_init(&sp->slots, 0, n);
sem_init(&sp->items, 0, 0);
}
static void sbuf_insert(sbuf_t *sp, int item) {
sem_wait(&sp->slots); /* 1) 等一个空槽位 */
sem_wait(&sp->mutex); /* 2) 锁住缓冲区 */
sp->buf[(++sp->rear) % (sp->n)] = item;
sem_post(&sp->mutex); /* 3) 解锁 */
sem_post(&sp->items); /* 4) 通知有新 item */
}
static int sbuf_remove(sbuf_t *sp) {
int item;
sem_wait(&sp->items); /* 1) 等一个 item */
sem_wait(&sp->mutex); /* 2) 锁住缓冲区 */
item = sp->buf[(++sp->front) % (sp->n)];
sem_post(&sp->mutex); /* 3) 解锁 */
sem_post(&sp->slots); /* 4) 通知有空槽位 */
return item;
}
static void *producer(void *arg) {
long id = (long)arg;
for (int i = 0; i < NITERS; i++) sbuf_insert(&sbuf, (int)(id * 100 + i));
return NULL;
}
static void *consumer(void *arg) {
int sum = 0;
for (int i = 0; i < NITERS; i++) sum += sbuf_remove(&sbuf);
printf("consumer total = %d\n", sum);
return NULL;
}
int main(void) {
pthread_t p[2], c[2];
sbuf_init(&sbuf, N);
for (long i = 0; i < 2; i++) pthread_create(&p[i], NULL, producer, (void *)i);
for (long i = 0; i < 2; i++) pthread_create(&c[i], NULL, consumer, (void *)i);
for (int i = 0; i < 2; i++) pthread_join(p[i], NULL);
for (int i = 0; i < 2; i++) pthread_join(c[i], NULL);
sem_destroy(&sbuf.mutex); sem_destroy(&sbuf.slots); sem_destroy(&sbuf.items);
return 0;
}
【代码做什么?】 4 线程(2 生产者 + 2 消费者),每个生产者插入 5 个唯一编号 item(id*100+i),每个消费者取出 5 个。slots 归零时生产者自动阻塞,items 递增时唤醒消费者。
【底层机制透视】 front/rear 按 mod n 循环前进,二者之差恒等于 items,维护不变量 $\text{slots} + \text{items} + \text{占用} = n$。sem_wait(&slots) 在 sem_wait(&mutex) 之前(先资源后锁);sem_post(&mutex) 在 sem_post(&items) 之前(先放锁后通知)。两个顺序都不能颠倒。
【实测验证】 真实运行输出(timeout 10 ./sbuf_pc,退出码 0):
buf size N=4, producers=2, consumers=2, each produces/consumes 5
[producer 584] insert 0 -> rear=1 front=0
[producer 584] insert 1 -> rear=2 front=0
[producer 584] insert 2 -> rear=3 front=0
[producer 584] insert 3 -> rear=0 front=0 <- 缓冲区满,rear 回绕到 0
[consumer 176] remove 0 <- front=1 rear=0
[consumer 176] remove 1 <- front=2 rear=0
[consumer 176] remove 2 <- front=3 rear=1
[consumer 176] remove 3 <- front=0 rear=1
[consumer 176] remove 4 <- front=1 rear=1
[consumer 176] total = 10
[producer 880] insert 100 -> rear=2 front=1
[producer 880] insert 101 -> rear=3 front=1
[producer 880] insert 102 -> rear=0 front=1
[producer 880] insert 103 -> rear=1 front=1
[producer 584] insert 4 -> rear=1 front=2
[consumer 472] remove 100 <- front=2 rear=1
[consumer 472] remove 101 <- front=3 rear=1
[consumer 472] remove 102 <- front=0 rear=1
[producer 880] insert 104 -> rear=2 front=1
[consumer 472] remove 103 <- front=1 rear=2
[consumer 472] remove 104 <- front=2 rear=2
[consumer 472] total = 510
done: 2*5 items produced and consumed.
两条断言在输出中可验证:消费者 176 取到 0,1,2,3,4,消费者 472 取到 100..104——没有 item 被重复取出或丢失;rear/front 在 0–3 间正确回绕。
22.3.3 读者-写者(第一类,读者优先)
代码 (C):
/* rw1.c : 编译 gcc -g -Wall -std=c11 rw1.c -o rw1 -lpthread */
#define _DEFAULT_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
static int readcnt = 0;
static sem_t mutex; /* 保护 readcnt */
static sem_t w; /* 写者互斥 / 读者与写者互斥 */
static int shared_data = 0;
static volatile int stop = 0;
static void *reader(void *arg) {
long id = (long)arg;
while (!stop) {
sem_wait(&mutex);
readcnt++;
if (readcnt == 1) sem_wait(&w); /* 第一个读者拦住写者 */
sem_post(&mutex);
printf("reader %ld: read shared_data = %d (readcnt=%d)\n", id, shared_data, readcnt);
usleep(1000); /* 模拟读耗时 */
sem_wait(&mutex);
readcnt--;
if (readcnt == 0) sem_post(&w); /* 最后一个读者放行写者 */
sem_post(&mutex);
usleep(1000);
}
return NULL;
}
static void *writer(void *arg) {
long id = (long)arg;
for (int i = 0; i < 3; i++) {
sem_wait(&w);
shared_data++;
printf("writer %ld: wrote shared_data = %d\n", id, shared_data);
usleep(2000);
sem_post(&w);
usleep(3000);
}
return NULL;
}
int main(void) {
pthread_t r[3], wt[2];
sem_init(&mutex, 0, 1); sem_init(&w, 0, 1);
for (long i = 0; i < 3; i++) pthread_create(&r[i], NULL, reader, (void *)i);
for (long i = 0; i < 2; i++) pthread_create(&wt[i], NULL, writer, (void *)i);
for (int i = 0; i < 2; i++) pthread_join(wt[i], NULL);
stop = 1; usleep(10000);
for (int i = 0; i < 3; i++) pthread_join(r[i], NULL);
printf("done: final shared_data = %d (expected 6)\n", shared_data);
return 0;
}
【代码做什么?】 3 个读者循环读、2 个写者各写 3 次。readcnt 是共享变量,必须由 mutex 保护。第一个读者(0→1)去抢 w;最后一个读者(1→0)释放 w。
【底层机制透视】 关键在”计数 + 首末哨兵”:w 只在读者数 0→1 与 1→0 时被触碰,故 $k$ 个并发读者只付常数次 P/V。mutex 不可省:若两读者同时 readcnt++,可能”都以为自己是第一个”(第二次 sem_wait(&w) 白白阻塞)或”都以为自己不是最后一个”(w 永不释放)。
【实测验证】 真实输出(节选,退出码 0):
reader 0: read shared_data = 0 (readcnt=1)
reader 1: read shared_data = 0 (readcnt=2)
reader 2: read shared_data = 0 (readcnt=3) <- 三个读者同时持有读权限
writer 0: wrote shared_data = 1
writer 1: wrote shared_data = 2
reader 0: read shared_data = 2 (readcnt=1)
...
writer 1: wrote shared_data = 6
done: final shared_data = 6 (expected 6)
readcnt=3 证明读者确实并发;shared_data 单调递增且无丢失证明写者互斥正确。
22.3.4 死锁演示:加锁顺序不一致
代码 (C)(⚠️ 仅供演示,请勿模仿):
/* deadlock.c : 编译 gcc -g -Wall -std=c11 deadlock.c -o deadlock -lpthread */
#define _DEFAULT_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
static pthread_mutex_t m1 = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t m2 = PTHREAD_MUTEX_INITIALIZER;
static void *t0(void *arg) { /* 顺序: m1 -> m2 */
(void)arg;
pthread_mutex_lock(&m1); printf("T0: locked m1\n"); fflush(stdout);
usleep(200000);
printf("T0: waiting for m2 ...\n"); fflush(stdout);
pthread_mutex_lock(&m2); /* <= 永远等不到 */
pthread_mutex_unlock(&m2); pthread_mutex_unlock(&m1);
return NULL;
}
static void *t1(void *arg) { /* 顺序: m2 -> m1 —— 相反! */
(void)arg;
pthread_mutex_lock(&m2); printf("T1: locked m2\n"); fflush(stdout);
usleep(200000);
printf("T1: waiting for m1 ...\n"); fflush(stdout);
pthread_mutex_lock(&m1); /* <= 永远等不到 */
pthread_mutex_unlock(&m1); pthread_mutex_unlock(&m2);
return NULL;
}
int main(void) {
pthread_t a, b;
printf("pid = %d\n", (int)getpid()); fflush(stdout);
pthread_create(&a, NULL, t0, NULL);
pthread_create(&b, NULL, t1, NULL);
pthread_join(a, NULL); pthread_join(b, NULL);
printf("never reached: no deadlock?\n"); /* 永远不会打印 */
return 0;
}
【实测验证】 用 timeout 退出码与 gdb attach 双重确认:
$ timeout 5 ./deadlock
pid = 3623184
T0: locked m1
T1: locked m2
T0: waiting for m2 ...
T1: waiting for m1 ...
$ echo $?
124 # 124 = timeout 到点强杀 => 程序确实卡死
$ ./deadlock & sleep 1.2 ; gdb -p $(pgrep -n -x deadlock) -batch -ex "thread apply all bt"
Thread 3 (LWP 3629611):
#0 __lll_lock_wait () <- 阻塞在 futex 上
#1 pthread_mutex_lock ()
#2 t1 (arg=0x0) at deadlock.c:37 <- T1 等 m1
Thread 2 (LWP 3629610):
#0 __lll_lock_wait ()
#1 pthread_mutex_lock ()
#2 t0 (arg=0x0) at deadlock.c:23 <- T0 等 m2
Thread 1 (LWP 3629608):
#0 __futex_abstimed_wait_common ()
#1 __pthread_clockjoin_ex ()
#2 main () at deadlock.c:50 <- main 卡在 pthread_join
$ grep -E "^(State|Threads)" /proc/<pid>/status
State: S (sleeping)
Threads: 3
两个线程各自停在 deadlock.c:23 与 deadlock.c:37 这两行 pthread_mutex_lock 上——__lll_lock_wait 说明它们已进入内核 futex 睡眠,是真死锁,CPU 占用为 0。
【补充演示:生产者-消费者顺序错误也会死锁】 把 22.2.5 的顺序陷阱写成程序(pcdeadlock.c:生产者 wait(mutex) → wait(slots),消费者正确):
$ timeout 5 ./pcdeadlock
pid = 3631668
producer: try acquire mutex (i=0)
producer: got mutex, now wait slots
producer: got slot
producer: try acquire mutex (i=1)
producer: got mutex, now wait slots
producer: got slot
producer: try acquire mutex (i=2)
producer: got mutex, now wait slots <- 缓冲区满(N=2),持锁等空位
consumer: got item, try mutex <- 消费者想拿 mutex 腾空位,被挡住
$ echo $?
124 # 死锁成立
这正是 22.2.5 的机制在真机上的复现:生产者攥着 mutex 等 slots,而能增加 slots 的消费者必须拿到 mutex。
22.3.5 临界区性能:加锁的代价与可扩展性
讲义给出的实测三档(同一计数器、同一工作量),下表中的本机数据经真实测量:
| 版本 | 讲义耗时 | 本机实测(2 线程 × 1,000,000 次,-O2) |
|---|---|---|
无锁(cnt++,结果错误) | 0.48 s | 0.000 s(被优化掉/极快,但结果不可信) |
pthread_mutex | 15 s | 0.099 s |
sem_wait/sem_post | 27.6 s | 0.208 s(约为 mutex 的 2.1×) |
_Atomic + lock addq | 3.41 s | 0.012 s(约为 mutex 的 1/8) |
结论一致:mutex 比信号量快,原子操作比 mutex 更快一个数量级——这与讲义的排序完全相同,也正是”能用 mutex 就别用信号量,能用原子操作就别用锁“的量化依据。
可扩展性(scalability):把总工作量固定为 16,000,000 次加锁自增,只改变线程数(本机真实测量):
TOTAL work = 16000000 lock-protected increments
threads time(s) Mops/s speedup
1 0.130 122.92 1.00
2 0.489 32.72 0.27
4 0.680 23.51 0.19
8 1.087 14.72 0.12
线程越多越慢——加锁把并行计算变成了串行队列:每次 lock 都要独占那条缓存行,其余核心空转等待。这是 Amdahl 定律在同步原语上的体现:临界区占比 $p$ 时加速比上限为 $1/p$;临界区本质串行则 $p \to 1$,上限是 1。这也解释了 recitation 为何要把”一棵树一把锁”改成”一个结点一把锁”:粒度缩小,$p$ 才真正下降。
22.4 实验关联
- L8 SFS Lab(tiny 文件系统):多线程服务器会并发调用
open/read/write/stat,底层是同一份tiny状态。必须给共享元数据(目录树、空闲链表、引用计数)加锁。 - L7 Proxy Lab(缓存):缓存是天然的读者-写者对象——大量请求线程查缓存(读者),只有 miss 后的插入/淘汰才写。单一 mutex 会让全部请求串行。注意 22.2.6 的提醒:若读者命中后要更新 LRU 链表,它其实在写,不能当纯读者。
- 本讲的坑:① SFS 中给”每个操作”加锁太粗、”每个字段”加锁太细易死锁,中间粒度(一个文件/一个目录项一把锁)通常最稳;② 代理缓存的”淘汰”与”插入”若分两次拿锁,会出现”刚放进去就被淘汰”的窗口;③ 加锁顺序必须全局统一(先缓存锁后日志锁,绝不可反之)。
22.5 常见错误与调试技巧
- 数据竞态(data race):未同步地并发读写同一变量。调试:
valgrind --tool=helgrind ./badcnt 2000——实测输出Possible data race during write of size 8 at 0x404070 by thread #3,而goodcnt为ERROR SUMMARY: 0 errors(本机-fsanitize=thread因 ASLR 冲突无法运行)。 - 忘记初始化 / 忘记销毁信号量:漏
sem_init→ 死锁或随机唤醒(UB);漏sem_destroy→ 资源泄漏。调试:valgrind --leak-check=full。 - 死锁(deadlock):现象是 CPU 占用为 0 但永不结束。调试:
timeout 5 ./prog; echo $?(返回 124 即卡死),再gdb -p $(pgrep -n -x prog) -batch -ex "thread apply all bt",看是否所有线程都停在__lll_lock_wait;用grep State /proc/<pid>/status确认是S (sleeping)。 - 信号量 P/V 不配对:计数器漂移导致越界或永久阻塞。调试:在
sem_wait前后sem_getvalue(&s,&v)打点看值是否漂移;或strace -f -e trace=futex ./prog看 futex 是否卡死。 - 加锁顺序不一致:只在特定调度下复现,最隐蔽。调试:为每把锁编号并要求严格递增;用
pthread_mutexattr_settype(&a, PTHREAD_MUTEX_ERRORCHECK)让错误用法返回EDEADLK而非静默卡死。 - 持锁调用阻塞函数:
malloc/printf/write内部有锁,易与自定义锁形成循环等待。调试:perf stat -e context-switches,cpu-migrations ./prog看上下文切换是否异常升高;strace -f -e trace=write,writev ./prog看临界区内是否有系统调用。 - 虚假共享(false sharing):两线程各改不同变量但落在同一缓存行(64 B),表现为本质串行。调试:
perf stat -e cache-misses,cache-references ./prog;把计数器用__attribute__((aligned(64)))对齐到独立缓存行。
22.6 关键要点
- “共享”取决于实例而非声明:变量 $x$ 是共享的,当且仅当它的某个实例被多于一个线程引用——局部
static变量是共享的,errno不是。 - 把不安全区域排除在轨迹之外就是互斥:
L/U/S三条指令之间的窗口是不安全区域的几何来源,mutex 制造的禁止区域必须完整包住它。 - 信号量的真正价值是”等待事件”,不是”当锁用”:它比 mutex 慢(实测约 2.1×),只有它能表达计数与顺序;能用 mutex 就别用信号量。
- 生产者-消费者三条命:先等资源后拿锁,先放锁后通知;
mutex管buf/front/rear,slots/items只管计数,二者不可互相替代。 - 加锁顺序全局一致是避免死锁的第一原则:循环等待 = 死锁,破坏它的最简单方法就是给所有锁规定一个全序。
- 锁粒度决定可扩展性:临界区越”串行”,Amdahl 上限越低;实测固定工作量下线程数 1→8,吞吐反而降到 12%。
22.7 思考题(带答案)
题 1(推演题·判断题):某生产者-消费者程序,缓冲区大小 $n = 4$,三个信号量初值为 mutex=1、slots=4、items=0。生产者代码写成 sem_wait(&mutex); sem_wait(&slots); ...,消费者代码写成 sem_wait(&items); sem_wait(&mutex); ...。问:(a) 系统是否可能死锁?(b) 若可能,给出最短的死锁触发序列并说明此时各信号量的值。
答:(a) 会死锁。(b) 生产者先执行 4 次完整插入,4 次后 slots=0, items=4, mutex=1,缓冲区满。第 5 次生产者第一步 sem_wait(&mutex) 成功(mutex=0),第二步 sem_wait(&slots) 因 slots=0 阻塞,此时生产者持有 mutex。消费者随后第一步 sem_wait(&items) 成功(items=3),第二步 sem_wait(&mutex) 因 mutex=0 阻塞。状态:mutex 被生产者持有且生产者阻塞在 slots 上,消费者阻塞在 mutex 上;slots 只能由消费者增加,而消费者进不去临界区 → 循环等待,永久死锁。若消费者在缓冲区满之前取走了 item 则不触发——这是取决于调度的 bug,最是危险。
题 2(推演题·饥饿分析):给定 22.2.6 的第一类读者-写者代码(读者优先),假设读者源源不断到达,且总是能在任何写者被唤醒之前完成”wait(mutex); readcnt++; ...; readcnt--; post(mutex)“。请证明写者会饥饿;并给出一个能打破饥饿、且不破坏读者并发性的改法。
答:只要写者准备 sem_wait(&w) 时 readcnt > 0,它就必须等待;而读者源源不断到达,新读者看到 readcnt>0 就不会去碰 w(只有”第一个读者”才 wait(&w)),因此 readcnt 永远回不到 0,w 永不释放——写者饥饿(starvation)。改法:引入 turnstile(旋转门)信号量,初值 1。读者先 sem_wait(&turnstile),登记 readcnt(第一个读者抢 w),随后立刻 sem_post(&turnstile);写者先 sem_wait(&turnstile),抢 w,写完再 sem_post(&turnstile)。效果:写者一到就关门,后续读者全部排在其后(不再饥饿),而已进入的读者仍可并发。注意:若把读者写成”进入 sem_wait(&w)、退出 sem_post(&w)“,读者之间就完全串行——那是”能跑但性能糟糕”的错误设计。
题 3(”想法错在哪”):小张认为”既然 slots 和 items 已经把生产者和消费者管得严严实实——有空位才能放、有 item 才能取——那 mutex 完全是多余的,删掉能提高性能”。这个想法错在哪?请给出一个具体的最短反例。
答:错在把”计数正确“当成了”数据访问正确“。slots/items 保证”什么时候可以动”,不保证”动的时候没有别人同时在动”。反例:$n=4$、slots=2,两个生产者 P1、P2 同时 sem_wait(&slots) 各拿到一个空位(两步被原子串行化),随后并发执行 sp->buf[(++sp->rear) % n] = item;。++sp->rear 是读-改-写三步,若两者都读到 rear=3 则都算出下标 0:一个 item 覆盖另一个,而 items 却被 post 两次——计数与实际数据永久错位,消费者最终会取到从未插入的槽位或永久阻塞。同理两个消费者会取出同一槽位两次。结论:slots/items 管”能不能进”,mutex 管”进去别撞车”,职责正交,缺一不可。
题 4(计算/推演题·性能):某临界区代码本身耗时 $C$,加锁/解锁开销为 $L$($L \ll C$),程序其余部分可完美并行。若单线程执行总时间为 $T_1 = C + O$($O$ 为可并行部分),用 Amdahl 定律写出 $N$ 线程的加速比,并解释 22.3.5 中实测”线程数 1→8 吞吐率降到 1/8”说明了什么。
答:串行比例 $p = C/(C+O)$,$N$ 线程加速比 $S(N) = 1/(p + (1-p)/N)$,上界 $S(\infty) = 1/p = (C+O)/C$。当临界区占主导($p \to 1$)时 $S(N) \to 1$,加更多线程完全无益。22.3.5 的实测更严重:吞吐不升反降到 $1/8$,说明瓶颈不是”串行比例”而是锁本身的竞争开销——lock xadd 要独占缓存行、让其他核心副本失效,$N$ 个核心互相弹来弹去(cache line ping-pong),协调成本随 $N$ 增长。因此”锁竞争(lock contention)”比 Amdahl 定律更悲观:临界区极小且竞争激烈时,加线程会主动降低性能。对策是缩小临界区、用读者-写者锁分离读写、用原子操作替代锁,或按数据分片(sharding)消除共享。