一句话 + 关键问题

给定一个数据结构,该怎样给它加锁,才能让它正确工作?进一步,怎样加锁才能让它性能好——让许多线程能同时(并发地)访问它?

CRUX: HOW TO ADD LOCKS TO DATA STRUCTURES — When given a particular data structure, how should we add locks to it, in order to make it work correctly? Further, how do we add locks such that the data structure yields high performance, enabling many threads to access the structure at once, i.e., concurrently?

上一章我们亲手造出了锁;这一章换个角度:拿着锁去用。给数据结构加上锁、让多个线程可以安全地使用它,这叫让它变成线程安全(thread safe)的。锁加在哪里、加几把,同时决定了正确性和性能。

本章依次看四种常见结构:计数器、链表、队列、哈希表。每一种都先用"一把大锁"得到正确版本,再看怎样提升并发度——以及什么时候不值得提升。下一章(条件变量)会在本章的队列基础上,造出能"队列空就等待"的有界队列。

核心概念速览

线程安全(thread safe)

加了锁、多个线程同时调用也能正确工作的数据结构。最简单的做法:每个操作进入时加锁,返回时解锁。

完美扩展(perfect scaling)

多个处理器上的多个线程做了 N 倍的工作,花的时间却和一个线程在一个处理器上做 1 份一样多。

近似计数器(approximate / sloppy counter)

每个 CPU 一个局部计数器 + 一个全局计数器;局部值达到阈值 S 才转移到全局。更新几乎无争用,读数会"滞后"。

阈值 S

S 小:更准,但频繁抢全局锁,接近不可扩展的计数器;S 大:扩展性好,但全局值最多落后约 CPU 数 × S。

手递手锁(hand-over-hand locking)

又叫锁耦合(lock coupling):链表每个节点一把锁,遍历时先拿下一个节点的锁、再放当前节点的锁。并发度高,但锁开销常常得不偿失。

双锁队列(Michael & Scott)

头一把锁、尾一把锁,再加一个哑节点(dummy node),让入队和出队通常互不干扰。

每桶一把锁

并发哈希表的每个桶是一条带锁的链表,不同桶上的操作完全并行,扩展性极好。

避免过早优化(Knuth 定律)

先用一把大锁做对;确实慢了再细化。Linux 早年的大内核锁(BKL)就是这么走过来的。

并发计数器:从一把锁开始

计数器是最简单的数据结构。先看没有任何同步的版本(Figure 29.1):

typedef struct __counter_t {
    int value;
} counter_t;

void init(counter_t *c)      { c->value = 0; }
void increment(counter_t *c) { c->value++; }   // 读-改-写,三条指令,会竞争!
void decrement(counter_t *c) { c->value--; }
int  get(counter_t *c)       { return c->value; }

c->value++ 正是第 26 章那个 counter = counter + 1:一次读、一次加、一次写,两个线程交错执行就会丢更新。修法同样直接——加一把锁(Figure 29.2):

typedef struct __counter_t {
    int             value;
    pthread_mutex_t lock;         // ① 锁和数据放在一起
} counter_t;

void init(counter_t *c) {
    c->value = 0;
    Pthread_mutex_init(&c->lock, NULL);
}
void increment(counter_t *c) {
    Pthread_mutex_lock(&c->lock);    // ② 进入操作时加锁
    c->value++;
    Pthread_mutex_unlock(&c->lock);  // ③ 离开时解锁
}
int get(counter_t *c) {
    Pthread_mutex_lock(&c->lock);    // ④ 连读也要加锁,保证读到一致的值
    int rc = c->value;
    Pthread_mutex_unlock(&c->lock);
    return rc;
}

这是最基本的并发数据结构设计模式:调用操作时获取一把锁,返回时释放。它和管程(monitor)很像——管程在调用和返回对象方法时自动加锁和解锁。

它是正确的。问题在于性能:原书在一台 4 核 iMac 上让每个线程把计数器加 100 万次,1 个线程大约 0.03 秒,2 个线程并发却要 5 秒以上,线程越多越糟。原因见下图——所有线程都挤在同一把锁上,临界区被完全串行化了;更糟的是,锁和 value 所在的缓存行在各个 CPU 之间来回搬运。

精确计数器:所有 CPU 抢同一把锁 → 同一时刻只有一个在干活 CPU 1CPU 2CPU 3CPU 4 彩色块 = 持锁执行 value++;空白 = 在锁上等待(其他 CPU 在用) 近似计数器:各 CPU 只碰自己的局部计数器,偶尔(每 S 次)才碰一次全局锁 CPU 1CPU 2CPU 3CPU 4 橙色 = 转移到全局
一把锁把 4 个 CPU 变成了"排队办事";近似计数器让它们各干各的,只在局部值攒够 S 时才短暂地碰一次全局锁。

理想情况是:线程在多个处理器上做了更多的工作,完成时间却和单线程在单处理器上一样——工作是并行做的,所以总时间不增加。这叫完美扩展(perfect scaling)。一把锁的计数器离这个目标差得很远;近似计数器则相当接近。

可扩展的计数:近似计数器

研究者为"可扩展计数"下了多年功夫,而且它真的重要:对 Linux 在多核上的性能分析发现,没有可扩展的计数器,一些负载会遭遇严重的扩展性问题。本章介绍其中一种方法:近似计数器(approximate counter),Linux 社区也叫它 sloppy counter。

CPU 0 上的线程 CPU 1 上的线程 CPU 2 上的线程 CPU 3 上的线程 update() local[0]+ llock[0] local[1]+ llock[1] local[2]+ llock[2] local[3]+ llock[3] local ≥ S 时:拿 glock,加到 global,local 清零 global + glock get() 只读这里 → 近似值
逻辑上的一个计数器 = 每 CPU 一个局部计数器(各带一把局部锁)+ 一个全局计数器(带全局锁)。原书机器有 4 个 CPU,所以是 4 个局部计数器。

工作方式:

  1. 运行在某个核上的线程要加计数时,只加本核的局部计数器,用对应的局部锁同步。各 CPU 的局部计数器互不相干,更新没有争用,因此可扩展。
  2. 为了让全局计数器保持"大致最新"(万一有线程要读),局部值会周期性地转移到全局:拿全局锁,把局部值加进全局,然后把局部计数器清零。
  3. 多久转移一次由阈值 S 决定。S 越小,越像前面那个不可扩展的计数器;S 越大,越可扩展,但全局值可能偏离真实计数越远。

为什么还需要局部锁?原书脚注解释:因为一个核上可能运行不止一个线程。如果保证每核只有一个线程,局部锁就可以省掉。想得到精确值也可以:按固定顺序(避免死锁)拿齐所有局部锁和全局锁再求和——但这就不可扩展了。

原书追踪表(Figure 29.3)

S = 5,4 个 CPU 上的线程各自更新 L1…L4,时间向下增长。每一步某些局部计数器 +1;某个局部值达到 S 时,就转移到全局 G 并清零。

TimeL1L2L3L4G
000000
100110
210210
320310
430320
541330
65 → 01345(来自 L1)
70245 → 010(来自 L4)

注意第 7 步结束时:真实计数是 0+2+4+0+10 = 16,而 get() 只会返回 G = 10——滞后了 6。下面的模拟器可以逐步复现这张表,并让你改 CPU 数和 S,观察滞后怎样随 S 变化。

模拟器

近似计数器:局部攒够 S,再交给全局

每一步随机若干个 CPU 的局部计数器 +1;达到阈值 S 就拿全局锁转移。观察:全局值 G 比真实值落后多少?和 S、CPU 数是什么关系?全局锁被拿了几次?

把模拟器里 S 调大调小,你会看到原书说的这个准确度/性能权衡:

下面是原书的实现(Figure 29.4),关键行已标注:

typedef struct __counter_t {
    int             global;           // 全局计数
    pthread_mutex_t glock;            // 全局锁
    int             local[NUMCPUS];   // 每 CPU 局部计数
    pthread_mutex_t llock[NUMCPUS];   // ……和各自的锁
    int             threshold;        // 转移频率 S
} counter_t;

void update(counter_t *c, int threadID, int amt) {
    int cpu = threadID % NUMCPUS;              // ① 线程 → 哪个局部计数器
    pthread_mutex_lock(&c->llock[cpu]);       // ② 只拿局部锁:通常无人争用
    c->local[cpu] += amt;
    if (c->local[cpu] >= c->threshold) {       // ③ 攒够了:
        pthread_mutex_lock(&c->glock);         //    才去拿全局锁
        c->global += c->local[cpu];
        pthread_mutex_unlock(&c->glock);
        c->local[cpu] = 0;                     //    局部清零(假设 amt > 0)
    }
    pthread_mutex_unlock(&c->llock[cpu]);
}

int get(counter_t *c) {
    pthread_mutex_lock(&c->glock);
    int val = c->global;                       // ④ 只读全局值
    pthread_mutex_unlock(&c->glock);
    return val;                                // 只是近似值!
}

原书的测量(Figure 29.5):S = 1024 时,4 个线程在 4 个处理器上共更新 400 万次,耗时和 1 个线程在 1 个处理器上更新 100 万次相差无几;而精确计数器随线程数急剧变慢。Figure 29.6 则固定 4 线程、改变 S:S 小时性能差(但全局值总是很准),S 大时性能极好(但全局值有滞后)。本页"动手实验"一节会在本机重做这两个实验。

更多并发不一定更快。如果你的设计引入了大量额外开销(比如频繁加锁解锁而不是只加一次),那么它"更并发"这件事可能根本不重要。简单的方案往往表现不错,尤其当它很少调用昂贵的操作时。唯一可靠的办法:两种都实现出来,测。性能上没法作弊——你的点子要么更快,要么不更快。

并发链表

先只关注插入和查找(删除留给读者思考)。最基本的做法仍然是一把大锁:插入入口加锁、出口解锁(Figure 29.7):

// Figure 29.7:最初版本
int List_Insert(list_t *L, int key) {
    pthread_mutex_lock(&L->lock);
    node_t *new = malloc(sizeof(node_t));
    if (new == NULL) {
        perror("malloc");
        pthread_mutex_unlock(&L->lock);  // 失败路径也得记得解锁
        return -1;
    }
    new->key  = key;
    new->next = L->head;
    L->head   = new;
    pthread_mutex_unlock(&L->lock);
    return 0;
}
int List_Lookup(list_t *L, int key) {
    pthread_mutex_lock(&L->lock);
    node_t *curr = L->head;
    while (curr) {
        if (curr->key == key) {
            pthread_mutex_unlock(&L->lock); // 第二个出口
            return 0;
        }
        curr = curr->next;
    }
    pthread_mutex_unlock(&L->lock);
    return -1;
}
// Figure 29.8:重写后
int List_Insert(list_t *L, int key) {
    // 不需要同步:malloc 本身线程安全
    node_t *new = malloc(sizeof(node_t));
    if (new == NULL) {
        perror("malloc");
        return -1;          // 此时还没拿锁,直接返回
    }
    new->key = key;
    pthread_mutex_lock(&L->lock);    // 只锁真正的临界区
    new->next = L->head;
    L->head   = new;
    pthread_mutex_unlock(&L->lock);
    return 0;
}
int List_Lookup(list_t *L, int key) {
    int rv = -1;
    pthread_mutex_lock(&L->lock);
    node_t *curr = L->head;
    while (curr) {
        if (curr->key == key) {
            rv = 0; break;   // 跳出循环,走唯一出口
        }
        curr = curr->next;
    }
    pthread_mutex_unlock(&L->lock);
    return rv;
}

第一版有个小麻烦:malloc() 失败时,必须在返回前记得解锁。这种罕见的异常控制流非常容易出错——一项对 Linux 内核补丁的研究发现,近 40% 的 bug 出现在这类很少走到的代码路径上。

重写的思路有两点:

Figure 29.7 的查找:两条返回路径,各自负责解锁 lock 遍历 找到:unlock return 0 没找到:unlock return -1 每多一条出口,就多一处可能漏写 unlock Figure 29.8:break → 唯一出口 lock 遍历(找到就 break) unlock return rv
小心锁与控制流:函数开头拿了锁、分配了内存或做了其他有状态的操作,出错返回时就必须一一撤销。尽量把代码组织成不需要这样做的形状。

让链表扩展:手递手锁

一把锁的链表同样扩展性差。研究者探索过一种提高并发度的技术:手递手锁(hand-over-hand locking),也叫锁耦合(lock coupling)。思路很简单:不再给整个链表一把锁,而是每个节点一把锁;遍历时,先拿下一个节点的锁,再放当前节点的锁——像双手交替抓绳子往前爬。

n0 n1 n2 n3 n4 T1 T1 ② 然后放开 n2 ① 先拿住 n3 T2 T1 在前面爬,T2 可以同时在后面的节点上工作——多个遍历可以"流水线"式地并行进行。
手递手锁:任何时刻一个线程最多同时持有两把相邻节点的锁,先拿后放,保证链表结构不会在它脚下被改动。

概念上这很合理,它允许链表操作有很高的并发度。但原书指出:实践中很难让它比一把锁更快——遍历时对每个节点都要加锁、解锁,这些开销大得惊人。即使链表很长、线程很多,允许多个遍历同时进行带来的并发收益,也不太可能超过"拿一把锁、做完、放掉"的简单做法。也许某种混合方案(每隔若干个节点才换一把锁)值得研究。下面的模拟器让你调"加锁代价"和"每个节点的工作量",亲眼看看手递手什么时候赢、什么时候输。

模拟器

手递手遍历 vs 一把大锁

两个线程(在两个 CPU 上)同时在 8 个节点的链表里查找。每次加锁/解锁耗 c 拍,检查一个节点耗 w 拍。单步看每个节点的锁被谁持有;最后比较两种方案谁先把两个查找都做完。

并发队列:Michael & Scott 双锁队列

给队列加一把大锁的做法留给你自己想。这里看一个并发度稍高的设计,来自 Michael 和 Scott(Figure 29.9)。它有两把锁:一把管队头(head_lock),一把管队尾(tail_lock)。目的是让入队和出队可以并发:通常情况下,入队只碰尾锁,出队只碰头锁。

typedef struct __node_t { int value; struct __node_t *next; } node_t;
typedef struct __queue_t {
    node_t         *head, *tail;
    pthread_mutex_t head_lock, tail_lock;     // ① 头尾各一把锁
} queue_t;

void Queue_Init(queue_t *q) {
    node_t *tmp = malloc(sizeof(node_t));    // ② 哑节点(dummy)
    tmp->next = NULL;
    q->head = q->tail = tmp;
    pthread_mutex_init(&q->head_lock, NULL);
    pthread_mutex_init(&q->tail_lock, NULL);
}

void Queue_Enqueue(queue_t *q, int value) {
    node_t *tmp = malloc(sizeof(node_t));    // ③ 锁外准备新节点
    assert(tmp != NULL);
    tmp->value = value;
    tmp->next  = NULL;
    pthread_mutex_lock(&q->tail_lock);       // ④ 只拿尾锁
    q->tail->next = tmp;
    q->tail       = tmp;
    pthread_mutex_unlock(&q->tail_lock);
}

int Queue_Dequeue(queue_t *q, int *value) {
    pthread_mutex_lock(&q->head_lock);       // ⑤ 只拿头锁
    node_t *tmp      = q->head;              //    head 永远指向哑节点
    node_t *new_head = tmp->next;
    if (new_head == NULL) {
        pthread_mutex_unlock(&q->head_lock);
        return -1;                           //    只有哑节点 = 队列空
    }
    *value  = new_head->value;               // ⑥ 真正的队首是哑节点的下一个
    q->head = new_head;                      //    它变成新的哑节点
    pthread_mutex_unlock(&q->head_lock);
    free(tmp);                               // ⑦ 旧哑节点在锁外释放
    return 0;
}
哑节点值无意义 5 7 NULL head tail head_lock tail_lock 出队者:拿 head_lock,读 head->next(值 5),head 前移 → 节点 5 成为新哑节点 入队者:拿 tail_lock,把新节点挂在 tail 后面,tail 后移
head 永远指向哑节点,真正的队首是哑节点的下一个。出队只动 head 一侧,入队只动 tail 一侧,两把锁通常互不干扰。

哑节点(dummy node)是 Michael 和 Scott 的关键技巧:它在初始化时分配,使头部操作和尾部操作得以分离。如果没有哑节点,空队列时 head = tail = NULL,第一次入队必须同时改 head 和 tail、最后一次出队也必须同时改两者——两个操作就又得碰两把锁了。有了哑节点,队列里至少永远有一个节点,入队只改 tail 和 tail->next,出队只改 head。

原书也提醒:只用锁的队列往往不能完全满足多线程程序的需要。更完整的有界队列——队列空或满时能让线程等待——是下一章条件变量的重点。

模拟器

双锁队列逐行执行

选一个场景,用"调度串"决定每一步由哪个线程执行一行(和 x86.py 的 -P 类似,如 1122 表示 T1 走两步、T2 走两步)。观察:入队和出队能否同时持锁?两个入队者会怎样?哑节点如何"移动"?

队列为空时,head 和 tail 都指向哑节点。这时入队者(持尾锁)写 dummy->next,出队者(持头锁)读 dummy->next——两个线程没有持同一把锁却访问了同一个字段。原书没有展开这一点。算法之所以仍然成立,是因为一个对齐指针的读写是原子的:出队者要么读到 NULL(认为队列空,返回 −1),要么读到已经完整初始化的新节点(value 和 next 在拿锁之前就写好了)。在现代弱内存模型的机器上,这还依赖锁操作自带的内存屏障;自己改写这类代码时要格外小心。在模拟器的"空队列"场景里可以看到这一刻。

并发哈希表

最后是一个简单又非常实用的结构:不需要扩容的哈希表(Figure 29.10)。它直接用前面的并发链表搭成:

#define BUCKETS (101)
typedef struct __hash_t { list_t lists[BUCKETS]; } hash_t;   // 每个桶是一条带锁的链表

void Hash_Init(hash_t *H) {
    for (int i = 0; i < BUCKETS; i++) List_Init(&H->lists[i]);
}
int Hash_Insert(hash_t *H, int key) { return List_Insert(&H->lists[key % BUCKETS], key); }
int Hash_Lookup(hash_t *H, int key) { return List_Lookup(&H->lists[key % BUCKETS], key); }
101 个桶,每桶一把锁、一条链表 桶 0 桶 1 桶 2 桶 3 ⋮ 桶 100 102 1 204 3 407 T1: 1 T2: 204 T3: 407 T4: 3 T1、T2、T3 落在不同的桶 → 三把不同的锁,完全并行 T4 的键 3 和 T3 的 407 同落桶 3(407 mod 101 = 3) → 只有它们俩需要排队 相比之下,一把锁的链表:四个线程全排一队
每个桶一把锁,而不是整个结构一把锁:落在不同桶上的操作可以同时进行。

它性能好的原因就是每个桶一把锁,而不是整个结构一把锁,于是大量并发操作可以同时进行。原书 Figure 29.11 在同一台 4 核 iMac 上测试:4 个线程各做 1 万到 5 万次并发插入,并发哈希表的耗时几乎不随插入量增长(扩展性"极好"),而一把锁的链表耗时则随插入量迅速上升。

构建并发数据结构时,先从最基本的方法开始:加一把大锁提供同步访问。这样你很可能先得到一个正确的版本;如果之后发现它有性能问题,再去细化——只在需要时才让它更快。正如 Knuth 所说:"过早优化是万恶之源(Premature optimization is the root of all evil)。"

许多操作系统刚转向多处理器时都只用一把锁,包括 Sun OS 和 Linux;Linux 里这把锁甚至有名字:大内核锁(big kernel lock,BKL)。多年里这是个不错的选择;但当多 CPU 成为常态,内核里同一时刻只允许一个活动线程就成了瓶颈。Linux 采取了直接的办法:把一把锁换成许多把;Sun 则更激进:从头构建一个把并发作为根本设计的新系统——Solaris。

先预测再验证

在近似计数器模拟器里点"载入原书 Figure 29.3"。先不要单步:G 第一次变成非零是在第几步?那一刻的真实计数是多少、滞后多少?
看答案
第 6 步 L1 从 4 变 5,达到 S=5,转移后 G = 5。此时真实计数 = 0 + 1 + 3 + 4 + 5 = 13,滞后 8。第 7 步 L4 转移后 G = 10,真实计数 16,滞后 6。滞后不会单调增长:每次转移都会把它"清掉"一部分。
4 个 CPU、S = 5,把"每步更新概率"拉到 1(每个 CPU 每步都 +1)。预测滞后的变化规律和最大值,再运行验证。滞后最大可能是多少?
看答案
所有局部计数器同步地 1,2,3,4,然后同时到 5 并转移。滞后依次是 4, 8, 12, 16, 0, 4, 8…,最大 16 = 4 × (5 − 1),恰好碰到上界 CPU 数 × (S − 1)。这也说明上界是可以达到的:读者在最坏时刻读 get(),会少看到 16。
把 S 设成 1。预测:全局锁获取次数和"精确计数器的锁获取次数"是什么关系?这时近似计数器还有什么优势吗?
看答案
两者完全相等——每次更新都立刻转移,每次都要拿全局锁,滞后永远是 0。此时近似计数器比精确计数器还多拿一把局部锁,纯属额外开销。本页 C 实验里 S = 1 的耗时(约 0.1 秒)甚至比 4 线程精确计数器(约 0.06 秒)还慢,就是这个原因。
在双锁队列模拟器选"T1 入队 ‖ T2 出队",用默认调度串运行。预测:有没有某一步两把锁同时被持有?如果换成"两个入队者"场景呢?
看答案
入队 ‖ 出队:会出现 T1 持 tail_lock、T2 持 head_lock 的时刻,两者都不等待——这正是双锁的意义。两个入队者:它们都要 tail_lock,后到的那个会在 lock 行上等待(narrate 会标红),直到前者解锁。入队者之间、出队者之间依然是串行的。
在手递手模拟器里,先用默认参数,再分别点两个预设。预测:哪种参数下手递手比一把大锁先完成?为什么原书说"实践中很难更快"?
看答案
加锁代价 c 相对每节点工作量 w 越大,手递手越吃亏:它每前进一个节点都要一次加锁 + 一次解锁,总锁开销约为 2c × 节点数,而一把锁只有 2c。只有当每个节点上的工作很重(w 远大于 c)、两个线程又能在链表不同位置"流水线"并行时,手递手才能赢。真实系统里比较一个 key 只要几纳秒,而加锁解锁(尤其涉及缓存行在 CPU 之间迁移)贵得多,所以很难赢。
(C 实验)运行 ./counters 之前先预测:16 个线程的近似计数器(S=1024)总共做 1600 万次更新,耗时大约是 1 个线程做 100 万次的几倍?精确计数器呢?
看答案
本机实测:近似计数器 1 线程 0.007 秒、16 线程 0.017 秒,只增长到约 2.4 倍,工作量却是 16 倍——接近完美扩展(没有完全做到,是因为 16 线程用满了 8 个物理核的全部超线程)。精确计数器从 0.005 秒涨到 0.273 秒,约 55 倍,比串行做 1600 万次(约 0.08 秒)还慢得多:争用和缓存行来回搬运的代价远超计数本身。

动手实验:精确计数器 vs 近似计数器

程序 counters.c 实现了 Figure 29.2 的精确计数器和 Figure 29.4 的近似计数器,重做原书两个实验:线程数扫描(Figure 29.5 风格,每线程 100 万次,S=1024)和阈值扫描(Figure 29.6 风格,固定 4 线程)。每项跑 3 次取中位数。

cd labs/29-lock-data-structures && make
./counters            # 完整实验,约 2~3 秒
./counters-nopad      # 原书写法:局部计数器不做缓存行填充
./counters approx 4 1000000 1024   # 单独跑一个配置

程序用 sysconf(_SC_NPROCESSORS_ONLN) 取 NUMCPUS,线程 i 使用局部计数器 i % NUMCPUS。与原书的唯一区别:每个局部计数器和它的锁单独占一条 64 字节缓存行(counters-nopad 是不填充的原书版本,用来对比)。

示例输出(本机:AMD Ryzen 7 8745H,8 核 16 线程,WSL2,gcc 14 -O2;你的机器上数字会不同):

$ ./counters
NUMCPUS (online) = 16, 每线程 1000000 次递增, 每项取 3 次运行的中位数

== 实验一:线程数扫描(Figure 29.5 风格,近似计数器 S=1024)==
 threads   precise(s)    approx(s)  precise get()   approx get()
       1        0.005        0.007        1000000         999424
       2        0.033        0.007        2000000        1998848
       4        0.064        0.007        4000000        3997696
       8        0.154        0.010        8000000        7995392
      16        0.273        0.017       16000000       15990784

== 实验二:阈值 S 扫描(Figure 29.6 风格,4 线程)==
       S    approx(s)   approx get()          lag
       1        0.096        4000000            0
       2        0.066        4000000            0
       4        0.038        4000000            0
       8        0.012        4000000            0
      16        0.009        4000000            0
      32        0.008        4000000            0
      64        0.009        4000000            0
     128        0.010        3999744          256
     256        0.010        3999744          256
     512        0.009        3999744          256
    1024        0.008        3997696         2304
$ ./counters-nopad
[NOPAD 未填充缓存行] NUMCPUS (online) = 16, 每线程 1000000 次递增, 每项取 3 次运行的中位数

== 实验一:线程数扫描(Figure 29.5 风格,近似计数器 S=1024)==
 threads   precise(s)    approx(s)  precise get()   approx get()
       1        0.005        0.007        1000000         999424
       2        0.030        0.007        2000000        1998848
       4        0.059        0.048        4000000        3997696
       8        0.138        0.050        8000000        7995392
      16        0.257        0.052       16000000       15990784

== 实验二:阈值 S 扫描(Figure 29.6 风格,4 线程)==
       S    approx(s)   approx get()          lag
       1        0.122        4000000            0
       2        0.087        4000000            0
       4        0.077        4000000            0
       8        0.067        4000000            0
      16        0.060        4000000            0
      32        0.056        4000000            0
      64        0.052        4000000            0
     128        0.048        3999744          256
     256        0.053        3999744          256
     512        0.048        3999744          256
    1024        0.049        3997696         2304
把上面的真实输出画成图(鼠标悬停数据点看数值)。上:总耗时 vs 线程数;下:4 线程时耗时 vs 阈值 S(横轴按 2 的幂等距)。

怎么读这些数字:

自测题

1. 给计数器的每个操作加一把锁(Figure 29.2)之后,下列说法正确的是:
一把锁的计数器是正确的(选项 D 错:丢更新的是 increment,而它已加锁)。但所有更新被串行化,外加缓存行在 CPU 间搬运,原书中 2 线程就比 1 线程慢了一百多倍,本机也慢了约 6 倍。
2. 近似计数器中,4 个 CPU,阈值 S = 1024。任意时刻 get() 返回值比真实计数少多少(最多)?
每个局部计数器最多攒到 S − 1 而未转移(攒到 S 立即转移),4 个局部加起来最多 4 × 1023。原书用"至多 CPU 数乘以 S"作近似上界。
3. 近似计数器的阈值 S 从 1024 调到 1,最可能出现:
S = 1 时每次更新都要转移,全局锁获取次数等于更新次数;再加上多拿的局部锁,甚至比精确计数器还慢。换来的是零滞后。
4. 把 Figure 29.7 的 List_Insert 改写成 Figure 29.8,主要收益是什么?
改写的前提是 malloc 本身线程安全,所以可以移到锁外。临界区更短,而且错误返回路径不再持锁。查找则用 break 走唯一出口。它仍然是一把锁的链表。
5. 关于手递手锁(hand-over-hand locking),哪一项与原书一致?
正确顺序是先拿下一个、再放当前(B 顺序反了,会出现一瞬间什么都没锁住、链表可能被改动)。原书明确说实践中很难让它比简单的单锁方法更快。
6. Michael & Scott 队列里的哑节点(dummy node)的作用是:
没有哑节点时,空队列 head = tail = NULL,首次入队和最后一次出队都得同时改 head 和 tail,就要同时拿两把锁。哑节点消除了这种情况。
7. 双锁队列中,一个线程在入队、另一个线程在出队(队列非空)。它们:
这正是两把锁的设计目的:通常情况下入队只访问尾锁,出队只访问头锁。两个入队者之间(或两个出队者之间)仍然串行。
8. 为什么 Figure 29.10 的并发哈希表扩展性极好?
不再是整个结构一把锁,而是 101 把锁。只有落到同一个桶的操作才会互相等待。
9. 关于 Knuth 定律与大内核锁(BKL),哪一项最贴近原书的建议?
"过早优化是万恶之源。"Linux 早期的 BKL 多年间都是好选择,直到多 CPU 普及它才成为瓶颈,于是被拆成许多把锁。

常见误区与本章小结

本章要点

接下来:本章的队列在空的时候只能返回 −1。如果想让消费者"等到有数据再继续",锁就不够用了——第 30 章条件变量会构建能等待的有界队列(生产者/消费者问题)。第 32 章还会简要接触不用传统锁的非阻塞(non-blocking)数据结构。