并发BUG讲解与应对(死锁等等)

370 阅读5分钟

Overview

复习

  • 并发编程的基本工具:线程库、互斥和同步

  • 并发编程的应用场景:高性能计算、数据中心、网页/移动应用

  • 应对 bug (和并发 bug) 的方法

  • 死锁和数据竞争

应对bug的方法

把任何你认为 “不对” 的情况都检查一遍

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

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

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

  • alipay.c 的例子
    • 变量 

balance 代表 “余额”

    • 怎么看 withdraw 以后 0 → 18446744073709551516 都不对

防御性编程

写出验证代码的东西:assert

死锁 (Deadlock)

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

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

image.png

避免死锁

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

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

避免死锁 (cont'd)

AA-Deadlock

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

ABBA-Deadlock

  • 任意时刻系统中的锁都是有限的
  • 严格按照固定的顺序获得所有锁 (lock ordering; 消除 “循环等待”)

T1:A→B→C;T2:B→C 是安全的

      • “在任意时刻总是有获得 “最靠后” 锁的可以继续执行”

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

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

数据竞争

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

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

用互斥锁保护好共享数据,来消灭数据竞争

数据竞争:例子

// 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)

原子性违反 (AV)

“ABA”

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

image.png

原子性违反 (cont'd)

有时候上锁也不解决问题

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

发邮件,检查是否是一个普通文件的时候,攻击者在文件检查完以后偷偷删掉然后重新创建出一个文件链接(不安全的)发送给接收方

image.png

顺序违反 (OV)

“BA”

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

image.png

应对并发 Bug 的方法

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

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

然后呢?

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

Lockdep: 运行时的死锁检查

Lockdep 规约 (Specification)

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

检查方法:printf

  • 记录所有观察到的上锁顺序,例如[x,y,z]⇒x→y,x→z,y→z
  • 检查是否存在顺序错误,或者说x->y 并 y ->x

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

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

  • Program-order + release-acquire
  • 对于发生在不同线程且至少有一个是写的 x,y 检查

image.png

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

在事件发生时记录

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

解析记录检查问题

  • Lockdep: x⇝y∧y⇝x
  • ThreadSanitizer: x⊀y∧y⊀x

付出的代价和权衡

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

动态分析工具:Sanitizers

没用过 lint/sanitizers?

  • AddressSanitizer (asan); (paper): 非法内存访问
    • Buffer (heap/stack/global) overflow, use-after-free, use-after-return, double-free, ...
    • Demo: uaf.ckasan
  • ThreadSanitizer (tsan): 数据竞争
  • MemorySanitizer (msan): 未初始化的读取
  • UBSanitizer (ubsan): undefined behavior
    • Misaligned pointer, signed integer overflow, ...
    • Kernel 会带着 -fwrapv 编译

以上这些就是防御性编程

我们也可以!Buffer Overrun 检查

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

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

计算机系统中的 canary

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

防御性编程:低配版 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 一秒诊断死锁

总结

  • 常见的并发 bug
    • 死锁、数据竞争、原子性/顺序违反
  • 不要盲目相信自己:检查、检查、检查
    • 防御性编程:检查
    • 动态分析:打印 + 检查