背景

并发编程:从入门到放弃

  • 入门:threads.h
  • 放弃:原子性、顺序、可见性的丧失

互斥:恢复 “块” 之间的原子性、顺序、可见性

  • lock(&lk)
  • unlock(&lk)

本次课内容与目标

理解数据竞争

  • 数据竞争是什么?有什么危害?

用互斥锁实现并发数据结构

  • 线程安全的链表
  • malloc/free

数据竞争 (Data Race)

数据竞争

不同的线程同时访问同一段内存,且至少有一个是写

  • 两个内存访问在 “赛跑”,“跑赢” 的操作先执行
    • peterson.c: 内存访问都在赛跑
      • MFENCE如何留下最少的 fence,依然保证算法正确?

数据竞争 (cont'd)

Peterson 算法告诉大家:

  • 你们写不对无锁的并发程序
  • 所以事情反而简单了


用互斥锁保护好共享数据


消灭一切数据竞争

数据竞争:例子

以下代码概括了你们遇到数据竞争的大部分情况

  • 不要笑,你们的 bug 几乎都是这两种情况的变种

// Case #1: 上错了锁
void thread1() { spin_lock(&lk1); sum++; spin_unlock(&lk1); }
void thread2() { spin_lock(&lk2); sum++; spin_unlock(&lk2); }

// Case #2: 忘记上锁
void thread1() { spin_lock(&lk1); sum++; spin_unlock(&lk1); }
void thread2() { sum++; }

数据结构和并发数据结构

数据结构 (抽象数据类型)

Abstract Data Type (ADT): A mathematical model for data types, where a data type is defined by its behavior semantics from the point of view of a user of the data, specifically in terms of possible values, possible operations on data of this type, and the behavior of these operations.

  • ADT 是接口 (描述数据结构上操作的行为)
  • ADT 用某个具体的数据结构实现

学过的数据结构

  • Array, vector, linked list, (balanced) binary search tree, interval tree, hash table, skip list, ...

例子:链表

(面试题) 实现双向链表操作

  • 操作系统中非常常用 (请参考 Linux list_head)
typedef struct node {
  void *ptr;
  struct node *prev, *next;
} node_t;

node_t head; // 注意不是指针

void list_init(node_t *head) {
  head->prev = head->next = head;
}

void list_insert_before(node_t *node, node_t *new) { TODO(); }
void list_insert_after (node_t *node, node_t *new) { TODO(); }
void list_remove(node_t *node) { TODO(); }

编程建议 (1): 不要直接上手就编

白板面试:数据结构、算法……


Google 经典面试

  • Phone interview
    • 在 Google Docs 中写出 bug-free 的代码 (x1)
  • Onsite interview
    • 在白板上写出 bug-free 的代码 (x3)
  • 国内大厂难度普遍比这个更高 😂

在白板上编程必须时刻保持头脑清醒

  • 时刻理解代码全局和局部的 specification 和实现的途径

编程建议 (2): 不言自明、不言自证

void list_remove(node_t *node) {
  // 2021 年百度搜索 “双向链表 删除” 仍然得到这个回答 (WTF)
  node->prev->next = node->next;
  node->next->prev = node->prev;
}
static inline void list_check(node_t *node) {
  assert(node == node->prev->next); // circular list's invariant
  assert(node == node->next->prev);
}
void list_remove(node_t *node) {
  node_t *prev = node->prev;
  node_t *next = node->next;
  assert(node != prev && node != next); // possibly prev == next
  prev->next = next;
  next->prev = prev;
  list_check(prev); list_check(next);
}

把链表改成线程安全的

typedef struct node {
  lock_t lock;
  void *ptr;
  struct node *prev, *next;
} node_t;

呃……好像不太对……

void list_remove_r(node_t *node) {
  lock(&node->lock);
  list_remove(node);
  unlock(&node->lock);
}

把链表改成线程安全的 (cont'd)

typedef struct node {
  lock_t lock;
  void *ptr;
  struct node *prev, *next;
} node_t;

呃……好像还是不太对……

void list_remove_r(node_t *node) {
  lock(&node->lock);
  lock(&node->prev->lock);
  lock(&node->next->lock);
  list_remove(node);
  unlock(&node->lock);
  unlock(&node->prev->lock);
  unlock(&node->next->lock);
}

所以……

Many operating systems utilized a single lock when first transitioning to multiprocessors, including Sun OS and Linux. In the latter, this lock even had a name, the big kernel lock (BKL).

往简单里写

  • 总是假设自己写不对并发程序:使用链表级的锁

时刻警惕 “Heisenbug

  • 稍有不慎
    • 莫名其妙的值出错、虚拟机卡死、虚拟机神秘重启……
  • 防御性地编程
    • 使你更早、更容易地诊断出 bug

线程安全的 malloc/free

内存分配和管理

假设有一段空闲的内存 (“堆”;已知大小)

  • malloc(size) - 返回一个新鲜的对象,内存地址 [x, x+size)
    • 无法分配时返回 NULL
  • free(x) - 无情地和对象 [x, x+size) 分手
    • double-free = undefined behavior
  • 思考题:空闲的一段内存怎么来?(程序是状态机)

(那就 new 一个吧)

数据结构:分析

不要现在就开始设计,先理解问题!

我们需要维护一个已被分配区间的集合

  • $[\ell_0, r_0) \cup [\ell_1, r_1) \cup \ldots \cup [\ell_i, r_i) \subseteq [L, R)$
    • 互不相交:$\ell_0 < r_0 \le \ell_1 < r_1 \le \ldots < r_i \le \ell_{i+1}$

支持的操作

  • malloc($x$): 从 $[L, R)$ 中返回一段与 $[\ell_i, r_i)$ 不相交的 $[\ell, \ell + x)$
  • free($\ell_i$): 给定 $\ell_i$, 删除 $[\ell_i,r_i)$
    • 挑战 1: 怎么通过区间左端点找到右端点???
    • 挑战 2: 设计怎样的数据结构管理区间的集合
      • 空闲链表, binary search tree, interval tree, ...

实现高效的 malloc/free

Premature optimization is the root of all evil.

——D. E. Knuth

重要的事情说三遍:

  • 脱离 workload 做优化就是耍流氓
  • 脱离 workload 做优化就是耍流氓
  • 脱离 workload 做优化就是耍流氓
    • 在开始考虑性能之前,理解你需要考虑什么样的性能

想查看程序的 malloc/free 序列?

  • 计算机系统公理:你想到的就一定有人做到。

Workload 分析

指导思想:$O(n)$ 大小的对象分配后至少有 $\Omega(n)$ 的读写操作,否则就是 performance bug (不应该分配那么多)。

  • 越小的对象创建/分配越频繁
    • 字符串、临时对象等 (几十到几百字节);生存周期可长可短
  • 较为频繁地分配中等大小的对象
    • 较大的数组、复杂的对象;更长的生存周期
  • 低频率的大对象
    • 巨大的容器、分配器;很长的生存周期
  • 并行、并行、再并行
    • 所有分配都会在所有处理器上发生
    • 使用链表/区间树可不是个好想法

malloc, Fast and Slow

设置两套系统:

  • fast path
    • 性能极好、并行度极高、覆盖大部分情况
    • 但有小概率会失败
  • slow path
    • 不在乎那么快
    • 但把困难的事情做好
      • 计算机系统里有很多这样的例子 (比如 cache)

人类也是这样的系统

  • Daniel Kahneman. Thinking, Fast and Slow. Farrar, Straus and Giroux, 2011.

Slab 分配: Fast Path

对象越小、分配越频繁,越可能是性能瓶颈

  • 使小对象的分配尽可能在线程本地完成而无需互斥

Slab 分配器 (segregated list)

  • 每个 slab 里的每个对象都一样大
    • 每个线程拥有每个对象大小的 slab
    • fast path → 立即在线程本地分配完成

Slab 分配: Slow Path

Slab 分配失败

  • 线程申请很大的内存
  • 或 per-thread 的缓存满了

此时的情况:大内存分配

  • 因为不频繁,简单起见,上锁就好了
    • buddy system (1963): 线段树 (单位: page)

释放

回收小对象 (slab 中的对象)

  • 小心:在一个线程分配的内存可以在另一个线程回收
  • 小心:slab 空后应回收 slab

回收大对象 (回收 slab 或大块内存)

  • 上锁就好了 (或者用聪明的无锁算法实现)

现实世界中的 malloc/free

以上就是所有现代 malloc/free 实现的基础

  • 当然,实际情况会复杂得多,性能也是锱铢必较
    • glibc: arena → heap → tcache (thread-local)
    • TCMalloc: thread-caching malloc
    • OpenJDK: ZGC: region based + tlab (thread-local)
      • managed memory 允许 object move,因此复杂得多……

总结

总结

本次课内容与目标

  • 理解数据竞争
  • 用互斥锁实现并发数据结构 (和 malloc/free)

Take-away messages

  • 千万不要高估自己写并发程序的能力
    • 用简单、清楚的方式写出可以证明的算法
  • 抛开 workload 谈优化就是耍流氓
    • 理解 workload 再设计并发数据结构
      • 用好 trace/profiler
    • 多处理器上的互斥 (串行化) 是瓶颈