4.2-并发 Bug 和应对

复习

  • 并发编程的基本工具:线程库、互斥和同步
  • 并发编程的应用场景:高性能计算、数据中心、网页/移动应用

本次课回答的问题

  • Q: 并发编程那么难,我写出 bug 怎么办?

本次课主要内容

  • 应对 bug (和并发 bug) 的方法
  • 死锁和数据竞争

一、应对 Bug 的方法

基本思路:否定你自己

虽然不太愿意承认,但始终假设自己的代码是错的。


然后呢?

  • 做好测试
  • 检查哪里错了
  • 再检查哪里错了
  • 再再检查哪里错了
    • (把任何你认为 “不对” 的情况都检查一遍)

Bug 多的根本原因:编程语言的缺陷

软件是需求 (规约) 在计算机数字世界的投影。

只管 “翻译” 代码,不管和实际需求 (规约) 是否匹配

  • alipay.c的例子
    • 变量 balance 代表 “余额”
    • 怎么看 withdraw 以后 0 → 18446744073709551516 都不对

三十年后的编程语言和编程方法?

更实在的方法:防御性编程

把程序需要满足的条件用 assert 表达出来。

例子:peterson-barrier.c、二叉树的旋转

img

防御性编程和规约给我们的启发

你知道很多变量的含义

#define CHECK_INT(x, cond) \
  ({ panic_on(!((x) cond), "int check fail: " #x " " #cond); })
#define CHECK_HEAP(ptr) \
  ({ panic_on(!IN_RANGE((ptr), heap)); })

变量有 “typed annotation”

  • CHECK_INT(waitlist->count, >= 0);
  • CHECK_INT(pid, < MAX_PROCS);
  • CHECK_HEAP(ctx->rip); CHECK_HEAP(ctx->cr3);
  • 变量含义改变 → 发生奇怪问题 (overflow, memory error, …)
    • 不要小看这些检查,它们在底层编程 (M2, L1, …) 时非常常见
    • 在虚拟机神秘重启/卡住/…前发出警报

二、并发 Bug:死锁 (Deadlock)

死锁 (Deadlock)

A deadlock is a state in which each member of a group is waiting for another member, including itself, to take action.

出现线程 “互相等待” 的情况

img

AA-Deadlock

假设你的 spinlock 不小心发生了中断

  • 在不该打开中断的时候开了中断
  • 在不该切换的时候执行了 yield()

void os_run() {
  spin_lock(&list_lock);
  spin_lock(&xxx);
  spin_unlock(&xxx); // ---------+
}                          //    |
                           //    |
void on_interrupt() {      //    |
  spin_lock(&list_lock);   // <--+
  spin_unlock(&list_lock);
}

ABBA-Deadlock

void swap(int i, int j) {
  spin_lock(&lock[i]);
  spin_lock(&lock[j]);
  arr[i] = NULL;
  arr[j] = arr[i];
  spin_unlock(&lock[j]);
  spin_unlock(&lock[i]);
}

上锁的顺序很重要……

  • swap本身看起来没有问题

避免死锁

死锁产生的四个必要条件 (Edward G. Coffman, 1971):

  • 互斥:一个资源每次只能被一个进程使用
  • 请求与保持:一个进程请求资阻塞时,不释放已获得的资源
  • 不剥夺:进程已获得的资源不能强行剥夺
  • 循环等待:若干进程之间形成头尾相接的循环等待资源关系

“理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。所以,在系统设计、进程调度等方面注意如何不让这四个必要条件成立,如何确定资源的合理分配算法,避免进程永久占据系统资源。此外,也要防止进程在处于等待状态的情况下占用资源。因此,对资源的分配要给予合理的规划。” ——Bullshit.

避免死锁 (cont’d)

AA-Deadlock

  • AA 型的死锁容易检测,及早报告,及早修复
  • spinlock-xv6.c 中的各种防御性编程
    • if (holding(lk)) panic();

ABBA-Deadlock

  • 任意时刻系统中的锁都是有限的
  • 严格按照固定的顺序获得所有锁 (lock ordering; 消除 “循环等待”)
    • 遇事不决可视化:lock-ordering.py
    • 进而证明T1 : ABC; T2 : BC是安全的
      • “在任意时刻总是有获得 “最靠后” 锁的可以继续执行”

Lock Ordering: 应用 (Linux Kernel: rmap.c)

img

但是……你又 Naive 了……

Textbooks will tell you that if you always lock in the same order, you will never get this kind of deadlock. Practice will tell you that this approach doesn’t scale: when I create a new lock, I don’t understand enough of the kernel to figure out where in the 5000 lock hierarchy it will fit.

The best locks are encapsulated: they never get exposed in headers, and are never held around calls to non-trivial functions outside the same file. You can read through this code and see that it will never deadlock, because it never tries to grab another lock while it has that one. People using your code don’t even need to know you are using a lock.

—— Unreliable Guide to Locking by Rusty Russell

  • 我们稍后回到这个问题,继续看更多的 bugs

三、并发 Bug:数据竞争 (Data Race)

不上锁不就没有死锁了吗?

数据竞争

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

  • 两个内存访问在 “赛跑”,“跑赢” 的操作先执行

    • peterson-barrier.c
      内存访问都在赛跑
      • MFENCE如何留下最少的 fence,依然保证算法正确?

img

数据竞争 (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++; }

四、更多类型的并发 Bug

程序员:花式犯错

回顾我们实现并发控制的工具

  • 互斥锁 (lock/unlock) - 原子性
  • 条件变量 (wait/signal) - 同步

忘记上锁——原子性违反 (Atomicity Violation, AV)

忘记同步——顺序违反 (Order Violation, OV)


Empirical study: 在 105 个并发 bug 中 (non-deadlock/deadlock)

  • MySQL (14/9), Apache (13/4), Mozilla (41/16), OpenOffice (6/2)
  • 97% 的非死锁并发 bug 都是 AV 或 OV。

原子性违反 (AV)

“ABA”

  • 我以为一段代码没啥事呢,但被人强势插入了

img

原子性违反 (cont’d)

有时候上锁也不解决问题

  • “TOCTTOU” - time of check to time of use

img

顺序违反 (OV)

“BA”

  • 怎么就没按我预想的顺序来呢?
    • 例子:concurrent use after free

img

五、应对并发 Bug 的方法

完全一样的基本思路:否定你自己

还是得始终假设自己的代码是错的。


然后呢?

  • 做好测试
  • 检查哪里错了
  • 再检查哪里错了
  • 再再检查哪里错了
    • (把任何你认为 “不对” 的情况都检查一遍)

例如:用 lock ordering 彻底避免死锁?

  • 你想多了:并发那么复杂,程序员哪能充分测试啊

Lockdep: 运行时的死锁检查

Lockdep 规约 (Specification)

  • 为每一个锁确定唯一的 “allocation site”
    • lock-site.c
    • assert: 同一个 allocation site 的锁存在全局唯一的上锁顺序

检查方法:printf

  • 记录所有观察到的上锁顺序,例如[x,y,z]⇒xy,xz,yz
  • 检查是否存在 xyyx*

Lockdep 的实现

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

typedef struct lock {
  int locked;
  const char *site;
} lock_t;

#define STRINGIFY(s) #s
#define TOSTRING(s)  STRINGIFY(s)
#define LOCK_INIT() \
  ( (lock_t) { .locked = 0, .site = __FILE__ ":" TOSTRING(__LINE__), } )

lock_t lk1 = LOCK_INIT();
lock_t lk2 = LOCK_INIT();

void lock(lock_t *lk) {
  printf("LOCK   %s\n", lk->site);
}

void unlock(lock_t *lk) {
  printf("UNLOCK %s\n", lk->site);
}

struct some_object {
  lock_t lock;
  int data;
};

void object_init(struct some_object *obj) {
  obj->lock = LOCK_INIT();
}

int main() {
  lock(&lk1);
  lock(&lk2);
  unlock(&lk1);
  unlock(&lk2);

  struct some_object *obj = malloc(sizeof(struct some_object));
  assert(obj);
  object_init(obj);
  lock(&obj->lock);

  lock(&lk2);
  lock(&lk1);
}
gcc lock-site.c && ./a.out                     

LOCK   lock-site.c:15
LOCK   lock-site.c:16
UNLOCK lock-site.c:15
UNLOCK lock-site.c:16
LOCK   lock-site.c:32
LOCK   lock-site.c:16
LOCK   lock-site.c:15

ThreadSanitizer: 运行时的数据竞争检查

为所有事件建立 happens-before 关系图

更多的检查:动态程序分析

在事件发生时记录

  • Lockdep: lock/unlock
  • ThreadSanitizer: 内存访问 + lock/unlock

解析记录检查问题

  • Lockdep: xyyx
  • ThreadSanitizer: x\≺yy\≺x

付出的代价和权衡

  • 程序执行变慢
  • 但更容易找到 bug (因此在测试环境中常用)

动态分析工具:Sanitizers

没用过 lint/sanitizers?

#include <stdlib.h>
#include <string.h>

int main() {
  int *ptr = malloc(sizeof(int));
  *ptr = 1;
  free(ptr);
  *ptr = 1;
}
# -fsanitize=address:对地址进行消毒
gcc -g lock-site.c -fsanitize=address && ./a.out

==16531==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010 at pc 0x0000004011fc bp 0x7ffe2d3ce3d0 sp 0x7ffe2d3ce3c8      
#include "thread.h"

#define N 100000000

long sum = 0;

void Tsum() {
  for (int i = 0; i < N; i++) {
    sum++;
  }
}

int main() {
  create(Tsum);
  create(Tsum);
  join();
  printf("sum = %ld\n", sum);
}
gcc sum.c -lpthread -fsanitize=thread && ./a.out

WARNING: ThreadSanitizer: data race (pid=16760)
#include "thread.h"

#define LENGTH(arr) (sizeof(arr) / sizeof(arr[0]))

enum { A = 1, B, C, D, E, F, };

struct rule {
  int from, ch, to;
};

struct rule rules[] = {
  { A, '<', B },
  { B, '>', C },
  { C, '<', D },
  { A, '>', E },
  { E, '<', F },
  { F, '>', D },
  { D, '_', A },
};
int current = A, quota = 1;

pthread_mutex_t lk   = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t  cond = PTHREAD_COND_INITIALIZER;

int next(char ch) {
  for (int i = 0; i < LENGTH(rules); i++) {
    struct rule *rule = &rules[i];
    if (rule->from == current && rule->ch == ch) {
      return rule->to;
    }
  }
  return 0;
}

void fish_before(char ch) {
  pthread_mutex_lock(&lk);
  while (!(next(ch) && quota)) {
    // can proceed only if (next(ch) && quota)
    pthread_cond_wait(&cond, &lk);
  }
  quota--;
  pthread_mutex_unlock(&lk);
}

void fish_after(char ch) {
  pthread_mutex_lock(&lk);
  quota++;
  current = next(ch);
  assert(current);
  pthread_cond_broadcast(&cond);
  pthread_mutex_unlock(&lk);
}

const char roles[] = ".<<<<<>>>>___";

void fish_thread(int id) {
  char role = roles[id];
  while (1) {
    fish_before(role);
    putchar(role); // can be long; no lock protection
    fish_after(role);
  }
}

int main() {
  setbuf(stdout, NULL);
  for (int i = 0; i < strlen(roles); i++)
    create(fish_thread);
}
gcc fish.c -lpthread -fsanitize=thread && ./a.out > /dev/null 

这不就是防御性编程吗?

只不过不需要我亲自动手把代码改得乱七八糟了……

我们也可以!Buffer Overrun 检查

Canary (金丝雀) 对一氧化碳非常敏感

  • 用生命预警矿井下的瓦斯泄露 (since 1911)

img

计算机系统中的 canary

  • “牺牲” 一些内存单元,来预警 memory error 的发生
    • (程序运行时没有动物受到实质的伤害)

Canary 的例子:保护栈空间 (M2/L2)

#define MAGIC 0x55555555
#define BOTTOM (STK_SZ / sizeof(u32) - 1)
struct stack { char data[STK_SZ]; };

void canary_init(struct stack *s) {
  u32 *ptr = (u32 *)s;
  for (int i = 0; i < CANARY_SZ; i++)
    ptr[BOTTOM - i] = ptr[i] = MAGIC;
}

void canary_check(struct stack *s) {
  u32 *ptr = (u32 *)s;
  for (int i = 0; i < CANARY_SZ; i++) {
    panic_on(ptr[BOTTOM - i] != MAGIC, "underflow");
    panic_on(ptr[i] != MAGIC, "overflow");
  }
}

烫烫烫、屯屯屯和葺葺葺

msvc 中 debug mode 的 guard/fence/canary

  • 未初始化栈: 0xcccccccc,烫烫烫
  • 未初始化堆: 0xcdcdcdcd,屯屯屯
  • 对象头尾: 0xfdfdfdfd,
  • 已回收内存: 0xdddddddd,葺葺葺
(b'\xcc' * 80).decode('gb2312')

# 烫烫烫烫烫烫烫烫烫烫

手持两把锟斤拷,口中疾呼烫烫烫

脚踏千朵屯屯屯,笑看万物锘锘锘

(它们一直在无形中保护你)

防御性编程:低配版 Lockdep

不必大费周章记录什么上锁顺序

  • 统计当前的 spin count
    • 如果超过某个明显不正常的数值 (1,000,000,000) 就报告
int spin_cnt = 0;
while (xchg(&locked, 1)) {
  if (spin_cnt++ > SPIN_LIMIT) {
    printf("Too many spin @ %s:%d\n", __FILE__, __LINE__);
  }
}
  • 配合调试器和线程 backtrace 一秒诊断死锁

防御性编程:低配版 Sanitizer (L1)

内存分配要求:已分配内存 S*=[ℓ0,r0)∪[ℓ1,r1)∪…

  • kalloc(s) 返回的 [ℓ,r) 必须满足 [ℓ,r)∩S=∅
    • thread-local allocation + 并发的 free 还蛮容易弄错的
// allocation
for (int i = 0; (i + 1) * sizeof(u32) <= size; i++) {
  panic_on(((u32 *)ptr)[i] == MAGIC, "double-allocation");
  arr[i] = MAGIC;
}

// free
for (int i = 0; (i + 1) * sizeof(u32) <= alloc_size(ptr); i++) {
  panic_on(((u32 *)ptr)[i] == 0, "double-free");
  arr[i] = 0;
}

总结

本次课回答的问题

  • Q: 如何拯救人类不擅长的并发编程?

Take-away message

  • 常见的并发 bug
    • 死锁、数据竞争、原子性/顺序违反
  • 不要盲目相信自己:检查、检查、检查
    • 防御性编程:检查
    • 动态分析:打印 + 检查
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

绿洲213

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值