“并行程序易于产生 bug 不是什么秘密。编写这种程序是一种挑战,并且在编程过程中悄悄产生的 bug 不容易被发现。许多并行 bug 只有在系统测试、功能测试时才能被发现或由用户发现。到那时修复它们需要高昂的费用 -- 假设能够修复它们 -- 因为它们是如此难于调试。”以上论述来自IBM论坛中关于 ConTest 的一篇介绍文章,并且,我还要补充一点,这种 BUG 通常根本无法重现,以至于要找到发生 BUG 的原因都是非常的困难;即使幸运的找到了可能原因,修改了代码,要确认问题确实得到了解决,依然是非常的困难,因为根本无法进行有效的测试。
JUnit提供了很好的单元测试框架,但是对于多线程的并发测试,却无能为力。经过一番搜索,找到了一个叫 GroboUtils 的东西,关于它的介绍不少,这里不再重复,GroboUtils 解决了启动多线程以及当某个线程出错时将错误返回给 JUnit 的问题,现在我们可以比较简单启动多个线程并验证结果了。具体介绍请自己 google 一下,我这里简单的贴一个 Demo 代码,当然和官方以及一般的例子不同,我比较喜欢使用内部匿名类来启动线程。这个测试很简单,启动 5 个线程,并发的进行 i++ 运算,我有意的注释掉了同步代码,这样应该会导致最后测试无法通过。
Code highlighting produced by Actipro CodeHighlighter (freeware)http://www.CodeHighlighter.com/-->
class ParalInc {
public int i=0;
}
public class ParalIncTest {
public static class Incrementor extends Thread {
public void run() {
}
}
@Test
public void testInc() throws Throwable {
for (int i = 0; i < 1000; i++) {
final ParalInc target = new ParalInc();
TestRunnable r = new TestRunnable() {
public void runTest() throws Throwable {
// synchronized(ParalIncTest.class) {
target.i++;
// }
}
};
TestRunnable[] tcs = {r, r, r, r, r};
int threadCount = tcs.length;
MultiThreadedTestRunner mttr = new MultiThreadedTestRunner( tcs );
mttr.runTestRunnables( 2 * 60 * 1000 );
System.out.println("final value: " + target.i);
if (target.i != threadCount) {
System.err.println(" Bug - at loop " + i);
}
assertEquals(threadCount, target.i);
}
}
}
开始的时候我并没有添加最外层循环,然而测试的结果很遗憾,怎么都无法让这段代码出错。加上循环以后,才偶尔会出错,也一般要循环到数百次以后才会发生错误。导致这种结果的原因,估计是线程的切换其实并发象我们想象的那么频繁,而且要恰恰在共享冲突点发生切换,这个概率也确实非常的小。如果能够控制虚拟机的线程切换,使得共享冲突概率更大一些那该是多么好啊。
因此,我找到了这篇文章 http://www.ibm.com/developerworks/cn/java/j-contest.html ,ConTest,这真是个好东西啊,配置好以后,还是上面那段测试代码,但是测试结果几乎都会检测到冲突,而且通常是在循环了2-3次后就检测到了。
ConTest 下载链接在从上面的文章中可直接找到,可以用 Eclipse plug-in 方式安装,update 地址 http://awwebx04.alphaworks.ibm.com/ettktechnologies/updates 。但是我实在没发现它给 Eclipse 添加了什么功能,其实我觉得主要还是只要下载到那个 ZIP 包就可以了,并不需要安装为 plug-in。
下载下来以后,ConTest 并不能自动的开始工作,需要一点点配置工作,这有点烦人。
首先要将 ConTest.jar 包拷贝到一个便于指定位置的地方,我一般倾向于将它放在工程目录的lib子目录下,另外 ZIP 包的 LIB 目录下还有一个 KingProperties 文件,要拷贝到和 ConTest.jar 相同的目录中。修改 KingProperties 文件,当然也可以不改,我主要是修改 output 目录,为 output = target,和 maven 保持一致,一切都输出到那里去。
这还只是一些准备活动,最后,要让 ConTest 开始工作,还需要修改 Run|RunConfigurations,在单元测试的 jvm 启动参数中指定启动 ConTest , 既添加 JVM 参数:
-javaagent:lib/ConTest.jar -Dcontest.targetClasses=demo/ParalIncTest
这里 -Dcontest.targetClasses=demo/ParalIncTest 既指定要进行并行性分析的类名,可以用逗号分隔指定多个类,当然这个参数本身也可以 KingProperties 文件中指定的,但是我觉得在命令行测试比较方便一些,毕竟从单元测试的角度来说,一次应该只测试一个类。在命令行测试,就不需要多套 KingProperties 配置文件。
至此,所有配置工作完成,依然像以前一样启动单元测试,ConTest 作为 javaagent 装载,因此它其实是在 ClassLoader 加载类字节码的时候介入,修改字节码在其中加入一些线程切换的代码,这样线程切换更频繁了,特别在一些同步块中,发生同步冲突的概率自然就大很多了。
总的来说,将 GroboUtils 和 ConTest 结合起来使用,效果还是相当不错的,并发测试变得简单多了,而且检测到冲突的可能性非常的大。
使用 ConTest 进行多线程单元测试
为什么并行测试很困难以及如何使用 ConTest 辅助测试
并行程序易于产生 bug 不是什么秘密。编写这种程序是一种挑战,并且在编程过程中悄悄产生的 bug 不容易被发现。许多并行 bug 只有在系统测试、功能测试时才能被发现或由用户发现。到那时修复它们需要高昂的费用 -- 假设能够修复它们 -- 因为它们是如此难于调试。
在本文中,我们介绍了 ConTest,一种用于测试、调试和测量并行程序范围的工具。正如您将很快看到的,ConTest 不是单元测试的取代者,但它是处理并行程序的单元测试故障的一种补充技术。
注意本文中包含了一个 示例软件包 ,一旦理解了有关 ConTest 如何工作的基本知识,您就可以自己使用该软件包进行试验。
当问任何 Java™ 开发者时,他们都会告诉您单元测试是一种好的实践。在单元测试上做适当的投入,随后将得到回报。通过单元测试,能较早地发现 bug 并且能比不进行单元测试更容易地修复它们。但是普通的单元测试方法(即使当彻底地进行了测试时)在查找并行 bug 方面不是很有效。这就是为什么它们能逃到程序的晚期 。
为什么单元测试经常遗漏并行 bug?通常的说法是并行程序(和 bug)的问题在于它们的不确定性。但是对于单元测试目的而言,荒谬性在于并行程序是非常 确定的。下面的两个示例解释了这一点。
第一个例子是一个类,该类除了打印由两部分构成的名字之外,什么也不做。出于教学目的,我们把此任务分在三个线程中:一个线程打印人名,一个线程打印空格,一个线程打印姓和一个新行。一个包括对锁进行同步和调用 wait()
和 notifyAll()
的成熟的同步协议能保证所有事情以正确的顺序发生。正如您在清单 1 中看到的,main()
充当单元测试,用名字 "Washington Irving" 调用此类:
清单 1. NamePrinter
public class NamePrinter { private final String firstName; private final String surName; private final Object lock = new Object(); private boolean printedFirstName = false; private boolean spaceRequested = false; public NamePrinter(String firstName, String surName) { this.firstName = firstName; this.surName = surName; } public void print() { new FirstNamePrinter().start(); new SpacePrinter().start(); new SurnamePrinter().start(); } private class FirstNamePrinter extends Thread { public void run() { try { synchronized (lock) { while (firstName == null) { lock.wait(); } System.out.print(firstName); printedFirstName = true; spaceRequested = true; lock.notifyAll(); } } catch (InterruptedException e) { assert (false); } } } private class SpacePrinter extends Thread { public void run() { try { synchronized (lock) { while ( ! spaceRequested) { lock.wait(); } System.out.print(' '); spaceRequested = false; lock.notifyAll(); } } catch (InterruptedException e) { assert (false); } } } private class SurnamePrinter extends Thread { public void run() { try { synchronized(lock) { while ( ! printedFirstName || spaceRequested || surName == null) { lock.wait(); } System.out.println(surName); } } catch (InterruptedException e) { assert (false); } } } public static void main(String[] args) { System.out.println(); new NamePrinter("Washington", "Irving").print(); } } |
如果您愿意,您可以编译和运行此类并且检验它是否像预期的那样把名字打印出来。 然后,把所有的同步协议删除,如清单 2 所示:
清单 2. 无修饰的 NamePrinter
public class NakedNamePrinter { private final String firstName; private final String surName; public NakedNamePrinter(String firstName, String surName) { this.firstName = firstName; this.surName = surName; new FirstNamePrinter().start(); new SpacePrinter().start(); new SurnamePrinter().start(); } private class FirstNamePrinter extends Thread { public void run() { System.out.print(firstName); } } private class SpacePrinter extends Thread { public void run() { System.out.print(' '); } } private class SurnamePrinter extends Thread { public void run() { System.out.println(surName); } } public static void main(String[] args) { System.out.println(); new NakedNamePrinter("Washington", "Irving"); } } |
这个步骤使类变得完全错误:它不再包含能保证事情以正确顺序发生的指令。但我们编译和运行此类时会发生什么情况呢?所有的事情都完全相同!"Washington Irving" 以正确的顺序打印出来。
此试验的寓义是什么?设想 NamePrinter 以及它的同步协议是并行类。 您运行单元测试 -- 也许很多次 -- 并且它每次都运行得很好。自然地,您认为可以放心它是正确的。但是正如您刚才所看到的,在根本没有同步协议的情况下输出同样也是正确的,并且您可以安全地推断在有很多错误的协议实现的情况下输出也是正确的。因此,当您认为 已经测试了您的协议时, 您并没有真正地 测试它。
现在我们看一下另外的一个例子。
下面的类是一种常见的并行实用程序模型:任务队列。它有一个能使任务入队的方法和另外一个使任务出队的方法。在从队列中删除一个任务之前,work()
方法进行检查以查看队列是否为空,如果为空则等待。enqueue()
方法通知所有等待的线程(如果有的话)。 为了使此示例简单,目标仅仅是字符串,任务是把它们打印出来。再一次,main()
充当单元测试。顺便说一下,此类有一个 bug。
清单 3. PrintQueue
import java.util.*; public class PrintQueue { private LinkedList<String> queue = new LinkedList<String>(); private final Object lock = new Object(); public void enqueue(String str) { synchronized (lock) { queue.addLast(str); lock.notifyAll(); } } public void work() { String current; synchronized(lock) { if (queue.isEmpty()) { try { lock.wait(); } catch (InterruptedException e) { assert (false); } } current = queue.removeFirst(); } System.out.println(current); } public static void main(String[] args) { final PrintQueue pq = new PrintQueue(); Thread producer1 = new Thread() { public void run() { pq.enqueue("anemone"); pq.enqueue("tulip"); pq.enqueue("cyclamen"); } }; Thread producer2 = new Thread() { public void run() { pq.enqueue("iris"); pq.enqueue("narcissus"); pq.enqueue("daffodil"); } }; Thread consumer1 = new Thread() { public void run() { pq.work(); pq.work(); pq.work(); pq.work(); } }; Thread consumer2 = new Thread() { public void run() { pq.work(); pq.work(); } }; producer1.start(); consumer1.start(); consumer2.start(); producer2.start(); } } |
运行测试以后,所有看起来都正常。作为类的开发者,您很可能感到非常满意:此测试看起来很有用(两个 producer、两个 consumer 和它们之间的能试验 wait
的有趣顺序),并且它能正确地运行。
但是这里有一个我们提到的 bug。您看到了吗?如果没有看到,先等一下;我们将很快捕获它。
为什么这两个示例单元测试不能测试出并行 bug?虽然原则上线程调度程序可以 在运行的中间切换线程并以不同的顺序运行它们,但是它往往 不进行切换。因为在单元测试中的并行任务通常很小同时也很少,在调度程序切换线程之前它们通常一直运行到结束,除非强迫它(也就是通过 wait()
)。并且当它确实 执行了线程切换时,每次运行程序时它往往都在同一个位置进行切换。
像我们前面所说的一样,问题在于程序是太确定的:您只是在很多交错情况的一种交错(不同线程中命令的相对顺序)中结束了测试。更多的交错在什么时候试验?当有更多的并行任务以及在并行类和协议之间有更复杂的相互影响时,也就是当您运行系统测试和功能测试时 -- 或当整个产品在用户的站点运行时,这些地方将是暴露出 bug 的地方。
当进行单元测试时需要 JVM 具有低的确定性,同时是更“模糊的”。这就是要用到 ConTest 的地方。如果使用 ConTest 运行几次 清单 2 的 NakedNamePrinter
, 将得到各种结果,如清单 4 所示:
清单 4. 使用 ConTest 的无修饰的 NamePrinter
>Washington Irving (the expected result) > WashingtonIrving (the space was printed first) >Irving Washington (surname + new-line printed first) > Irving Washington (space, surname, first name) |
注意不需要得到像上面那样顺序的结果或相继顺序的结果;您可能在看到后面的两个结果之前先看到几次前面的两个结果。但是很快,您将看到所有的结果。ConTest 使各种交错情况出现;由于随机地选择交错,每次运行同一个测试时都可能产生不同的结果。相比较的是,如果使用 ConTest 运行如 清单 1 所示的 NamePrinter
,您将总是得到预期的结果。在此情况下,同步协议强制以正确的顺序执行,所以 ConTest 只是生成合法的 交错。
如果您使用 ConTest 运行 PrintQueue
,您将得到不同顺序的结果,这些对于单元测试来说可能是可接受的结果。但是运行几次以后,第 24 行的 LinkedList.removeFirst()
会突然抛出 NoSuchElementException
。bug 潜藏在如下的情形中:
- 启动了两个 consumer 线程,发现队列是空的,执行
wait()
。 - 一个 producer 把任务放入队列中并通知两个 consumer。
- 一个 consumer 获得锁,运行任务,并把队列清空。然后它释放锁。
- 第二个 consumer 获得锁(因为通知了它所以它可以继续向下进行)并试图运行任务,但是现在队列是空的。
这虽然不是此单元测试的常见交错,但上面的场景是合法的并且在更复杂地使用类的时候可能发生这种情况。使用 ConTest 可以使它在单元测试中发生。(顺便问一下,您知道如何修复 bug 吗?注意:用 notify()
取代 notifyAll()
能解决此情形中的问题,但是在其他情形中将会失败!)
ConTest 背后的基本原理是非常简单的。instrumentation 阶段转换类文件,注入挑选的用来调用 ConTest 运行时函数的位置。在运行时,ConTest 有时试图在这些位置引起上下文转换。 挑选的是线程的相对顺序很可能影响结果的那些位置:进入和退出 synchronized 块的位置、访问共享变量的位置等等。通过调用诸如 yield()
或 sleep()
方法来尝试上下文转换。决定是随机的以便在每次运行时尝试不同的交错。使用试探法试图显示典型的 bug。
注意 ConTest 不知道实际是否已经显示出 bug -- 它没有预期程序将如何运行的概念。是您,也就是用户应该进行测试并且应该知道哪个测试结果将被认为是正确的以及哪个测试结果表示 bug。ConTest 只是帮助显示出 bug。另一方面,没有错误警报:就 JVM 规则而言所有使用 ConTest 产生的交错都是合法的。
正如您看到的一样,通过多次运行同一个测试得到了多个值。实际上,我们推荐整个晚上都反复运行它。然后您就可以很自信地认为所有可能的交错都已经执行过了。
除了它的基本的方法之外,ConTest 在显示并行 bug 方面引入了几个主要特性:
- 同步覆盖:在单元测试中极力推荐测量代码覆盖,但是在测试并行程序时使用它,代码覆盖容易产生误导。在前两个例子中,无修饰的 NamePrinter 和多 bug 的 Print Queue,给出的单元测试显示完整的语句覆盖( 除了
InterruptedException
处理)没有显示出 bug。 同步覆盖弥补了此缺陷:它测量在 synchronized 块之间存在多少竞争;也就是说,是否它们做了“有意义的”事情,您是否覆盖了有趣的交错。有关附加信息请参见 参考资料 。
- 死锁预防: ConTest 可以分析是否以冲突的顺序嵌套地拥有锁,这表明有死锁的危险。此分析是在运行测试后离线地进行。
- 调试帮助:ConTest 可以生成一些对并行调试有用的运行时报告:关于锁的状态的报告(哪个线程拥有哪个锁,哪个线程处于等待状态等等),当前的线程的位置的报告和关于最后分配给变量和从变量读取的值的报告。您也可以远程进行这些查询;例如,您可以从不同的机器上查询服务器(运行 ConTest)的状态。另一个对调试有用的特性可能是重放,它试图重复一个给定运行的交错(不能保证,但是有很高的可能性)。
- UDP 网络混乱:ConTest 支持通过 UDP(数据报)套接字进行网络通信的域中的并行混乱的概念。 UDP 程序不能依靠网络的可靠性;分组可能丢失或重新排序,它依靠应用程序处理这些情况。与多线程相似,这带来对测试的挑战:在正常环境中,分组往往是按正确的顺序到达,实际上并没有测试混乱处理功能。ConTest 能够模拟不利的网络状况,因此能够运用此功能并显示它的 bug。
ConTest 是为 Java 平台创建的。用于 pthread 库的 C/C++ 版本的 ConTest 在 IBM 内部使用,但是不包含 Java 版的所有特性。出于两种原因,用 ConTest 操作 Java 代码比操作 C/C++ 代码简单:同步是 Java 语言的一部分,并且字节码非常容易使用。我们正在开发用于其他库的 ConTest,例如 MPI 库。如果您想要使用 C/C++ 版的ConTest,请与作者联系。硬实时软件对于 ConTest 也是一个问题,因为工具是通过增加延迟而工作。为使用 ConTest,我们正在研究与监视硬实时软件相似的方法,但是在目前我们还不能确定如何克服此问题。
至于将来的方向,我们正在研究发布一种 监听器 体系结构,它将允许我们在 ConTest 上应用基于监听器的工具。使用监听器体系结构将使创建原子数检查器、死锁侦听器和其他分析器以及尝试不必写入有关的基础设施的新的延迟机制成为可能。