第十章 多线程程序的测试与调试
第十章 多线程程序的测试与调试
与并发相关的Bug类型
与并发直接相关的Bug主要分为两大类:非预期的阻塞(Unwanted blocking) 和 竞态条件(race condition).
Unwanted blocking
不希望的阻塞是指线程在等待某件事情(如互斥锁、条件变量、未来值或I/O操作)时无法继续执行,而这种等待是不必要的或是有害的(不符合对当前线程的设计 )。
不希望的阻塞会让其他等待当前被阻塞的线程也被阻塞。
不希望的阻塞通常发生在以下几种情况:
- 死锁(deadlock):
- 当两个或更多线程相互等待对方释放资源时,形成一个循环依赖,导致所有涉及的线程都无法继续执行。
- 最明显的情况是,如果负责用户界面的线程死锁,界面将失去响应。
也有一些情况是,界面可以保持响应,但一些任务无法完成,比如搜索不返回结果,或者文档不被打印。
- 活锁(livelock):
- 与死锁类似,但涉及的是非阻塞等待,如自旋锁。
线程在循环中主动检查条件,但由于某种原因(如调度问题),条件始终不满足,导致线程无法继续执行有用的工作。 - 其表现的症状就和死锁一样,比如程序不进行,此外由于线程仍在运行,CPU 会处于高使用率状态。
在不太严重的情况下,活锁最终会被操作系统的随机调度解决,但仍然会造成任务的长时间延迟,并且延迟期间 CPU 使用率很高。
- 与死锁类似,但涉及的是非阻塞等待,如自旋锁。
- I/O 阻塞或其他外部输入:
- 线程在等待外部输入(如网络数据、文件读取等)时无法继续执行。如果这种输入永远不会到来,线程将无限期地阻塞,从而浪费系统资源。
建议的解决方案:
- 使用适当的同步机制,如条件变量或信号量,可以帮助管理线程之间的依赖关系,从而避免死锁和活锁。
- 为I/O操作或其他可能阻塞的操作设置超时,以确保线程不会无限期地等待。
- 将耗时的操作与主线程分离,使用异步方法或线程池来处理这些操作,从而避免阻塞主线程。
Race conditions
竞态条件是指在一个设备或系统试图同时执行两个或多个操作时出现的非预期状况。这通常发生在多个线程或进程尝试并发访问和修改共享资源时,而这些操作必须按照特定的顺序执行。
不过很大一部分竞态条件是良性的,比如要处理任务队列的下一个任务,决定用哪个工作线程去处理是无关紧要的。
造成问题的竞态条件包含以下几种情况:
- 数据竞争(data race):
- 由于对共享内存位置的未同步并发访问而导致的未定义行为。
这通常是由于错误使用原子操作同步线程或在未锁定适当互斥锁的情况下访问共享数据造成的。
- 由于对共享内存位置的未同步并发访问而导致的未定义行为。
- 被破坏的不变量(broken invariant):
- 表现为悬空指针(其他线程可以删除被访问的数据)、
随机内存损坏(由于局部更新导致线程读取的值不一致)、
双重释放(比如两个线程弹出队列的同一个数据)等问题,这些可能是由于线程未按预期顺序执行操作而导致的。
- 表现为悬空指针(其他线程可以删除被访问的数据)、
- 生命周期问题(lifetime issue):
- 线程访问的数据在其生命周期之外被删除或销毁,导致线程正在操作无效的数据。
建议的解决方案:
- 解决竞态条件的关键是确保对共享资源的访问是同步的,即同一时间只允许一个线程执行访问共享资源的代码段(临界区)。
- 需要确保你的线程和它们操作的数据的生命周期是绑定在一起的,以防止生命周期问题。
- 如果你手动调用join()以等待线程完成,你需要确保如果抛出异常,不会跳过对join()的调用。
定位并发相关Bug的方法
Reviewing code to locate potential bugs
通过code review 定位潜在的错误时,需要注意下面几个角度:
- 彻底性与外部视角**:**
- 深入剖析代码中的每一个部分,考虑到所有可能的并发执行情况。
- 邀请同事或其他人进行审查,他们能以全新的视角发现潜在问题。
- 时间与深度:
- 确保审查者有足够的时间进行深思熟虑的审查,因为并发错误往往依赖于微妙的时间问题。
- 避免草率的审查,并发错误往往隐藏在代码的细微之处需要细致入微的观察和深入的思考才能发现。
- 自我审查:
- 暂时将注意力从代码上移开(放松下), 让代码没那么熟悉——你可能会设法从不同的角度看待它。
- 以讲解者的身份详细解释代码的工作原理,通过自问自答的方式深入探究每一行代码的潜在影响和数据访问情况。
- 编写详细的笔记也是一种有效的自我审查技巧,通过记录对代码的思考和疑问,有助于发现潜在的并发错误。
审查多线程代码时需考虑的问题:
- 哪些数据需要被保护,以防止并发访问?
- 如何确保数据得到保护?
- 其他线程此时可能运行到代码的哪个位置?
- 当前线程持有哪些互斥锁?其他线程可能持有哪些互斥锁?
- 是否存线程间操作的排序要求?这些要求是如何被强制执行的?
- 当前线程加载的数据是否仍然有效?是否可能已被其他线程修改?
- 假设另一个线程正在修改数据,将如何影响当前线程?如何确保这种情况不会发生?
Locating concurrency-related bugs by testing
定位并发错误是测试多线程代码的关键。由于线程调度的不确定性,测试多线程代码通常比单线程应用更复杂。
即使使用相同的输入数据,潜在的竞态条件也可能导致测试时而成功时而失败。
为有效测试并发代码,需精心设计测试,最小化每次测试运行的代码量,以便快速隔离问题。
比如测试一个并发队列,分别测试并发的 push 和 pop 的工作,就直接比测试整个队列的功能要好。
此外,消除测试中的并发性有助于确定问题是否与并发直接相关。如果在单线程环境中运行代码时仍出现问题,则问题更可能是普通的代码错误,而非并发问题。
测试用例:
- 单线程调用 push() 或 pop() 来验证队列的基本功能
- 空队列,一个线程调用 push(),另一个线程调用 pop()
- 多个线程在空队列上调用 push()
- 多个线程在满队列上调用 push()
- 多个线程在空队列上调用 pop()
- 多个线程在满队列上调用 pop()
- 有部分数据但不够所有线程用的队列,多个线程调用 pop()
- 多个线程在空队列上调用 push(),同时一个线程调用 pop()
- 多个线程在满队列上调用 push(),同时一个线程调用 pop()
- 多个线程在空队列上调用 push(),同时多个线程调用 pop()
- 多个线程在满队列上调用 push(),同时多个线程调用 pop()
基于上面的测试用例,需要考虑测试环境的额外因素:
- 多线程在每种 case 中具体指多少线程 (3, 4, 1,024?)
- 是否有足够的处理器核,让每个线程运行在自己的核上
- 在哪些处理器架构上进行测试
- 你如何确保测试的“while”部分(蓝色标记)得到适当的调度
Designing for testability
一般满足以下条件的代码就是易于测试的,这些条件单线程和多线程中同样适用:
- 确保每个函数和类的职责明确
- 函数简短,功能单一
- 测试时可以完全控制被测试代码周围的环境。这包括模拟输入、设置特定条件以及隔离外部依赖项。
- 执行特定操作的代码集中在一起,而不是分散在系统中。
- 在编写代码之前就思考如何测试它。确定测试用例、输入数据和预期结果。
其他需要注意的地方:
- 消除并发性:
- 设计并发代码以便测试的一个有效方法是消除并发性。
- 通过分解代码为线程间通信部分和单线程数据处理部分,可以简化测试过程,使得那些只由一个线程访问的数据部分可以使用常规的单线程技术进行测试。。
- 分解多线程状态机:
- 对于设计为多线程状态机的应用程序,可以将其分解为多个部分进行测试。
- 每个线程的状态逻辑和转换操作可以使用单线程技术独立测试,而核心状态机和消息路由代码则需要模拟并发环境和简单状态逻辑进行测试。
- 关注共享数据访问:
- 多线程环境中,共享数据的访问是一个关键问题。
- 需要注意库调用可能使用内部变量存储状态,并在多线程环境下导致状态共享。因此,需要识别这类库调用,并添加适当的同步机制或使用线程安全的替代函数。
- 最小化并发相关代码:
- 设计多线程代码的可测试性意味着要结构化代码,以最小化需要处理并发相关问题的部分。
- 这包括注意避免使用非线程安全的库调用,以减少潜在的并发问题。
Multithreaded testing techniques
多线程测试技术是确保应用程序在各种场景和系统中稳定工作的关键。在测试多线程代码时,有几种主要的测试方法和技术需要考虑。
- 暴力测试(压力测试) - BRUTE-FORCE TESTING
- 暴力测试的思想是压力测试代码,通过多次运行代码来尝试暴露出潜在的错误。
- 然而,暴力测试有其局限性,例如可能无法覆盖所有可能的调度序列,且对测试环境敏感,,可能产生结果误导。
- 组合模拟测试 - COMBINATION SIMULATION TESTING
- 使用特殊的软件来模拟代码的真实运行环境,并记录线程的数据访问、锁和原子操作的序列。
- 通过重复运行每个允许的操作组合,可以识别竞态条件和死锁。然而,这种方法计算量大,通常适用于对单个代码片段的细粒度测试。
- 通过特殊库检测测试中暴露的问题
- 这些库提供同步原语(如互斥锁、锁和条件变量)的特殊实现,可以识别诸如互斥锁使用不当等问题。
- 它们还能记录锁的序列,并允许测试编写者控制线程的调度,从而设置特定场景并验证代码的行为。
如果一个特定的线程一次持有多个互斥锁。如果另一个线程以不同的顺序锁定相同的互斥锁,即使测试在运行时实际上没有死锁,这也可以被记录为潜在的死锁。 - 另一种特殊的库是在测试多线程代码时可以使用的,其中线程原语(如互斥锁和条件变量)的实现使测试编写者可以控制在多个线程等待时哪个线程获取锁,或者在条件变量上调用notify_one()时哪个线程被通知。
Structuring multithreaded test code
多线程测试代码可以分为以下几部分
- 必须先执行的总体设置
- 必须运行在每个线程上的线程特定的设置
- 要并发运行在每个线程上的代码
- 并发执行结束后的状态断言
具体例子——一个线程在空队列上调用push(),而另一个线程调用pop()
// sample
IMPLEMENT_MODULE(FEngineSettingsModule, EngineSettings);
void test_concurrent_push_and_pop_on_empty_queue() {
ConcurrentQueue<int> q; // 总体设置:先创建一个队列
std::promise<void> go, push_ready, pop_ready;
std::shared_future<void> ready(go.get_future());
std::future<void> push_done;
std::future<int> pop_done;
try {
push_done = std::async(
std::launch::async, // 指定异步策略保证每个任务运行在自己的线程上
[&q, ready, &push_ready]() {
push_ready.set_value(); // push_ready线程已准备好
ready.wait();
q.push(42); // 线程特定的设置:存入一个 int
});
pop_done = std::async(
std::launch::async,
[&q, ready, &pop_ready]() {
pop_ready.set_value(); // Pop_ready线程已经准备好
ready.wait();
return q.try_pop();
});
push_ready.get_future().wait(); // 等待开始测试的通知
pop_ready.get_future().wait(); // 同上
go.set_value(); // 通知开始真正的测试
push_done.get(); // 获取结果
assert(pop_done.get() == 42); // 检查结果
assert(q.empty());
}
catch (...) {
go.set_value(); // 避免空悬指针
throw; // 再抛出异常
}
}
Testing the performance of multithreaded code
使用并发的一个主要目的就是利用多核处理器来提高程序性能,因此测试多线程代码的性能和可扩展性对于确保应用充分利用多核处理器至关重要。
在测试时,我们需要在不同配置的处理器系统上进行全面的性能测试,以观察性能如何随核心数量变化。
关键指标包括执行时间、吞吐量和延迟,同时需要确保测试的可重复性。
此外,我们还应关注代码的可扩展性,即是否能够有效利用额外的处理器核心。
通过性能测试和代码分析,我们可以确保应用实现最佳性能表现,并充分利用多核处理器的优势。