电脑技术学习

Java平台上进行多线程编程的缺陷

dn001

  争用条件

  据说 争用条件 存在于这样的系统中:多个线程之间存在对共享资源的竞争,而胜出者决定系统的行为。Allen Holub 在他撰写的文章 “programming Java threads in the real world 提供了一个带有这样 bug 的简单的多线程程序示例。在冲突的访问请求之间进行不正确同步的另一个更可怕的后果是 数据崩溃,此时,共享的数据结构有一部分由一个线程更新,而另一部分由另一个线程更新。在这种情况下,系统的行为不是按照胜出线程的意图进行,系统根本不按照任何一个线程的意图行动,所以两个线程最后都将以失败告终。

  死锁

  死锁 的情况是指:线程由于等候某种条件变成真(例如资源可以使用),但是它等候的条件无法变成真,因为能够让条件变成真的线程在等候第一个线程“做某件事。这样,两个线程都在等候对方先采取第一步,所以都无法做事。

  活动锁

  活动锁 与 死锁 不同,它是在线程实际工作的时候发生的,但这时还没有完成工作。这通常是在两个线程交叉工作的时候发生,所以第一个线程做的工作被另一个线程取消。一个简单的示例就是:每个线程已经拥有了一个对象,同时需要另外一个线程拥有的另外一个对象。可以想像这样的情况:每个线程放下自己拥有的对象,捡起另外一个线程放下的对象。显然,这两个线程会永远都运行在上锁这一步操作上,结果是什么都做不成。(常见的真实示例就是,两个人在狭窄的走廊相遇。每个人都礼貌地让到另一边让对方先行,但却在相同的时间都让到同一边了,所以两个人还都没法通过。这种情况会持续一些时间,然后两个人都从这边闪到那边,结果还是一点进展也没有。)
  资源耗尽

  资源耗尽,又称为 线程耗尽,是 Java 语言的 wait/notify 原语无法保证 live-ness 的后果。Java 强制这些方法要拥有它们等候或通知的对象的锁。在某个线程上调用的 wait() 方法在开始等候之前必须释放监视器锁,然后在从方法返回并获得通知之后,必须再次重新获得锁。因此,Java 语言规范在锁本身之外,还描述了一套与每个对象相关的 等候集(wait set)。一旦线程释放了对象上的锁(在 wait 的调用之后),线程就会放在这个等候集上。

  多数 JVM 实现把等候线程放在队列中。所以,如果在通知发生的时候,还有其他线程在等候监视器,那么就会把一个新线程放在队列尾部,而它并不是下一个获得锁的线程。所以,等到被通知线程实际得到监视器的时候,通知该线程的条件可能已经不再为真,所以它不得不再次 wait。这种情况可能无限持续下去,从而造成运算工作上浪费(因为要反复把该线程放入等候集和从中取出)和线程耗尽。

  贪心哲学家的寓言

  演示这种行为的原型示例是 Peter Welch 教授描述的“聪明人没有鸡肉。在这个场景中考虑的系统是一所由五位哲学家、一位厨师和一个食堂组成的学院。所有的哲学家(除了一位)都要想想(在代码示例中,考虑的时间是 3 秒)之后才去食堂取饭。而“贪心的哲学家则不想把时间浪费在思考上 —— 相反,他一次又一次地回到食堂,企图拿到鸡肉来吃。

  厨师按照一批四份的定量准备鸡肉,每准备好一批,就送到食堂。贪心的哲学家不断地去厨房,但他总是错过食物!事情是这样的:他第一次到的时候,时间太早,厨师还没开火。因此贪心的哲学家只好干等着(通过 wait() 方法调用)。在开饭的时候(通过 notify() 方法调用),贪心的哲学家再一次回到食堂排队等候。但是这次,在他前来等候的时候,他的四位同事已经到了,所以他在食堂队列中的位置在他们后面。他的同事们把厨房送来的一批四份鸡肉全部拿走了,所以贪心的哲学家又要在一边等着了。 可怜(也可能是公平的) ,他永远处在这个循环之外。

  验证的问题

  一般来说,很难按照普通的规范对 Java 编程的多线程程序进行验证。同样,开发自动化工具对于常见的并发问题(例如死锁、活动锁和资源耗尽)进行完整而简单的分析也不太容易——特别是在任意 Java 程序中或者在缺乏并发的正式模型的时候。

  更糟的是,并发性问题出了名的变化多端、难于跟踪。每个 Java 开发人员都曾经听说过(或者亲自编写过)这样的 Java 程序:经过严格分析,而且正常运行了相当一段时间,没有表现出潜在的死锁。然后突然有一天,问题发生了,结果弄得开发团队经历许多的不眠之夜来试图发现并修补根本原因。

  一方面,多线程 Java 程序容易发生的错误非常不明显,有可能在任意什么时候发生。另一方面,完全有可能这些 bug 在程序中从不出现。问题取决于一些不可知的因素。多线程程序的复杂本质,使得人们很难有效地对其进行验证。没有一套现成的规则可以找出多线程代码中的这类问题,也无法确切地证明这些问题不存在,这些导致许多 Java 开发人员完全避开多线程应用程序的设计和开发,即使用并发和并行的方式对系统进行建模会非常棒,他们也不使用多线程。

  确实想进行多线程编程的开发人员通常准备好了以下一个或两个解决方案(至少是一部分):

  长时间艰苦地测试代码,找出所有出现的并发性问题,诚心地希望到应用程序真正运行地时候已经发现并修复了所有这类问题。

  大量运行设计模式和为多线程编程建立的指导原则。但是,这类指导原则只在整个系统都按照它们的规范设计的时候才有效,没有设计规则能够覆盖所有类型的系统。

  虽然知道的人不多,但是对于编写(然后验证)正确的多线程应用程序这一问题,还有第三个选项。使用称为通信顺序进程( Communicating Sequential Processes,CSP)的精确的线程同步的数学理论,可以在设计时最好地处理死锁和活动锁之类的问题。CSP 由 C.A.R. Hoare 与 20 世纪 70 年代后期设计,CSP 提供了有效的方法,证明用它的构造和工具构建的系统可以免除并发的常见问题。

  结束语

  在这份面向 Java 程序员的 CSP 全面介绍中,我把重点放在克服多线程应用程序开发常见问题的第一步上,即了解这些问题。我介绍了 Java 平台上目前支持的多线程编程构造,解释了它们的起源,讨论了这类程序可能会有的问题。我还解释了用正式理论在任意的、大型的和复杂的应用程序中清除这些问题(即竞争冒险、死锁、活动锁和资源耗尽)或者证明这些问题不存在的困难。

标签: 编程