CHAPTER 第「章 介绍 Introduction 编写正确的程序并不容易,而编写正确的并发程序就更难了。与顺序执行的程序相比, 并发程序中显然更容易出现错误。那么,我们为什么会对并发如此烦恼呢?线程是Java 语言不可避免的特性,它们把复杂、异步的代码转化为更简单、更直观的代码,从而简化 复杂系统的开发。进一步而言,线程是控制和利用多处理器系统计算能力的最简单方式。 同时,伴随着处理器数量的增加,有效地采用并发会变得越来越重要。 1.1并发的(非常)简短历史 在发展的初期,计算机还没有操作系统:它们自始至终执行一个程序,这个程序直接 访问机器的所有资源。这样一个程序运行在无保护的金属器件上,不仅写起来困难,而且 每次只运行一个程序,不能很好地利用昂贵且稀缺的计算机资源。 操作系统的发展使得多个程序能够同时运行,程序在各自的进程(processes)中运行: 相互分离,各自独立执行,由操作系统来分配资源,比如内存、文件句柄、安全证书。如 果需要的话,进程会通过一些原始的机制相互通信:Socket、信号处理(signal handlers)、 共享内存(shared memory)、信号量(semaphores)和文件。 有一些促进因素,它们推动了操作系统支持多程序同时执行的发展: 资源利用。程序有时候需要等待外部的操作,比如输入和输出,并且在等待的时候不 可能进行有价值的工作。在等待的时候,让其他的程序运行会提高效率。 公平。多个用户或程序可能对系统资源有平等的优先级别。让他们通过更好的时间片 方式来共享计算机,这要比结束一个程序后才开始下一个程序更可取。 Java并发编程实践
2 第1章介绍 方便。写一些程序,让它们各自执行一个单独任务并进行必要的相互协调,这要比编 写一个程序来执行所有的任务更容易,更让人满意。 在早期的分时共享系统中,每一个进程都是一个虚拟的冯诺依曼(von Neumann)机: 它拥有一个内存空间,储存着指令和数据,根据机器语言的语义来顺序地执行指令,并且 通过操作系统的/O原语(primitive)集来实现与外部世界的交互。对于每一条指令的执 行,都有一个对“下一条指令”的明确定义,并根据程序中的指令集来进行流程的控制。 现在几乎所有广泛使用的编程语言都遵循这个顺序的编程模型,其中语言规范明确定义了 在一个给定动作完成后,下一个动作是什么。 顺序编程模型是自然的、常规的,就像是遵守着人类的工作方式:一次做一件事情, 顺序进行一通常如此。起床,穿上浴衣,下楼,开始准备早茶。在编程语言中,真实世 界中的每一个动作,都会抽象成一个规则的动作序列一打开食品柜,选择你喜欢的茶, 在罐里放入适量的茶叶,看看茶壶中是不是有足够多的水,如果不够就加些水,把茶壶放 在炉子上,打开炉子,等待水的沸腾等等。最后一步—等待水沸腾一也引入了异步这 个要点。当水在加热的时候,你可以选择做什么一一等待,或者开始准备吐丝面包(另一 个异步任务),还可以取一份报纸看,同时仍然要记得煮开水的壶马上就会需要你的关注。 开水和吐丝面包的生产者知道他们的产品通常在异步的情况下使用,所以在任务结束的时 候,它们会提高信号的音量。找到顺序和异步之间最好的平衡,通常是那些高效率人士的 一个特点一对于程序来说也是如此。 相同的关注点(资源利用,公平和方便)不仅促进了进程的发展,也促进了线程的发 展。线程允许程序控制流(control flow)的多重分支同时存在于一个进程。它们共享进程 范围内的资源,比如内存和文件句柄,但是每一个线程有其自己的程序计数器(program counter).、栈(stack)和本地变量。线程也为多处理器系统中并行地使用硬件提供了一个 自然而然的分解;同一程序内的多个线程可以在多CPU的情况下同时调度。 线程有些时候被称为轻量级进程(lightweight processes),并且大多数现代操作系统 把线程作为时序调度的基本单元,而不是进程。在没有明确协调的情况下,线程相互间同 时或异步地执行。因为线程共享其所属进程的内存地址空间,因此所有同一进程中的线程 访问相同的变量,并从同一个堆中分配对象,这相对于进程间通信(inter--process)机制来 说实现了良好的数据共享。但是如果没有明确的同步来管理共享数据,一个线程可能会修 改其他线程正在使用的数据,产生意外的结果。 Java并发编程实践
1.2线程的优点 ⊙ 1.2 线程的优点 恰当地使用线程时,可以降低开发和维护的开销,并且能够提高复杂应用的性能。线 程通过把异步的工作流程转化为普遍存在的顺序流程,使程序模拟人类工作和交互变得更 容易了。另一方面,它们可以把复杂、难以理解的代码转化为直接、简洁的代码,这样更 容易读写及维护。 线程在GUI应用程序中是非常有用的,可用来改进用户接口的响应性,并且在服务器 应用中,用于提高资源的利用率和吞吐量。它们也可以简化VM的实现一垃圾收集器 (garbage collector)通常运行于一个或多个持续工作的线程之间。大部分至关重要的Java 应用都依赖于线程,某种程度上是因为它们的组织结构需要这样。 1.2.1使用多处理器 多处理器系统以往比较昂贵、稀少,只用于大的数据中心和科学计算设备。如今,多 处理器系统已经比较便宜,数量也增多了;即使低端服务器和中端的桌面系统也常常采用 多处理器。这个趋势只会逐渐增加:因为处理器很难再提高它的时钟频率,取而代之的是, 处理器厂商会在一块芯片上放置更多的处理器内核。所有主要的芯片制造商都开始了这种 转变,并且我们已经显著地看到机器中处理器数量的增加。 因为程序调度的基本单元是线程,一个单线程应用程序一次只能运行在一个处理器 上。在双处理器系统中,一个单线程程序,放弃了其中一半的空闲CPU资源;在拥有100 个处理器的系统中,这个单线程程序放弃了99%的资源。从另一方面来看,拥有多个活跃 线程的程序可以同时在多处理器上运行。在设计良好的情况下,多线程程序通过更有效的 利用空闲处理器资源,来提高吞吐量。 使用多线程也可以帮助我们在单处理器系统中实现更佳的吞吐量。如果一个程序是单 线程的,这个处理器在等待一个同步/O操作完成的时候,仍然是空闲的。在一个多线程 程序中,当第一个线程等待O结束的同时,另外一个线程也可以运行,这样就使得应用 程序在遇到/O阻塞的时候仍然有进展。(这就像在等待水烧开的时间里面读报纸,优于 等待水开之后再去读报。) 1.2.2模型的简化 当你需要完成的任务全都是同一类型(修改12个bug)的时候,掌控你的时间通常比 完成多种类型的任务要容易(修改bug,面试系统管理员的替代候选人,完成你团队的效 率评估,为你下周的演讲制作幻灯片)。当你只有一种任务的时候,你可以从这堆任务的 第一个开始,逐一去做,直到将它们全部完成(或者你自己精疲力竭);你不 Java并发编程实践
4 第1章介绍 需要花精力去思考接下来去执行哪个任务。另一方面,管理多重优先级和截止日期,还要 在各任务之间切换,这通常会带来一些成本开销。 对于软件来说也是同样的道理:相对于需要同时管理多种类型的任务,一个顺序处理 相同类型任务的程序,写起来更简单,更少出错,也更容易测试。在模拟的情况下,为每 一个类型的任务分配一个线程,或者为每一个元素分配一个线程,提供理想上的顺序,并 且这样做可以把域逻辑(domain logic)与时序调度的细节隔绝开来,进行相互交替的操作, 进行异步/O,以及等待资源。一个复杂、异步的流程可以被分解为一系列更简单的同步 流程,它们中每一个在相互独立的线程中运行,只有在特定的同步点才进行彼此间的交互。 这些优点通常被一些框架(framework)所使用,比如Servlets或者远程方法调用(RMI, Remote Method Invocation)。这些框架需要处理请求管理,线程创建和负载均衡的细节, 与此同时还要处理不同部分转发到合适的组件的相应流程状态中去。Servlet的开发者不需 要担心容器究竟同时正在处理多少个请求,或者Socket的输入输出流是否阻塞;当一个 Servlet的Service方法作为Web请求的响应被调用时,它可以同步地处理这些请求,就像 它是一个单线程程序一样。这可以简化组件开发,并且可以使学习曲线变缓。 1.2.3 对异步事件的简单处理 一个服务器应用程序,接受来自多个远程客户端的连接,如果每一个连接服务器都为 其分配一个线程,并允许使用同步O,这样的程序开发起来更容易。 如果程序在读取Socket时没有可用数据,那么read方法会被阻塞直到有数据可用。 在一个单线程应用程序中,这不仅意味着处理相应的请求停止了,也意味着在线程阻塞期 间对所有请求的处理都停止了。为了避免这样的问题,单线程服务器程序被迫使用了非阻 塞/O,这要比同步O复杂得多,也更容易出错。然而,如果每一个请求都拥有自己的 线程,那么阻塞就不会影响到其他请求的处理了。 历史上,操作系统把一个进程能够创建的线程限制在相对比较少的数量上,大约有几百 个(甚至更少).因此,操作系统为多元化的VO开发了一些高效的机制,比如Unix的se1ect 和poll系统调用,为了访问这些机制,Java类库对非阻塞/O提供了一组包(java.nio)。 然而,今天的操作系统在支持更大数量的线程方面有了巨大的进步,这使得即使是在那些拥 有许多客户的平台上',“每线程每客户(thread-per-client)”模型也是现实的。 NPTL线程包,现在作为Liux发布的一部分,设计它的目的是用来支持数十万甚至于更多的线 程。非阻塞的O有它自身的优势,但是操作系统对线程更好的支持意味着出现更少的底层困境。 Java并发编程实践
1.3线程的风险 5 1.2.4用户界面的更佳响应性 GUI应用程序过去通常为单线程的,这意味着你要么通过大量输入事件频繁地测试整 个代码(这通常杂乱无章且麻烦),要么执行所有应用代码,间接地贯穿整个主事件循环 (main event loop)。如果从主事件循环中调用的代码执行的时间过长,那么直到代码执 行完毕,用户界面看上去都是冻结的,因为在控制权返回到主事件循环之前,程序无法执 行用户界面事件。 AWT和Swing工具集这样的现代GUI框架,用事件派发线程(event dispatch thread, EDT)取代了主事件循环。当一个用户界面事件发生时,比如按下一个按钮,事件线程会 调用程序定义的事件处理器。大部分GUI框架都是单线程化的子系统,所以主事件循环的 有效性仍然可以得到体现,但是它运行于它自身线程GUI toolkit的控制下,而并非受控于 应用程序。 在事件发生的线程中如果只有短暂的任务,那么界面总能够作出响应,因为事件线程总 能够及时有效地处理好用户的活动。然而,在事件线程中处理一个长期、耗时的任务,比如 一个大文档的拼写检查,或者从网络上获取一个资源,这会削减响应的效率。如果用户在这 个任务运行的时候发生了一个新的动作,事件线程能够开始处理甚至知晓这个动作都会被延 迟很久。雪上加霜的情况是,不仅UI失去了响应,而且用户很可能不能取消这个不偷快的任 务,即使程序提供了cancel按钮,因为事件线程正在忙碌工作,直到那个冗长的任务结束, 才能够开始处理cancel按钮的按下事件!但是,如果让这个耗时的任务运行在单独的线程里, 那么事件线程就能够自由地处理UI事件,使之具有更好的响应能力。 1.3 线程的风险 Jva对线程内置的支持是一把双刃剑。它通过提供语言和类库,以及一个规范的跨平 台存储模型(这个规范的存储模型使得在Java中开发“一次开发,随处运行(write-once, run-anywhere)”的并发程序成为可能),简化了并发应用的开发。这样做同时还提高了开 发人员的门槛,因为更多的程序需要使用线程。曾几何时,当线程还十分深奥的时候,并 发还是一个“高级”的话题:现在,主流的开发人员都必须知道线程安全性的问题。 1.3.1安全危险 线程安全的问题是微妙且出乎意料的,因为在没有进行充分同步的情况下,多线程中 的各个操作的顺序是不可预测的,有时甚至令人惊讶。清单1.1中,UnsafeSequence试 图生成一个唯一整数值的序列。下面提供了一个简单的插图来解释多线程中交替 (interleaving)的动作如何导致意外结果的。如果在单一线程的环境中,它能够正确运行, 但是在多线程环境中却不行。 Ja阳并发综程实践