与并发相关的错误类型
测试和调试就像一个硬币的两面——测试是为了找到代码中可能存在的错误,需要调试来修复错误。如果在开发阶段发现了某个错误,而非发布后发现,这将会将使错误的破坏力降低好几个数量级。
错误类型:
- 不必要阻塞
- 条件竞争
不必要阻塞
通常,是因为其他线程在等待该阻塞线程上的某些操作完成,如果该线程阻塞了,那那些线程必然会被阻塞。 - 死锁,常考读书笔记2给出的指导意见。
- 活锁,线程不是阻塞等待,而是在循环中持续检查。在一些不太严重的情况下,因为使用随机调度,活锁的问题还是可以解决的。
- I/O阻塞或外部输入。
条件竞争
对独立线程相关操作的调度,决定了条件竞争发生的时间。很多条件竞争是良性的。
错误类型: - 数据竞争,因为未同步访问一块共享内存,将会导致代码产生未定义行为。
- 破坏不变量,主要表现为悬空指针(因为其他线程已经将要访问的数据删除了),随机存储错误(因为局部更新,导致线程读取了不一样的数据),以及双重释放((比如:当两个线程对同一个队列同时执行pop操作,想要删除同一个关联数据)。
- 生命周期问题,生命周期问题,通常是在一个线程引用了局部变量,在线程还没有完成前,局部变量的“死期”就已经到了,不过这个问题并不止存在这种情况下。
共享内存系统的诅咒——需要通过线程尝试限制可访问的数据,并且还要正确的使用同步,应用中的任何线程都可以复写(可被其他线程访问的)数据。定位并发错误的技术
我们可以在审阅代码的时候,考虑一些具体的事情,并且发现问题。代码审阅——发现潜在的错误
作者的清单: - 并发访问时,那些数据需要保护?
- 如何确定访问数据受到了保护?
- 是否会有多个线程同时访问这段代码?
- 这个线程获取了哪个互斥量?
- 其他线程可能获取哪些互斥量?
- 两个线程间的操作是否有依赖关系?如何满足这种关系?
- 这个线程加载的数据还是合法数据吗?数据是否被其他线程修改过?
- 当假设其他线程可以对数据进行修改,这将意味着什么?并且,怎么确保这样的事情不会发生?
通过测试定位并发相关的错误
需要考虑测试环境的因素: - “多线程”是有多少个线程(3个,4个,还是1024个?)
- 系统中是否有足够的处理器,能让每个线程运行在属于自己的处理器上
- 测试需要运行在哪种处理器架构上
- 在测试中如何对“同时”进行合理的安排
第一个和最后一个会影响测试结构本身,另外两个就和实际的物理测试环境相关了。使用线程数量相关的测试代码需要独立测试,可通过很多结构化测试获得最合适的调度方式。设计的时候,每段代码都需要进行测试,以保证没有问题,这样才能在测试出现问题的时候,剔除并发相关的bug。可测试性设计
通常,如果代码满足一下几点,就很容易进行测试: - 每个函数和类的关系都很清楚。
- 函数短小精悍。
- 测试用例可以完全控制被测试代码周边的环境。
- 执行特定操作的代码应该集中测试,而非分布式测试。
- 需要在完成编写后,考虑如何进行测试。
并发代码测试的一种最好的方式:去并发化测试(单线程形式)。或者,如果将代码分割成多个块(比如:读共享数据/变换数据/更新共享数据),就能使用单线程来测试变换数据的部分。多线程测试技术
蛮力测试。
代码有问题的时候,就要求蛮力测试一定能看到这个错误。这就意味着代码要运行很多遍,可能会有很多线程在同一时间运行。缺点就是,可能会误导你。如果写出来的测试用例就为了不让有问题的情况发生,那么怎么运行,测试都不会失败,可能会因环境的原因,出现几次失败的情况。
要避免误导的产生,关键点在于成功的蛮力测试。这就需要进行仔细考虑和设计,不仅仅是选择相关单元测试,还要遵守测试系统设计准则,以及选定测试环境。保证代码分支被尽可能的测试到,尽可能多的测试线程间的互相作用。还有,需要知道哪部分被测试覆盖到,哪些没有覆盖。
组合仿真测试。
使用一种特殊的软件,用来模拟代码运行的真实情况。
你应该知道这种软件,能让一台物理机上运行多个虚拟环境或系统环境,而硬件环境则由监控软件来完成。除了环境是模拟的以外,模拟软件会记录对数据序列访问,上锁,以及对每个线程的原子操作。然后使用C++内存模型的规则,重复的运行,从而识别条件竞争和死锁。
虽然,这种组合测试可以保证所有与系统相关的问题都会被找到,不过过于零碎的程序将会在这种测试中耗费太长时间,因为组合数目和执行的操作数量将会随线程的增多呈指数增长态势。这个测试最好留给需要细粒度测试的代码段,而非整个应用。另一个缺点就是,代码对操作的处理,往往会依赖与模拟软件的可用性。所以,测试需要在正常情况下,运行很多次,不过这样可能会错过一些问题;也可以在一些特殊情况下运行多次,不过这样更像是为了验证某些问题。
使用专用库对代码进行测试。
这个选择不会像组合仿真的方式提供彻底的检查,不过可以通过特别实现的库(使用同步原语)来发现一些问题,比如:互斥量,锁和条件变量。
当测试多线程代码的时候,另一种库可能会用到,以线程原语实现的库,比如:互斥量和条件变量;当多线程代码在等待,或是被条件变量通过notify_one()提醒的某个线程,测试者可以通过线程,获取到锁。就可以让你来安排一些特殊的情况,以验证代码是否会在这些特定的环境下产生期望的结果。构建多线程测试代码
在特定时间内,你需要安排一系列线程,同时去执行指定的代码段。最简单的情况:两个线程的情况,就很容易扩展到多个线程。
首先,你需要知道每个测试的不同之处: - 环境布置代码,必须首先执行
- 线程设置代码,需要在每个线程上执行
- 线程上执行的代码,需要有并发性
- 在并发执行结束后,后续代码需要对代码的状态进行断言检查
了解了各个代码块,就需要保证所有事情按计划进行。一种方式是使用一组std::promise
来表示就绪状态。
对一个队列并发调用push()
和pop()
的测试用例:
使用void test_concurrent_push_and_pop_on_empty_queue() { threadsafe_queue<int> q; // 1 环境设置代码中创建了空队列 std::promise<void> go, push_ready, pop_ready; // 2 为准备状态创建promise对象 std::shared_future<void> ready(go.get_future()); // 3 并且为go信号获取一个 std::shared_future 对象 std::future<void> push_done; // 4 创建了future用来表示线程是否结束 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 通过future返回检索值 } ); 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 需要通过对go信号的设置来避免悬空指针的产生,再重新抛出异常 throw; } }
std::launch::async
保证每个任务在自己的线程上完成。注意,使用std::async
会让你任务更容易成为线程安全的任务;这里不用普通std::thread
,因为其析构函数会对future进行线程汇入。测试多线程代码性能
在对数据访问的时候,处理器之间会有竞争,会对性能有很大的影响。需要合理的权衡性能和处理器的数量,处理器数量太少,就会等待很久;处理器过多,又会因为竞争的原因等待很久。
因此,在对应的系统上通过不同的配置,检查多线程的性能就很有必要,这样可以得到一张性能伸缩图。最起码,(如果条件允许)你应该在一个单处理器的系统上和一个多处理核芯的系统上进行测试。