Chinaunix首页 | 论坛 | 博客
  • 博客访问: 149335
  • 博文数量: 49
  • 博客积分: 2025
  • 博客等级: 大尉
  • 技术积分: 630
  • 用 户 组: 普通用户
  • 注册时间: 2007-05-11 11:27
文章分类

全部博文(49)

文章存档

2008年(49)

我的朋友

分类: 项目管理

2008-04-23 17:02:18

1.问题引入

小时候,我既害怕吃药,又害怕打针,宁可高烧40度不退,也不愿意走进满是消毒水味儿的医院,任由医生和护士摆布。这个毛病延续到今天的后果是:自己开发的软件一旦进入测试阶段,我就会莫名其妙地紧张,有时还要张开鼻孔使劲嗅上几下,然后信誓旦旦地告诉别人“这间屋肯定洒了消毒水”,弄得对方一头雾水。

我把自己在测试时的紧张状态称为“测试综合症”。当然,患上“测试综合症”也未必是一件坏事,它至少能提醒我们:所有刚开发出来的软件都必须在“软件测试”的诊室门口排队候诊;无论是因为讳疾忌医而拒绝接受测试,还是因为爱慕虚荣而为自己的代码文过饰非,这些做法其实都和小孩子们撒泼耍赖不上医院的行为没什么两样。

言归正传。为了引出本文的案例,我们不妨换一个角度来思考问题。假如我们就是在“软件测试”的诊室里坐诊的医生,那么,对于在屋外候诊的形形色色、林林总总的软件“病人”来说,你最害怕哪一位“病人”推门而入呢?换句话说,作为软件测试者,你认为最难于测试、最不容易发现Bug 的是哪一类软件呢?

对这个问题,大多数有测试经验的人都会毫不犹豫地指出:最难测试的软件是那些有并发特性的软件系统。无论是运行在一台计算机上的多线程、多进程等多任务程序,还是部署在多个计算机节点中并发运行的分布式软件,它们的测试难度都要远远高于普通的单线程软件。这是因为,并发系统的执行序列是不可预知的,对于同样的输入,并发系统可能会产生不同的输出。这种不确定性为并发系统带来了许多与众不同的特性,比方说,并发系统可能出现以下两种特殊的故障:

l      死锁Deadlock):不同的线程或进程互相等待对方所持有的资源,以至于大家都无法继续执行。

l      竞态条件Race Condition):不同的线程或进程同时访问相同的共享资源,这可能会破坏该资源的一致性和完整性规则,从而引发共享资源的访问冲突问题。

此外,并发系统还存在活锁(Livelock)等更为复杂的错误情况,但限于篇幅,它们并不在本文的讨论对象之列。许多论述并发系统开发的书籍和文章都已明确指出了在编程中防范并发系统故障的方法。本文所要讨论的是并发系统的测试问题,即对于一个已经完成的并发系统,我们该使用何种手段进行测试,以发现系统中可能存在的死锁、竞态条件等特殊Bug 呢?

例如,下面这段Java 代码是并发系统中常见的对象池管理模块的一个缩影。对象池管理类PoolMan 使用一个布尔类型的数组pool 来模拟对象池(在实际的系统中,这个对象池可以存放远程数据库连接、可用的通信信道等不同类型的对象资源),其分配规则是,同一个对象在同一时刻只能有一个使用者。客户程序通过PoolMan 提供的getObject()方法获取对象池中的对象。当某个对象被使用时,getObject()就将pool 数组中的对应元素设为true,以防止该对象被重复使用。为了讨论上的方便,我略去了PoolMan 类中releaseObject()等其他方法的代码。

class PoolMan

{

private boolean[] pool;

public PoolMan(int size)

{

pool = new boolean[size];

}

public int getObject()

{

int i;

synchronized (this)

{

for (i = 0; i < pool.length; i++)

if (!pool[i])

break;

}

if (i < pool.length)

{

pool[i] = true;

return i;

}

else

return -1;

}

public boolean releaseObject(int obj)

{

// . . .

}

}

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


PoolMan 类的代码非常简单,简单到许多有经验的程序员一眼就可以看出,在多线程的环境下,PoolMan 类的getObject()方法存在一个明显的竞态条件Bug:如果getObject()方法被多个线程同时调用,PoolMan 类就可能发生资源共享冲突方面的错误。

当然,并不是所有软件都像PoolMan 这样简单。对于那些复杂的、无法一眼就找到Bug 的并发系统,我们又该如何处置呢?究竟有几种可行的测试方法,能够在测试阶段发现与PoolMan 相似的并发系统Bug 呢?更进一步地,对于这样的测试问题,存在可用和有效的自动化测试工具吗?这些问题的确值得我们认真思考。

2 一些题外话

不同的工作过程同时并发执行,这其实是现实世界里最为普遍的一种现象。中国古人早就明确指出:“万物并育而不相害,道并行而不相悖,小德川流,大德敦化,此天地之所以为大也。”①这段话的意思是说,天地间循环往复的生息、劳作、繁衍、运动等自然和社会过程都是并发执行的,简单的系统像河水流动一样脉络分明,复杂的系统则根本盛大、枝蔓繁多,状态变化无穷无尽,正是有了这许多并发系统的存在,天地万物才那么壮观、奇伟。

毫无疑问,两千多年前的中国古人已经参透了并发系统的奥妙所在,他们真诚地告诫我们,只有“并育而不相害”、“并行而不相悖”的系统才能健康、稳定地运行。今天的科学家和程序员们显然还没有彻底领悟古人这段话的真谛。自打电子计算机诞生的那一刻起,与并发系统相关的各类问题就一直令软件开发者们头疼不已。除了本文所说的测试问题以外,进程和线程的调度、并发任务间的通信与同步、分布式系统的时钟管理、分布式资源共享、并发环境下的事务处理、并发系统的安全等诸如此类的事情始终都是软件开发领域里最繁难、最棘手的问题。

难道,这一切仅仅是因为冯·诺依曼式的计算机结构背离了现实世界的基本规则,对天地万物的并发属性做了不恰当的简化?要不然,今天的科学家为什么还在努力研究以并发和分布式为基本特征的网格计算技术?要知道,现实世界其实就是一台最大、最神奇的网格计算机呀!

3 案例分析

并发系统的测试绝不是个简单的话题。如果没有科学的理论作指导,测试者通常很难找到并发系统中的Bug。一个在软件开发领域里反复上演的场景是:并发系统的开发者们满怀信心地将经历了“严格”测试的软件安装到生产环境中,却不幸地发现,在随后的几个星期里,服务程序每隔三、四个小时就会出现一次异常,而且,每次异常的现象都各不相同。为了尽早摆脱这一场景的纠缠,我们需要在测试过程中付出更多的努力。

就拿案例中PoolMan 类的代码为例,假设我们并没有聪明到一眼就能找出代码Bug 的地步,那么,该用什么样的方法对PoolMan 类的代码进行测试呢?根据前人的经验,要找出PoolMan 类中的Bug,大抵有两类方法可以使用:一类是静态的方法,即在源代码或编译层面进行分析和测试;另一类是动态的方法,即在程序运行时测试程序的状态和属性。这两类方法各有特点,在实际应用中也的确可以起到相辅相成的功效。

我们先来谈谈静态的测试方法。最简单的静态测试方法是阅读代码。也许你并不认为阅读代码也是一种测试方法,但对于执行序列无法预知的并发系统来说,阅读系统的代码并根据已有的经验发现Bug 确实是一种非常有效的手段。在一些航空航天企业的软件开发过程中,阅读代码(有时也被称为代码“审查”或“走查”)甚至被项目管理手册列为测试过程的一个必经阶段。

如果具备了并发系统开发的相关知识,我们就不难使用阅读代码的方法发现软件系统中的竞态条件Bug。例如,在PoolMan 类的代码中,我们可以知道,当多个线程同时

访问对象池,并通过同一个PoolMan 实例的getObject()方法获取对象时,PoolMan 类的私有成员pool 就成了这些线程的共享资源。根据并发系统的开发经验,对于共享资源的访问操作应当被适当地同步,否则就有可能引发竞态条件Bug。沿着这一思路,我们可以快速阅读并查找getObject()方法中所有访问pool 的代码。

getObject()方法中,第一处访问pool 的代码是:

for (i = 0; i < pool.length; i++)

if (!pool[i])

break;

 

 

 

 


这段代码全部是对pool 数组的操作没有改变pool 数组中任何元素的取值。而且,这段代码被封装在Java语言特有的synchronized 同步结构中,在只有一个对象池的情况下,它不可能和另一个线程中相同位置的代码同时执行。根据这样的理由,我们可以简单地认为这段代码不会引发竞态条件Bug

getObject()方法中,第二处访问pool 的代码是:

if (i < pool.length)

{

pool[i] = true;

return i;

}

 

 

 

 

 

    这段代码可能会改变pool 中某个元素的取值。同时,它并没有被封装到上述同步结构中。这非常清楚地提醒我们,它是一段“危险”的代码,可能导致共享资源的访问冲突。

事实也的确如此,PoolMan 类没有把为pool 赋值的操作封装在同步结构中的做法是错误的。在上面所列举的两个代码片断中,第一段代码的作用是查找pool 中未用的元素,第二段代码则是将找到的未用元素标记为“已使用”,并返回对象序号。让我们做一个假设:线程1 刚刚执行完第一段代码,发现pool 中第5 号元素的取值为false,当线程1 还没来得及执行第二段代码的时候,假设线程2 开始执行第一处代码,那么,线程2 也会发现第5 号元素处于未用状态,接下来,线程1 和线程2 分别执行第二段代码,它们会不约而同地将第5 号元素标记为true,然后返回同样的对象序号5。显然,这一结果与对象池管理程序要求每个对象同时只有一个使用者的初衷不符,PoolMan 类中的竞态条件Bug 也正在于此。

阅读代码的方法简便易行,但也存在相当多的局限:首先,通过阅读代码较难发现并发系统中的死锁Bug,因为死锁的问题并不总能在代码层面反映出来;其次,即使对于竞态条件Bug,阅读代码也不一定百分之百奏效,因为在某些情况下,对共享资源的并发访问并不会破坏共享资源的一致性和完整性,如果我们把线程中所有访问共享资源的代码都同步起来,反而会使并发系统的效率大幅降低;再次,阅读代码的效果取决于代码审查者的水平和经验,很难有一个量化的标准;最后,阅读代码并不是一种“自动化”的测试方法,而我们知道,好的测试都是自动化的测试,一个无法自动运行的测试过程是无法与增量开发、回归测试等现代软件开发技术相适应的。

为了实施更有效的静态测试,我们有必要在这里介绍一种重要的并发系统测试方法——模型检查(ModelChecking)。模型检查的基本思路是,大多数并发系统的执行过程都可以被视为一个有穷状态机,如果我们把系统的状态机模型抽象出来,并将其与并发系统必须遵循的若干规范或属性进行比较,就有可能通过自动化的方式发现系统中的Bug。——这个定义理论化太强,不大容易说明问题,我们最好还是结合实际案例来了解一下模型检查的基本原理。比如说,本文案例中PoolMan 类的getObject()方法可以被抽象为5 个独立的代码单元:

Synchronized (this)

{

for (i = 0; i < pool.length; i++)

if (!pool[i])

break;

}

If (i < pool.length)

pool[i]

return

return

 

在这里,我们简单地把这5 个代码单元看成多线程环境下独立的执行单位(暂不考虑一条Java 语句在编译后可能对应多条指令的情况)。其中,①是一个同步结构,在不同线程中不能同时执行;③、④和⑤是两个互斥的逻辑分支,在同一线程的一次方法调用中,要么执行③、④,要么执行⑤。

如果有两个线程同时执行,上述代码单元的执行顺序就存在许多种可能。我们用①.①这样的标记方法来表示第一个线程的第一个代码单元,这样,就可以将程序可能的执行顺序列举如下:

.①-①.②-①.③-①.④-②.①-②.②-②.

.①-①.②-②.①-②.②-①.③-①.④-②.③-②.

.①-②.②-②.③-②.④-①.①-①.②-①.③-①.

..

阅读(980) | 评论(0) | 转发(0) |
给主人留下些什么吧!~~