《C++ Concurrency in Action》笔记31 测试及调试多线程应用程序

来源:互联网 发布:网络计划图绘制软件 编辑:程序博客网 时间:2024/05/21 04:22

10 测试及调试多线程应用程序

到目前为止,我已经关注了在编写并行代码时涉及到的——可用的工具,怎样使用它们,以及全部的设计以及代码结构。但是软件开发的一个重要的部分我们还没有讨论:测试和调试。如果你希望通过通读这章以找到一种简单的方法去测试并行代码,你马上就会失望。测试和调试并行代码是艰难的。我能给你的就是一些技术:使事情变得简单,在面对问题时最重要的就是思考。

测试和调试就像是一块硬币的两个面——你测试代码希望找到任何可能存在的bug,调试它以清除这些bug。如果幸运的话,你只需要清除你自己测试出来的bug,而不是被最终用户发现。在我们讨论测试或者调试之前,重要的是理解可能出现的问题。

10.1 并行相关bug的不同类型

你能够在并行代码中发现任何种类的bug;在这方面没有什么特别的。但是某些类型的bug是与并行的使用直接相关的,因此与这本书所要讲的息息相关。一般来讲,这些并行相关的bug分为两个主要类别:

■  非期待阻塞

■  竞争条件

它们都是大的类别,所以让我们来细分一下。

10.1.1 非期待阻塞

非期待阻塞意味着什么?首先,当一个线程因为等待某事而不能继续处理时会陷入阻塞状态。这一般是因为像mutex,条件变量,或者future引起的,但也有可能在等待I/O。这是多线程代码的一种特性,但那并不总是处于意料之中——因为非期待阻塞问题。这将我们引入下一个疑问:为什么这个阻塞是非期待的?通常,这是由于另一个线程也在等待当前处于阻塞状态的线程去执行一些动作,所以导致那个线程也因此而阻塞。

这个主题有几个变体:

■ 死锁——就像你在第3章中看到的那样,此时一个线程在等待另一个,另一个线程反过来在等待第一个线程。如果你的线程产生了死锁,那么它们假定的任务将不会结束。在大多数可见情况下,被涉及到的这些线程中有一个是用来行使用户交互职责的,那么此刻用户响应就被终止了。其他的情况下,用户响应仍然有效,但是某些请求的任务将无法结束,例如搜索无返回,或者文件没有被打印。

■ 活锁——活锁在这方面类似于死锁:一个线程等待另一个线程,另一个线程反过来在等待第一个线程。关键的不同在于,等待不是一个阻塞等待,而是一个活动的循环检查,例如一个旋转锁。在某些情况下,症状与死锁相同(应用无法继续),除了CPU使用量居高不下,因为线程仍然运行但是阻塞了彼此。在非严肃的情况下,活锁可能会最终因为随机的时序而得到解决,但是涉及到的任务会延迟很久才会完成,并且伴随延迟的是超高的CPU使用量。

■ 阻塞于I/O或其他的外部输入——如果你的线程因为等待外部输入而陷入等待,它不能继续,即使它等待的外部输入永远不到来。因此一个总是处理一些有可能被其他线程等待的任务的线程,不应该等待外部输入。

10.1.2 竞争条件

竞争条件是并行代码中最普遍存在的问题——许多死锁或活锁都是因为竞争条件引起的。并非所有的竞争条件都是有问题的——竞争条件的出现依赖于不同线程中操作的时序关系。大部分的竞争条件完全是良性的,例如,哪个线程去处理任务队列中的下一个任务是完全没有关系的。尽管如此,很多多线程bug都是因竞争条件引起的。一般来说,竞争条件常常会导致如下问题:

■ 数据竞争——数据竞争是一种特殊类型的竞争条件,它导致未定义行为,因为非同步的并行访问同一个共享内存地址。在第5章,当我们讨论C++内存模型时,我介绍过数据竞争。数据竞争通常因为错误的使用原子操作去同步线程或者不锁定mutex而访问对应的共享数据引起。

■ 破坏数据一致性——这会通过悬挂指针显现出来(因为另一个线程删除了正在访问的数据),随机内存腐败(random memory corruption)(由于线程读取了部分更新导致的不一致数据),以及重复释放(例如两个线程都从队列中pop了同一个值,所以都去删除相关数据),等等。数据一致性被破坏可能是基于时间上的也可能是基于数值上的。如果不同线程的操作需要以特定顺序执行,那么不正确的同步可能导致竞争条件,导致所需的顺序偶尔会被打乱。

■ 生命期问题——尽管你可以将这个问题与数据一致性被破坏捆绑在一起,但这个问题真的是另一种类别。这类bug的本质问题在于线程访问了超出生命期的数据,它访问的数据已经被删除或一起他的形式被销毁,而且其保存的数据可能只是重用了其他对象。产生这个问题的通常原因在于,一个线程在线程函数结束前引用了一个跳出当前代码块的本地变量,但原因也不只限于此。无论什么时候,只要线程的生命期和其访问的数据的生命期不能够以某种方式绑定在一起,就可能出现这种情况:在线程完成之前,数据就被销毁了,对这个线程来说,相当于脚下的地毯被抽走。如果你手动调用join()函数来等待线程结束,那么你需要确保join()调用不会被之前抛出的异常所忽略(用结构体的析构函数来join())。这是线程应用的基本异常安全。

如果出现死锁或活锁,应用会表现得停滞不前,变得完全无响应,或者要花很久才能完成任务。通常,你可以在运行中附加一个调试器,来识别哪个线程涉及到了死锁或者活锁,以及哪个同步对象被它们争夺。如果出现数据竞争,数据一致性被破坏,以及生命期问题,问题的可见症状(例如随机崩溃或错误的输出)可能出现在代码的任何位置——代码可能覆盖了系统其他部分使用的内存,但这块内存很久以后才会被碰触。出故障的代码表现得与导致bug的代码好像完全无关,而且程序要执行很久才会引发这个bug。这是共享内存系统的真正诅咒——尽管你做了很多以尝试限制数据对哪个线程来说是可访问的,以及尝试保证使用正确的同步,但应用中任何线程都可能覆盖了本应该被别的线程访问的数据。

现在我们已经区分了问题的种类,下面让我们来看看如何在代码中定位它们以便修复。

10.2 定位并行相关bug的技术

有了上面的知识,现在可以检查一下你的代码,看看哪里可能出现bug。

或许最明显最直接的事情就是代码中的锁定。尽管这看起来明显,但是要彻底执行却很难。当你读你刚写的代码时,你太容易回想你原来的思路,而不是从实际角度去查看。同理,当看别人的代码时,总是想要快速读完,找出与你的标准不同之处,然后强调任何显眼的问题。现在需要的是,拿出时间好好梳理整个代码,思考并发问题——和思考非并发问题一样(当你这样做时,你一定可以的。毕竟,bug就是bug)。当我们短暂的审查代码时,我们要覆盖指定的问题。

即使审查了代码,你也仍然可能错过一些bug,无论何时,你想都需要确保它真的能够工作,没有bug才能安心。所以,当测试多线程代码时,我们继续以采用一些审查代码的技术的形式。

10.2.1 审查代码以定位潜在的bug

我已经说过,当审查多线程代码以找出并行相关的bug时,彻底审查是很重要的,要详细的梳理。如果可以的话,让别人帮着审查。因为他们没有参与编写代码,他们将思考它如何工作,这对避免错过bug是有帮助的。花费适当的时间来审查代码是必要的,不应该一瞥而过,而是适当的、深思熟虑的审查。大多数并行bug需要一定时间才能找出——它们通常依靠微妙的时间安排才能实际显现出来。

如果你找来同事帮你审查代码,他们将带来新鲜的事物。他们会从不同的角度看待事物,可能会指出你没有意识到的事情。如果没有同事可问,那就问你的朋友,或者将代码发送到网上(小心不要惊扰到你们公司的律师)。如果你无法找到任何人帮你审查代码,或者他们没有发现任何问题,不用担心——还有很多事情你可以做。首先,值得先让代码搁置一段时间——先去编写应用的其他部分,读一本书,或者出去散步。如果你中断一下,当你将注意力放到别的事情上时,你的潜意识会在背后思考这个问题。当你回头再去查看这段代码时,它会变得稍微陌生一些——你会以一个与自己不同的视角去看待这段代码。

除了找别人帮你审查代码外,你也可以自己审查代码。一种有用的方式就是尝试向别人解释它工作的细节。他们甚至不需要真实存在——许多团队都为了这个目的而配备一个玩具熊或者一只橡胶小鸡。另外,我亲身体会到编写详细的笔记能够获得很大的好处。作为你的解释,思考每一行,会发生什么,哪些数据会被访问,等等。向自己问一些关于代码的问题,然后解释出答案。我发现这是一个无比强大的技术——向自己提问,然后仔细思考答案,问题常常自己暴露出来。这些问题不仅对你自己的代码审查有帮助,它们对于所有的代码审查都有帮助。

审查多线程代码时需要思考的问题

这些提问可以帮助识别一些潜在的问题。我喜欢问的问题包括下面这些,虽然它是一个不全面的列表。你可能会发现其他的问题来帮助你更好的去聚焦问题。

■ 在并行访问中,那些数据需要被保护?

■ 你如何确保数据是被保护的?

■ 此刻其他线程可能运行到代码中的哪一行?

■ 这个线程持有哪个mutex?

■ 其他线程可能持有哪个mutex?

■ 两个线程中的操作是否要求一个确定的顺序?这些要求怎样得到保证?

■ 这个线程读取的数据是否还有效?它是否已经被别的线程修改了?

■ 如果你假设别的线程可能正在修改数据,那么这意味着什么?你如何确保它永不发生?

最后一个问题是我的最爱,因为他确实让我去思考线程间的职责。通过假设某一行代码存在bug,你会采取实际行动并且探寻原因。为了说服你自己那没有bug,你必须探寻每一个角落以及各种可能的时序。当一个被保护数据在整个生命期中涉及到多个mutex时,这非常管用,就像第6章我们涉及到的线程安全的队列,我们将队列头和尾分别使用不同的mutex来保护:当锁定一个mutex时为了确保对数据的访问是安全的,你必须确保持有另一个mutex的线程不能访问同一个元素。那很明显,对于公有数据,或者其他代码很容易获取指针或者引用的数据,必须经过仔细的审核。

倒数第2个问题也很重要,因为指出了另一个容易犯的错误:如果你释放了一个mutex然后又锁定它,那么你必须假设别的线程可能已经修改了共享数据。尽管这很明显,但是如果mutex的锁定操作并非直观可见的——或许由于mutex操作位于一个对象的内部——你可能不知不觉地就犯了错。在第6章你看到了这是如何导致竞争条件和bug的,那个线程安全的数据结构的函数提供了过于细粒度的操作。而对于一个非线程安全的stack来说,它的情况是分开了top()和pop()操作。对于并行环境下的一个stack来说,它可能被多个线程同时访问,情况就不同了,因为在两个操作之间内部mutex是被释放了的,所以其他的线程可能会修改stack的数据。就像你在第6章中看到的那样,解决方案就是合并两个操作,这样它们就都在同一个mutex的保护下去执行,就消除了潜在的竞争条件。

10.2.2 通过测试来定位并行相关bug

当开发单线程应用时,测试应用是很直接的,如果时间允许的话。原则上来说,你可以确定所有可能的输入数据的组合(或者所有感兴趣的情况)然后在应用上运行它们。如果应用表现出正确的行为和输出,你就可以得出结论:应用可以在这些给定的输入数据中工作。测试例如硬盘满载的错误状态则要复杂一些,但是思想是一样的——设置初始条件然后允许应用运行。

测试多线程代则要面对更高数量级的难度,因为线程的精确时序是不确定的,而且可能每次运行都不同。所以,即使对应用使用同样的输入数据,如果代码中潜伏则竞争条件的话,那么仍有可能有时正确有时失败。就是因为代码中潜在的竞争条件并不意味着总是运行失败,只是偶尔失败。

鉴于并行相关的bug很难重现,它需要精心的设计测试代码。你想要分别测试可能存在问题的最小块代码,所以如果它的测试结果失败了最好隔离它——对一个并行队列直接测试pop和push比使用一整个访问队列的代码去测试更好。在本章的后面你将看到关于可测试性的设计,那么当你设计代码时,它将帮助你思考代码应该被如何测试。

为了证明一个问题是并行相关的,那么消除测试中的并行行为也是有意义的。如果当所有事情都在单线程中运行时,依然存在一个问题,那么它只是一个普通的bug,而不是并行相关的bug。当你试图追踪一个发生在“野外”的bug,而不是发生在你的测试范围内的bug时,这很重要。这仅仅是因为,一个发生在并行部分的bug并不意味着它自动成为一个并行相关的bug。如果你在使用线程池来管理并行层,通常都有一个用于指定工作线程数量的参数。如果你手动管理线程,你将不得不修改代码以使用单线程做测试。无论哪种方式,如果你能将应用变为单线程模式,你就可以消除并行引发的嫌疑。反过来说,如果在单核操作系统(尽管带着多线程运行)上运行时问题不见了,但是在多核操作系统或多处理器操作系统上运行时依然存在,那么你就存在一个竞争条件而且可能存在同步问题或者内存序列问题。

测试的代码量要比被测试的结构的代码量更多;测试结构和环境都很重要。如果你继续例子中的并行队列的测试,那么你必须思考不同的情况:

■ 使用一个线程调用push()或者pop()以证明队列是否可以在基本层面上工作

■ 一个线程对一个空队列调用push(),而另一个线程调用pop()

■ 多个线程对一个空队列调用push()

■ 多个线程对一个满队列调用push()

■ 多个线程对一个空队列调用pop()

■ 多个线程对一个满队列调用pop()

■ 多个线程对一个半满队列调用pop(),队列中的元素数量小于线程数

■ 多个线程对一个空队列调用push(),同时另一个线程调用pop()

■ 多个线程对一个满队列调用push(),同时另一个线程调用pop()

■ 多个线程对一个空队列调用push(),同时多个线程调用pop()

■ 多个线程对一个满队列调用push(),同时多个线程调用pop()

通过思考所有这些情况(以及更多情况),那么你就需要考虑一些关于测试环境的附加因素:

■ 多个线程意味着多少(3,4,1024?)

■ 是否拥有足够多的处理器单元使得每个线程都能够运行在自己的内核上?

■ 应该在哪种处理器体系架构上运行测试?

■ 如何确保测试时的“同时”时序?

特定的情况有特定的附加因素需要考虑。对于这4个因素来说,第一个和最后一个影响测试本身的结构(10.2.5将会讲到),而另外两个则与使用的物理测试系统有关。线程的数量则与要测试的代码有关,但是有不同的方式去构造测试以便获得适当的时序。在我们查看这些技术之前,让我们来看看怎样设计你的应用程序代码,使其更容易被测试。

10.2.3 可测试性设计

测试多线程代码是困难的,所以你尽可能使其变得更简单。最重要的事情之一是为了可测试性而做出的设计。关于单线程代码的可测试性设计已经说得太多,但大部分仍然有效。通常来说,如果符合下面的要求,那么代码就会更容易测试:

■ 保持每个函数和类的职责清晰

■ 函数简短而且直达要点

■ 测试代码可以完全掌控被测试代码的周边环境

■ 执行特定操作的被测试代码应该集中在一起,而不是分散在系统各处

■ 在编写代码前思考如何测试它

这些要求对于多线程代码来说仍然有效。实际上,我坚持认为多线程代码比单线程代码更应该重视代码的可测试性。上面最后一点很重要:虽然在编写代码前你还远没有到达编写测试代码那步,在编写前思考你将如何测试这段带也是值得的——什么样的输入被用到,哪些条件会出问题,怎样的方式能够触发代码可能的问题,等等。

为了可测试性而设计代码的最好的方式之一就是消除并行。如果你能将代码分解为不同部分,这部分负责线程间的通讯路径,而那些部分负责操作一个线程内部的通讯数据,那么你就在很大程度上减少了问题。应用程序中的这部分代码负责操作的数据,如果只能被一个线程访问到,那么这部分代码就可以使用普通的单线程测试技术来测试。难于测试的并行代码,负责处理线程间的通讯,确保一次只有一个线程访问一个特定的数据块,现在规模变得更小了,而且更容易测试。

例如,你的应用程序被设计作为一个多线程状态机,你可以将其分为多个部分。负责每个线程的状态的逻辑,它确保对于每种可能的输入事件,转换和操作都是正确的,这个逻辑可以用单线程测试技术来独立测试,测试设施提供的输入数据可以来自其他线程。那么,状态机代码和消息路由代码,它们负责事件以正确的顺序被投递到正确的线程上,这些代码可以被单独测试,但是需要多线程以及为测试设计的简单的状态逻辑。

或者,你可以将你的代码分为多个块:读共享数据、转换数据、更新共享数据,那么你就可以使用所有的单线程测试技术来测试数据转换部分的代码,因为现在它只是一个单线程代码。测试多线程转换的最难的问题现在被削减为读取和更新共享数据,那是非常简单的。

有一件事需要小心,库调用可以使用内部变量保存状态,如果多个线程使用了库的同一组调用,那将会变为共享数据。这将变为一个问题,因为代码访问共享数据后不是立刻就表现出来。尽管如此,随着时间的推移你总是能够知道那是哪个库调用,就像它们伸出了疼痛的拇指一样。然后你就可以增加一个合适的保护机制去同步它,或者使用另外一个可以被多线程安全访问的函数。

与结构化多线程代码以最小化代码量相比,针对代码的可测试性则需要更多的设计,需要处理并行相关的问题以及注意非线程安全的库调用。从10.2.1节中来看,当你审查代码时考虑自问同样的问题组合也是很有帮助的。尽管这些问题不是测试以及可测试性直接相关的,如果你以测试的眼光去看待这些问题,考虑怎样测试代码,它会影响你的设计选择,并使得测试更加容易。

现在我们已经讨论了如何设计代码使得测试更容易,以及修改代码以区分并行部分(例如线程安全容器或者状态机逻辑)和单线程部分(它可能通过并行数据块在多个线程之间相互作用),现在来看看测试并发代码的技术。

10.2.4 多线程测试技术

那么,你通过思考如何按照你期待的情形去测试而编写了一个小规模的函数用来被测试。那么你如何确保有问题的时序能够在运行时表现出来呢?

有几种方法可以实现,先从强力测试(brute-force testing)或压力测试开始。

强力测试

强力测试的思想就是给代码施加压力看它是否出问题。这一般意味着运行代码很多次,可能使用多个线程运行一次。如果存在一个bug,只有当线程以特定时序执行才会发生时,那么代码运行的次数越多,这个bug越容易出现。如果测试一次,而且通过了,那么你会对这段代码能够正常工作怀有一点信心。如果运行10次都通过了,你就更有信心了。如果运行10亿次都通过了,你的信心更加充足了。

你对结果的信心依赖于每次测试代码的数量。如果你测试的粒度很细,就象之前对线程安全队列的测试概括,这样的一个强力测试会给你很大程度的信息。另一面,如果测试的粒度相当大,那么可能的时序排列的范围就太宽了,即使运行10亿次测试,你的信心也不是很足。

强力测试的缺点是它可能给了你错误的信心。如果你测试的方式本身不会导致有问题的情况触发,那么你无论运行多少次,它都不会失败,尽管只要方式稍微不同它每次都失败。最坏的例子就是,错误情况不会在你的操作系统上出现,因为你所在的操作系统的工作方式。如果你的代码将来不只是运行在与测试系统完全一样的系统上,那么特定的硬件和操作系统的组合可能不允许问题情况的触发。

注明的例子就是在单处理器系统上运行多线程应用。因为所有的线程都运行在同一个处理器上,每件事都自动串行化了,你在真正的多处理器系统上能够遇到的许多竞争条件和乒乓缓存问题都蒸发了。这并不是唯一的变数;不同的处理器架构支持不同的同步和时序手段。例如在x86和x86-64架构上,原子读取操作总是相同的,无论你指定memory_order_relaxed还是memory_order_seq_cst(见5.3.3节)。这就意味着,使用自由序列的代码可以在x86架构系统上工作,但是运行在一个内存序列指令细粒度的架构系统上,例如SPARC,就会失败。

如果你需要将应用程序在一系列目标系统中移植,那么在这些系统上测试就是很重要的。这就是我在10.2.2中列出将处理器架构作为测试考虑的原因。

避免因强力测试成功所带来的错误信心是很重要的。这要求仔细思考测试设计,不仅仅是对于被测试代码的单元的选择,还是关于测试方式的设计,以及测试环境的选择。你要保证尽可能多的测试路径,以及尽可能多的线程间的交互。不仅如此,你还需要知道哪些操作被测试到了,哪些还没有。

尽管清理测试可以给你某种程度的信心,它不能保证找出所有问题。这里有一种技术可以找出问题,如果你有时间将其应用到你的代码和适当的软件上。我把他叫做综合模拟测试(combination simulation testing)。

综合模拟测试

这有点拗口,所以我需要解释一下。想法是,使用一个专门的软件运行代码,以模拟真实的运行场景。你可能意识到在一个物理机器上运行多个虚拟机的软件,虚拟机的特性和它的硬件由监管软件来模拟。这里的想法类似,除了模拟操作系统,仿真软件记录这些顺序:数据访问、锁、每个线程的原子操作。然后它使用C++内存模型的规则带着每个特定的综合操作重复运行,以识别竞争条件和死锁。

虽然这样详尽的综合测试可以找出系统设计的所有问题,对于琐碎的程序来说,它会花费大量的时间,因为组合的数量会随着线程数以及每个线程执行的操作数的增加而成倍增加。因此这种技术最好留给小块代码测试,而不是对整个应用进行测试。其他明显的缺点是,它依赖于可以处理操作的模拟软件的可用性。

除了上面所说的两种测试方法外,第三种选择是,使用库来检测运行时发现的问题。

使用特定的库来测试发现问题

虽然这种选择不能提供像综合模拟测试那样详尽的检查。但通过使用库同步原语,例如mutex、锁、条件变量,你可以识别很多问题。例如,对一块共享数据的所有访问都要求锁定响应的mutex。当数据被访问时,如果你能检查哪个mutex被锁定,那当调用线程访问了数据时你就能核实相应的mutex的确被锁定,否则就记录一个错误。通过以某种方式标记共享数据,你就可以允许库为你检查。

这样的库实现也可以记录锁定顺序,如果多个mutex被一个指定的线程一次性锁定的话。如果其他线程以不同的顺序锁定了同一批mutex的话,那么即使运行时没有真的出现死锁,这也应该被记录为死锁事件。

当测试多线程代码时另外一种可以使用的特定库的类型是,线程原语的实现,例如mutex以及条件变量,它给了测试编写者控制权以可以做到:当多个线程处于等待状态时哪个线程获取了锁,或者哪个线程被notify_one()调用通知到了。那将允许你去构建特殊场景,并核实在这些场景下是否按预期工作。

这些测试设施中的一些应该作为C++标准库实现的一部分提供出来,而其他的则可以建立在标准库之上,作为测试工具的一部分。

看过了如何执行测试代码,现在来看看如何构建代码以实现你想要的时序。

10.2.5 构建多线程测试代码

10.2.2节中我说过,你找到方法去提供适当的时序以满足测试中你要的“同时”效应。现在是时候来看看相关问题了。

基本问题就是,你需要安排一组线程中每个线程在同一时刻执行你指定的代码块。在大部分基本情况下,你有两个线程,但是这将很容易扩展为多个。第一步,你需要识别出每个测试的不同部分:

在其他任何事情之前必须执行的一般设置性代码

必须运行在每个线程上的线程相关设置性代码

■ 每个线程上你希望并行运行的实际代码

■ 并行部分完成后执行的代码,可能包含代码状态的断言

稍后我们再解释这些,先来看一个来自10.2.2节的特殊例子:一个线程在空队列上调用pop(),同时另一个线程调用pop()。

通常的设置性代码是简单的:你必须创建一个队列。执行pop()的线程没有线程相关的设置性代码。执行push的线程的线程相关的设置性代码依赖于队列接口以及保存对象的类型。如果对象存储的时间开销很大,或者需要在堆上分配内存,你想将这些作为线程相关的设置性代码的一部分,那么它不会影响测试。另一面,如果队列只是存储普通的类型例如int,那么在设置性代码中构造一个int就没什么必要了。被测试的代码就简单多了:一个线程调用pop(),另一个线程调用push(),但是,完成之后的代码该怎么写呢?

这种情况下,它依赖于你希望pop()做什么。如果它被提供成,一直等待直到有数据,你希望看到返回的数据是提供给push()调用的数据,而且操作完成后队列是空的。如果pop()是非阻塞的,即使队列中没有数据,它也能够完成,那么你需要为两种可能做测试:要么pop()返回了被提供给push()的数据,然后队列变成空的;要么pop()示意队列中没有数据,之后队列中还有一个数据。两者必中其一;你需要避免的情况就是pop()示意没有数据,结果操作完之后队列变为空,或者pop()返回了数据,但是队列里仍然还有一个数据。为了简化测试,假设你的pop()是阻塞操作。最后的代码因此是一个断言:pop()出的数据正是push()进去的数据,然后队列为空。

现在已经识别了不同的代码块,现在你需要尽可能保证每件事都按计划执行。一种方法就是使用一组std::promise去表示每件事何时准备好。每个线程通过设置promise来表示它已经准备好,然后等待一个(拷贝一个)std::shared_future(从第三个std::promise获取到的);主线程等待来自所有线程的promise被设置,然后触发go信号让所有线程继续。这样可以保证,在应该被并行运行的代码块之前,所有的线程已经开始了;在设置线程等待的promise之前,那个线程的任何线程相关的设置已经完成。最后,主线程等待所有线程完成,检查最后的状态。你还需要注意异常,以及确保go信号不再发送时不要留下任何线程在等待go信号。下面的代码显示了一种构建这种测试的实现:

Listing 10.1 An example test for concurrent  push() and  pop() calls on a queue

void test_concurrent_push_and_pop_on_empty_queue(){threadsafe_queue<int> q;//(1)std::promise<void> go, push_ready, pop_ready;//(2)std::shared_future<void> ready(go.get_future());//(3)std::future<void> push_done;//(4)std::future<int> pop_done;try{push_done = std::async(std::launch::async, [&q, ready, &push_ready]()//(5){push_ready.set_value();ready.wait();q.push(42);});pop_done = std::async(std::launch::async, [&q, ready, &pop_ready]()//(6){pop_ready.set_value();ready.wait();return q.pop();//(7)});push_ready.get_future().wait();//(8)pop_ready.get_future().wait();go.set_value();//(9)push_done.get();//(10)assert(pop_done.get() == 42);//(11)assert(q.empty());}catch (...){go.set_value();//(12)throw;}}

这个结构非常符合之前的描述。首先创建一个空的队列作为普通的设置(1)。然后,创建所有为了“准备好”而建立的promise(2),并且为go信号获取到std::shared_future对象(3)。然后创建用于判断线程已经完成的future(4)。它们定义在了try块之外,因此当异常抛出时,你可以发送go信号而不用等测试线程完成(那将导致死锁——测试代码中存在死锁将是不理想的)。

然后就可以在try块内开始开始线程(5)(6)——你使用std::launch::async来保证任务分别运行在自己的线程中。注意,使用std::async比使用普通的std::thread获取异常安全更容易,因为future的析构函数会join新线程。lambad表达式的捕获列表指定了每个任务将引用队列以及任务相关的用于发送已准备好信号的promise, 而用于接收go信号的ready则使用拷贝。

之前说过,每个任务设置自己的ready信号,然后在运行真正的测试代码前等待通用的ready信号。主线程则相反——在发送开始测试信号(9)前等待从两个线程发送来的等待信号(8)。

最后,主线程对来自两个线程的future调用get()以等待它们完成(10)(11),然后检查结果。注意,pop通过future返回获取到的数据(7),所以可以使用future获取结果作为断言表达式的一部分(11)。

如果抛出了一个异常,你设置go信号以避免任何的悬挂线程,然后重新抛出异常(12)。对应于任务的future最后声明(4),所以它们将首先销毁,那么如果任务没有完成,它们的析构函数将会等待任务完成。

虽然只是测试了两个调用看起来就用了很多的样板(boilerplate),但有必要使用类似的东西获取最大的机会来达到你的测试目的。例如,实际开启一个线程是一个很耗时的操作,所以如果你不让线程等待go信号,那么push线程很可能在pop线程启动前就执行完毕了,那将使测试点完全失效。以这种方式使用future保证了两个线程已经开始运行并且阻塞在同一个future上。然后解锁future将允许两个线程同时运行。一旦你熟悉了这种构,在相同的模式下创建新的测试就比较简单了。对于需要更多线程的测试,这个模式也很容易扩展。

到目前为止,我们一直在研究多线程代码的正确性。尽管这是最重要的议题,但它不是你测试的唯一原因:测试多线程代码的性能也同样重要,那么接下来我们就来看看吧。

10.2.6 测试多线程代码的性能

你选择并行化应用程序的一个主要原因就是利用日益普及的多核处理器以提升应用程序的性能。因此实际测试你的代码以确认它的性能的确提高了是很重要的,就像你为程序做的其他优化一样。使用并发以提高性能的特殊议题就是可伸缩性——你希望你的代码运行在24核机器上与运行在单核机器上相比,速度提升为24倍,或者可处理的数据量提升为24倍,所有其他的都一样。你不希望你的代码运行在双核机器上时可以获得双倍的速度,但是运行在24核机器上却变慢了。就像你在8.4.2节中看到的那样,如果你的代码的关键部分都运行在单线程上,这可能会限制性能获取。因此在测试之前值得去看看整个代码的设计,那么你就能知道你是否希望达到24倍的性能提升,或者你的代码的串行部分是否意味着你的最大性能提升限制为3倍。

看完了之前的章节,你知道,处理器之间为了争夺访问数据对性能是个大的影响。Something that scales nicely with the number of processors when that number is small may actually perform badly when the number of processors is much larger because of the huge increase in contention.

因此,当测试多线程代码的性能时,最好在尽可能多的不同配置的系统上检查代码的性能,那么你就会得到一张可伸缩性能图。至少你应该在一个单核系统上以及一个尽可能多处理器的系统上测试一下。

阅读全文
0 0
原创粉丝点击