测试和除错也是开发中的一个关键步骤,并发的代码的测试和除错更加困难。要掌握一些排错的技法,首先得了解错误可能出现的地方。
目录
1.与并发相关的错误类型
某些类型的错误与并发的使用直接关联,这些并发错误主要分为两类:
1.多余的阻塞
2.条件竞争
两个大类可以进一步细分。
1.1多余的阻塞
若某线程等待某项条件成立或某一状态出现,而无法执行任务,该状态被称为阻塞。等待的目标可能是互斥、条件变量、future或I/O操作。阻塞有几种变化:死锁(线程相互等待)、活锁(线程相互等待,区别在于活动状态的循环,如自旋锁)、I/O阻塞或其它外部阻塞(等到外部输入而阻塞,应避免一个线程等另一个,另一个却等外部输入)。
1.2条件竞争
条件竞争是各种问题的常见诱因,许多死锁、活锁只是条件竞争的表现形式。当多个独立线程因调度导致相对次序有异,其上的操作又取决于这种差异,才会出现条件竞争。多数条件竞争为良性,如:就任务队列而言,由哪个线程执行下一项任务根本无关紧要,但是条件竞争经常造成如下问题:
数据竞争:由于对共享区域内的并发访问缺乏同步措施,导致未定义行为。
受破坏的不变量:
·悬空指针:当前线程正通过指针访问目标数据,而其它线程却同时删除指针。
·随机内存数据的破坏:数据更新到一半,其他线程同时读取,造成数据不一致。
·重复释放内存:两个线程同时从队列弹出相同的值,同时删除关联的数据。
错误的同步方式会破坏特定次序导致不变量破坏。
生存期问题:线程的生存期超过其访问数据的生存期。主要表现为:数据被删除后,线程仍试图访问。情形:局部变量引用在线程函数结束前传到函数外部,线程在作用范围以外访问失效或销毁变量。
以上三类问题均会产生可见后果(崩溃、错误输出),它们可能在代码的任何部分造成问题。
[多线程安全基本要求:若我们以join手动结束线程,要保证join()调用不会因异常而跳过]
2.定位并发相关错误的方法
代码经过全面审查,还是可能出现错误,在任何情况下,我们至少确保代码能够运作,我们先从代码审查开始,逐步过渡到多线程代码测试的方法。
2.1审查代码并定位潜在错误
通过审查代码找出错误,最关键在于“彻底”。在条件允许下,尽可能让别人审查自己的代码,他人往往能从不同角度出发发现自己疏忽的地方,同时能避免自己按照原来的设计思路理解而难以辨别错误。或者实在找不到他人,也可以先搁置一段时间,潜意识会暗自思考问题,等代码变得稍微陌生,可能就会从不同角度它。另一种方式是自问自答,向自己解释一遍代码工作逻辑,思考每段代码访问的数据,会产生什么效果等。
此处列举几个建议考虑的具体问题,我们还可以根据需要提出其他问题:
进行并发访问需要保护哪些数据?
如何确保数据受保护?
若当前线程在操作受保护数据,其他线程可能在执行什么代码?
当前线程有哪些互斥?
其他线程可能有哪些互斥?
当前线程和其他线程上的操作需要什么次序?
当前线程读取的数据是否仍合法、有效?数据是否可能已由别的线程修改?
假定其他线程可能以并发方式改动数据,那么改动的发生条件和影响是什么?
最后一个问题提出了明确要求:如果一份数据有指针或引用形式,它们能被数据作用域之外的代码轻易获取,我们对其处理就必须额外谨慎,对象所含的公有数据成员也是如此。
2.2通过测试定位与并发相关的错误
对于单线程应用软件,原则上我们可以找出所有可能的输入数据集,如果应用软件的行为符合预期并产生真正的结果,即可保证它在某个数据集上正确运行。
而多线程代码的测试却异常困难,因为多线程调度次序不可能精准确定,它们随着应用软件的多次运行而有异。尽管输入相同的数据,软件每次运行的情况也不唯一。
代码在多线程上运行报错,不一定与并发有关,若将代码改为单线程,错误依旧存在,则就是与并发无关的错误。若并发软件在多核处理器上有错误,转到单核系统上错误消失,则很有可能与同步操作或内存次序有关。
普通代码的测试主要针对逻辑结构,并发代码则还需另行测试更多项目,并且还要考虑代码自身结构(比如:全空、全满队列上,分别并发调用push()和pop()的多种组合情况)和测试环境(如:测试多线程的数目,处理器架构,硬件系统的处理器内核)。
2.3设计可测试的代码
要使代码相对容易测试,通常要做到以下几点:
·每个函数和类的职责明确
·接受测试的目标代码处于测试环境中
·函数短小精悍
·执行特定操作的代码汇聚在一起
·着手编写前,先想清楚如何对齐进行测试
相比单线程代码,多线程代码的设计应更关注代码的可测试性,在编写代码前,想清楚如何测试(如采用哪些输入、错误可能在哪些条件下产生、如何按可能的方式触发代码错误等)。
另一种方式是把代码拆分为几个部分,一部分专门处理线程通信,另一部分则在单线程内部操作数据。针对单线程的部分就能用普通的测试手段,并发代码负责处理线程间通信,保证特定数据互斥地访问。如:把软件设计成状态机,每个线程处理一个状态;将代码划分为读取数据、转换数据、更新共享数据三个部分,其中转换数据部分是单线程,多线程测试部分简化为数据读取和更新。
库函数的调用可能会以内部变量存储状态,当多个线程使用通一个库函数,可能无意中共享了内部变量,需要施以安全措施,或替换为安全并发的函数。
2.4多线程测试技术
为了验证线程的准确性,要尽可能地发现线程的错误,使线程复现报错的调度次序。有几种实现的方法。
2.4.1强力测试(压力测试)
让代码承受压力运行,往往需要多次运行代码,可能在同一测试中发起许多线程,假设代码按某种特定调度编排才会发生错误,则运行次数越多,错误就越可能被发现。我们的置信程度随着测试次数的增加而增加,其增长率因代码量的增加而降低(测试目标代码量大,多线程调度次序的编排组合会暴增,即便通过多轮测试,置信度仍然较低)。
强力测试可能导致错误的置信,若导致错误的环境只存在于特定系统中,测试系统与之不同,则无法复现错误。经典例子是在单处理器系统上测试多线程应用,许多错误只有在多处理器系统上出现(条件变量和缓存乒乓(两个CPU频繁轮流读写同一数据块,在两者缓存间不断切换))。而且在不同架构处理器中,同步功能和内存次序机制也不同。
因此如果我们要将应用软件移植到多个平台上,就有必要从各类平台中,选择有代表意义的系统进行专门测试。还要仔细设计驱动测试的代码,令测试尽量涵盖所有的代码路径,记录已经测试和尚未测试的功能。
2.4.2组合模拟测试
用特定软件模拟真实运行环境,在其中运行测试代码。我们知道虚拟机能模拟出各种特性和硬件配置的环境。而模拟软件没有完全虚拟出系统,而是记录每个线程上的数据访问、所操作和原子操作构成的序列,然后以C++内存模型为准则,重新组合前面所记录的操作。
这种方式能模拟出所有可能的操作序列组合,但是随着线程数与操作量的增加,组合呈指数级增长,所需时间也大大增长,因此适用于独立代码片段的精细测试,且依赖于相关的模拟软件。
2.4.3采用特殊的程序可检测错误
选取采用特殊实现方式的程序库,凭其同步原语(互斥、锁和条件变量等)检测各种错误。通常要求一份共享数据对应某个特定互斥,检查所有访问该数据的线程是否正确锁住互斥。如果某线程同时持有多个锁,程序库还能记录锁操作的序列,记录造成潜在隐患的部分。
另一种特殊程序库向测试的驱动代码授予超强的控制力度:多个线程在某互斥或某条件变量等待时,测试者可以指定哪个线程能获取锁,或指定notify_one()唤醒哪个线程,使我们基于还原特定运行场景。
2.5以特定结构组织多线程的测试代码
我们需要找到方法给出适当的调度次序,令测试中的操作真正实现“并发”。
考虑一个具体的例子:一个线程在空队列上调用push(),另一线程同时调用pop():
void test_concurrent_push_and_pop_on_empty_queue()
{
threadsafe_queue<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();//表示线程启动完成
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();
});
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;
}
}
测试首先发起两个线程执行并发操作,分别设立相关std::promise表明启动完成;线程从promise上获取std::shared_future对象,接收“开始”信号。主线程等待并发的promise全部就绪,发出“开始”信号,让他们同时开始操作。最后主线程等待并发线程结束,查验数据和容器最终态。如果有异常抛出,令go实例跳过就绪信号,使线程在go上空等。
测试代码先发起两个并发线程,再使它们等待主线程(而非直接创建线程),得以令两个线程同时工作。
2.6测试多线程代码的性能
采用并发功能的主要原因之一是借助多核处理器提升性能,具体问题在于可伸缩性。若代码中的关键部分只能在一个线程上运行,则会限制潜在性能增益。根据Amdahl定律,串行代码是限制性能的主要因素,而并行代码也要注意,多个处理器访问同一数据结构时,是否容易发生资源争夺,导致性能问题。
若要测试多线程代码的性能,应在尽量多不同配置的系统上进行,才能更客观全面地分析软件的可伸缩性。(至少在单处理器和手头可用的多处理器系统上测试)