此文被笔者收录在系列文章 架构师必备(系列) 中
很多顺序程序的测试方法在并发程序中也适用,但需要更广泛的覆盖度和更长的运行时间。并发类的测试主要分为:安全性与活跃度的测试。活跃度测试包括:
- 吞吐量:在一个并发任务集中,已完成任务所占的比例。
- 响应性:从请求到完成一些动作之间的等待时间。
- 可伸缩性:增加更多的硬件或软件资源后,吞吐量是否会提高。
一、正确性测试
为并发类开发单元测试流程,也要先识别出不变约束和后验条件,这些都属于例行检查。
测试阻塞操作
大多数测试框架对并发性并不友好,它们只包含很少的工具,用来创建线程或监视线程,确保它们不会意外地终结。
能否把失败明确地与一个特定的测试关联起来,变得很重要。如果一个方法应该在某些特定条件下被阻塞,那么测试这种行为时,只有在线程没有执行完毕前,测试才是成功的。测试方法的阻塞,类似于测试方法抛出的异常,如果方法可以正常返回,则意味着失败。当方法成功地被阻塞后,还必须想办法解决方法的阻塞,做到这一点最常见的方法是通过中断。这需要你的阻塞活动可以提前返回或者抛出InterruptedException,以响应中断。
测试安全性
测试正确性一般都会采用单元测试的方式,测试安全性主要是发现数据竞争引起的错误,这需要一个并发的多线程测试环境,过段时间查看是否发生了问题。
为并发类创建有效的安全测试,其挑战在于:如何在程序出现问题并导致某些属性极度可能失败时,简单地识别出这些受检查的属性来,同时不要人为地让查找错误的代码限制住程序的并发性,最好能做到在检查测试的属性时,不需要任何的同步。
针对生产--消费这种设计比较好的一种测试方法是:只核对所有队列和缓冲的元素最终是否都出来了,其他的什么也不做。当元素插入队列时,同时把它插入到一个“影子”清单中,当它从队列中被删除时,同时从“影子”清单中删除,然后等测试完成后断言影子清单是否为空。但是,修改影子清单需要同步,而且可能阻塞,所以这种方法会干扰测试线程的调度。2、另一咱方法是求和,只计算同一时间内队列中元素的数量。测试的数据最好采用随机的数据。
如果测试会在完成了一定数量的操作之前一直运行,那么倘若被测试的代码由于BUG而遇到一个异常,测试用例就可能永远不会结束,解决这个问题最常用的方式是,让测试框架去终止那些没有在规定的时间内完成的测试,这个“规定的时间”取决于个人经验,同时要分析每次失败,以确保问题出现的起因不是你没有等待足够的时间。
测试资源管理
以上的测试都是测试这个类应该做什么,另外一个测试是测试这个类没有做它不应该做的,比如资源泄露。任何持有或管理着其他对象的对象,都应该在不需要某个对象时,许诺该对象的引用。
对内存或其它资源的不合理占有,可以简单地通过堆检查工具测试出来。比如heap-profiling工具。通过-XX:+DisableExplicitGC,可以告诉HotSpot忽略System.gc调用。
使用回调
回调用户提供的代码,有助于创建测试用例,回调常常发生在一个对象生命周期的已知点上,这些点提供了很好的机会来断言不变约束。比如测试池的扩展性,可以在测试的适当时机向池中加入一些对象,来测试池是否如期的进行了扩展。
产生更多的交替操作
大多数的并发代码中潜在的错误都是低可能性的事件,在多处理器系统中,如果处理器的数量少于活动线程的数量,可以产生更多的交替行为,这种时间可以使用Thread.yield激发更多的上下文切换。这行代码应该使用AOP工具来实现,而不要加在被测试的代码中。
二、性能测试
几乎所有的生产--消费者设计都会用到有限缓存,所以要去测试吞吐量,我们只要简单地扩展PutTakeTest,就能让它变成针对这一场景的性能测试。
性能测试的第二个目的就是确定—线程数、缓存容量等等。主要的目的就是通过改变不同的配置参数来测试出哪些因素约束了总体的吞吐量,通常的作法是测试整个运行计时,然后除以操作的数量,得到每个操作的耗时。下面是一个例子。
import junit.framework.TestCase;import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class PutTakeTest extends TestCase {
protected static final ExecutorService pool = Executors.newCachedThreadPool();
protected CyclicBarrier barrier;
protected final SemaphoreBoundedBuffer<Integer> bb;
protected final int nTrials, nPairs;
protected final AtomicInteger putSum = new AtomicInteger(0);
protected final AtomicInteger takeSum = new AtomicInteger(0);
public static void main(String[] args) throws Exception {
new PutTakeTest(10, 10, 100000).test(); // sample parameters
pool.shutdown();
}
public PutTakeTest(int capacity, int npairs, int ntrials) {
this.bb = new SemaphoreBoundedBuffer<Integer>(capacity);
this.nTrials = ntrials;
this.nPairs = npairs;
this.barrier = new CyclicBarrier(npairs * 2 + 1);
}
void test() {
try {
for (int i = 0; i < nPairs; i++) {
pool.execute(new Producer());
pool.execute(new Consumer());
}
barrier.await(); // wait for all threads to be ready
barrier.await(); // wait for all threads to finish
assertEquals(putSum.get(), takeSum.get());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
static int xorShift(int y) {
y ^= (y << 6);
y ^= (y >>> 21);
y ^= (y << 7);
return y;
}
class Producer implements Runnable {
public void run() {
try {
int seed = (this.hashCode() ^ (int) System.nanoTime());
int sum = 0;
barrier.await();
for (int i = nTrials; i > 0; --i) {
bb.put(seed);
sum += seed;
seed = xorShift(seed);
}
putSum.getAndAdd(sum);
barrier.await();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
class Consumer implements Runnable {
public void run() {
try {
barrier.await();
int sum = 0;
for (int i = nTrials; i > 0; --i) {
sum += bb.take();
}
takeSum.getAndAdd(sum);
barrier.await();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}import java.util.concurrent.CyclicBarrier;
public class TimedPutTakeTest extends PutTakeTest {
private BarrierTimer timer = new BarrierTimer();
public TimedPutTakeTest(int cap, int pairs, int trials) {
super(cap, pairs, trials);
barrier = new CyclicBarrier(nPairs * 2 + 1, timer);
}
public void test() {
try {
timer.clear();
for (int i = 0; i < nPairs; i++) {
pool.execute(new PutTakeTest.Producer());
pool.execute(new PutTakeTest.Consumer());
}
barrier.await();
barrier.await();
long nsPerItem = timer.getTime() / (nPairs * (long) nTrials);
System.out.print("Throughput: " + nsPerItem + " ns/item");
assertEquals(putSum.get(), takeSum.get());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws Exception {
int tpt = 100000; // trials per thread
for (int cap = 1; cap <= 1000; cap *= 10) {
System.out.println("Capacity: " + cap);
for (int pairs = 1; pairs <= 128; pairs *= 2) {
TimedPutTakeTest t = new TimedPutTakeTest(cap, pairs, tpt);
System.out.print("Pairs: " + pairs + "\t");
t.test();
System.out.print("\t");
Thread.sleep(1000);
t.test();
System.out.println();
Thread.sleep(1000);
}
}
PutTakeTest.pool.shutdown();
}
}
三、响应性测试
吞吐量通常是燕发程序里最重要的性能指标,有时间一个独立的动作完成要花费多少时间,也是很重要的。这种情况下,我们要测量的是服务时间的差异性,即不同的数据处理起来所花费的时间。
四、避免性能测试的陷阱
理论上,开发性能测试是最简单的--发现一个典型的使用场景,编写一段程序以多次执行这一场景,并对它计时,在实际中,你还必须要提防很多编码的陷阱,它们会导致性能测试产生毫无意义的结果。
垃圾回收
GC回收是个自动的机制可能会影响测试的结果,有两种策略可以避免垃圾回收对测试结果带来的误差:1、确保在测试的整个期间,GC根本不会运行,通过调用JVM时设定 -verbose参数;2、确保执行测试期间GC运行的次数,后一种策略更佳,它需要更长的测试时间,并且更可能反映现实环境下的性能。
动态编译
对于java这样的动态编译语言,当一个类被首次加载后,JVM会以解释字节码的方式执行,如果一个方法运行得足够频繁,动态编译器最终会将它挑出来,置换成本地代码。编译的时间不可预知的。所以时序测试应该在所有代码编译完成后再运行。
解决这些问题就需要测试长时间运行,在运行一段时间后再进行计时。在HotSpot JVM中,运行程序时使用-XX:+PrintCompilation,那么程序会在动态编译运行时打印出信息,通过信息来确保测试是在全部编译完成后再运行的。
JVM会启动不同的后台线程去执行常规任务,当在单一的运行中测量多个不相关的计算密集型活动时,在不同的测量活动之间置入显示的暂停是个很好的做法,这会让JVM得以与后台任务保持一致,同时会把来自被测线程的干扰降到最低。
不切实际的竞争程度
并发的应用程序总在交替执行两种非常不同的工作:1、访问共享数据;2、线程本地的计算,依赖于两种工作类型的相关特性,应用程序会经历不同级别的竞争,并表现出不同的性能与伸缩性行为。如果N个线程从共享工作队列中获取任务并执行,这种况几乎没有竞争,吞吐量只受限于可用的CPU资源。所以一般来讲测试也要还原真实的应用场景。
五、测试方法补遗
测试的目的不是为了发现错误,而是提高信心,相信代码能够如期地工作。
代码审查
平台的问题,比如JVM的实现细节或者存储器的存储模型,都会在特定的硬件或软件配置下,屏敝一些BUG的出现。
静态分析工具
静态分析工具用来测试和代码审查,它不执行代码只对代码进行必要的分析。FindBugs是个开源工具。
【文章原创作者:香港显卡服务器 http://www.558idc.com/hkgpu.html 网络转载请说明出处】