Lecture 23: 同步:高级 (Synchronization: Advanced)
Lecture 23: 同步:高级 (Synchronization: Advanced)
讲义对应:CMU 15-213 Lecture 23 — Synchronization: Advanced(素材:
F25-23-sync-advanced.txt) 教材对应:CS:APP3e 第 12 章 12.5.4–12.5.5、12.7–12.8 关联 Lab:L8 SFS Lab(并直接支撑 L7 Proxy Lab 的并发缓存)
23.1 概述
互斥只是同步的起点:一旦出现时序依赖、多把锁嵌套获取、反复调用的库函数,就会冒出竞争(race)、死锁(deadlock)、饥饿(starvation)三类更隐蔽的故障。本讲把同步从”会用锁”推进到”会设计并发系统”,直接决定 SFS Lab 里”临界区识别”的成败——SFS 的超级块、inode 位图、目录条目全是共享资源,锁错一处文件系统就会静默损坏。
23.2 核心概念与底层机制图解
23.2.1 预线程化并发服务器(Prethreading)
- 定义与目的:主线程在
accept之前先创建一族固定数量的工作线程(worker pool),再用有界缓冲区(bounded buffer)把连接的connfd传给它们。它消除”每连接一线程”的两个缺陷:每条新连接都要付一次pthread_create(内核复制 task_struct 并分配栈),以及并发无上限时线程总数爆炸。像餐厅后厨——主线程只领位,N 位厨师谁空了谁自己去候客区领。 - 底层机制图解:
主线程 MAIN THREAD (producer) 工作线程池 WORKER POOL (N 个 consumer)
+---------------------------------+ +------------------------------------------+
| sbuf_init(&sbuf); | | void *worker(void *vargp) |
| | | { |
| /* 先建线程池,再 accept */ | | pthread_detach(pthread_self()); <== *|
| for (i = 0; i < N; i++) | | for (;;) { |
| pthread_create(&tid, | | connfd = sbuf_remove(&sbuf); |
| NULL, worker, | | process(connfd); |
| NULL); | | close(connfd); |
| | | } |
| for (;;) { | | } |
| connfd = accept(listenfd, ...); | |
| sbuf_insert(&sbuf, connfd); | | N 个线程阻塞在同一个 sbuf 上; |
+---------------------------------+ +------------------------------------------+
| ^
| sbuf_insert() | sbuf_remove()
| 投递 connfd | 取走 connfd
v |
+----------------------------------------------------------------------------------+
| 有界缓冲区 sbuf_t sbuf (CS:APP 图 12.26) |
| struct { int buf[16]; int front, rear, n; |
| sem_t mutex; /* init 1 */ |
| sem_t slots; /* init 16 */ sem_t items; /* init 0 */ }; |
| 下标 0 1 2 3 4 5 6 7 ... 15 |
| 内容 4 9 10 14 -- -- -- -- ... -- |
+----------------------------------------------------------------------------------+
环形下标与”空槽位/条目”计数:
sbuf 环形缓冲区,SBUFSIZE = 8,当前 n = 3、front = 5、rear = 0
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
-----+---+---+---+---+---+---+---+---+
内容 | | | | | | A | B | C |
-----+---+---+---+---+---+---+---+---+
^rear=0 ^front=5
insert: P(slots); buf[rear]=x; rear=(rear+1)%8; n++; V(items)
remove: P(items); x=buf[front]; front=(front+1)%8; n--; V(slots)
- 与硬件的对应:
sem_wait/sem_post在 glibc 里走 futex——无竞争时只在用户态做一次原子lock xadd,有竞争时才syscall 202 (futex)挂起。 - 关键细节:工作线程必须自己
pthread_detach(pthread_self())。它”永久循环、永不返回”,主线程从不join;若不 detach,线程退出时其 TCB 与栈永不回收。由线程自己调用最稳妥——主线程拿到tid时它可能已退出。 - 与”每连接一线程”的对比:
pthread_create开销从”每连接一次”降为”启动时一次”,并发上限从”无限”变为”可控的 N”,并天然获得限流;代价是多一次入队/出队。
23.2.2 线程安全与可重入(Thread Safety and Reentrancy)
- 定义:函数线程安全(thread-safe),当且仅当它被多个并发线程反复调用时总能产生正确结果——该定义同时覆盖单次调用内部的竞态与跨调用的状态污染。
- 四类线程不安全函数:
| 类别 | 根本原因 | 典型例子 | 修复方法 | 代价 |
|---|---|---|---|---|
| 第 1 类 | 不保护共享变量 | badcnt 里的 cnt++;自写 malloc 空闲链表 | 函数首尾加 pthread_mutex_lock/unlock | 加锁降性能;多把锁引入死锁 |
| 第 2 类 | 状态跨越多次调用保持 | rand(依赖全局 next)、strtok、srand | 状态改为调用者传入的参数(rand_r(nextp)) | 必须改 API,调用者负责分配状态空间 |
| 第 3 类 | 返回指向静态变量的指针 | ctime、asctime、localtime、gethostbyname、inet_ntoa、itoa | lock-and-copy:锁内调用并把结果拷到调用者私有存储;或改用 strftime/getaddrinfo | 需改签名;调用者要知道缓冲多大 |
| 第 4 类 | 调用了上述三类中任一个 | 任何内部用了 rand/strtok 的函数(传递性) | 只调用线程安全版本(_r 后缀) | 可能触发连锁 API 改动 |
第 2、3 类的要害是加锁治不好:给 rand 加锁,两线程各调 100 次的序列仍与”单线程调 200 次”不同。第 3 类更微妙:char *s = ctime(&t); 拿到指针后锁已释放,从解锁到使用 s 之间存在窗口,别的线程任何一次 ctime 都会覆盖该缓冲区。
- 可重入与线程安全:函数可重入(reentrant),当且仅当它被多线程调用时不访问任何共享变量,故不需要同步操作,可重入 ⇒ 线程安全;反之不成立(加锁的
rand仍改全局next)。显式可重入指函数内部完全不碰共享变量;隐式可重入指改成局部变量后再通过指针参数由调用者传入(如rand_r(&seed))。
+-----------------------------------------------------------------------+
| 所有函数 (all functions) |
| |
| +-----------------------------+ +--------------------------------+ |
| | 线程不安全 (thread-unsafe) | | 线程安全 (thread-safe) | |
| | | | | |
| | 第 1 类 不保护共享变量 | | 需要同步操作 (mutex / sem) | |
| | 第 2 类 跨调用保持状态 | | | |
| | 第 3 类 返回静态变量指针 | | +--------------------------+ | |
| | 第 4 类 调用上面三类 | | | 可重入 (reentrant) | | |
| | | | | 不访问任何共享变量 | | |
| | 修复:加锁 / 改 API / | | | 不需要任何同步操作 | | |
| | lock-and-copy | | | 可重入 ==> 线程安全 | | |
| | | | +--------------------------+ | |
| +-----------------------------+ +--------------------------------+ |
+-----------------------------------------------------------------------+
23.2.3 竞争(Races)
- 定义:竞争发生在一个程序的正确性依赖于一个线程要在另一个线程到达 y 点之前到达 x 点——这涵盖一切时序假设,比”多线程写同一变量”更宽。
- 互斥锁解决不了的三类竞争:①
cnt++(无锁时两线程各加 10 000 次结果不到 20 000,因为addq $1, cnt(%rip)是读-改-写三步)——这类可以加锁修好。②pthread_create(&tid[i], NULL, thread, &i)把i的地址传给线程,等线程真正读*(int *)vargp时主线程早已把i推得更大;锁改变不了”传的是地址”,必须拷贝数据(改传值(void *)i)。③ TOCTOU(time of check to time of use):先access("myfile.txt", R_OK)再fopen,两次调用之间文件可能已被删;修复原则是”不要先检查,直接使用并处理失败“。信号处理程序与主程序之间同样有 TOCTOU。 - 三条总策略:① 不共享状态(
malloc给每线程复制参数);② 不检查就使用;③ 使用同步原语。检测首选ThreadSanitizer(gcc -fsanitize=thread),其次valgrind --tool=helgrind/--tool=drd。
23.2.4 死锁(Deadlock)与进度图
- 定义:程序死锁,当它在等待一个永远不可能发生的事件——讲义强调这是数学意义上的不可能,而不只是实践上的困难。常见形式:两线程互相等待,成因是加锁顺序不一致(inconsistent lock ordering)。
- 进度图(progress graph):把两线程各自执行到的”指令数”当横纵坐标,状态空间是二维网格。某区域对锁 a 而言禁止(forbidden region),表示”两线程同时持有 a”——互斥锁语义直接排除。死锁区域(deadlock region)就是两条禁止带的交集:进入该区域的轨迹停在死锁状态,无法向上或向右移动。
(A) 不一致的加锁顺序:T0 = L(a)->L(b)->U(b)->U(a) T1 = L(b)->L(a)->U(a)->U(b)
Thread 0 i = 已完成的指令数 -->
i=0 i=1 i=2 i=3 i=4
+-----+-----+-----+-----+-----+
j=4 | | a | X | a | |
+-----+-----+-----+-----+-----+
j=3 | X | X | X | X | X |
+-----+-----+-----+-----+-----+
j=2 | X | X | X | X | X |
+-----+-----+-----+-----+-----+
j=1 | b | X | X | X | b |
+-----+-----+-----+-----+-----+
j=0 | | a | X | a | |
+-----+-----+-----+-----+-----+
j = Thread 1 已完成的指令数 -->
(B) 一致的加锁顺序:T0 与 T1 都按 L(a)->L(b)->U(b)->U(a)
Thread 0 i = 已完成的指令数 -->
i=0 i=1 i=2 i=3 i=4
+-----+-----+-----+-----+-----+
j=4 | | a | X | a | |
+-----+-----+-----+-----+-----+
j=3 | a | a | X | a | a |
+-----+-----+-----+-----+-----+
j=2 | X | X | X | X | X |
+-----+-----+-----+-----+-----+
j=1 | a | a | X | a | a |
+-----+-----+-----+-----+-----+
j=0 | | a | X | a | |
+-----+-----+-----+-----+-----+
j = Thread 1 已完成的指令数 -->
图 A 的死锁状态是 (i,j) = (1,1):T0 持 a 等 b、T1 持 b 等 a,向右走落入”禁止区域 for b”、向上走落入”禁止区域 for a”,被彻底卡住。图 B 两条禁止带不再相交,死锁区域消失;代价是整个 3×3 内部方块全属 a 的禁止区域,任何交错轨迹都进不去,两线程被强制串行化。轨迹的微小差异让程序”有时死、有时不死”——死锁 bug 是不确定的(nondeterministic)。
- 避免死锁的规则清单:
- 加锁顺序(lock ordering):约定全局唯一的锁编号,所有线程一律按编号从小到大加锁(解锁顺序无所谓)。
- 减小临界区中同时持有的锁数:能合并成一把(粗粒度)就合并,牺牲并行度换确定性。
- 用
pthread_mutex_trylock或带超时加锁:失败就释放已持有的锁再重试;可能引入活锁,需要退避(backoff)。 - 消除 hold-and-wait:把”先申请 A 再申请 B”改成”一次性申请全部”或”申请前先释放”。
- 不要从持有锁的代码里回调用户代码(回调可能去拿别的锁)。
- 不要从信号处理程序里调用会加锁的库函数(见 23.5)。
- 哲学家就餐问题:5 位哲学家围坐,每人需同时拿到左右两把叉子。若所有人同时先拿左边叉子,就形成环形 hold-and-wait,全部饿死。解法之一是给叉子编号并规定”先拿编号小的”。
23.2.5 条件变量(Condition Variable)
- 定义与目的:条件变量让线程在某个条件为假时挂起,并在条件可能变真时被唤醒,解决”轮询忙等浪费 CPU”的问题。它自身不提供互斥,总与一把互斥锁配对使用。
- 接口表:
| 接口 | 语义 | 备注 |
|---|---|---|
pthread_cond_init(pthread_cond_t *c, const pthread_condattr_t *a) | 动态初始化(a 传 NULL 用默认属性) | 也可用静态初始化器 PTHREAD_COND_INITIALIZER |
pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m) | 原子地:释放 m → 把本线程加入 c 的等待队列并挂起 → 被唤醒后重新获取 m → 返回 | 调用前必须已持有 m;返回时也持有 m |
pthread_cond_signal(pthread_cond_t *c) | 唤醒 c 等待队列中的至少一个线程 | 不保证唤醒谁,也不保证立即运行 |
pthread_cond_broadcast(pthread_cond_t *c) | 唤醒 c 等待队列中的全部线程 | 用于条件变化影响所有等待者的场景 |
pthread_cond_destroy(pthread_cond_t *c) | 销毁条件变量 | 必须在无等待者时调用 |
pthread_cond_wait的三步语义:核心是把”释放锁”与”挂起”做成一个原子操作——若两步之间有窗口,通知者就能发出”谁也收不到”的信号,让等待者永远睡下去(经典的 lost wakeup)。醒来时必须重新获得互斥锁,因此从wait返回到继续执行的区间里,锁可能已多次易主、条件可能已被改回假。- 必须用
while而不是if:
CONSUMER C (等待者) PRODUCER P (通知者)
|
pthread_mutex_lock(&m); | pthread_mutex_lock(&m);
while (count == 0) { <== 必须 while | /* 拿到 C 刚空出的锁 */
pthread_cond_wait(&cv,&m) { |
/*1*/ 原子地 unlock(&m) | count = 1;
/*2*/ 加入 cv 等待队列 | buf[0] = item;
/*3*/ park,不占 CPU |
| pthread_cond_signal(&cv);
/*4*/ 被唤醒 | /* 把 C 挪到 mutex 队列 */
-> 重新 lock(&m) |
/*5*/ wait 返回 | pthread_mutex_unlock(&m);
} <== 锁可能已易主, |
} count 可能又变成 0 | signal 只是"通知",不是"交接":
| C 必须重新竞争锁,
item = buf[--count]; | 并且必须回到 while
pthread_mutex_unlock(&m); | 再检查一次条件。
用 if 会出错的三种情形:① 虚假唤醒(spurious wakeup)——pthread_cond_wait 允许在无 signal 时返回(POSIX 明确许可),if 版会直接往下走读到非法数据;② broadcast 惊群——唤醒多个消费者却只有一份数据;③ 条件被抢先改变——别的线程可能在你重新拿到锁之前把条件改回假。while 把”重新检查”变成结构性保证。用错 signal 会导致 lost wakeup。
- 用条件变量实现屏障与生产者-消费者:屏障用”计数器 +
broadcast“:每线程到达时count++;若count < N则wait,否则清零并broadcast。
23.2.6 信号量的更多用法:事件、队列与屏障
- 与互斥锁的本质区别:信号量是非负整数,支持 P(
sem_wait,减 1,为 0 则等待)与 V(sem_post,加 1 并可能唤醒一个等待者)。关键在于任何线程都可以执行 P 和 V,没有所有权约束——互斥锁只有加锁者能解锁。这正是它能做事件通知的原因:”Can’t do this with a mutex. Why? Only thread that locked the mutex can unlock it.” - 队列:1 槽位队列只需
full与empty两个信号量(初值 0 和 1)。为什么 1 个槽位却要两个信号量? 因为生产者之间争夺空槽位、消费者之间争夺条目,两类等待的原因不同,必须放在不同信号量上排队,否则会互相误导。n 槽位队列需mutex(保护内部结构)+slots(空槽位,初值 n)+items(条目,初值 0);必须先 P 计数信号量再 P 互斥量,否则会以”拿着锁去等待”的方式死锁。 - 屏障:简版”最后一个到达者放行所有人”不能循环复用(下一轮会立刻穿过去)。可复用版本用两阶段 + sense 翻转:每线程为其余 N−1 个线程各 V 一次、自己最后 P 一次;再用
phase在两个闸门信号量间交替,避免上一轮的 V 泄漏到下一轮。
23.2.7 读者-写者锁与饥饿
- 问题陈述:读者只读取对象,写者修改对象;写者必须独占,读者可无限多个同时访问。常见于机票预订系统、多线程缓存代理(Proxy Lab)。
- Pthreads 接口:
pthread_rwlock_t;pthread_rwlock_rdlock(读锁)、pthread_rwlock_wrlock(写锁)、pthread_rwlock_unlock(共用同一解锁函数)。必须正确使用——”什么操作需要读、什么需要写”完全由程序员判断,这正是 Proxy Lab 里”读缓存算读、插入或淘汰算写”的判断题。 - 饥饿(starvation):线程长时间得不到前进。与死锁的关键区别是饥饿可能最终解套,死锁在数学上不可能解套。
- 读优先 vs 写优先的两难:设线程 1 持读锁、线程 2 等写锁,此时线程 3 申请读锁——”读者优先”会让源源不断的读者饿死 W;”写者优先”则让源源不断的写者饿死读者。保证无饥饿的算法称为公平(fair),它让等待者按先来先服务(FCFS)获得锁;代价是公平可能让所有线程都更慢——”锁护送(lock convoy)问题”。
23.2.8 性能与可扩展性
- 加锁的开销与锁竞争:无竞争时一次
pthread_mutex_lock只需几十纳秒;一旦竞争,失败的线程要陷入内核挂起、稍后唤醒再重新调度,单次代价上升三到四个数量级。因此性能的决定因素不是”有没有锁”,而是”有多少竞争”。 - 为什么”一个全局锁”会成为瓶颈(Amdahl 定律):设临界区占单线程执行时间的比例为 $f$,则 $N$ 个线程的加速比上限为
若 $f = 0.5$,加多少核加速比都超不过 2。更糟的是全局锁还引入额外串行化开销(临界区被拉长、cache line 在核间弹跳)——这正是”核越多反而越慢”的原因:
吞吐量对比(Kops/s,临界区 200 ns,8 核机器)
全局锁 (1 把) 分条带锁 (16 把)
1 核 4125 #################### 4123 ====================
2 核 1929 ######### 5076 ========================
4 核 1001 ##### 6304 ==============================
8 核 454 ## 3365 ================
1 核 -> 8 核:全局锁吞吐 4125 -> 454(只剩 11%,负扩展)
分条带锁 4123 -> 3365 峰值出现在 4 核 (6304)
全局锁从 4125 单调跌到 454 Kops/s(负扩展);分条带锁(按 inode 编号散列到 16 把锁)在 4 核达峰值 6304 Kops/s,是全局锁的 6 倍以上,直到 8 核才因跨条带竞争与内存带宽回落。
- 粗粒度锁 vs 细粒度锁:粗粒度锁(如”锁住整棵 BST”)实现简单、不会因加锁顺序而死锁,但把可并行的操作串行化;细粒度锁(如”每节点一把锁”)能暴露并行性,却引入”加锁顺序”与”锁数量爆炸”的新问题。递归地缩小共享资源范围——整棵树 → 两条分支 → 单个节点——是折中的标准路径。
- 观察工具:
perf record/perf report定位热点锁;valgrind --tool=helgrind/--tool=drd报告数据竞争与加锁顺序违规;ThreadSanitizer比 helgrind 快得多。
23.2.9 内存序与原子操作(补充说明)
编译器与 CPU 都可能重排内存访问(编译期重排、Store Buffer、乱序执行),因此用普通变量做跨线程标志位不可靠:while (!flag) ; 可能被优化成只读一次寄存器而死循环。volatile 只阻止编译器优化掉/合并访问,不提供原子性与内存序保证(见 23.5);正确工具是 __sync_* 与 __atomic_*(可指定 memory_order_relaxed/acquire/release/seq_cst,对应 C11 stdatomic.h)。互斥锁的加解锁隐含 acquire/release 语义,这正是”临界区内的写在解锁前对下一个加锁者可见”的硬件基础;无锁(lock-free)编程用 CAS(__atomic_compare_exchange_n)直接在原子变量上线性化,代价是自行处理 ABA 问题与内存回收。
23.3 代码示例与底层机制分析
以下示例均已在 /tmp 下用 gcc -g -Wall -std=c11 -lpthread 真实编译并运行(零 warning/error),输出为真实结果。
23.3.1 预线程化并发服务器(worker pool + sbuf)
代码 (C):
/* prethreaded.c —— 预线程化回显服务器
* gcc -g -Wall -std=c11 -lpthread prethreaded.c -o prethreaded
* ./prethreaded 12345 4 8 # 端口、工作线程数、并发客户端数
*/
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <netinet/in.h>
#include <pthread.h>
#include <semaphore.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#define MAXLINE 1024
#define SBUFSIZE 16
typedef struct { /* 有界缓冲区(CS:APP 图 12.26) */
int buf[SBUFSIZE];
int front, rear, n;
sem_t mutex, slots, items;
} sbuf_t;
static sbuf_t sbuf;
static void sbuf_init(sbuf_t *sp)
{
sp->front = sp->rear = sp->n = 0;
sem_init(&sp->mutex, 0, 1);
sem_init(&sp->slots, 0, SBUFSIZE);
sem_init(&sp->items, 0, 0);
}
static void sbuf_insert(sbuf_t *sp, int item) /* 生产者:主线程 */
{
sem_wait(&sp->slots); /* ① 等一个空槽位 */
sem_wait(&sp->mutex); /* ② 加锁写结构 */
sp->buf[sp->rear] = item;
sp->rear = (sp->rear + 1) % SBUFSIZE;
sp->n++;
sem_post(&sp->mutex); /* ③ 解锁 */
sem_post(&sp->items); /* ④ 通告"多了一个条目" */
}
static int sbuf_remove(sbuf_t *sp) /* 消费者:工作线程 */
{
int item;
sem_wait(&sp->items); /* ① 等一个条目 */
sem_wait(&sp->mutex); /* ② 加锁读结构 */
item = sp->buf[sp->front];
sp->front = (sp->front + 1) % SBUFSIZE;
sp->n--;
sem_post(&sp->mutex);
sem_post(&sp->slots); /* ③ 通告"多了一个空槽位" */
return item;
}
static pthread_mutex_t stat_lock = PTHREAD_MUTEX_INITIALIZER;
static int served = 0, total = 0;
static void process(int connfd)
{
char buf[MAXLINE], out[MAXLINE + 8];
ssize_t n = read(connfd, buf, sizeof(buf) - 1);
if (n <= 0) return;
buf[n] = '\0';
snprintf(out, sizeof(out), "ECHO: %s", buf);
write(connfd, out, strlen(out));
int v = atoi(buf);
pthread_mutex_lock(&stat_lock); /* 第 1 类不安全函数的修复:加锁 */
served++; total += v;
printf("[worker] connfd=%-3d value=%-4d served=%d total=%d\n",
connfd, v, served, total);
fflush(stdout);
pthread_mutex_unlock(&stat_lock);
}
static void *worker(void *vargp)
{
pthread_detach(pthread_self()); /* ★ 每个工作线程自我分离 */
(void)vargp;
for (;;) {
int connfd = sbuf_remove(&sbuf); /* 阻塞等待一个连接 */
process(connfd);
close(connfd);
}
return NULL;
}
static int g_port, g_nclient;
static void *one_client(void *vargp) /* 内置客户端,制造并发连接 */
{
long id = (long)vargp;
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in a; memset(&a, 0, sizeof(a));
a.sin_family = AF_INET;
a.sin_port = htons((unsigned short)g_port);
inet_pton(AF_INET, "127.0.0.1", &a.sin_addr);
if (connect(fd, (struct sockaddr *)&a, sizeof(a)) < 0) { close(fd); return NULL; }
char msg[32], buf[MAXLINE];
int len = snprintf(msg, sizeof(msg), "%ld\n", id * 100);
write(fd, msg, len);
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n > 0) { buf[n] = '\0'; printf("[client %ld] %s", id, buf); fflush(stdout); }
close(fd);
return NULL;
}
static void *client_driver(void *vargp)
{
(void)vargp;
struct timespec ts = {0, 300 * 1000 * 1000}; /* 让主线程先进 accept */
nanosleep(&ts, NULL);
pthread_t ct[64];
for (long i = 0; i < g_nclient; i++) pthread_create(&ct[i], NULL, one_client, (void *)(i + 1));
for (int i = 0; i < g_nclient; i++) pthread_join(ct[i], NULL);
return NULL;
}
int main(int argc, char **argv)
{
int port = (argc > 1) ? atoi(argv[1]) : 12345;
int nthread = (argc > 2) ? atoi(argv[2]) : 4;
int nclient = (argc > 3) ? atoi(argv[3]) : 8;
signal(SIGPIPE, SIG_IGN);
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
struct sockaddr_in addr; memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons((unsigned short)port);
bind(listenfd, (struct sockaddr *)&addr, sizeof(addr));
listen(listenfd, 1024);
printf("listening on %d with %d worker threads\n", port, nthread); fflush(stdout);
sbuf_init(&sbuf);
/* ★★ 预线程化:主线程先建好一族工作线程,然后才开始 accept ★★ */
pthread_t tid;
for (int i = 0; i < nthread; i++)
pthread_create(&tid, NULL, worker, NULL);
g_port = port; g_nclient = nclient;
pthread_t drv;
pthread_create(&drv, NULL, client_driver, NULL);
int accepted = 0;
for (int i = 0; i < nclient; i++) { /* 主线程 = 生产者 */
struct sockaddr_in caddr; socklen_t clen = sizeof(caddr);
int connfd = accept(listenfd, (struct sockaddr *)&caddr, &clen);
if (connfd < 0) break;
printf("[main] accepted connfd=%d\n", connfd); fflush(stdout);
sbuf_insert(&sbuf, connfd);
accepted++;
}
pthread_join(drv, NULL);
sleep(1);
printf("[main] accepted=%d served=%d total=%d\n", accepted, served, total);
close(listenfd);
return 0;
}
【代码做什么?】 main 建监听套接字并用 sbuf_init 初始化三个信号量(mutex=1、slots=16、items=0),先创建 4 个工作线程、再进入 accept 循环把 connfd 插入队列;每个工作线程 pthread_detach(pthread_self()) 后循环取出 connfd、回显、累加受锁保护的统计量、关闭连接。
【底层机制透视】 accept 返回最小的可用文件描述符,close 后该号立刻被回收再分配——这就是日志里 connfd=4 出现两次的原因。pthread_detach 设置 TCB 的不可 join 状态,线程退出时内核直接回收栈与 TCB。served/total 由同一把锁保护;不加锁时 served++ 拆成三步,输出会重复计数。
【内存布局 / 数据结构图解】 sbuf 在全局静态存储区(.bss)被所有线程共享;工作线程的 connfd、v、buf[MAXLINE] 都在线程栈上,天然线程安全。
【与汇编 / 硬件的对应】 cnt++ 是读-改-写三步(gcc -S -O0):
movq cnt(%rip), %rax # 读
addq $1, %rax # 改
movq %rax, cnt(%rip) # 写
在 -O1 下编译器把它优化成单条 addq $1, cnt(%rip)——但这仍不是原子操作(x86 的 add 到内存不隐含 lock 前缀),所以开优化并不能修好竞争。
【实测验证】(真实输出,节选)
$ ./prethreaded 12400 4 8
listening on 12400 with 4 worker threads
[main] accepted connfd=4
[main] accepted connfd=9
[main] accepted connfd=10
[client 1] ECHO: 100
[main] accepted connfd=14
[worker] connfd=9 value=200 served=1 total=200
[worker] connfd=4 value=100 served=2 total=300
[client 3] ECHO: 300
[main] accepted connfd=5
[client 4] ECHO: 400
[client 2] ECHO: 200
[main] accepted connfd=4
[client 6] ECHO: 600
[main] accepted connfd=6
[client 5] ECHO: 500
[worker] connfd=10 value=300 served=3 total=600
[main] accepted connfd=7
[worker] connfd=14 value=400 served=4 total=1000
[worker] connfd=5 value=600 served=5 total=1600
[worker] connfd=4 value=500 served=6 total=2100
[worker] connfd=6 value=700 served=7 total=2800
[client 7] ECHO: 700
[worker] connfd=7 value=800 served=8 total=3600
[client 8] ECHO: 800
[main] accepted=8 served=8 total=3600
三个特征:① connfd=4 出现两次——描述符被回收后重新分配;② 8 条连接被 4 个 worker 交错处理(connfd=9 先完成而 connfd=4 后完成),证明真并发;③ 并发写统计量最终 served=8、total=3600 完全正确——加锁生效。
23.3.2 死锁演示(相反加锁顺序)
代码 (C):
/* b_deadlock.c —— 相反加锁顺序导致死锁
* gcc -g -Wall -std=c11 -lpthread b_deadlock.c -o b_deadlock
* ./b_deadlock 0 # 不一致顺序:真死锁 -> timeout 5 退出码 124
* ./b_deadlock 1 # 一致顺序:安全,退出码 0
* ./b_deadlock 2 # 不一致顺序 + pthread_timedjoin_np 自检:退出码 3
*/
#define _GNU_SOURCE
#include <errno.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
static pthread_mutex_t mA = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t mB = PTHREAD_MUTEX_INITIALIZER;
static atomic_int done_flag = 0, in_cs = 0;
static int g_mode = 0;
/* 让两个线程在拿到第一把锁之后精确对齐,使死锁可确定复现 */
static void rendezvous(void)
{
struct timespec ts = {0, 30 * 1000 * 1000}; /* 30 ms */
nanosleep(&ts, NULL);
}
static void *thread_1(void *arg)
{
(void)arg;
pthread_mutex_lock(&mA);
rendezvous();
printf("[T1] holds mA, now asks for mB ...\n"); fflush(stdout);
pthread_mutex_lock(&mB); /* ← 若 T2 持有 mB 则卡死 */
printf("[T1] acquired BOTH locks (in_cs=%d)\n", atomic_fetch_add(&in_cs, 1) + 1);
fflush(stdout);
atomic_fetch_sub(&in_cs, 1);
pthread_mutex_unlock(&mB);
pthread_mutex_unlock(&mA);
atomic_fetch_add(&done_flag, 1);
return NULL;
}
static void *thread_2(void *arg)
{
(void)arg;
if (g_mode == 0 || g_mode == 2) {
pthread_mutex_lock(&mB); /* ★ 相反顺序:先 B 后 A */
rendezvous();
printf("[T2] holds mB, now asks for mA ...\n"); fflush(stdout);
pthread_mutex_lock(&mA); /* ← 若 T1 持有 mA 则卡死 */
} else {
pthread_mutex_lock(&mA); /* ★ 一致顺序:先 A 后 B */
rendezvous();
printf("[T2] holds mA, now asks for mB ...\n"); fflush(stdout);
pthread_mutex_lock(&mB);
}
printf("[T2] acquired BOTH locks (in_cs=%d)\n", atomic_fetch_add(&in_cs, 1) + 1);
fflush(stdout);
atomic_fetch_sub(&in_cs, 1);
pthread_mutex_unlock(&mB);
pthread_mutex_unlock(&mA);
atomic_fetch_add(&done_flag, 1);
return NULL;
}
static int timed_join(pthread_t t, int timeout_sec)
{
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += timeout_sec;
return pthread_timedjoin_np(t, NULL, &deadline);
}
int main(int argc, char **argv)
{
g_mode = (argc > 1) ? atoi(argv[1]) : 0;
printf("mode=%d (%s)\n", g_mode,
g_mode == 1 ? "consistent order: every thread locks A then B"
: "inconsistent order: T1 does A->B, T2 does B->A");
fflush(stdout);
pthread_t t1, t2;
pthread_create(&t1, NULL, thread_1, NULL);
pthread_create(&t2, NULL, thread_2, NULL);
if (g_mode == 1) {
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("OK: no deadlock, done_flag=%d, in_cs=%d\n",
atomic_load(&done_flag), atomic_load(&in_cs));
return 0;
}
int rc1 = timed_join(t1, 3);
int rc2 = rc1 ? ETIMEDOUT : timed_join(t2, 3);
if (rc1 == ETIMEDOUT || rc2 == ETIMEDOUT) {
printf("*** DEADLOCK: done_flag=%d, threads blocked waiting for each other "
"(rc1=%d rc2=%d) ***\n", atomic_load(&done_flag), rc1, rc2);
fflush(stdout);
if (g_mode == 0) for (;;) pause(); /* 真挂起,留给 timeout/gdb */
_exit(3);
}
printf("no deadlock: done_flag=%d\n", atomic_load(&done_flag));
return 0;
}
【代码做什么?】 thread_1 固定按 mA → mB 加锁,thread_2 在 mode 0/2 下按 mB → mA(相反顺序)、mode 1 下按 mA → mB(一致顺序);rendezvous() 的 30 ms 睡眠保证两线程都已在”持有一把、等另一把”的位置对齐,把偶发的死锁变成 100% 可复现。
【底层机制透视】 pthread_mutex_lock 竞争时先做原子 CAS 尝试(fast path),失败后进入 __lll_lock_wait 用 futex 挂起。死锁时两线程都停在内核 futex 等待队列里,/proc/<pid>/status 的 State: 为 S (sleeping)、Threads: 为 3——没有线程消耗 CPU,这是死锁与”活锁/自旋”的可观测区别。
【内存布局 / 数据结构图解】 死锁时锁的归属形成环路:
mA (mutex, owner=T1) mB (mutex, owner=T2)
│ │
│ locked │ locked
▼ ▼
┌───────────┐ ┌───────────┐
│ thread_1 │ wants mB ──────▶│ (T2 持有)│
│ T1 栈帧 │ │ │
└───────────┘ └───────────┘
▲ │
│ wants mA │
┌───────────┐ │
│ (T1 持有)│◀───────────────────────┘
│ thread_2 │
│ T2 栈帧 │
└───────────┘
⇒ 环路:T1 等 mB(T2 持有)→ T2 等 mA(T1 持有)
【实测验证】 三种模式的真实运行结果:
$ ./b_deadlock 1
mode=1 (consistent order: every thread locks A then B)
[T1] holds mA, now asks for mB ...
[T1] acquired BOTH locks (in_cs=1)
[T2] holds mA, now asks for mB ...
[T2] acquired BOTH locks (in_cs=1)
OK: no deadlock, done_flag=2, in_cs=0
exit=0
$ timeout 5 ./b_deadlock 0; echo "exit=$?"
mode=0 (inconsistent order: T1 does A->B, T2 does B->A)
[T1] holds mA, now asks for mB ...
[T2] holds mB, now asks for mA ...
*** DEADLOCK: done_flag=0, threads blocked waiting for each other (rc1=110 rc2=110) ***
exit=124 # 124 = 被 timeout 杀掉 ⇒ 进程真的卡死了
$ ./b_deadlock 2; echo "exit=$?"
mode=2 (inconsistent order: T1 does A->B, T2 does B->A)
[T1] holds mA, now asks for mB ...
[T2] holds mB, now asks for mA ...
*** DEADLOCK: done_flag=0, threads blocked waiting for each other (rc1=110 rc2=110) ***
exit=3
mode 1 里 in_cs 两次都是 1——没有任何时刻两线程同时进入临界区,互斥生效。rc1=110 正是 ETIMEDOUT。gdb -p 附到 mode 0 的进程上,栈回溯精确定位两处阻塞点:
$ ./b_deadlock 0 & sleep 1.5; PID=$(pgrep -x b_deadlock -n)
$ gdb -p $PID -batch -ex "thread apply all bt"
Thread 3 (LWP 3609910) "b_deadlock":
#0 __lll_lock_wait () from /lib64/libc.so.6
#1 pthread_mutex_lock@@GLIBC_2.2.5 () from /lib64/libc.so.6
#2 thread_2 (arg=0x0) at b_deadlock.c:57 ← 卡在 pthread_mutex_lock(&mA)
#3 start_thread ()
#4 clone3 ()
Thread 2 (LWP 3609909) "b_deadlock":
#0 __lll_lock_wait () from /lib64/libc.so.6
#1 pthread_mutex_lock@@GLIBC_2.2.5 () from /lib64/libc.so.6
#2 thread_1 (arg=0x0) at b_deadlock.c:40 ← 卡在 pthread_mutex_lock(&mB)
#3 start_thread ()
#4 clone3 ()
$ grep -E "^(Name|State|Threads)" /proc/$PID/status
Name: b_deadlock
State: S (sleeping) ← 内核态睡眠,0% CPU
Threads: 3
valgrind --tool=helgrind 也能直接报出这个模式:
$ valgrind --tool=helgrind ./b_deadlock 2
Thread #2: Exiting thread still holds 1 lock
at __lll_lock_wait ... by thread_1 (b_deadlock.c:40)
Thread #3: Exiting thread still holds 1 lock
at __lll_lock_wait ... by thread_2 (b_deadlock.c:57)
ERROR SUMMARY: 2 errors from 2 contexts
“Exiting thread still holds 1 lock” = 线程带着锁阻塞在另一把锁上,是死锁/锁顺序违规的典型指纹。
23.3.3 条件变量版生产者-消费者(while 而非 if)
代码 (C):
/* c_condvar.c —— 条件变量版生产者-消费者
* gcc -g -Wall -std=c11 -lpthread c_condvar.c -o c_condvar
* ./c_condvar while # 正确:while 包裹等待
* ./c_condvar if # ⚠️ 仅供演示:if 的 bug
*/
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define NITEMS 20
#define CAPACITY 4
#define NPROD 2
#define NCONS 2
static int use_while = 1;
/* ---- 定向实验:一个条目、两个等待者、broadcast ---- */
static pthread_mutex_t m2 = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cv2 = PTHREAD_COND_INITIALIZER;
static int slot = 0;
static int if_wrong_wakeups = 0;
static void *waiter_if(void *arg)
{
(void)arg;
pthread_mutex_lock(&m2);
if (slot == 0) /* ❌ if:只检查一次 */
pthread_cond_wait(&cv2, &m2);
if (slot == 0) { /* 醒来后条件早已不成立 */
__sync_fetch_and_add(&if_wrong_wakeups, 1);
printf("[if-waiter] woke up but slot still empty --> check was stale\n");
} else {
slot = 0;
printf("[if-waiter] consumed the only item\n");
}
fflush(stdout);
pthread_mutex_unlock(&m2);
return NULL;
}
static void *waiter_while(void *arg)
{
(void)arg;
pthread_mutex_lock(&m2);
while (slot == 0) /* ✅ while:醒来后重查 */
pthread_cond_wait(&cv2, &m2);
slot = 0;
printf("[while-waiter] consumed the only item\n"); fflush(stdout);
pthread_mutex_unlock(&m2);
return NULL;
}
static void directed_experiment(int use_while_local)
{
printf("\n=== directed experiment: ONE item, TWO waiters, broadcast (%s) ===\n",
use_while_local ? "while" : "if");
fflush(stdout);
if_wrong_wakeups = 0;
slot = 0;
pthread_t a, b;
pthread_create(&a, NULL, use_while_local ? waiter_while : waiter_if, NULL);
pthread_create(&b, NULL, use_while_local ? waiter_while : waiter_if, NULL);
struct timespec ts = {0, 200 * 1000 * 1000};
nanosleep(&ts, NULL); /* 保证两个等待者都已挂起 */
pthread_mutex_lock(&m2);
slot = 1; /* 只放入一个条目 */
pthread_cond_broadcast(&cv2); /* 两个等待者都被唤醒 */
pthread_mutex_unlock(&m2);
if (use_while_local) { /* while 版必须再放一个才能让第二人拿到 */
nanosleep(&ts, NULL);
pthread_mutex_lock(&m2);
slot = 1;
pthread_cond_broadcast(&cv2);
pthread_mutex_unlock(&m2);
}
pthread_join(a, NULL);
pthread_join(b, NULL);
printf("--> stale-condition wakeups: %d\n", if_wrong_wakeups);
fflush(stdout);
}
/* ---- 有界缓冲区本体 ---- */
typedef struct {
int buf[CAPACITY];
int count, in, out, produced, consumed, corrupt;
pthread_mutex_t m;
pthread_cond_t not_empty, not_full;
} cb_t;
static cb_t cb;
static void *producer(void *arg)
{
long id = (long)arg;
for (int i = 0; i < NITEMS / NPROD; i++) {
int item = (int)(id * 1000 + i);
pthread_mutex_lock(&cb.m);
if (use_while)
while (cb.count == CAPACITY) /* ✅ */
pthread_cond_wait(&cb.not_full, &cb.m);
else
if (cb.count == CAPACITY) /* ❌ */
pthread_cond_wait(&cb.not_full, &cb.m);
cb.buf[cb.in] = item;
cb.in = (cb.in + 1) % CAPACITY;
cb.count++; cb.produced++;
printf("[P%ld] put %4d count=%d\n", id, item, cb.count); fflush(stdout);
pthread_cond_signal(&cb.not_empty);
pthread_mutex_unlock(&cb.m);
}
return NULL;
}
static void *consumer(void *arg)
{
long id = (long)arg;
for (int i = 0; i < NITEMS / NCONS; i++) {
pthread_mutex_lock(&cb.m);
if (use_while) {
while (cb.count == 0) {
printf("[C%ld] buffer empty, waiting ...\n", id); fflush(stdout);
pthread_cond_wait(&cb.not_empty, &cb.m);
}
} else {
if (cb.count == 0) {
printf("[C%ld] buffer empty, waiting ...\n", id); fflush(stdout);
pthread_cond_wait(&cb.not_empty, &cb.m);
}
if (cb.count == 0) { /* 检出 if 版的"陈旧条件"唤醒 */
cb.corrupt++;
printf("[C%ld] *** BUG: woke up but count==0 ***\n", id); fflush(stdout);
pthread_mutex_unlock(&cb.m);
continue;
}
}
int item = cb.buf[cb.out];
cb.out = (cb.out + 1) % CAPACITY;
cb.count--; cb.consumed++;
printf("[C%ld] got %4d count=%d\n", id, item, cb.count); fflush(stdout);
pthread_cond_signal(&cb.not_full);
pthread_mutex_unlock(&cb.m);
}
return NULL;
}
int main(int argc, char **argv)
{
use_while = !(argc > 1 && strcmp(argv[1], "if") == 0);
printf("mode=%s\n", use_while ? "while" : "if(buggy)");
memset(&cb, 0, sizeof(cb));
cb.m = (pthread_mutex_t)PTHREAD_MUTEX_INITIALIZER;
cb.not_empty = (pthread_cond_t)PTHREAD_COND_INITIALIZER;
cb.not_full = (pthread_cond_t)PTHREAD_COND_INITIALIZER;
directed_experiment(use_while);
printf("\n=== bounded buffer: %d producers, %d consumers, capacity %d ===\n",
NPROD, NCONS, CAPACITY);
fflush(stdout);
pthread_t p[NPROD], c[NCONS];
for (long i = 0; i < NPROD; i++) pthread_create(&p[i], NULL, producer, (void *)i);
for (long i = 0; i < NCONS; i++) pthread_create(&c[i], NULL, consumer, (void *)i);
for (int i = 0; i < NPROD; i++) pthread_join(p[i], NULL);
for (int i = 0; i < NCONS; i++) pthread_join(c[i], NULL);
printf("\nproduced=%d consumed=%d final count=%d corrupt=%d\n",
cb.produced, cb.consumed, cb.count, cb.corrupt);
return cb.corrupt ? 1 : 0;
}
【代码做什么?】 定向实验只用一份数据、两个等待者加一次 broadcast,把 if 与 while 的差别放大成 100% 可复现;有界缓冲区用 2 生产者 + 2 消费者 + 容量 4,while 版累计 produced=20, consumed=20, corrupt=0。
【底层机制透视】 pthread_cond_wait 在一个原子临界区内做三件事:把本线程加入 cv 的等待链表、释放 m、调用 futex 挂起。pthread_cond_signal 把等待链表队首节点移到 m 的等待链——这就是”signal 只是通知、不是交接”的机制根源:被唤醒者必须先竞争到 m,而在此之前别的线程完全可能又把 count 改回 0。
【内存布局 / 数据结构图解】 条件变量与互斥锁的配对关系:
pthread_cond_t not_empty pthread_mutex_t m
┌──────────────────┐ ┌──────────────────┐
│ waiters: [C1, C2]│──signal──▶ │ owner: NULL │
└──────────────────┘ 把队首节点 │ waiters: [C1] │
移到 m 队列 └──────────────────┘
│
C1 必须在 m 上重新竞争成功才能返回 wait
count / in / out 三者都在 m 的保护下;while 在"重新拿到 m 之后"重查 count
【实测验证】 while 模式完整跑通,corrupt=0:
$ ./c_condvar while
mode=while
=== directed experiment: ONE item, TWO waiters, broadcast (while) ===
[while-waiter] consumed the only item
[while-waiter] consumed the only item
--> stale-condition wakeups: 0
=== bounded buffer: 2 producers, 2 consumers, capacity 4 ===
[P0] put 0 count=1
[P0] put 1 count=2
[P0] put 2 count=3
[P0] put 3 count=4
[C0] got 0 count=3
[C0] got 1 count=2
[C0] got 2 count=1
[C0] got 3 count=0
[C0] buffer empty, waiting ...
[C1] buffer empty, waiting ...
[P0] put 4 count=1
...
produced=20 consumed=20 final count=0 corrupt=0
exit=0
if 模式在同一定向实验里 100% 复现了陈旧条件唤醒(连续 8 次运行结果一致):
$ ./c_condvar if
mode=if(buggy)
=== directed experiment: ONE item, TWO waiters, broadcast (if) ===
[if-waiter] consumed the only item
[if-waiter] woke up but slot still empty --> check was stale
--> stale-condition wakeups: 1
...
produced=20 consumed=20 final count=0 corrupt=0
exit=0
if 版有界缓冲区在本次运行里 corrupt=0——if 的 bug 是概率性的,多数运行不触发:它只在”被唤醒者还没拿到锁时另一个消费者已把缓冲抽空”的窄窗口出错。定向实验用 broadcast + 单条目把该窗口开到最大,才让它必然暴露。
23.3.4 第 3 类线程不安全函数演示:ctime 的静态缓冲区
⚠️ 仅供演示,请勿模仿:下面
unsafe_thread的写法会产生真实的、静默的数据损坏。
代码 (C):
/* d_ctime.c —— 第 3 类线程不安全函数:ctime 返回静态缓冲区
* gcc -g -Wall -std=c11 -lpthread d_ctime.c -o d_ctime && ./d_ctime
*/
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#define NTHREADS 4
static pthread_barrier_t barrier; /* 让"取结果"和"用结果"分离,放大竞态 */
/* ---------- ❌ 第 3 类:ctime 返回库内部静态缓冲区 ---------- */
static void *unsafe_thread(void *arg)
{
long id = (long)arg;
time_t t = (time_t)(1000000000L + id * 86400L * 365L); /* 每人一个不同时间 */
char *s = ctime(&t); /* ← 返回内部 static char buf[26] */
pthread_barrier_wait(&barrier); /* 所有线程在这里对齐 */
printf("[unsafe %ld] my time should be 0x%lx, ctime says: %s",
id, (unsigned long)t, s); /* ← s 早已不是"自己"的字符串 */
fflush(stdout);
return NULL;
}
/* ---------- ✅ 修复:lock-and-copy ---------- */
static pthread_mutex_t ctime_lock = PTHREAD_MUTEX_INITIALIZER;
static char *correct_ctime(const time_t *t, char *dst, size_t dstsz)
{
pthread_mutex_lock(&ctime_lock);
char *r = ctime(t);
if (r != NULL && dstsz > 0)
snprintf(dst, dstsz, "%s", r); /* ★ 拷贝发生在锁内! */
pthread_mutex_unlock(&ctime_lock);
return (r == NULL) ? NULL : dst;
}
static void *safe_thread(void *arg)
{
long id = (long)arg;
time_t t = (time_t)(1000000000L + id * 86400L * 365L);
char mybuf[64]; /* ★ 每个线程自己的存储 */
char *s = correct_ctime(&t, mybuf, sizeof(mybuf));
pthread_barrier_wait(&barrier);
printf("[safe %ld] my time should be 0x%lx, ctime says: %s",
id, (unsigned long)t, s);
fflush(stdout);
return NULL;
}
/* ---------- 讲义原样出现的 itoa(static char buf[11]) ---------- */
static char *itoa_unsafe(int x)
{
static char buf[11];
snprintf(buf, sizeof(buf), "%d", x);
return buf;
}
static void itoa_safe(int x, char *buf, size_t bufsz)
{
snprintf(buf, bufsz, "%d", x);
}
static pthread_barrier_t b2;
static void *itoa_thread(void *arg)
{
long id = (long)arg;
char *s = itoa_unsafe((int)(id * 11111111L));
pthread_barrier_wait(&b2);
printf("[itoa %ld] expected %8ld, got: %s\n", id, id * 11111111L, s);
fflush(stdout);
return NULL;
}
static void *itoa_r_thread(void *arg)
{
long id = (long)arg;
char buf[12];
itoa_safe((int)(id * 11111111L), buf, sizeof(buf));
pthread_barrier_wait(&b2);
printf("[itoa_r %ld] expected %8ld, got: %s\n", id, id * 11111111L, buf);
fflush(stdout);
return NULL;
}
int main(void)
{
pthread_t t[NTHREADS];
printf("######## 1) ctime: UNSAFE (class 3, static buffer) ########\n");
pthread_barrier_init(&barrier, NULL, NTHREADS);
for (long i = 0; i < NTHREADS; i++) pthread_create(&t[i], NULL, unsafe_thread, (void *)i);
for (int i = 0; i < NTHREADS; i++) pthread_join(t[i], NULL);
pthread_barrier_destroy(&barrier);
printf("\n######## 2) ctime: FIXED with lock-and-copy ########\n");
pthread_barrier_init(&barrier, NULL, NTHREADS);
for (long i = 0; i < NTHREADS; i++) pthread_create(&t[i], NULL, safe_thread, (void *)i);
for (int i = 0; i < NTHREADS; i++) pthread_join(t[i], NULL);
pthread_barrier_destroy(&barrier);
printf("\n######## 3) itoa (from lecture slide): UNSAFE vs _r ########\n");
pthread_barrier_init(&b2, NULL, NTHREADS);
for (long i = 0; i < NTHREADS; i++) pthread_create(&t[i], NULL, itoa_thread, (void *)i);
for (int i = 0; i < NTHREADS; i++) pthread_join(t[i], NULL);
for (long i = 0; i < NTHREADS; i++) pthread_create(&t[i], NULL, itoa_r_thread, (void *)i);
for (int i = 0; i < NTHREADS; i++) pthread_join(t[i], NULL);
pthread_barrier_destroy(&b2);
return 0;
}
【代码做什么?】 第 1 段让 4 个线程各用不同的时间调用 ctime、在屏障处对齐之后才去读返回的字符串;第 2 段用 correct_ctime(锁内调用 + 锁内拷贝到调用者私有缓冲)做同样的事;第 3 段对比讲义里的 itoa 与 itoa_r。
【底层机制透视】 ctime 在 glibc 里返回一个静态的 26 字节缓冲区。pthread_barrier_wait 是触发器:它强制所有线程先完成 ctime 调用、再统一去读字符串,于是线程 0 去读 s 时该缓冲区早已被覆盖。第 3 类的要害正在于此:函数本身没有出错痕迹,出错的是调用者在”取回指针之后”的使用时机。 lock-and-copy 的要点是”拷贝必须发生在锁内“。
【实测验证】 未修复时四个线程拿到完全相同的时间字符串(真实输出,稳定复现):
$ ./d_ctime
######## 1) ctime: UNSAFE (class 3, static buffer) ########
[unsafe 3] my time should be 0x413e6480, ctime says: Tue Sep 7 20:46:40 2004
[unsafe 2] my time should be 0x3f5d3100, ctime says: Tue Sep 7 20:46:40 2004
[unsafe 0] my time should be 0x3b9aca00, ctime says: Tue Sep 7 20:46:40 2004
[unsafe 1] my time should be 0x3d7bfd80, ctime says: Tue Sep 7 20:46:40 2004
四个 time_t(0x3b9aca00=2001-09-09、0x3d7bfd80=2002-09-09、0x3f5d3100=2003-09-09、0x413e6480=2004-09-07)各不相同,字符串却全是 2004——经典的静默数据损坏。修复后:
######## 2) ctime: FIXED with lock-and-copy ########
[safe 3] my time should be 0x413e6480, ctime says: Tue Sep 7 20:46:40 2004
[safe 2] my time should be 0x3f5d3100, ctime says: Mon Sep 8 20:46:40 2003
[safe 1] my time should be 0x3d7bfd80, ctime says: Sun Sep 8 20:46:40 2002
[safe 0] my time should be 0x3b9aca00, ctime says: Sat Sep 8 20:46:40 2001
itoa 的对比同样干净——未修复版 4 个线程全输出 33333333,修复版各归各位:
######## 3) itoa (from lecture slide): UNSAFE vs _r ########
[itoa 3] expected 33333333, got: 33333333
[itoa 0] expected 0, got: 33333333 ← 错误
[itoa 2] expected 22222222, got: 33333333 ← 错误
[itoa 1] expected 11111111, got: 33333333 ← 错误
[itoa_r 0] expected 0, got: 0
[itoa_r 3] expected 33333333, got: 33333333
[itoa_r 1] expected 11111111, got: 11111111
[itoa_r 2] expected 22222222, got: 22222222
valgrind --tool=helgrind ./d_ctime 报出 ERROR SUMMARY: 48 errors from 7 contexts,其中一处直接指到 ctime 内部:Possible data race during read of size 1 ... by 0x40120A: unsafe_thread (d_ctime.c:25),调用链为 strlen ← __tzstring ← __tzfile_compute ← __tz_convert ← ctime——连时区字符串的处理都不是线程安全的。
23.3.5 补例:竞争的三种形态与锁粒度实验
竞争三形态(race.c,真实输出):
$ ./race
== Race A: passing &i ==
[bad ] id=1
[bad ] id=2
...
[bad ] id=7
[bad ] id=8 ← 没有 0!把 &i 传给线程,读到的永远是"当时"的 i
== Race A: passing the value ==
[good] id=0
[good] id=1
...
== Race B(join WINS ): detach after 300000us, join after 50000us
[worker] self-detached, finishing
pthread_join -> 0 (join won the race)
== Race B(detach WINS ): detach after 50000us, join after 300000us
[worker] self-detached, finishing
pthread_join -> 22 (EINVAL: detach won the race)
== Race C: 2 x 1000000 increments, WITHOUT mutex -> cnt=1000000 (want 2000000) *** LOST UPDATES ***
== Race C: 2 x 1000000 increments, WITH mutex -> cnt=2000000 (want 2000000) OK
竞态 B 尤其值得注意:同一个 pthread_join 调用,只看 detach 与 join 谁先到,返回值就分别是 0 和 EINVAL(22)——”正确性依赖于某线程先到达 x 点”的定义级演示。竞态 C 的 cnt=1000000(正好丢失一半更新)说明两线程的读-改-写几乎完全交错。
锁粒度与可扩展性(contention.c,真实输出):
$ ./contention 200000 200
临界区内模拟 200 ns 的"磁盘"工作(SFS 元数据读写)
1 把全局锁 nth= 1 time= 0.048s throughput= 4125 Kops/s
1 把全局锁 nth= 2 time= 0.207s throughput= 1929 Kops/s
1 把全局锁 nth= 4 time= 0.799s throughput= 1001 Kops/s
1 把全局锁 nth= 8 time= 3.525s throughput= 454 Kops/s
16 把分条带锁 nth= 1 time= 0.049s throughput= 4123 Kops/s
16 把分条带锁 nth= 2 time= 0.079s throughput= 5076 Kops/s
16 把分条带锁 nth= 4 time= 0.127s throughput= 6304 Kops/s
16 把分条带锁 nth= 8 time= 0.475s throughput= 3365 Kops/s
全局锁的加速比 $S(4) = 1001/4125 \approx 0.24$、$S(8) = 454/4125 \approx 0.11$——都不是正扩展,与 Amdahl 定律预测一致。分条带锁 $S(4) = 6304/4123 \approx 1.53$,到 8 核反降到 0.82:64 个文件只映射到 16 个条带,8 个线程必然撞在同一批条带上,竞争又回来了。
信号量三用(sem.c,真实输出):1 槽位队列(full+empty)、n 槽位队列(mutex+slots+items,校验和与理论值一致)、可复用屏障、事件通知,全部通过:
=== 2) n-entry (8) queue (mutex + slots + items) ===
checksum=8100 (expect 8100), remaining=0
=== 3) reusable barrier: 5 threads x 3 rounds ===
all 5 threads passed 3 rounds together
=== 4) event notification ===
worker 0 ready, local=0
...
(信号量允许任何线程执行 V —— 所以 main 能唤醒 worker)
23.4 实验关联
L8 SFS Lab(本讲最直接的应用)。SFS(”Shark” File System)用 mmap 把整个”磁盘”文件映射进内存,磁盘是 512 字节块的数组,块 0 是超级块(superblock),目录是平坦结构(root directory 就在超级块里)。它始终驻留内存,不存在写回磁盘的时序问题,但必须处理多线程并发访问。识别临界区的三步法:
- 哪些变量实例是共享的? 超级块(含根目录的文件表)、inode/块位图、空闲块链表、打开文件表共享;
sfs_open的局部变量fileEntry、emptyEntry私有。 - 这些变量在多少地方被访问?
sfs_open的循环既读又写superBlock->files[]:strcmp(...)是读,addOpenFileEntry(fileEntry)改打开文件表——两处必须落在同一临界区内。 - 临界区里是读还是写? 纯读可用读锁;有写必须独占。讲义提醒:”关键难点是识别临界区;临界区由共享变量/资源定义,它可能是两个或多个线程调用相同或不同函数造成的。”——
sfs_open与sfs_read可能同时改同一个 inode,加锁方案必须在函数之间保持一致顺序。
SFS 加锁粒度建议:① 先用一把全局大锁把功能做对;② 再按资源分层细化——超级块/目录、每个 inode、块位图与空闲链表各一把锁;③ 细化时必须固定加锁顺序(永远按”目录锁 → inode 锁 → 位图锁”,绝不反向),否则会出现 23.3.2 那样的死锁;④ 许多操作读多写少,用 pthread_rwlock_t 能显著提升并发。
L7 Proxy Lab 的缓存:官方 writeup 要求”多个线程必须能同时从缓存读取,但只有一个线程可以写;用一把大的独占锁保护缓存是不可接受的解决方案”,并建议分区缓存、用读写锁或用信号量自建读写方案。缓存近似 LRU 淘汰(MAX_CACHE_SIZE = 1 MiB、MAX_OBJECT_SIZE = 100 KiB)。LRU 与并发存在冲突:严格 LRU 要求”读也要更新使用顺序”,把读变成了写,与”多读者并发”矛盾;writeup 特意说明”不要求严格 LRU”正是为了留出”多读者”的设计空间——这也是为什么单用 pthread_rwlock_t 往往不够,通常需要”分区 + 每分区一把读写锁”。
23.5 常见错误与调试技巧
- 用
if而不是while包裹pthread_cond_wait:现象是”偶发读到空缓冲区/负计数”或进程偶发挂死。原因是虚假唤醒、broadcast惊群、条件被抢改。调试:代码审查优先,再用valgrind --tool=helgrind或gcc -fsanitize=thread压测。 - 锁顺序不一致导致死锁:现象是”程序偶尔永久挂起、CPU 占用 0%”。调试:
timeout 5 ./prog; echo $?(124 = 卡死);gdb -p $(pgrep -x prog)后thread apply all bt看各线程卡在哪把锁;valgrind --tool=helgrind报Exiting thread still holds 1 lock;也可把锁设成PTHREAD_MUTEX_ERRORCHECK,让”自锁”立刻返回EDEADLK。 - 信号处理程序里调用会加锁的库函数:现象是”收到信号后整个进程卡死”,
gdb显示主线程与处理程序卡在同一把锁上。原因是信号处理是非对称并发(asymmetric concurrency):主线程持锁时被中断,处理程序又去要同一把锁,而它不返回主线程就永远拿不到锁。调试:只在处理程序中调用异步信号安全(async-signal-safe)的函数(write、_exit,而不是printf/malloc)。 pthread_join一个已 detach 的线程:现象是join返回EINVAL(22)却查不出原因,且行为依赖时序(如 23.3.5 的竞态 B)。调试:检查pthread_create是否在创建时就设了PTHREAD_CREATE_DETACHED;统一约定”要么全部可 join、要么全部 detach”。- 把栈上变量的地址传给线程:现象是线程打印出越界的
id(如 23.3.5 里”没有 0,只有 1..8”)或直接段错误。调试:valgrindmemcheck 报 “Invalid read”,helgrind 报栈地址的竞争;修复是按值传递((void *)(long)i)或用malloc为每线程复制参数。 - 加了锁却仍不正确(第 2/3 类误诊):现象是”明明加了锁,
rand序列还是不对 / 字符串还是被覆盖”。原因是这两类加锁治不好。调试:查手册页 Attributes 一节(man 3 ctime写MT-Unsafe);改用rand_r/strftime/getaddrinfo;第 3 类必须用 lock-and-copy。 sbuf的 P 顺序写反(先 P 互斥量再 P 计数信号量):现象是”生产者或消费者拿着锁在等空位,整个队列永久卡死”。调试:牢记规则”先 P 计数信号量,后 P 互斥量“,V 的顺序则相反。- 粗粒度锁使”并发”名存实亡,或用
volatile当同步原语:前者的现象是”开了 8 个线程,CPU 只跑满 1 个核”(perf record -g ./prog && perf report找热点锁,pidstat -t -p <pid> 1看线程分布);后者的现象是”标志位明明改了,另一个线程却看不到”(while (!flag) ;被优化成只读寄存器而死循环)。volatile只阻止编译器优化掉/合并访问,不提供原子性与内存序保证,正确做法是__atomic_load_n(&flag, __ATOMIC_ACQUIRE)。
23.6 关键要点
- 临界区的边界由共享资源定义,而不是由函数边界定义。SFS 里
sfs_open与sfs_read可能同时改同一个 inode——识别临界区的能力就是并发编程的核心能力。 - 加锁只能修好第 1 类线程不安全函数。第 2、3 类必须改 API(传状态 / lock-and-copy / 可重入版本),第 4 类具”传染性”,须沿调用链清理到底;可重入是线程安全的真子集。
pthread_cond_wait的”原子释放锁 + 挂起 + 唤醒后重新取锁”语义,决定了等待必须写在while里;if版在虚假唤醒、broadcast惊群、条件被抢改三种场景下都会出错。- 死锁是”数学上不可能解套”,根因是循环等待;唯一可靠的系统性防御是全局一致的加锁顺序(lock ordering)。解锁顺序无关紧要。
- 信号量与互斥锁的本质分野是”所有权”:互斥锁只有持有者能解锁,无法表达跨线程事件通知;信号量任何线程都能 P 或 V,所以能做事件、队列与屏障。
- 性能取决于锁竞争而非锁本身。一把全局锁保护整个文件系统/缓存会产生负扩展(实测 1→8 核吞吐跌到 11%);细粒度或分条带锁能暴露并行度,但必须以固定加锁顺序为前提。
23.7 思考题(带答案)
题 1(推演题):三个线程的加锁顺序是:T1 m1→m2→m3,T2 m1→m3→m2,T3 m2→m3。是否可能出现死锁?若可能,给出一个具体死锁状态;若不可能,说明理由。
答:可能出现死锁。lock ordering 要求的是全序(total order),而这里有 m1 < m2 < m3(T1)与 m1 < m3 < m2(T2)两个互相矛盾的顺序。死锁状态:让 T1 持有 m2 且未取 m3,T2 持有 m3;此时 T1 等 m3、T2 等 m2——循环等待即死锁。反证:若只有 T1 和 T3,两者在 m2/m3 上顺序一致,不死锁;故罪魁是 T2 的 m1→m3→m2。修复:把 T2 改成 m1→m2→m3,全序成立,任何轨迹都不会进入死锁区域。
题 2(找错题):下面的代码错在哪?会在什么条件下出错?
pthread_mutex_lock(&m);
if (head == NULL) /* 队列为空 */
pthread_cond_wait(&cv, &m);
Node *n = head; /* 取队首 */
head = head->next;
pthread_mutex_unlock(&m);
答:错在用 if 而在 while。三类触发条件:① 虚假唤醒——pthread_cond_wait 允许在无 signal 时返回,此时 head 仍为 NULL,n = head 得 NULL,head->next 立即段错误;② broadcast 惊群——两个等待者都被唤醒,第一个拿走后 head 又变 NULL,第二个同样段错误;③ 条件被抢改——从”被唤醒”到”重新拿到 m“之间存在窗口,新来的消费者可能先取走元素。修复:
pthread_mutex_lock(&m);
while (head == NULL) /* ✅ 循环重查 */
pthread_cond_wait(&cv, &m);
Node *n = head;
head = head->next;
pthread_mutex_unlock(&m);
题 3(计算题):某并发服务器里,每条连接的总处理时间为 300 μs,其中 240 μs 必须在一把全局锁的保护下执行,其余 60 μs 可并行。若机器有 8 个核,(a) 按 Amdahl 定律,加速比上限是多少?8 核实际能到这个上限吗?(b) 若把临界区压缩到 60 μs(占 20%),上限变成多少?(c) 为什么实测值甚至可能低于单核?
答:(a) 临界区比例 $f = 240/300 = 0.8$。上限 $S(\infty) = 1/f = 1.25$;8 核时为
\[S(8) = \frac{1}{0.8 + \frac{0.2}{8}} = \frac{1}{0.825} \approx 1.21\]即最多 1.21 倍。实际到不了:失败的加锁要走 futex 系统调用、线程被挂起再唤醒、cache line 在核间来回弹跳(cache line ping-pong),这些都不在理想模型里。(b) $f = 0.2$,$S(\infty) = 5$,$S(8) = 1/(0.2+0.1) = 3.33$。可见把临界区从 80% 压到 20%,上限从 1.25 提升到 5,收益远大于加核。(c) 分工增加后串行部分的绝对时间被拉长:8 个线程争一把锁时,每次加锁失败的代价(内核挂起 + 重新调度,可达微秒级)远超临界区本身的 240 ns;同时锁变量所在 cache line 必须在 8 个核间反复失效与传输。”竞争税”超过并行收益,于是 $S(8) < 1$——实测全局锁从 4125 跌到 454 Kops/s(11%)正是此现象。
题 4(找错题):有人说:”既然 ctime 不是线程安全的,我在调用它前后各加一次 pthread_mutex_lock/unlock 就修好了。”这句话错在哪?
答:错在锁的生存周期覆盖不了结果的使用周期。ctime 返回指向库内部静态缓冲区的指针;解锁之后别的线程完全可以再调用 ctime 覆盖这块内存,而调用者手里的 s 仍指着它——从解锁到使用 s 之间存在无保护的窗口。所以第 3 类需要专门的 lock-and-copy:在锁内完成调用并把结果拷贝到调用者提供的私有存储(如 correct_ctime(&t, mybuf, sizeof mybuf)),然后才解锁。更彻底的做法是换可重入版本(strftime 取代 ctime/asctime/localtime)。