Lecture 22: 同步:基础 (Synchronization: Basic)

目录 · ← l21 · l23 →

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(互斥、顺序、生产者-消费者、读者-写者) 关联 LabL8 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 整个包住。
  • 与硬件/机器码的对应:不安全区域之所以存在,是因为 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$ 中,唤醒其中一个。
  • 原子性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_initpthread_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 必须存在? slotsitems 只保证计数语义不保证对 buffrontrear 的访问互斥。两个生产者都成功 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 保护 readcntw 作为写者锁。
           读者进入                              写者进入
  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)

  1. sem_waitsem_post 不配对:少一个 post → 其他线程永久阻塞;少一个 wait → 计数错乱、缓冲区越界。
  2. 忘记 sem_init:未初始化的 sem_t 是垃圾字节,行为未定义(UB),通常表现为立刻死锁或随机唤醒。
  3. mutex + if 而不是 while:这是条件变量的典型陷阱(Lecture 23 详述),此处提前埋点——条件判断必须重新检查,不能只信一次
  4. 加锁顺序不一致 → 死锁:线程 A 按 $R_1 \to R_2$、线程 B 按 $R_2 \to R_1$ 加锁,必然可能循环等待。铁律:所有线程按同一全序加锁。
  5. 锁的粒度太粗 → 性能崩塌:recitation 里用一把锁保护整棵 BST,所有插入完全串行;改成”每结点一把锁”后即可并发。但细粒度锁会引入新的加锁顺序问题。
  6. 持有锁时调用可能阻塞的函数mallocprintfreadwrite):临界区被无谓拉长,甚至与库内部锁形成新的循环等待。

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);
}

【代码做什么?】

  1. 主线程把 niters 强转成 void * 传给两个线程(讲义认可的”cast of int/long”传参法)。
  2. 两个 incr_thread 各循环 niters 次读-改-写 cnt
  3. mainpthread_join 等待结束,再检查 cnt == 2*niters
  4. badcnt 中三条指令可被任意切开;goodcntsem_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

即使加 -O2badcnt 依然会失败(实测 cnt=163023cnt=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/rearmod 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:23deadlock.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 的机制在真机上的复现:生产者攥着 mutexslots,而能增加 slots 的消费者必须拿到 mutex

22.3.5 临界区性能:加锁的代价与可扩展性

讲义给出的实测三档(同一计数器、同一工作量),下表中的本机数据经真实测量:

版本讲义耗时本机实测(2 线程 × 1,000,000 次,-O2
无锁(cnt++,结果错误)0.48 s0.000 s(被优化掉/极快,但结果不可信)
pthread_mutex15 s0.099 s
sem_wait/sem_post27.6 s0.208 s(约为 mutex 的 2.1×
_Atomic + lock addq3.41 s0.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,而 goodcntERROR 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 就别用信号量
  • 生产者-消费者三条命:先等资源后拿锁,先放锁后通知;mutexbuf/front/rearslots/items 只管计数,二者不可互相替代。
  • 加锁顺序全局一致是避免死锁的第一原则:循环等待 = 死锁,破坏它的最简单方法就是给所有锁规定一个全序。
  • 锁粒度决定可扩展性:临界区越”串行”,Amdahl 上限越低;实测固定工作量下线程数 1→8,吞吐反而降到 12%。

22.7 思考题(带答案)

题 1(推演题·判断题):某生产者-消费者程序,缓冲区大小 $n = 4$,三个信号量初值为 mutex=1slots=4items=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(”想法错在哪”):小张认为”既然 slotsitems 已经把生产者和消费者管得严严实实——有空位才能放、有 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)消除共享。