《C++并发实例》 10.2 定位并发相关错误的技术
在上一节中,我们研究了您可能会看到的并发相关错误的类型以及它们在代码中的表现方式。记住这些信息后,您就可以查看代码以了解错误可能存在的位置以及如何尝试确定特定片段中是否存在错误。
也许最明显、最直接的事情就是查看代码。虽然这看起来很明显,但实际上很难彻底做到。当您阅读刚刚编写的代码时,很容易读到您打算编写的内容,而不是实际存在的内容。同样,在审查其他人编写的代码时,人们很容易会快速通读一遍,检查是否违反本地编码规则,并突出明显问题。我们实际上需要做的是花时间仔细梳理代码,思考并发问题——非并发问题也一样。(您也可以同时进行。毕竟,bug 就是 bug。)我们将很快在审查代码时介绍需要考虑的具体事项。
即使在彻底审核代码之后,您仍然可能会错过一些错误,并且无论如何您都需要确认它确实有效,以便安心。因此,我们将继续介绍审核代码到测试多线程代码时使用的一些技术。
10.2.1 审核代码以定位潜在问题
正如我已经提到的,在检查多线程代码以检查与并发相关的错误时,仔细地彻底审核它很重要。如果可能的话,请其他人对其进行审核。因为他们还没有编写代码,所以他们必须思考它是如何工作的,这将有助于发现可能存在的任何错误。审核者有时间正确地进行审核非常重要——不是随意的两分钟快速浏览,而是适当的、经过深思熟虑的审核。大多数并发错误需要的不仅仅是快速浏览才能发现,它们通常依赖于微妙的计时问题才能真正显现出来。
如果你让一位同事审查代码,他们会对代码产生新鲜感。因此,他们会从不同的角度看待事物,并且很可能会发现你看不到的事情。如果您没有同事,您可以询问、询问朋友,甚至可以将代码发布到互联网上(注意不要让您公司的律师不高兴)。如果您无法让任何人为您审核您的代码,或者他们没有找到任何内容,请不要担心 —— 您还可以做更多事情。对于初学者来说,可能值得将代码暂时搁置 —— 处理应用程序的另一部分、读书或散步。如果你休息一下,你的潜意识就会在后台处理问题,而你有意识地专注于其他事情。此外,当您返回代码时,您可能会不太熟悉它,您自己可能会设法从不同的角度来看待它。
让别人审查您的代码的另一种方法是您自己审核。一种有用的技巧是尝试向其他人详细解释它是如何工作的。他们甚至不需要真正的队友在身边——很多团队会用一只玩具熊或橡皮鸡代替。而我个人认为,写下详细的笔记也是一个非常有用的习惯。当您解释时,请考虑每一行、可能发生什么、它访问哪些数据等等。问自己有关代码的问题,并解释答案。我发现这是一种非常强大的技术——通过问自己这些问题并仔细思考答案,问题往往会自行显现。这些问题对于任何代码审核都有帮助,而不仅仅是在审核您自己的代码时。
检查多线程代码时要考虑的问题
正如我已经提到的,对于审核者(无论是代码的作者还是其他人)来说,考虑与正在审阅的代码相关的具体问题可能很有用。这些问题可以让审阅者将注意力集中在代码的相关细节上,并可以帮助识别潜在的问题。我想问的问题包括以下内容,尽管这绝对不是一个完整的列表。您可能会发现其他问题可以帮助您更好地集中注意力。无论如何,这里有一些问题:
■ 需要保护哪些数据免受并发访问?
■ 如何确保数据受到保护?
■ 此时其他线程可能位于代码中的什么位置?
■ 该线程拥有哪些互斥体?
■ 其他线程可能持有哪些互斥锁?
■ 该线程中完成的操作与另一个线程中完成的操作之间是否有任何顺序要求?这些要求是如何执行的?
■ 该线程加载的数据仍然有效吗?它可能被其他线程修改了吗?
■ 如果您假设另一个线程可能正在修改数据,这意味着什么?您如何确保这种情况永远不会发生?
最后一个问题是我最喜欢的,因为它确实让我思考线程之间的关系。通过假设存在与特定代码行相关的错误,您可以充当侦探并追踪原因。为了让自己相信没有错误,你必须考虑每一个极端情况和可能的顺序。当数据在其生命周期内受到多个互斥体的保护时,这一点特别有用,例如第 6 章中的线程安全队列,其中我们为队列的头部和尾部设置了单独的互斥体:为了确保持有一个互斥体时访问是安全的,您必须确保持有另一个互斥体的线程不能同时访问同一元素。它还清楚地表明公共数据、或其他代码可以通过指针或引用轻松访问的数据必须受到特别审查。
列表中的倒数第二个问题也很重要,因为它解决了一个容易犯的错误:如果释放然后重新获取互斥锁,那么必须假设其他线程可能已经修改了共享数据。尽管这是显而易见的,但如果互斥锁不是立即可见的(可能是因为它们位于对象内部),您可能会无意中这样做。在第 6 章中,您看到线程安全的数据结构上提供的函数粒度太细而导致竞争场景和错误。对于非线程安全的堆栈,单独的 top() 和 pop() 操作是有意义的,而对于可能被多个线程同时访问的栈来说,情况不再是这样,因为内部的锁互斥体在两次调用之间被释放,因此另一个线程可以修改栈。正如您在第 6 章中看到的,解决方案是将这两个操作组合起来,以便它们都在同一个互斥锁的保护下执行,从而消除潜在的竞争场景。
好的,您已经审核了代码(或让其他人审核了)。您确定没有错误。正如他们所说,布丁的证明在于吃——你如何测试你的代码来确认或否认你对它的信心没有错误?
10.2.2 通过测试定位相关错误
开发单线程应用程序时,测试应用程序相对简单,但比较耗时。原则上,您可以收集所有可能的输入数据集(或至少所有有趣的情况)并通过应用程序运行它们。如果应用程序产生了正确的行为和输出,您就会知道它适用于给定的输入集。测试错误状态(例如磁盘已满错误的处理)比这更复杂,但想法是相同的 —— 设置初始条件并允许应用程序运行。
测试多线程代码要困难一个数量级,因为线程的精确调度是不确定的,并且可能因运行而异。因此,即使您使用相同的输入数据运行应用程序,如果代码中潜伏着竞争场景,它也可能有时会正确工作,有时会失败。仅仅因为存在潜在的竞争场景并不意味着代码总是会失败,只是有时可能会失败。
考虑到重现并发相关错误的实际困难,仔细设计测试是值得的。您希望每个测试运行可能会出现问题的最少的代码,以便在测试失败时能够最好地隔离问题代码 —— 最好直接测试并发队列来验证并发的推入和弹出操作工作而不是通过使用队列的整个代码块来测试它。如果您在设计代码时考虑如何测试代码,这会有所帮助 —— 请参阅本章后面有关可测试性设计的部分。
为了验证问题是否与并发相关,从测试中移除并发也是值得的。如果当所有内容都在单个线程中运行时也会遇到问题,那么这只是一个普通的常见错误,而不是与并发相关的错误。当试图追踪“野生”错误而不是在测试流程中检测到的错误时,这一点尤其重要。应用程序的多线程部分出现错误并不意味着它是并发相关错误。如果您使用线程池来管理并发,通常可以设置一个配置参数来指定工作线程的数量。如果您手动管理线程,那么可以修改代码以使用单个线程进行测试。无论哪种方式,如果您可以将应用程序减少为单个线程,则可以消除并发这一原因。另一方面,如果问题在单核系统上消失(即使运行多个线程)但在多核系统或多处理器系统上出现,则存在竞争场景,并且可能存在同步或内存排序问题。
测试并发代码不仅仅是测试代码的结构;测试的结构和测试环境一样重要。如果继续测试并发队列的示例,您必须考虑各种场景:
■ 单个线程自行调用push() 或pop() 以验证队列是否在基本级别上工作
■ 一个线程对空队列调用push(),而另一个线程调用pop()
■ 多个线程在空队列上调用push()
■ 多个线程在满队列上调用push()
■ 多个线程在空队列上调用 pop()
■ 多个线程在满队列上调用 pop()
■ 多个线程在部分满的队列上调用 pop(),但所有线程的项都不足
■ 多个线程调用push(),而一个线程对空队列调用pop()
■ 多个线程在满队列上调用push(),同时一个线程调用pop()
■ 多个线程在空队列上调用push(),同时多个线程调用pop()
■ 多个线程在满队列上调用push(),同时多个线程调用pop()
考虑完所有这些场景以及更多内容后,您需要考虑有关测试环境的其他因素:
■ 每种情况下“多线程”的含义(3、4、1024?)
■ 系统中是否有足够的处理核心供每个线程在自己的核心上运行
■ 测试应在哪些处理器架构上运行
■ 如何确保测试的“while”部分得到适当的安排
针对您的具体情况,还需要考虑其他因素。在这四个环境考虑因素中,第一个和最后一个影响测试本身的结构(并在第 10.2.5 节中介绍),而其他两个则与所使用的硬件测试系统有关。使用的线程数量与正在测试的特定代码有关,但是有多种方法可以构建测试以获得合适的调度。在研究这些技术之前,让我们看看如何设计应用程序代码以使其更易于测试。
10.2.3 可测试性设计
测试多线程代码非常困难,因此您希望尽一切努力使其变得更容易。您可以做的最重要的事情之一就是设计具有可测试性的代码。关于设计单线程代码以实现可测试性,已经写了很多文章,并且许多建议仍然适用。一般来说,如果满足以下因素,代码更容易测试:
■ 每个函数和类的职责都很明确。
■ 功能简短明了。
■ 您的测试可以完全控制被测试代码周围的环境。
■ 执行正在测试的特定操作的代码集合在一起,而不是分散在整个系统中。
■ 在编写代码之前,您考虑过如何测试代码。
所有这些对于多线程代码仍然适用。事实上,我认为关注多线程代码的可测试性比单线程代码的可测试性更重要,因为它本质上更难测试。最后一点很重要:即使您在编写代码之前没有编写测试,也值得在编写代码之前考虑如何测试代码 —— 使用什么输入,哪些条件可能有问题,如何测试代码潜在的问题,等等。
设计并发测试代码的最佳方法之一是移除并发。如果您可以将代码分解为线程间通信部分和单个线程内部处理通信数据部分,那么您就大大减少了问题。然后,可以使用普通的单线程来测试应用程序中仅由一个线程操作数据的部分。难以测试的处理线程间通信部分代码和确保单个线程一次只访问一个数据块的代码现在体量要小得多,并且更容易测试。
例如,如果您的应用程序被设计为多线程状态机,您可以将其分成几个部分。每个线程的状态逻辑确保每个可能的输入事件集的转换和操作都是正确的,可以使用单线程技术独立测试,并使用测试工具提供来自其他线程的输入事件。然后,确保事件按正确顺序正确传递到正确线程的核心状态机制和消息路由可以单独测试,专门为该测试提供了多个并发线程和简单状态逻辑设计。
或者,如果您可以将代码划分为多个读取共享数据(read shared data)/转换数据(transform data)/更新共享数据块(update shared data),则可以使用所有常用的单线程技术来测试转换数据(transform data)部分,因为这现在只是单线程代码。测试多线程转换的难题将降低为测试共享数据的读取和更新,这要简单得多。
需要注意的一件事是,库调用可以使用内部变量来存储状态,如果多个线程使用同一组库调用,则状态将被共享。这可能是一个问题,因为代码访问共享数据并不是立即显现的。然而,随着时间的推移,你会知道这些是哪个库调用的,并且它们会像抽筋的拇指一样突出。然后,您可以添加适当的保护和同步,或者使用对于多个线程的并发安全的替代函数。
设计多线程代码实现可测试性不仅仅是构建代码以最大限度地减少处理并发相关问题的代码量,以及注意非线程安全库调用的使用。记住第 10.2.1 节中您在检查代码时问自己的一组问题也很有帮助。尽管这些问题并不直接涉及测试和可测试性,但如果您戴上“测试帽子”思考问题并考虑如何测试代码,它将影响您做出的设计选择并使测试变得更容易。
现在我们已经研究了设计代码以使测试更容易,并可能修改代码以将“并发”部分(例如线程安全容器或状态机事件逻辑)与“单线程”部分分开(它仍然可以通过并发块与其他线程交互),让我们看看测试并发感知(concurrency-aware)代码的技术。
10.2.4 多线程测试技术
因此,您已经仔细考虑了想要测试的场景,并编写了少量代码来执行正在测试的功能。您如何确保执行任何可能有问题的调度顺序以消除错误?
嗯,有几种方法可以解决这个问题,首先是暴力测试或压力测试。
暴力测试(BTUTE-FORCE TESTING)
暴力测试背后的想法是对代码施加压力,看看它是否会损坏。这通常意味着多次运行代码,可能同时运行许多线程。如果只有当以特定方式调度线程时才会出现错误,那么代码运行的次数越多,出现该错误的可能性就越大。如果您运行一次测试并通过,您可能会对代码的工作有一定的信心。如果你连续运行十次并且每次都通过,你可能会感到更加自信。如果您运行测试十亿次并且每次都通过,您会感到更加自信。
您对结果的信心确实取决于每个测试所测试的代码量。如果您的测试非常细粒度,就像前面概述的线程安全队列的测试一样,这种暴力测试可以让您对代码充满信心。另一方面,如果正在测试的代码相当大,则可能的组合测试数量如此之大,以至于即使十亿次测试运行也只能产生较低的信心。
暴力测试的缺点是它可能会给你错误的信心。如果您编写的测试方式意味着有问题的情况不会发生,那么您可以根据需要多次运行该测试,并且它不会失败,即使每次在稍微不同的情况下就会失败。最糟糕的例子是,由于您正在测试的系统的特定运行方式,有问题的情况不会出现在您的测试系统上。除非您的代码仅在与正在测试的系统相同的系统上运行,特定的硬件和操作系统组合可能不允许出现导致问题出现的情况。
这里的经典案例是在单处理器系统上测试多线程应用程序。因为每个线程都必须在同一处理器上运行,所以所有内容都会自动序列化(serialized),并且在真正的多处理器系统中可能遇到的许多竞争场景和缓存乒乓问题都会消失。但这并不是唯一的变量。不同的处理器架构提供不同的同步和排序组件。例如,在 x86 和 x86-64 体系结构上,原子加载操作始终相同,无论标记为 memory_order_relaxed 还是 memory_order_seq_cst(请参阅第 5.3.3 节)。这意味着使用宽松的内存排序编写的代码可以在 x86 架构的系统上运行,但在具有更细粒度的内存排序指令集(例如 SPARC)的系统上可能会失败。
如果您需要您的应用程序能够跨系统平台移植,那么在这些系统的代表性实例上进行测试非常重要。这就是为什么我在第 10.2.2 节中列出了测试的处理器架构作为考虑因素。
避免潜在的错误信任对于成功的暴力测试至关重要。这需要仔细设计测试,不仅要考虑被测试代码的单元选择,还要考虑测试工具的设计和测试环境的选择。您需要确保测试尽可能多的代码路径以及尽可能多的潜在线程交互。不仅如此,您还需要知道哪些选项已被涵盖,哪些选项尚未测试。
尽管暴力测试确实让您对代码有一定程度的信心,但并不能保证找到所有问题。如果您有充足的时间测试代码和软件,那么有一种技术可以保证找到问题。我称之为组合模拟测试(combination simulation testing)。
组合模拟测试
这有点拗口,所以我最好解释一下我的意思。这个想法是,您使用一个模拟代码真实运行时环境的特殊软件来运行代码。您可能知道一台物理计算机上可以使用软件运行多个虚拟机,其中虚拟机及其硬件的特性由管理程序软件模拟。这里的想法是相似的,只不过模拟软件不只是模拟系统,而是记录每个线程的数据访问、加锁和原子操作的序列。然后,它使用 C++ 内存模型的规则对每个允许的操作组合重复运行,从而识别竞争条件和死锁。
虽然这种详尽的组合测试能确保找到系统设计要检测的所有问题,但对于除了最琐碎的程序意外,它还会花费大量时间,因为组合的数量随着线程数和每个线程的操作数量增加而呈指数增长。因此,该技术最适合用于单个代码片段而不是整个应用程序的细粒度测试。另一个明显的缺点是它依赖于可以处理代码中操作的模拟软件的可用性。
因此,目前您有一项技术需要在正常条件下多次运行测试,但可能会漏掉问题,还有一项技术需要在特殊条件下多次运行测试,但更有可能发现存在的任何问题。还有其他选择吗?
第三种选择是使用一个库来检测测试运行中出现的问题。
使用特殊库测试暴露问题
尽管该选项不如组合模拟测试详尽,但您仍然可以通过使用库的原生同步方式(例如互斥体、锁和条件变量)的特殊实现来识别许多问题。例如,要求共享数据的所有访问都在持有特定互斥体的情况下完成。如果您可以检查访问数据时哪些互斥锁被锁定,那么可以验证访问数据时调用线程确实锁定了对应的互斥锁,如果情况并非如此,则会报告失败。通过以某种方式标记您的共享数据,您可以允许库为您检查。
如果特定线程同时持有多个互斥锁,这样的库实现还可以记录锁的序列。如果另一个线程以不同的顺序锁定相同的互斥体,则即使测试在运行时实际上没有死锁,这也可能被记录为潜在的死锁。
测试多线程代码时可以使用的另一种特殊库是这样的:它的线程原生方法(例如互斥体和条件变量)的实现可以使测试编写者在多线程等待的情况下控制哪个线程获取锁,或者调用条件变量时控制哪个线程被 notify_one() 通知。这将允许您设置特定场景并验证您的代码在这些场景中是否按预期工作。
其中一些测试工具必须作为 C++ 标准库实现的一部分提供,而其他测试工具可以作为测试工具的一部分构建在标准库之上。
了解了执行测试代码的各种方法之后,现在让我们看看构建代码以实现您想要的调度的方法。
10.2.5 构建多线程测试代码
回到第 10.2.2 节,我说过您需要找到为测试的“while”部分提供合适的调度的方法。现在是时候看看其中涉及的问题了。
基本问题是您需要安排一组线程,每个线程在您指定的时间执行选定的代码段。在最基本的情况下,您有两个线程,但这可以很容易地扩展到更多。第一步,您需要识别每个测试的不同部分:
■ 必须在执行其他操作之前执行的一般设置代码
■ 必须在每个线程上运行特定的线程设置代码
■ 您希望同时运行的每个线程的实际代码
■ 并发执行完成后要运行的代码,可能包括代码状态的断言
为了进一步解释,让我们考虑 10.2.2 节中测试列表中的一个具体示例:一个线程在空队列上调用 push(),而另一个线程调用 pop()。
一般设置代码很简单:您必须创建队列。执行 pop() 的线程没有线程特定(thread-specific)设置代码。执行push() 的线程的线程特定设置代码取决于队列的接口和所存储的对象的类型。如果要存储的对象构造昂贵或者必须进行堆分配,那么您希望将其作为线程特定设置的一部分,这样就不会影响测试。另一方面,如果队列仅存储普通 ints ,则通过在设置代码中构造 int 没有任何好处。测试的实际代码相对简单——从一个线程调用push(),从另一个线程调用pop()——但是“完成后”代码又如何呢?
在这种情况下,这取决于您想要 pop() 做什么。如果它应该阻塞直到有数据,那么显然您希望看到返回的数据是提供给push()调用的数据,并且队列随后为空。如果 pop() 没有阻塞并且即使队列为空也可能完成,则需要测试两种可能性:要么 pop() 返回的数据项提供给 push() 并且队列为空,要么 pop() 表示没有数据并且队列有一个元素。其中之一必须为真;你要避免的是 pop() 发出“无数据”信号但队列为空的情况,或者 pop() 返回值但队列仍不为空的情况。为了简化测试,假设您有一个阻塞 pop()。因此,最终代码是一个断言,即 pop 的值是 push 的值并且队列为空。
现在,确定了各个代码块后,您需要尽最大努力确保一切按计划运行。一种方法是使用一组 std::promises 来指示一切何时准备就绪。每个线程设置一个 promises 来表明它已准备好,然后等待从第三个 std::promise 获得的 std::shared_future(副本);主线程等待所有线程的所有 promises 被设置,然后触发线程 go。这可以确保每个线程在并发运行的代码块之前启动;任何线程特定设置都应该在设置该线程的 promises 之前完成。最后,主线程等待线程完成并检查最终状态。您还需要注意异常,并确保在不会发生异常时没有任何线程等待 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, - [5]
[&q,ready,&push_ready]()
{
push_ready.set_value();
ready.wait();
q.push(42);
}
);
pop_done=std::async(std::launch::async, - [6]
[&q,ready,&pop_ready]()
{
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]。然后,为“ready”信号创建所有 promise [2],并为 go 信号获取 std::shared_future [3]。然后,创建用于指示线程已完成的 futures [4]。这些必须在 try 代码块之外,以便您无需等待测试线程完成即可异常上设置 go 信号(这会死锁 —— 测试代码中的死锁将不太理想)。
在 try 块内,您可以启动线程 [5]、[6] —— 您使用 std::launch::async 来保证每个任务都在自己的线程上运行。请注意,使用 std::async 使异常安全任务比使用普通 std::thread 更容易,因为 future 的析构函数将与 join 线程。 lambda 捕获指定每个任务将引用队列和相关的 promise 来发出就绪信号,同时从 go 的 promise 获取 ready 的 future 的副本。
如前所述,每个任务设置自己的 ready 信号,然后在运行实际测试代码之前等待通用 ready 信号。主线程执行相反的操作 —— 等待来自两个线程的信号 [8],然后再向它们发出信号开始真正的测试 [9]。
最后,主线程从异步调用中调用 futures 上的 get() 来等待任务完成 [10], [11] 并检查结果。请注意,pop 任务通过 future 返回检索到的值 [7],因此您可以使用它来获取断言的结果 [11]。
如果抛出异常,您可以设置 go 信号以避免悬空线程并重新抛出异常 [12]。与任务对应的 future [4] 是最后声明的,因此它们将首先被销毁,并且它们的析构函数将等待任务完成(如果尚未完成)。
尽管对于两个简单的调用测试这看起来是相当多的工作,但有必要使用类似的手段以便有机会测试您真正想要测试的内容。例如,实际上启动一个线程可能是一个相当耗时的过程,因此如果您没有让线程等待 go 信号,那么 push 线程可能在 pop 线程启动之前就已经完成,这将完全击败测试点。这种使用 future 的方式可确保两个线程在同一个 future 上运行和阻塞。解除 future 的阻塞将允许两个线程运行。一旦熟悉了结构,就可以相对简单地以相同的模式创建新的测试。对于需要两个以上线程的测试,该模式很容易扩展到其他线程。
到目前为止,我们只是研究了多线程代码的正确性。尽管这是最重要的问题,但这并不是您测试的唯一原因:测试多线程代码的性能也很重要,所以让我们接下来看看。
10.2.6 测试多线程代码的性能
您选择在应用程序中使用并发性的主要原因之一是希望利用日益普及的多核处理器来提高应用程序的性能。因此,实际测试您的代码以确认性能确实有所提高非常重要,就像您对任何其他优化尝试所做的那样。
使用并发来提高性能的一个特殊问题是可扩展性 —— 在其他条件相同的情况下,您希望代码在 24 核计算机上运行速度大约是单核计算机上的 24 倍,或者处理的数据量是单核计算机上的 24 倍。您不希望代码在双核计算机上有双倍运行速度,但在 24 核计算机上实际运行速度更慢。正如您在第 8.4.2 节中看到的,如果代码的重要部分仅在一个线程上运行,这可能会限制潜在的性能增益。因此,在开始测试之前,有必要查看代码的整体设计,这样您就知道您有希望获得 24 倍的改进,或者串型部分代码意味着您被最大因数 3 限制。
正如您在前面的章节中已经看到的,处理器之间对数据结构访问的争用可能会对性能产生很大的影响。当处理器数量较少时可扩展性可能表现很好,当处理器数量较多时可扩展性可能表现不佳,因为争用的大幅增加。
因此,在测试多线程代码的性能时,最好尽可能多的检查不同配置的系统上的性能,以便您了解整体可扩展性。至少,您应该在单处理器系统和尽可能多的处理核心的系统上进行测试。
10.3 总结
在本章中,我们研究了您可能遇到的各种类型的并发相关错误,从死锁和活锁到数据竞争和其他有问题的竞争场景。我们随后采用了定位错误的技术。其中包括代码审查期间要考虑的问题、编写可测试代码的指南以及如何构建并发代码的测试。最后,我们研究了一些可以帮助测试的实用组件。