C++ 并发编程实战 第十一章 多线程应用的测试和除错

 

目录

11.1 与并发相关的错误类型

11.1.1 不必要的阻塞

11.1.2 条件竞争

11.2 定位并发错误的技术

11.2.1 代码审阅——发现潜在的错误

11.2.2 通过测试定位并发相关的错误

11.2.3 可测试性设计

11.2.4 多线程测试技术

11.2.5 构建多线程测试代码

11.2.6 测试多线程代码性能


参考《C++ 并发编程实战》 著:Anthony Williams 译: 吴天明

11.1 与并发相关的错误类型

有一些错误与使用并发直接相关,本章重点关注这些错误。通常,并发相关的错误通常有两大类:
不必要的阻塞
条件竞争

11.1.1 不必要的阻塞

这个主题可以分成以下几个问题:
死锁——如在第3章所见,死锁的情况下,两个线程会互相等待。当线程产生死锁,应该完成的任务就会持续搁置。举个例子来说,一些线程是负责对用户界面操作的线程,在死锁的情况下,用户界面就会无响应。另一些例子中,界面接口会保持响应,不过有些任务就无法完成,比如:查询无结果返回或文档未打印。
活锁——与死锁的情况类似。不同的地方在于线程不是阻塞等待,而是在循环中持续检查,例如:自旋锁。比较严重的情况下,其表现和死锁一样(应用不会做任何处理,停止响应),CPU的使用率还居高不下;因为线程还在循环中被检查,而不是阻塞等待。不太严重的情况下,因为使用随机调度,活锁的问题还是可以解决的。
I/O阻塞或外部输入——当线程被外部输入所阻塞,线程也就不能做其他事情了(即使,等待输入的情况永远不会发生)。因此,被外部输入所阻塞,就会让人不太高兴,因为可能有其他线程正在等待这个线程完成某些任务。

11.1.2 条件竞争

条件竞争经常会产生以下几种类型的错误:
数据竞争——因为未同步访问一块共享内存,将会导致代码产生未定义行为。第5章已经介绍了数据竞争,也了解了C++的内存模型。数据竞争通常发生在错误的使用原子操作上,做同步线程的时候,或没使用互斥量保护共享数据的时候。
破坏不变量——主要表现为悬空指针(因为其他线程已经将要访问的数据删除了),随机存储错误(因为局部更新,导致线程读取了不一样的数据),以及双重释放(比如:当两个线程对同一个队列同时执行pop操作,想要删除同一个关联数据),等等。不变量被破坏可以看作为“基于数据”的问题。当独立线程需要以一定顺序执行某些操作时,错误的同步会导致条件竞争,比如:顺序被破坏。
生命周期问题——虽然这类问题也能归结为破坏了不变量,不过这里将其作为一个单独的类别给出。这里的问题是线程会访问不存在的数据,这可能是因为数据被删除或销毁了,或者转移到其他对象中去了。生命周期问题,通常是在一个线程引用了局部变量,在线程还没有完成前,局部变量的“死期”就已经到了,不过这个问题并不止存在这种情况下。当手动调用join()等待线程完成工作,需要保证异常抛出的时候,join()还会等待其他未完成工作的线程。这是线程中基本异常安全的应用。

11.2 定位并发错误的技术

11.2.1 代码审阅——发现潜在的错误

如果让你的同事来审阅代码,他/她肯定对你的代码不是很熟悉。因此,他/她会从不同的角度来看你的代码,然后指出你没有注意的事情。如果你的同事都没有空,你可以叫朋友,或传到网络上,让网友审阅(注意,别传一些机密代码上去)。实在没有人审阅,不要着急。对于初学者,可以将代码放置一段时间——先去做应用的另外的部分,或是阅读一本书籍,亦或出去溜达溜达。在休息之后,当再集中注意力做某些事情(潜意识会考虑很多问题)。同样,当你做完其他事情,回头再看这段代码,就会有些陌生——你可能会从另一个角度来看你自己以前写的代码。
另一种方式就是自己审阅。可以向别人详细的介绍你所写的功能,可能并不是一个真正的人——可能要对玩具熊或橡皮鸡来进行解释,并且我个人觉得写一些比较详细的注释是非常有益的。在解释过程中,会考虑每一行过后,会发生什么事情,有哪些数据被访问了,等等。问自己关于代码的问题,并且向自己解释这些问题。我觉得这是种非常有效的技巧——通过自问自答,对每个问题认真考虑,这些问题往往都会揭示一些问题,也会有益于任何形式的代码审阅。

审阅多线程代码需要考虑的问题
并发访问时,哪些数据需要保护?
如何确定访问数据受到了保护?
是否会有多个线程同时访问这段代码?
这个线程获取了哪个互斥量?

其他线程可能获取哪些互斥量?
两个线程间的操作是否有依赖关系?如何满足这种关系?
这个线程加载的数据还是合法数据吗?数据是否被其他线程修改过?
当假设其他线程可以对数据进行修改,这将意味着什么?并且,怎么确保这样的事情不会发生?
我最喜欢最后一个问题,因为它让我去考虑线程之间的关系。通过假设一个bug和一行代码相关联,你就可以扮演侦探来追踪bug出现的原因。为了让你自己确定代码里面没有bug,需要考虑代码运行的各种情况。数据被多个互斥量所保护时,这种方式尤其有用,比如:使用线程安全队列(第6章),可以对队头和队尾使用独立的互斥量:就是为了确保在持有一个互斥量的时候,访问是安全的,必须保持有其他互斥量的线程不能同时访问同一元素。还需要特别关注的是,对公共数据的显式处理,使用一个指针或引用的方式来获取数据。
倒数第二个问题也很重要,因为这里很容易产生错误:先释放再获取一个互斥量的前提是,其他线程可能会修改共享数据。虽然很明显,但当互斥锁不是立即可见——可能因为是内部对象——就会不知不觉的掉入陷阱中。第6章已经了解到这种情况是怎么引起条件竞争的,以及如何给细粒度线程安全数据结构带来麻烦的。不过,非线程安全栈将top()和pop()操作分开是有意义的,当多线程并发的访问这个栈,问题会马上出现,因为在两个操作的调用间,内部互斥锁已经被释放,并且另一个线程对栈进行了修改。解决方案就是将两个操作合并,就能用同一个锁来对操作的执行进行保护,就消除了条件竞争的问题。

11.2.2 通过测试定位并发相关的错误

11.2.3 可测试性设计

测试多线程代码很困难,所以需要将其变得简单一些。很重要的一件事就是设计代码时,考虑其的可测试性。可测试的单线程代码设计已经说烂了,而且其中许多建议现在依旧适用。通常,如果代码满足一下几点,就很容易进行测试:

每个函数和类的关系都很清楚。
函数短小精悍。
测试用例可以完全控制被测试代码周边的环境。
执行特定操作的代码应该集中测试,而非分布式测试。

需要在完成编写后,考虑如何进行测试。
以上这些在多线程代码中依旧适用。实际上,我会认为对多线程代码的可测试性要比单线程的更为重要,因为多线程的情况更加复杂。最后一个因素尤为重要:即使不在写完代码后,去写测试用例,这也是一个很好的建议,能让你在写代码之前,想想应该怎么去测试它——用什么作为输入,什么情况看起来会让结果变得糟糕,以及如何激发代码中潜在的问题,等等。

并发代码测试的一种最好的方式:去并发化测试。如果代码在线程间的通讯路径上出现问,就可以让一个已通讯的单线程进行执行,这样会减小问题的难度。在对数据进行访问的应用进行测试时,可以使用单线程的方式进行。这样线程通讯和对特定数据块进行访问时只有一个线程,就达到了更容易测试的目的。

例如,当应用设计为一个多线程状态机时,可以将其分为若干块。将每个逻辑状态分开,就能保证对于每个可能的输入事件、转换或其他操作的结果是正确的;这就是单线程测试的技巧,测试用例提供的输入事件将来自于其他线程。之后,核心状态机和消息路由的代码,就能保证时间能以正确的顺序,正确的传递给可单独测试的线程上,不过对于多并发线程,需要为测试专门设计简单的逻辑状态。
或者,如果将代码分割成多个块(比如:读共享数据/变换数据/更新共享数据),就能使用单线程来测试变换数据的部分。麻烦的多线程测试问题,转换成单线程测试读和更新共享数据,就会简单许多。某些库会用其内部变量存储状态时需要小心,当多线程使用同一库中的函数,这个状态就会被共享。这的确是一个问题,并且问题不会马上出现在访问共享数据的代码中。不过,随着你对这个库的熟悉,就会清楚这样的情况会在什么时候出现。之后,可以适当的加一些保护和同步或使用B计划——让多线程安全并发访问的功能。

11.2.4 多线程测试技术

蛮力测试
代码有问题的时候,就要求蛮力测试一定能看到这个错误。这就意味着代码要运行很多遍,可能会有很多线程在同一时间运行。要是有bug出现,只能线程出现特殊调度的时候;代码运行次数的增加,就意味着bug出现的次数会增多。
当有几次代码测试通过,你可能会对代码的正确性有一些信心。如果连续运行10次都通过,你就会更有信心。如果你运行十亿次都通过了,那么你就会认为这段代码没有问题了。
自信的来源是每次测试的结果。如果你的测试粒度很细,就像测试之前的线程安全队列,那么蛮力测试会让你对这段代码持有高度的自信。另一方面,当测试对象体积较大的时候,调度序列将会很长,即使运行了十亿次测试用例,也不让你对这段代码产生什么信心。

组合仿真测试
名字比较口语化,我需要解释一下这个测试是什么意思:使用一种特殊的软件,用来模拟代码运行的真实情况。你应该知道这种软件,能让一台物理机上运行多个虚拟环境或系统环境,而硬件环境则由监控软件来完成。除了环境是模拟的以外,模拟软件会记录对数据序列访问,上锁,以及对每个线程的原子操作。然后使用C++内存模型的规则,重复的运行,从而识别条件竞争和死锁。
虽然,这种组合测试可以保证所有与系统相关的问题都会被找到,不过过于零碎的程序将会在这种测试中耗费太长时间,因为组合数目和执行的操作数量将会随线程的增多呈指数增长态势。这个测试最好留给需要细粒度测试的代码段,而非整个应用。另一个缺点就是,代码对操作的处理,往往会依赖与模拟软件的可用性。
所以,测试需要在正常情况下,运行很多次,不过这样可能会错过一些问题;也可以在一些特殊情况下运行多次,不过这样更像是为了验证某些问题。

使用专用库对代码进行测试
虽然,这个选择不会像组合仿真的方式提供彻底的检查,不过可以通过特别实现的库(使用同步原语)来发现一些问题,比如:互斥量,锁和条件变量。例如,访问某块公共数据的时候,就要将指定的互斥量上锁。数据被访问后,发现一些互斥量已经上锁,就需要确定相关的互斥量是否被访问线程锁住;如果没有,测试库将报告这个错误。当需要测试库对某块代码进行检查时,可以对相应的共享数据进行标记。
多个互斥量同时被一个线程持有时,测试库也会对锁的序列进行记录。如果其他线程以不同的顺序进行上锁,即使在运行的时候测试用例没有发生死锁,测试库都会将这个行为记录为“有潜在死锁”可能。
测试多线程代码时,另一种库可能会用到,以线程原语实现的库,比如:互斥量和条件变量;当多线程代码在等待,或是被条件变量通过notify_one()提醒的某个线程,测试者可以通过线程,获取到锁。就可以让你来安排一些特殊的情况,以验证代码是否会在这些特定的环境下产生期望的结果。
C++标准库实现中,某些测试工具已经存在于标准库中,没有实现的测试工具,可以基于标准库进行实现。

11.2.5 构建多线程测试代码

在特定时间内,需要安排一系列线程,同时去执行指定的代码段。两个线程的情况,就很容易扩展到多个线程。
首先,需要知道每个测试的不同之处:
环境布置代码,必须首先执行
线程设置代码,需要在每个线程上执行
线程上执行的代码,需要有并发性
并发执行结束后,后续代码需要对代码的状态进行断言检查

了解了各个代码块,就需要保证所有事情按计划进行。一种方式是使用一组使用承诺值来表示是否准备好,然后让std::promise等待(复制)一个std::promise std::shared_future来表示就绪状态。每个线程;主线程会等待每个线程上的承诺值设置后,才按下“开始”键。这就能保证每个线程能够同时开始,并且在准备代码执行完成后,并发代码就可以开始执行了;任何线程的特定设置都需要在设置承诺值前完成。最终,主线程会等待所有线程完成,并且检查其最终状态。还需要格外关心异常,所有线程在准备好的情况下,再按下“开始”键;否则,未准备好的线程就不会运行。

清单11.1 对一个队列并发调用push()和pop()的测试用例

1.void test_concurrent_push_and_pop_on_empty_queue()
2.{
threadsafe_queue q;
3.
// 1
4.
5.std::promise go,push_ready,pop_ready;
6.std::shared_future ready(go.get_future());
// 2
// 3
7.
8.std::future push_done;
9.std::future pop_done;
// 4
10.
11.try
12.{
push_done=std::async(std::launch::async,
13.
// 5
14.[&q,ready,&push_ready]()
15.{
16.push_ready.set_value();
17.ready.wait();
q.push(42);
18.
}
19.
);
20.
pop_done=std::async(std::launch::async,
21.
// 6
22.[&q,ready,&pop_ready]()
23.{
24.pop_ready.set_value();
25.ready.wait();
26.return q.pop();
// 7
}
27.
);
28.
29.push_ready.get_future().wait();
30.pop_ready.get_future().wait();
31.go.set_value();
// 9
33.push_done.get();
// 10
34.assert(pop_done.get()==42);
// 8
32.
assert(q.empty());
35.
36.}
37.catch(...)
38.{
go.set_value();
39.
// 12
throw;
40.
}
41.
42.
// 11
}

11.2.6 测试多线程代码性能


选择以并发的方式开发应用,就是为了能够使用日益增长的处理器数量;通过处理器数量的增加,来提升应用的执行效率。因此,确定性能是否有真正的提高就很重要了(就像其他优化一样)。
并发效率中有个特别的问题——可扩展性——你希望代码能很快的运行24次,或在24芯的机器上对数据进行24(或更多)次处理,或其他等价情况。如8.4.2节中所述,当有重要的代码以单线程方式运行时,就会限制性能的提高。因此,在做测试之前,回顾一下代码的设计结构是很有必要的;这样就能判断,代码在24芯的机器上时,性能会不会提高24倍,或是因为有串行部分的存在,最大的加速比只有3。
对数据访问时,处理器之间会有竞争,会对性能有很大的影响。需要合理的权衡性能和处理器的数量,处理器数量太少,就会等待很久;处理器过多,又会因为竞争的原因等待很久。
因此,在对应的系统上通过不同的配置,检查多线程的性能就很有必要,这样可以得到一张性能图。最起码(如果条件允许)需要在一个单处理器的系统上和一个多处理核芯的系统上进行测试。

你可能感兴趣的:(开发语言,c++,C++并发编程)