资讯详情

《C++ Concurrencyin Action》第10章--多线程程序的测试和调试

· 并发相关错误

· 定位错误和代码审查

· 设计多线程测试用例

· 多线程代码的性能

到目前为止,我们知道如何编写并发代码——我们可以使用哪些工具,以及如何使用它们。然而,我们还没有提到软件开发的重要组成部分:测试和调试。如果您想在阅读本章后轻松调试并发代码,则本章不能满足您的期望。

并发代码的测试和调试更麻烦。除了思考一些重要的问题外,我还展示了一些技能,使测试和调试更容易。

测试和调试就像硬币的两面——测试是为了在代码中找到可能的错误,需要调试来修复错误。如果在开发阶段发现错误,而不是在发布后,它将减少几个数量级的损害。

在了解并发代码可能出现的问题之前,需要了解测试和调试。

**10.1 ******

你可以在并发代码中找到各种各样的错误,它们不会集中在某些方面。然而,有些错误与并发使用直接相关。本章重点关注这些错误。通常,并发错误通常有两类:

· 不必要阻塞

· 条件竞争

这两类粒度很大,所以我们把它们分为粒度较小的问题。

** **

“不必要阻塞”是什么意思?当一个线程被堵塞时,由于等待其他条件的实现,无法处理任何任务。通常这些条件是相互排斥、条件变量或条件变量future,也可能是一个I/O操作。这是多线程代码的先天特征,但在任何时候都不可取——衍生成不必要的阻塞。你会问:为什么不需要阻塞?通常,这是因为其他线程正在等待阻塞线程的某些操作。如果线程被阻塞,那些线程将不可避免地被阻塞。

本主题可分为以下问题:

· 死——如你在第三章看到的,两个线程会在死锁的情况下互相等待。当线程产生死锁时,应完成的任务将继续搁置。例如,有些线程是负责操作用户界面的线程,在死锁的情况下,用户界面将没有响应。在其他例子中,界面接口保持响应,但有些任务无法完成,如无结果返回或未打印文档。

· 活锁-类似于死锁。不同的是,线程不是阻塞等待,而是在循环中不断检查,如自旋锁。在某些情况下,其性能与死锁相同(应用程序不会做任何处理,停止响应),CPU利用率仍然很高;由于线程仍在循环中检查,而不是阻塞等待。在一些不太严重的情况下,活锁的问题可以通过随机调度来解决。

· I/O堵塞或外部输入-当线程被外部输入堵塞时,线程不能做其他事情(即使等待输入永远不会发生)。因此,被外部输入堵塞会让人不高兴,因为其他线程可能在等待线程完成某些任务。

简要介绍了不必要的阻塞的组成。

那条件竞争呢?

** **

条件竞争在多线程代码中很常见——许多条件竞争表现为死锁和活锁。此外,并非所有条件竞争都是恶性的——独立线程相关操作的调度决定了条件竞争的发生时间。许多条件竞争是良性的,例如:哪个线程处理任务队列中的下一个任务。然而,许多并发错误也是由条件竞争引起的。

特别是,条件竞争往往会产生以下类型的错误:

· 数据竞争——因为未同步访问共享内存会导致代码未定义的行为。数据竞争已经在第五章介绍,也已经理解了C 内存模型。数据竞争通常发生在错误使用原子操作、同步线程或共享数据时,不受相互排斥的保护。

· 破坏不变量-主要表现为悬挂指针(因为其他线程已经删除了要访问的数据)、随机存储错误(由于局部更新,线程读取了不同的数据)和双重释放(例如,当两个线程同时执行同一队列时pop操作,要删除相同的相关数据),等等。不变量被破坏可视为基于数据的问题。当独立线程需要按一定顺序执行某些操作时,错误的同步会导致条件竞争,如顺序损坏。

· 生命周期问题——虽然这类问题也可以归因于破坏不变量,但这里给出它作为一个单独的类别。这里的问题是线程访问不存在的数据,这可能是因为数据被删除或销毁,或转移到其他对象。生命周期问题通常在线程中引用局部变量。在线程完成之前,局部变量的死亡期已经到来,但这个问题不仅存在。手动调用join()等待线程完成,需要保证异常抛出,join()等待其他未完成的线程。这是线程中基本异常安全的应用。

恶性竞争就像杀手。死锁和活锁可以表现为应用程序悬挂和反应迟钝,或超长时间完成任务。当线程产生死锁或活锁时,调试器可以附着在线程上进行调试。代码可以看到条件竞争、破坏不变量和生命周期问题的性能(如随机崩溃或错误输出)——可以重写系统部分的内存使用(不会改变太多)。其中,问题可能无法定位到特定位置,因为执行时间。这是共享内存系统的诅咒——试图通过线程限制可访问数据,并正确使用同步。应用程序中的任何线程都可以重写(其他线程可以访问)数据。

现在我们已经了解了这两类的具体问题。

让我们了解如何在您的代码中定位和修复这些问题。

**10.2 ******

在前一章中,我们了解了与并发相关的错误类型以及如何反映在代码中。这些信息可以帮助我们判断代码中是否存在隐藏的错误。

最简单直接的就是直接看代码。虽然看起来很明显,但很难彻底修复问题。阅读刚写完的代码比阅读现有代码容易得多。同样,在审查他人编写的代码时,很容易给出一个通读结果,比如比较你自己的代码标准,以及一个明显的问题。为什么要花时间仔细梳理代码?想想前面提到的并发问题——考虑非并发问题。(也可以在很长一段时间后这样做。不过,最后bug它仍然存在我们可以考虑一些具体的事情,并在审查代码时发现问题。

即使你已经详细审查了代码,你仍然会错过一些bug,这就需要确定代码是否做了相应的工作。因此,在测试多线程代码时,会介绍一些代码审查技巧。

** ——**

在审查多线程代码时,重点是检查与并发相关的错误。如有可能,可以让同事/同伴审阅。因为不是他们写的代码,他们会考虑这个代码是如何工作的,可能会覆盖一些你没有想到的情况,从而发现一些潜在的错误。审查员需要有时间做审查——而不是简单地在休闲时间扫一眼。大多数并发问题不仅需要快速浏览——通常需要很多时间才能找到问题。

如果你让你同时审查代码,他/她肯定不太熟悉你的代码。因此,他/她会从不同的角度看你的代码,然后指出你没有注意到的事情。如果你的同事没有时间,你可以打电话给你的朋友,或者把它传到互联网上,让网审查(注意,不要传输一些机密代码)。真的没人看你的代码,别担心——你还能做很多事情。对于初学者来说,代码可以放置一段时间——先做应用的其他部分,或者读一本书,或者出去散步。休息后,集中精力做某事(潜意识会考虑很多问题)。同样,当你完成其他事情并回顾这个代码时,你会有点奇怪——你可能会从另一个角度看到你以前写的代码。

另一种方式是自己审阅。你可以向别人详细介绍你写的功能。你可能不是一个真正的人——你可能不得不解释一只玩具熊或一只橡皮鸡,我个人认为写一些详细的笔记是非常有益的。在解释过程中,我们会考虑每一行之后会发生什么,访问什么数据,等等。问问自己关于代码的问题,并向自己解释这些问题。我认为这是一项非常有效的技能——通过自问自答,认真考虑每一个问题,往往会揭示一些问题,有利于任何形式的代码审查。

在审查代码时,考虑与代码相关的问题,以及有利于发现代码中的问题。审查员需要在代码中找到相应的答案或错误。我认为你必须问以下问题(当然不是一个全面的列表),你也可以找到其他问题来帮助你找到代码。

在这里,列出我的清单:

· 并发访问时需要保护哪些数据?

· 如何保护访问数据?

· 同时访问这个代码会有多个线程吗?

· 这个线程获得了哪个相互排斥?

· 其他线程可以获得哪些相互排斥?

· 两行之间的操作是否有依赖关系?如何满足这种关系?

· 这个线程加载的数据还是合法数据吗?其他线程修改了数据吗?

· 假设其他线程可以修改数据,这意味着什么?而且,如何样的事情不会发生?

我最喜欢最后一个问题,因为它让我真正考虑线程之间的关系。假设一个bug与一行代码相关,可以扮演侦探跟踪bug原因。为了让自己确定代码里没有bug,需要考虑代码运行的各种情况。当数据受到多个相互排斥量的保护时,这种方法特别有用。例如,使用线程安全队列(第6章)可以使用独立的相互排斥:以确保在持有相互排斥时访问是安全的。在这里,必须确保其他相互排斥的线程不能同时访问相同的元素。还需要特别关注的是,对公共数据的显式处理,使用一个指针或引用的方式让其他代码来获取数据。

倒数第二个问题也很重要,因为这是一个容易出错的地方:先释放,然后获得相互排斥的前提是,其他线程可能会修改共享数据。虽然很明显,但当相互排斥锁不能立即看到时,它可能会不知不觉地落入陷阱,因为它是一个内部对象。在第六章中,我已经学会了这种感觉是怎么引起条件竞争,以及如何给细粒度线程安全数据结构带来麻烦。不过,非线程安全栈将top()和pop()操作分开是有意义的,当多线程会并发的访问这个栈,问题会马上出现,因为在两个操作的调用间,内部互斥锁已经被释放,并且另一个线程对栈进行了修改。解决方案就是将两个操作合并,就能用同一个锁来对操作的执行进行保护,就消除了条件竞争的问题。

OK,你已经审阅过代码了(或者让别人看过)。现在,你确信代码没有问题。

就像需要用味觉来证明,你现在吃的东西——怎么测试才能确认你的代码没有bug呢?

** **

写单线程应用时,如果时间充足,测试起来相对简单。原则上,设置各种可能的输入(或设置成感兴趣的情况),然后执行应用。如果应用行为和输出正确,就能判断其能对给定输入集给出正确的答案。检查错误状态(比如:处理磁盘满载错误)就会比处理可输入测试复杂的多,不过原理是一样的——设置初始条件,然后让程序执行。

测试多线程代码的难度就要比单线程大好几个数量级,因为不确定是线程的调度情况。因此,即使使用测试单线程的输入数据,如果有条件变量潜藏在代码中,那么代码的结果可能会时对时错。只是因为条件变量可能会在有些时候,等待其他事情,从而导致结果错误或正确。

因为与并发相关的bug相当难判断,所以在设计并发代码时需要格外谨慎。设计的时候,每段代码都需要进行测试,以保证没有问题,这样才能在测试出现问题的时候,剔除并发相关的bug——例如,对队列的push和pop,分别进行并发的测试,就要好于直接使用队列测试其中全部功能。这种思想能帮你在设计代码的时候,考虑什么样的代码是可以用来测试正在设计的这个结构——本章后续章节中看到与设计测试代码相关的内容。

测试的目的就是为了消除与并发相关的问题。如果在单线程测试的时候,遇到了问题,那这个问题就是普通的bug,而非并发相关的bug。当问题发生在未测试区域(in the wild),也就是没有在测试范围之内,像这样的情况就要特别注意。bug出现在应用的多线程部分,并不意味着该问题是一个多线程相关的bug。使用线程池管理某一级并发的时候,通常会有一个可配置的参数,用来指定工作线程的数量。当手动管理线程时,就需要将代码改成单线程的方式进行测试。不管哪种方式,将多线程简化为单线程后,就能将与多线程相关的bug排除掉。反过来说,当问题在单芯系统中消失(即使还是以多线程方式),不过问题在多芯系统或多核系统中出现,就能确定你被多线程相关的bug坑了,可能是条件变量的问题,还有可能是同步或内存序的问题。

测试并发的代码很多,不过通过测试的代码结构就没那么多了;对结构的测试也很重要,就像对环境的测试一样。

如果你依旧将测试并发队列当做一个测试例,你就需要考虑这些情况:

· 使用单线程调用push()或pop(),来确定在一般情况下队列是否工作正常

· 其他线程调用pop()时,使用另一线程在空队列上调用push()

· 在空队列上,以多线程的方式调用push()

· 在满载队列上,以多线程的方式调用push()

· 在空队列上,以多线程的方式调用pop()

· 在满载队列上,以多线程的方式调用pop()

· 在非满载队列上(任务数量小于线程数量),以多线程的方式调用pop()

· 当一线程在空队列上调用pop()的同时,以多线程的方式调用push()

· 当一线程在满载队列上调用pop()的同时,以多线程的方式调用push()

· 当多线程在空队列上调用pop()的同时,以多线程方式调用push()

· 当多线程在满载队列上调用pop()的同时,以多线程方式调用push()

这是我所能想到的场景,可能还有更多,之后你需要考虑测试环境的因素:

· “多线程”是有多少个线程(3个,4个,还是1024个?)

· 系统中是否有足够的处理器,能让每个线程运行在属于自己的处理器上

· 测试需要运行在哪种处理器架构上

· 在测试中如何对“同时”进行合理的安排

这些因素的考虑会具体到一些特殊情况。四个因素都需要考虑,第一个和最后一个会影响测试结构本身(在10.2.5节中会介绍),另外两个就和实际的物理测试环境相关了。使用线程数量相关的测试代码需要独立测试,可通过很多结构化测试获得最合适的调度方式。在了解这些技巧前,先来了解一下如何让你的应用更容易测试。

** **

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

· 每个函数和类的关系都很清楚。

· 函数短小精悍。

· 测试用例可以完全控制被测试代码周边的环境。

· 执行特定操作的代码应该集中测试,而非分布式测试。

· 需要在完成编写后,考虑如何进行测试。

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

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

例如,当应用设计为一个多线程状态机时,可以将其分为若干块。将每个逻辑状态分开,就能保证对于每个可能的输入事件、转换或其他操作的结果是正确的;这就是使用了单线程测试的技巧,测试用例提供的输入事件将来自于其他线程。之后,核心状态机和消息路由的代码,就能保证时间能以正确的顺序,正确的传递给可单独测试的线程上,不过对于多并发线程,需要为测试专门设计简单的逻辑状态。

或者,如果将代码分割成多个块(比如:读共享数据/变换数据/更新共享数据),就能使用单线程来测试变换数据的部分。麻烦的多线程测试问题,转换成单线程测试读和更新共享数据,就会简单许多。

一件事需要小心,就是某些库会用其内部变量存储状态,当多线程使用同一库中的函数,这个状态就会被共享。这的确是一个问题,并且这个问题不会马上出现在访问共享数据的代码中。不过,随着你对这个库的熟悉,就会清楚这样的情况会在什么时候出现。之后,可以适当的加一些保护和同步,或使用B计划——让多线程安全并发访问的功能。

将并发代码设计的有更好的测试性,要比以代码分块的方式处理并发相关的问题好很多。当然,还要注意对非线程安全库的调用。10.2.1节中那些问题,也需要在审阅自己代码的时候格外注意。虽然,这些问题和测试(可测试性)没有直接的关系,但带上“测试帽子”时候,就要考虑这些问题了,并且还要考虑如何测试已写好的代码,这就会影响设计方向的选择,也会让测试做的更加容易一些。

我们已经了解了如何能让测试变得更加简单,以及将代码分成一些“并发”块(比如,线程安全容器或事件逻辑状态机)以“单线程”的形式(可能还通过并发块和其他线程进行互动)进行测试。

下面就让我们了解一下测试多线程代码的技术。

** **

想通过一些技巧写一些较短的代码,来对函数进行测试,比如:如何处理调度序列上的bug?

这里的确有几个方法能进行测试,让我们从蛮力测试(或称压力测试)开始。

代码有问题的时候,就要求蛮力测试一定能看到这个错误。这就意味着代码要运行很多遍,可能会有很多线程在同一时间运行。要是有bug出现,只能线程出现特殊调度的时候;代码运行次数的增加,就意味着bug出现的次数会增多。当有几次代码测试通过,你可能会对代码的正确性有一些信心。如果连续运行10次都通过,你就会更有信心。如果你运行十亿次都通过了,那么你就会认为这段代码没有问题了。

自信的来源是每次测试的结果。如果你的测试粒度很细,就像测试之前的线程安全队列,那么蛮力测试会让你对这段代码持有高度的自信。另一方面,当测试对象体积较大的时候,调度序列将会很长,即使运行了十亿次测试用例,也不让你对这段代码产生什么信心。

蛮力测试的缺点就是,可能会误导你。如果写出来的测试用例就为了不让有问题的情况发生,那么怎么运行,测试都不会失败,可能会因环境的原因,出现几次失败的情况。最糟糕的情况就是,问题不会出现在你的测试系统中,因为在某些特殊的系统中,这段代码就会出现问题。除非代码运行在与测试机系统相同的系统中,不过特殊的硬件和操作系统的因素结合起来,可能就会让运行环境与测试环境有所不同,问题可能就会随之出现。

这里有一个经典的案例,在单处理器系统上测试多线程应用。因为每个线程都在同一个处理器上运行,任何事情都是串行的,并且还有很多条件竞争和乒乓缓存,这些问题可能在真正的多处理器系统中,根本不会出现。还有其他变数:不同处理器架构提供不同的的同步和内存序机制。比如,在x86和x86-64架构上,原子加载操作通常是相同的,无论是使用memory_order_relaxed,还是memory_order_seq_cst(详见5.3.3节)。这就意味着在x86架构上使用松散内存序没有问题,但在有更精细的内存序指令集的架构(比如:SPARC)下,这样使用就可能产生错误。

如果你希望你的应用能跨平台使用,就要在相关的平台上进行测试。这就是我把处理器架构也列在测试需要考虑的清单中的原因(详见10.2.2)。

要避免误导的产生,关键点在于成功的蛮力测试。这就需要进行仔细考虑和设计,不仅仅是选择相关单元测试,还要遵守测试系统设计准则,以及选定测试环境。保证代码分支被尽可能的测试到,尽可能多的测试线程间的互相作用。还有,需要知道哪部分被测试覆盖到,哪些没有覆盖。

虽然,蛮力测试能够给你一些信心,不过其不保证能找到所有的问题。如果有时间将下面的技术应用到你的代码或软件中,就能保证所有的问题都能被找到。

名字比较口语化,我需要解释一下这个测试是什么意思:使用一种特殊的软件,用来模拟代码运行的真实情况。你应该知道这种软件,能让一台物理机上运行多个虚拟环境或系统环境,而硬件环境则由监控软件来完成。除了环境是模拟的以外,模拟软件会记录对数据序列访问,上锁,以及对每个线程的原子操作。然后使用C++内存模型的规则,重复的运行,从而识别条件竞争和死锁。

虽然,这种组合测试可以保证所有与系统相关的问题都会被找到,不过过于零碎的程序将会在这种测试中耗费太长时间,因为组合数目和执行的操作数量将会随线程的增多呈指数增长态势。这个测试最好留给需要细粒度测试的代码段,而非整个应用。另一个缺点就是,代码对操作的处理,往往会依赖与模拟软件的可用性。

所以,测试需要在正常情况下,运行很多次,不过这样可能会错过一些问题;也可以在一些特殊情况下运行多次,不过这样更像是为了验证某些问题。

还有其他的测试选项吗?

第三个选项就是使用一个库,在运行测试的时候,检查代码中的问题。

虽然,这个选择不会像组合仿真的方式提供彻底的检查,不过可以通过特别实现的库(使用同步原语)来发现一些问题,比如:互斥量,锁和条件变量。例如,访问某块公共数据的时候,就要将指定的互斥量上锁。数据被访问后,发现一些互斥量已经上锁,就需要确定相关的互斥量是否被访问线程锁住;如果没有,测试库将报告这个错误。当需要测试库对某块代码进行检查时,可以对对应的共享数据进行标记。

当不止一个互斥量同时被一个线程持有,测试库也会对锁的序列进行记录。如果其他线程以不同的顺序进行上锁,即使在运行的时候测试用例没有发生死锁,测试库都会将这个行为记录为“有潜在死锁”可能。

当测试多线程代码的时候,另一种库可能会用到,以线程原语实现的库,比如:互斥量和条件变量;当多线程代码在等待,或是被条件变量通过notify_one()提醒的某个线程,测试者可以通过线程,获取到锁。就可以让你来安排一些特殊的情况,以验证代码是否会在这些特定的环境下产生期望的结果。

C++标准库实现中,某些测试工具已经存在于标准库中,没有实现的测试工具,可以基于标准库进行实现。

了解完各种运行测试代码的方式,将让我们来了解一下,如何以你想要的调度方式来构建代码。

** **

10.2.2节中提过,需要找一种合适的调度方式来处理测试中“同时”的部分,现在就是解决这个问题的时候。

在特定时间内,你需要安排一系列线程,同时去执行指定的代码段。最简单的情况:两个线程的情况,就很容易扩展到多个线程。

首先,你需要知道每个测试的不同之处:

· 环境布置代码,必须首先执行

· 线程设置代码,需要在每个线程上执行

· 线程上执行的代码,需要有并发性

· 在并发执行结束后,后续代码需要对代码的状态进行断言检查

这几条后面再解释,先让我们考虑一下10.2.2节中的一个特殊的情况:一个线程在空队列上调用push(),同时让其他线程调用pop()。

通常,布置环境的代码比较简单:创建队列即可。线程在执行pop()的时候,没有线程设置代码。线程设置代码是在执行push()操作的线程上进行的,其依赖与队列的接口和对象的存储类型。如果存储的对象需要很大的开销才能构建,或必须在堆上分配的对象,那么最好在线程设置代码中进行构建或分配;这样,就不会影响到测试结果。另外,如果队列中只存简单的int类型对象,构建int对象时就不会有太多额外的开销。实际上,已测试代码相对简单——一个线程调用push(),另一个线程调用pop()——那么,“完成后”的代码到底是什么样子呢?

在这个例子中,pop()具体做的事情,会直接影响“完成后”代码。如果有数据块,返回的肯定就是数据了,push()操作就成功的向队列中推送了一块数据,并在在数据返回后,队列依旧是空的。如果pop()没有返回数据块,也就是队列为空的情况下,操作也能执行,这样就需要两个方向的测试:要不pop()返回push()推送到队列中的数据块,之后队列依旧为空;要不pop()会示意队列中没有元素,但同时push()向队列推送了一个数据块。这两种情况都是真实存在的;你需要避免的情况是:pop()示意队列中没有数据的同时,队列还是空的,或pop()返回数据块的同时,队列中还有数据块。为了简化测试,可以假设pop()是可阻塞的。在最终代码中,需要用断言判断弹出的数据与推入的数据,还要判断队列为空。

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

下面的代码,构建了这样的测试。

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

void test_concurrent_push_and_pop_on_empty_queue()

{

threadsafe_queue q; //

std::promise go,push_ready,pop_ready; //

std::shared_future ready(go.get_future()); //

std::future push_done; //

std::future pop_done;

try

{

push_done=std::async(std::launch::async, //

                    [&q,ready,&push_ready]()

                     {

                      push_ready.set_value();

                       ready.wait();

                       q.push(42);

                     }

  );

pop_done=std::async(std::launch::async, //

                   [&q,ready,&pop_ready]()

                    {

                     pop_ready.set_value();

                      ready.wait();

                      return q.pop();  // **7**

                    }

  );

push_ready.get_future().wait(); //

pop_ready.get_future().wait();

go.set_value(); //

push_done.get(); //

assert(pop_done.get()==42);  // **11**

assert(q.empty());

}

catch(…)

{

go.set_value(); //

throw;

}

}

首先,环境设置代码中创建了空队列①。然后,为准备状态创建promise对象②,并且为go信号获取一个std::shared_future对象③。再后,创建了future用来表示线程是否结束④。这些都需要放在try块外面,再设置go信号时抛出异常,就不需要等待其他城市线程完成任务了(这将会产生死锁——如果测试代码产生死锁,测试代码就是不理想的代码)。

try块中可以启动线程⑤⑥——使用std::launch::async保证每个任务在自己的线程上完成。注意,使用std::async会让你任务更容易成为线程安全的任务;这里不用普通std::thread,因为其析构函数会对future进行线程汇入。lambda函数会捕捉指定的任务(会在队列中引用),并且为promise准备相关的信号,同时对从go中获取的ready做一份拷贝。

如之前所说,每个任务集都有自己的ready信号,并且会在执行测试代码前,等待所有的ready信号。而主线程不同——等待所有线程的信号前⑧,提示所有线程可以开始进行测试了⑨。

最终,异步调用等待线程完成后⑩⑪,主线程会从中获取future,再调用get()成员函数获取结果,最后对结果进行检查。注意,这里pop操作通过future返回检索值⑦,所以能获取最终的结果⑪。

当有异常抛出,需要通过对go信号的设置来避免悬空指针的产生,再重新抛出异常⑫。future与之后声明的任务相对应④,所以future将会被首先销毁,如果future都没有就绪,析构函数将会等待相关任务完成后执行操作。

虽然,像是使用测试模板对两个调用进行测试,但使用类似的东西是必要的,这样会便于测试的进行。例如,启动一个线程就是一个很耗时的过程,如果没有线程在等待go信号时,推送线程可能会在弹出线程开始之前,就已经完成了;这样就失去了测试的作用。以这种方式使用future,就是为了保证线程都在运行,并且阻塞在同一个future上。future解除阻塞后,将会让所有线程运行起来。当你熟悉了这个结构,其就能以同样的模式创建新的测试用例。测试两个以上的线程,这种模式很容易进行扩展。

目前,我们已经了解了多线程代码的正确性测试。

虽然这是最最重要的问题,但是其不是我们做测试的唯一原因:多线程性能的测试同样重要。

下面就让我们来了解一下性能测试。

** **

选择以并发的方式开发应用,就是为了能够使用日益增长的处理器数量;通过处理器数量的增加,来提升应用的执行效率。因此,确定性能是否有真正的提高就很重要了(就像其他优化一样)。

并发效率中有个特别的问题——可扩展性——你希望代码能很快的运行24次,或在24芯的机器上对数据进行24(或更多)次处理,或其他等价情况。你不会希望,你的代码运行两次的数据和在双芯机器上执行一样快的同时,在24芯的机器上会更慢。如8.4.2节中所述,当有重要的代码以单线程方式运行时,就会限制性能的提高。因此,在做测试之前,回顾一下代码的设计结构是很有必要的;这样就能判断,代码在24芯的机器上时,性能会不会提高24倍,或是因为有串行部分的存在,最大的加速比只有3。

在对数据访问的时候,处理器之间会有竞争,会对性能有很大的影响。需要合理的权衡性能和处理器的数量,处理器数量太少,就会等待很久;处理器过多,又会因为竞争的原因等待很久。

因此,在对应的系统上通过不同的配置,检查多线程的性能就很有必要,这样可以得到一张性能伸缩图。最起码,(如果条件允许)你应该在一个单处理器的系统上和一个多处理核芯的系统上进行测试。

**10.3 ******

本章我们了解了各种与并发相关的bug,从死锁和活锁,再到数据竞争和其他恶性条件竞争;我们也使用了一些技术来定位bug。同样,也讨论了在做代码审阅的时候需做哪些思考,以及写可测试代码的指导意见,还有如何为并发代码构造测试用例。最终,我们还了解了一些对测试很有帮助的工具。

标签: 压力变送器std920

锐单商城拥有海量元器件数据手册IC替代型号,打造 电子元器件IC百科大全!

锐单商城 - 一站式电子元器件采购平台