Lecture 23: 同步:高级 (Synchronization: Advanced)

目录 · ← l22 · l24 →

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 关联 LabL8 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)、strtoksrand状态改为调用者传入的参数(rand_r(nextp)必须改 API,调用者负责分配状态空间
第 3 类返回指向静态变量的指针ctimeasctimelocaltimegethostbynameinet_ntoaitoalock-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 给每线程复制参数);② 不检查就使用;③ 使用同步原语。检测首选 ThreadSanitizergcc -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)

  • 避免死锁的规则清单
    1. 加锁顺序(lock ordering):约定全局唯一的锁编号,所有线程一律按编号从小到大加锁解锁顺序无所谓)。
    2. 减小临界区中同时持有的锁数:能合并成一把(粗粒度)就合并,牺牲并行度换确定性。
    3. pthread_mutex_trylock 或带超时加锁:失败就释放已持有的锁再重试;可能引入活锁,需要退避(backoff)。
    4. 消除 hold-and-wait:把”先申请 A 再申请 B”改成”一次性申请全部”或”申请前先释放”。
    5. 不要从持有锁的代码里回调用户代码(回调可能去拿别的锁)。
    6. 不要从信号处理程序里调用会加锁的库函数(见 23.5)。
  • 哲学家就餐问题:5 位哲学家围坐,每人需同时拿到左右两把叉子。若所有人同时先拿左边叉子,就形成环形 hold-and-wait,全部饿死。解法之一是给叉子编号并规定”先拿编号小的”。

23.2.5 条件变量(Condition Variable)

  • 定义与目的:条件变量让线程在某个条件为假时挂起,并在条件可能变真时被唤醒,解决”轮询忙等浪费 CPU”的问题。它自身不提供互斥,总与一把互斥锁配对使用。
  • 接口表
接口语义备注
pthread_cond_init(pthread_cond_t *c, const pthread_condattr_t *a)动态初始化(aNULL 用默认属性)也可用静态初始化器 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 < Nwait,否则清零并 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 槽位队列只需 fullempty 两个信号量(初值 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_tpthread_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$ 个线程的加速比上限为
\[S(N) = \frac{1}{f + \frac{1-f}{N}} \xrightarrow{N \to \infty} \frac{1}{f}\]

若 $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)被所有线程共享;工作线程的 connfdvbuf[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=8total=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>/statusState: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 正是 ETIMEDOUTgdb -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,把 ifwhile 的差别放大成 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 段对比讲义里的 itoaitoa_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_t0x3b9aca00=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 谁先到,返回值就分别是 0EINVAL(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 就在超级块里)。它始终驻留内存,不存在写回磁盘的时序问题,但必须处理多线程并发访问。识别临界区的三步法:

  1. 哪些变量实例是共享的? 超级块(含根目录的文件表)、inode/块位图、空闲块链表、打开文件表共享;sfs_open 的局部变量 fileEntryemptyEntry 私有。
  2. 这些变量在多少地方被访问? sfs_open 的循环既读又写 superBlock->files[]strcmp(...) 是读,addOpenFileEntry(fileEntry) 改打开文件表——两处必须落在同一临界区内。
  3. 临界区里是读还是写? 纯读可用读锁;有写必须独占。讲义提醒:”关键难点是识别临界区;临界区由共享变量/资源定义,它可能是两个或多个线程调用相同或不同函数造成的。”——sfs_opensfs_read 可能同时改同一个 inode,加锁方案必须在函数之间保持一致顺序。

SFS 加锁粒度建议:① 先用一把全局大锁把功能做对;② 再按资源分层细化——超级块/目录、每个 inode、块位图与空闲链表各一把锁;③ 细化时必须固定加锁顺序(永远按”目录锁 → inode 锁 → 位图锁”,绝不反向),否则会出现 23.3.2 那样的死锁;④ 许多操作读多写少,用 pthread_rwlock_t 能显著提升并发。

L7 Proxy Lab 的缓存:官方 writeup 要求”多个线程必须能同时从缓存读取,但只有一个线程可以写;用一把大的独占锁保护缓存是不可接受的解决方案”,并建议分区缓存、用读写锁或用信号量自建读写方案。缓存近似 LRU 淘汰(MAX_CACHE_SIZE = 1 MiBMAX_OBJECT_SIZE = 100 KiB)。LRU 与并发存在冲突:严格 LRU 要求”读也要更新使用顺序”,把读变成了写,与”多读者并发”矛盾;writeup 特意说明”不要求严格 LRU”正是为了留出”多读者”的设计空间——这也是为什么单用 pthread_rwlock_t 往往不够,通常需要”分区 + 每分区一把读写锁”。

23.5 常见错误与调试技巧

  • if 而不是 while 包裹 pthread_cond_wait:现象是”偶发读到空缓冲区/负计数”或进程偶发挂死。原因是虚假唤醒、broadcast 惊群、条件被抢改。调试:代码审查优先,再用 valgrind --tool=helgrindgcc -fsanitize=thread 压测。
  • 锁顺序不一致导致死锁:现象是”程序偶尔永久挂起、CPU 占用 0%”。调试timeout 5 ./prog; echo $?(124 = 卡死);gdb -p $(pgrep -x prog)thread apply all bt 看各线程卡在哪把锁;valgrind --tool=helgrindExiting 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”)或直接段错误。调试valgrind memcheck 报 “Invalid read”,helgrind 报栈地址的竞争;修复是按值传递(void *)(long)i)或用 malloc 为每线程复制参数。
  • 加了锁却仍不正确(第 2/3 类误诊):现象是”明明加了锁,rand 序列还是不对 / 字符串还是被覆盖”。原因是这两类加锁治不好调试:查手册页 Attributes 一节(man 3 ctimeMT-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_opensfs_read 可能同时改同一个 inode——识别临界区的能力就是并发编程的核心能力。
  • 加锁只能修好第 1 类线程不安全函数。第 2、3 类必须改 API(传状态 / lock-and-copy / 可重入版本),第 4 类具”传染性”,须沿调用链清理到底;可重入是线程安全的真子集。
  • pthread_cond_wait 的”原子释放锁 + 挂起 + 唤醒后重新取锁”语义,决定了等待必须写在 whileif 版在虚假唤醒、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 仍为 NULLn = headNULLhead->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)。