1 并发相关的错误
1.1 不必要的阻塞
一个线程阻塞的时候,不能处理任何任务,因为它在等待其他“条件”的达成。通常这些“条件”就是一个互斥量、一个条件变量或一个期望值,也可能是一个I/O操作。这是多线程代码的先天特性,可以细分为:
- 死锁——死锁的情况下,两个线程会互相等待。当线程产生死锁,应该完成的任务就会持续搁置。举个例子来说,一些线程是负责对用户界面操作的线程,在死锁的情况下,用户界面就会无响应。另一些例子中,界面接口会保持响应,不过有些任务就无法完成,比如:查询无结果返回或文档未打印。
- 活锁——与死锁的情况类似。不同的地方在于线程不是阻塞等待,而是在循环中持续检查,例如:自旋锁。
- 比较严重的情况下,其表现和死锁一样(应用不会做任何处理,停止响应),CPU的使用率还居高不下;因为线程还在循环中被检查,而不是阻塞等待。
- 不太严重的情况下,因为使用随机调度,活锁的问题还是可以解决的。
- I/O阻塞或外部输入——当线程被外部输入所阻塞,线程也就不能做其他事情了(即使,等待输入的情况永远不会发生)。因此,被外部输入所阻塞,就会让人不太高兴,因为可能有其他线程正在等待这个线程完成某些任务。
1.2 条件竞争
条件竞争在多线程代码中很常见——很多条件竞争表现为死锁与活锁。而且,并非所有条件竞争都是恶性的。很多条件竞争是良性的,比如:哪一个线程去处理任务队列中的下一个任务。
条件竞争经常会产生以下几种类型的错误:
- 数据竞争——因为未同步访问一块共享内存,将会导致代码产生未定义行为。数据竞争通常发生在错误的使用原子操作上,做同步线程的时候,或没使用互斥量保护共享数据的时候。
- 破坏不变量——不变量被破坏可以看作为“基于数据”的问题。当独立线程需要以一定顺序执行某些操作时,错误的同步会导致条件竞争,比如:顺序被破坏。主要表现为:
- 悬空指针(因为其他线程已经将要访问的数据删除了)
- 随机存储错误(因为局部更新,导致线程读取了不一样的数据),
- 双重释放(比如:当两个线程对同一个队列同时执行pop操作,想要删除同一个关联数据),等等。
- 生命周期问题——虽然这类问题也能归结为破坏了不变量,不过这里将其作为一个单独的类别给出。这里的问题是线程会访问不存在的数据,这可能是因为数据被删除或销毁了,或者转移到其他对象中去了。
- 通常是在一个线程引用了局部变量,在线程还没有完成前,局部变量的“死期”就已经到了,不过这个问题并不止存在这种情况下。
- 当手动调用join()等待线程完成工作,需要保证异常抛出的时候,join()还会等待其他未完成工作的线程。这是线程中基本异常安全的应用。
2 debug技术
2.1 Code Review
- 并发访问时,哪些数据需要保护?
- 如何确定访问数据受到了保护?
- 是否会有多个线程同时访问这段代码?
- 这个线程获取了哪个互斥量?
- 其他线程可能获取哪些互斥量?
- 两个线程间的操作是否有依赖关系?如何满足这种关系?
- 这个线程加载的数据还是合法数据吗?数据是否被其他线程修改过?
- 当假设其他线程可以对数据进行修改,这将意味着什么?并且,怎么确保这样的事情不会发生?
2.2 可测试性设计
因为与并发相关的bug相当难判断,所以在设计并发代码时需要格外谨慎。设计的时候,每段代码都需要进行测试保证没有问题,这样才能在测试出现问题的时候,剔除并发相关的bug——例如,对队列的push和pop,分别进行并发的测试,就要好于直接使用队列测试其中全部功能。
如果代码满足一下几点,就很容易进行测试:
- 每个函数和类的关系都很清楚。
- 函数短小精悍。
- 测试用例可以完全控制被测试代码周边的环境。
- 执行特定操作的代码应该集中测试,而非分布式测试。
- 需要在完成编写后,考虑如何进行测试。
并发代码测试的一种最好的方式:去并发化测试。如果代码在线程间的通讯路径上出现问,就可以让一个已通讯的单线程进行执行,这样会减小问题的难度。在对数据进行访问的应用进行测试时,可以使用单线程的方式进行。这样线程通讯和对特定数据块进行访问时只有一个线程,就达到了更容易测试的目的。