首页 新闻 论坛 群组 Blog 文档 下载 读书 Tag 网摘 搜索 开源 FAQ 第二书店 博文视点 程序员
频道: 研发 数据库 中间件 信息化 视频 .NET Java 游戏 移动 服务: 人才 外包 培训
    图书品种:235680
       
热门搜索: ASP.NET Ajax Spring Hibernate Java

1.3  线程的风险

Java对线程内置的支持是一把双刃剑。它通过提供语言和类库,以及一个规范的跨平台存储模型(这个规范的存储模型使得在Java中开发“一次开发,随处运行(write-once, run-anywhere)”的并发程序成为可能),简化了并发应用的开发。这样做同时还提高了开发人员的门槛,因为更多的程序需要使用线程。曾几何时,当线程还十分深奥的时候,并发还是一个“高级”的话题;现在,主流的开发人员都必须知道线程安全性的问题。

1.3.1  安全危险

线程安全的问题是微妙且出乎意料的,因为在没有进行充分同步的情况下,多线程中的各个操作的顺序是不可预测的,有时甚至令人惊讶。清单1.1中,UnsafeSequence试图生成一个唯一整数值的序列。下面提供了一个简单的插图来解释多线程中交替(interleaving)的动作如何导致意外结果的。如果在单一线程的环境中,它能够正确运行,但是在多线程环境中却不行。

清单1.1  非线程安全的序列生成器

@NotThreadSafe

public class UnsafeSequence {

  private int value;

 

  /** 返回一个唯一值. */

  public int getNext() {

    return value++;

  }

}

图1.1  UnsafeSequence.getNext执行失败

UnsafeSequence中的问题是,在一些特殊的时序情况下,两个线程可以调用getNext并得到相同的返回值。图1.1表现了这是如何发生的。自增操作value++可能看起来是一个单一的操作,但是事实上它分为3个独立的操作:读取这个值,使之加1,再写入新值。因为这些操作发生在多个线程中,这些线程可能交替占有运行时(runtime),所以两个线程很可能同时读取这个值,两个线程都得到相同的值,并都使之增加了1。结果就是不同的线程返回了相同的序列数。

图1.1中的图表描述了不同线程之间的交替操作。在这些图表中,时间由左至右发展,每一行表现一个不同线程的活动。这些交替的图表通常用来描述最坏的情况2,目的是表现特定顺序下产生错误的僭越带来的危险。

UnsafeSequence使用了一个非标准的标签(Annotation):@NotThreadSafe。这是本书中几个自定义的Annotation之一,这些Annotation用来标明类和类成员的并发特

性。(其他的类级Annotation还有:@ThreadSafe和@Immutable;详见附录A)用Annotation给线程安全进行标记对于各类读者来说是非常有用的。如果一个类标记为@ThreadSafe,用户就可以充满信心地把它应用于多线程环境,维护者看到它可以认为是线程安全的必然保证,而软件分析工具可以转而去识别那些可能存在的代码错误。

UnsafeSequence阐明了一种常见的并发危险:竞争条件(race condition)。当被多线程调用时,getNext是否能返回不重复的值,正像它的规约描述的,而这取决于运行时如何交替进行这些操作——这不是我们所希望看到的势态。

因为线程共享相同的内存地址空间,且并发地运行,它们可能访问或修改其他线程正在使用的变量。这是十分方便的,因为它使得数据共享相对于其他的线程间通信机制都更加简单。但是这其中也存在着巨大的风险:当数据意外改变时,线程可能会出现混乱。允许多线程访问和修改相同的变量,给顺序编程模型引入了一些非顺序因素,这可能会造成混乱,并且难以发现错误的原因。为了使多线程程序的行为可预见,访问共享的变量必须经过合理的协调,这样线程才不会相互干扰。幸运的是,Java提供了同步机制来协调这样的访问。

像清单1.23中那样,可以通过把getNext声明为synchronized类型的方法来修正UnsafeSequence,因此可以避免图1.1所示的那种不应出现的交互。(这样做能够避免这个错误的确切原因将是第2章和第3章的主题。)

清单1.2  线程安全的序列生成器

@ThreadSafe

public class Sequence {

  @GuardedBy("this") private int value;

  public synchronized int getNext() {

    return value++;

  }

}

在缺少同步的时候,编译器、硬件和运行时事实上对时间和活动(action)顺序是很随意的,比如在寄存器或者高速缓存中存储变量,这样会使它们对于其他线程暂时(甚至是永远)不可见。这样的行为被普遍认为是能提高性能的非常可行的办法,但是这给开发人员带来了负担,他们需要明确识别数据在线程中究竟如何共享,这样这些优化才不至于破坏安全。(第16章给出了一些非常详细的细节,JVM确切地产生了什么样的顺序担保,

同步如何影响这些担保的,不过只要你遵循第2章、第3章中的规则,那么避开这些底层的细节也能达到安全性。)

1.3.2  活跃度的危险

在开发并发代码时,对线程安全的关注是至关重要的:安全不能妥协。安全的重要性不仅仅存在于多线程程序中,单线程化的程序也必须注意保护安全性和正确性,但是线程的使用引入了不会出现在单线程化程序中的额外安全危险。举例来说,线程的使用引入了又一形式的活跃度失败(liveness failure),这不会出现在单线程化的程序中。

如果安全意味着“什么坏事都没有发生过”,活跃度关注是与之互补的一面“好事最终发生了”。当一个活动进入某种它永远无法再继续执行的状态时,活跃度失败就发生了。一种活跃度失败可以发生在顺序程序中,这就是粗心造成的无限循环,那些在循环之后的代码永远不会被执行。多线程的引入带来了更多的活跃度危险。例如,如果线程等待一个线程独立占有的资源,永远不释放这个资源,将永远等待下去。第10章将讲述各种形式的活跃度失败,包括死锁(deadlock,10.1小节)、饥饿(starvation,10.3.1小节)、活锁(livelock 10.3.3小节),以及如何避免它们发生。像大多数同步bug一样,引起活跃度失败的bug总是难以察觉到,因为它们取决于线程间的相关的事件时序,因此在开发和测试中,并没有很多机会发现它们。

1.3.3  性能危险

与活跃度相关的是性能(performance)。虽然活跃度意味着好的事情终究会发生,但是最后可能还是不够好——我们通常希望好事情尽快发生。性能问题涉及很多方面,包括服务时间、响应性、吞吐量、资源消费或者可伸缩性的不良表现。就像安全和活跃度一样,多线程程序出现所有单线程程序中遇到的性能危险,而且还会有因线程的使用带来的风险。

在设计良好的应用程序中使用线程,能够获得纯粹的性能收益,但是线程仍然会给运行时带来一定程度的开销。上下文切换(Context switches)—— 当调度程序临时挂起当前运行的线程时,另一个线程开始运行——这在多个线程组成的应用程序中是很频繁的,并且带来巨大的系统开销:保存和恢复线程执行的上下文,离开执行现场,并且CPU的时间会花费在对线程的调度而不是在运行上。当线程共享数据的时候,它们必须使用同步机制,这个机制会限制编译器的优化,能够清空或锁定内存和高速缓存,并在共享内存的总线上创建同步通信。所有这些因素又引入了新的性能开销;第11章将介绍分析和降低这些开销的技术。

查看所有评论(0)条】

最近评论



正在载入评论列表...
热点评论