⭐ 找 bug 的题目请先写出一个具体的交错序列再看答案。“感觉这里有问题"不算答对。


一、竞态(第 16 篇)

1. 下面这段代码在什么交错下会出错?给出具体的执行序列。

if (list->head == NULL)
    list->head = new_node;
else {
    new_node->next = list->head;
    list->head = new_node;
}
线程1                          线程2                     head
读 head == NULL  ✓                                       NULL
                              读 head == NULL  ✓         NULL
head = A                                                 A
                              head = B                   B     ← A 丢了

两个线程都看到空链表,都走了"直接赋值"分支,后写的覆盖了先写的。A 节点永远找不回来了。

⭐ 这是第 21 篇讲的原子性违反if 的检查和分支里的赋值之间,可以插进别人。⚠️ 注意 else 分支也有同样的问题(两个线程可能读到同一个 head,各自把 next 指向它,然后一个覆盖另一个)——加不加 if 都错,必须整段进临界区。

2. 第 16 篇说 -O2 编译会让竞态"消失”。那如果给 counter 加上 volatile,bug 会回来吗?加了 volatile 是不是就线程安全了?

bug 会回来(编译器不再把循环优化掉,每次都真的读写内存)。

但绝不是线程安全。 volatile 只保证两件事:不优化掉这次访问不重排编译器层面的顺序。它不保证

  • 原子性——counter++ 还是 ldr/add/str 三条指令。
  • CPU 的内存序——多核上的可见性和顺序问题它管不了。

⭐ ⚠️ 在 C/C++ 里,volatile 从来不是并发工具。 它是给"内存映射的硬件寄存器"用的。要原子性用 _Atomic / std::atomic,要互斥用锁。

(Java 的 volatile 是另一回事——它确实带内存屏障语义。同一个关键字,两种语言里含义完全不同,这是常见的混淆源。)


二、锁与扩展性(第 17–18 篇)

3. 一个程序 95% 的时间可以完美并行,5% 必须串行。用 8 核最多能快多少倍?1000 核呢?

阿姆达尔定律:加速比 = 1 / (s + (1−s)/n),s 是串行比例。

8 核: 1/(0.05 + 0.95/8) = 1/(0.05 + 0.11875) = 5.9 倍 1000 核: 1/(0.05 + 0.00095) = 19.6 倍 无穷核: 1/0.05 = 20 倍(上限)

从 8 核到 1000 核,核数多了 125 倍,加速比只从 5.9 涨到 19.6。 第 17 篇说"临界区是并行度的天花板",这就是那句话的数字版。

⚠️ 而且这还是乐观估计——它没算第 18 篇实测的那部分:竞争本身的开销会让加线程绝对变慢

4. 第 18 篇实测:一把大锁的计数器,1 线程 2.76 亿次/秒,2 线程 3292 万次/秒。假设临界区本身的执行时间不变,多出来的时间花在哪?估算一下每次加锁解锁的额外成本。

1 线程: 每次操作 1/2.76亿 ≈ 3.6 纳秒(无竞争,锁走的是快路径,几乎就是一次原子操作)。

2 线程: 总吞吐 3292 万次/秒 → 每次操作 30.4 纳秒

差值约 26.8 纳秒/次,来自:

  1. 原子指令在有竞争时变贵——缓存行要在两个核之间来回传(cache line ping-pong)。
  2. 拿不到锁要陷入内核挂起,锁放了要唤醒——futex 的往返是微秒级,但 pthread_mutex 会先自旋一会儿,所以摊薄了。
  3. 上下文切换及其带来的缓存变冷(第 4 篇)。

⭐ 关键结论:这 26.8 纳秒和临界区里做什么完全无关——临界区里就一条 global++你付的是"协调"的钱,不是"工作"的钱。 临界区越短,这个比例越难看。

5. 一个哈希表有 1024 个桶、每桶一把锁,8 个线程随机访问。两个线程撞上同一个桶的概率是多少?如果桶数降到 16 呢?

用生日问题:8 个线程落进 N 个桶,全都不撞的概率是

P(无冲突) = (N-1)/N × (N-2)/N × … × (N-7)/N

N = 1024: ≈ 1 − 28/1024 ≈ 97.3% 无冲突(冲突概率约 2.7%) N = 16: (15/16)(14/16)…(9/16) ≈ 9.9% 无冲突冲突概率约 90%

⭐ 所以第 18 篇那句"1024 个桶够不够 8 个线程分"的答案是:。但16 个桶几乎必然冲突,细粒度锁的收益基本没了。

⭐ 经验法则:桶数应该远大于线程数的平方(这里 8² = 64 ≪ 1024)。


三、条件变量与信号量(第 19–20 篇)

6. 下面这段代码有什么问题?给出一个会挂死的序列。

pthread_mutex_lock(&m);
if (queue_empty())
    pthread_cond_wait(&cv, &m);
item = dequeue();
pthread_mutex_unlock(&m);

两个问题,if 是主要的那个。

挂死(其实是取空)序列:

消费者甲:拿锁,队列空,cond_wait(放开锁,睡)
生产者:  拿锁,放一件,signal,放锁
          ↑ 甲被标记为可运行,但它还要重新抢锁
消费者乙:拿锁(抢在甲前面),dequeue 拿走那一件,放锁
消费者甲:终于拿到锁,从 cond_wait 返回
          ★ if 已经判过了,直接 dequeue —— 队列是空的

必须用 while 第 19 篇讲过,这不主要是"虚假唤醒",是 Mesa 语义signal 的意思是"你可以去看看了",不是"条件成立了"。

第二个问题: 没检查 dequeue 的返回值。即使改成 while,防御性地检查一下也是对的。

7. 用信号量实现"三个线程按 A→B→C 的顺序各打印一次,循环 10 轮"。

sem_t sA, sB, sC;
sem_init(&sA, 0, 1);      /* ★ A 先跑,初值 1 */
sem_init(&sB, 0, 0);
sem_init(&sC, 0, 0);

void *tA(void*_){ for(int i=0;i<10;i++){ sem_wait(&sA); puts("A"); sem_post(&sB);} }
void *tB(void*_){ for(int i=0;i<10;i++){ sem_wait(&sB); puts("B"); sem_post(&sC);} }
void *tC(void*_){ for(int i=0;i<10;i++){ sem_wait(&sC); puts("C"); sem_post(&sA);} }

每个信号量表达"轮到你了"这一件事。 这正是第 20 篇说的:“谁在等什么"必须用不同的信号量分开表达——如果三个人等同一个信号量,post 会叫错人。

⚠️ 注意初值:只有 sA 是 1,其余是 0。这决定了谁先跑。

8. 下面的生产者-消费者会死锁。指出为什么,并改正。

/* 生产者 */              /* 消费者 */
sem_wait(&mutex);          sem_wait(&mutex);
sem_wait(&empty);          sem_wait(&full);
put();                     get();
sem_post(&mutex);          sem_post(&mutex);
sem_post(&full);           sem_post(&empty);

缓冲区满时:生产者拿到 mutex,然后在 empty 上睡着——手里还攥着 mutex。消费者要取货腾空位,但它拿不到 mutex两个人一起挂死。

改正:把 mutex 挪到里面。

/* 生产者 */              /* 消费者 */
sem_wait(&empty);          sem_wait(&full);
sem_wait(&mutex);          sem_wait(&mutex);
put();                     get();
sem_post(&mutex);          sem_post(&mutex);
sem_post(&full);           sem_post(&empty);

⭐ 一条能记一辈子的规矩:永远不要拿着一把锁去睡等另一个东西。

⚠️ 注意这和第 21 篇的"锁排序"是两个不同的规矩——这里两个信号量的顺序本身没错,错在其中一个是"等条件"而不是"抢互斥”。等条件的必须在外层。


四、死锁(第 21 篇)

9. 四个哲学家围一张桌子,每人左右各一根筷子,都先拿左边再拿右边。为什么会死锁?给出三种不同的解法,各自说明破坏了四个条件里的哪一条。

死锁: 四个人同时拿起左手边的筷子,然后都在等右手边——而右手边那根在邻居左手里。构成一个环。

解法一:让其中一个人反过来拿(先右后左)。 → 破坏循环等待。⭐ 等价于给筷子编号、所有人按号从小到大拿。最常用,性能最好。

解法二:加一个服务生,最多允许三个人同时上桌。 → 破坏持有并等待(因为四根筷子三个人,一定有人能拿全)。⭐ 这就是第 20 篇那个许可数为 3 的信号量

解法三:拿不到右边的就把左边的也放下,等一会儿再试。 → 破坏不可抢占。⚠️ 会活锁——大家同时放、同时重试、同时再撞。⭐ 加一个随机退避就好了,和以太网的 CSMA/CD 是同一招(如果你上过计算机网络那一支)。

(互斥那一条破坏不了——筷子的性质就是一次只能一个人拿。)

10. 你的服务偶尔卡死,但从来抓不到现场。设计一个能在生产环境跑的检测方案。

一、看栈。 卡死时 gdb -p <pid> + thread apply all bt,或者 Java 的 jstack、Go 的 SIGQUIT。⭐ 如果多个线程的栈顶都停在 pthread_mutex_lock 上,基本就是死锁。

二、上工具。 pthread_mutex 支持 PTHREAD_MUTEX_ERRORCHECKvalgrind --tool=helgrind 和 TSan(第 73 号实验用的)都能检测锁序反转——⭐ 注意 TSan 能在没有真的死锁时就报出"锁序不一致",这比等它死了再抓有用得多。

三、自己加。 给每把锁编号,用一个线程局部的栈记录"我现在持有哪些锁",加锁时检查是不是在按序拿。这就是自己实现一个锁序检查器,开销可以做得很小,能常开。

四、加超时。 全部换成 pthread_mutex_timedlock,超时就打印当前持锁情况并告警。⚠️ 这是缓解不是修复——它把死锁变成了可观测的错误,但没消除它。

五、看趋势。 监控每把锁的等待时间分布。⭐ 死锁之前通常有一段"等待时间变长"的前兆,因为竞争在加剧。


五、事件驱动(第 22 篇)

11. 一个服务要支持 10 万并发连接。用一连接一线程,默认 8 MB 栈,需要多少地址空间?如果把栈降到 64 KB 呢?还有什么会先成为瓶颈?

8 MB 栈: 10 万 × 8 MB = 800 GB 地址空间。48 位地址空间理论上 256 TB,放得下,但

  • 页表会爆(第 14 篇):这些栈散布在整个地址空间里,每个栈至少要一张末级页表 → 10 万 × 4 KB = 400 MB 纯页表
  • 实际物理内存(碰过的页)按每个线程 8 KB 算,也有 800 MB

64 KB 栈: 地址空间降到 6.4 GB,页表压力大减。

先成为瓶颈的:

  1. 调度器。10 万个可运行线程,第 7 篇讲的红黑树操作和负载均衡都会变得很贵。
  2. 上下文切换。第 4 篇的隐性开销(缓存和 TLB 变冷)× 10 万。
  3. /proc/sys/kernel/threads-maxpid_max 这些硬上限。
  4. ⚠️ 64 KB 栈本身——一次深递归或者一个大的栈上数组就爆了,而且爆栈的表现是段错误,不是"栈不够"这种友好的错误

⭐ 所以第 22 篇那个结论成立:这不是调参能救的,是模型要换。

12. 用边缘触发的 epoll 时,什么情况下会丢事件?给出一段会丢的代码和它的修法。

边缘触发只在状态发生变化时通知一次。 如果你没把数据一次读干净,剩下的数据不会再触发。

会丢的写法:

n = epoll_wait(ep, evs, MAX, -1);
for (i = 0; i < n; i++) {
    char buf[1024];
    read(evs[i].data.fd, buf, sizeof buf);      /* ★ 只读一次 */
    handle(buf);
}

对端一次发来 4096 字节,你只读了 1024。剩下的 3072 字节还在内核缓冲区里,但不会再有新数据到达,所以不会再触发。这个连接就永远挂住了。

修法:循环读到 EAGAIN

for (;;) {
    ssize_t k = read(fd, buf, sizeof buf);
    if (k > 0) { handle(buf, k); continue; }
    if (k == 0) { close_conn(fd); break; }               /* 对端关了 */
    if (errno == EAGAIN || errno == EWOULDBLOCK) break;  /* ★ 读干净了 */
    if (errno == EINTR) continue;
    close_conn(fd); break;                                /* 真错了 */
}

⚠️ 前提是 fd 必须设成非阻塞的,否则最后那次 read 会把整个事件循环堵死——⭐ 这就是第 22 篇说的"一处阻塞全线卡死",而且它是边缘触发最经典的翻车方式。