This the multi-page printable view of this section. Click here to print.
JVM 并发
- 1: Java 并发实战
- 1.1: CH01-简介
- 1.2: CH02-线程安全性
- 1.3: CH03-对象共享
- 1.4: CH04-对象组合
- 1.5: CH05-基础构建块
- 1.6: CH06-任务执行
- 1.7: CH07-取消关闭
- 1.8: CH08-线程池
- 1.9: CH09-GUI应用
- 1.10: CH10-活跃性危险
- 1.11: CH11-性能与伸缩
- 1.12: CH12-测试
- 1.13: CH13-显式锁
- 1.14: CH14-自定义扩展
- 1.15: CH15-原子与非阻塞同步
- 1.16: CH16-内存模型
- 2: Java 并发模式
- 3: 深入理解并行
- 3.1: CH01-关于本书
- 3.2: CH02-简介
- 3.3: CH03-硬件特性
- 3.4: CH04-并行工具
- 3.5: CH05-计数
- 3.6: CH06-分割同步设计
- 3.7: CH07-锁
- 3.8: CH08-数据所有权
- 3.9: CH09-延后处理
- 3.10: CH10-数据结构
- 3.11: CH11-验证
- 3.12: CH12-形式验证
- 3.13: CH13-综合应用
- 3.14: CH14-高级同步
- 3.15: CH15-高级同步-内存序
- 3.16: ENDIX-C-内存屏障
- 4: 七并发模型
- 4.1: CH01-概述
- 4.2: CH02-线程与锁
- 4.3: CH03-函数式编程
- 4.4: CH04-分离标识与状态
- 4.5: CH05-Actor
- 4.6: CH06-CSP
- 4.7: CH07-数据并行
- 4.8: CH08-Lambda 架构
- 4.9: CH09-未来方向
1 - Java 并发实战
1.1 - CH01-简介
线程是 Java 语言中不可或缺的重要功能,它们能使复杂的异步代码更加简单,从而极大简化复杂系统的开发。此外,要想充分发挥多处理器的强大计算能力,最简单的方式就是使用线程。
1.1 并发简史
操作系统的出现使得计算机每次能运行多个程序,并且不同的程序都在单独的进程中运行:“操作系统为各个独立执行的进程分配各种资源,包括内存、文件句柄及安全证书等。”如果需要的话,在不同的进程之间可以通过一些粗粒度的通信机制来交换数据,包括:套接字、信号处理器、共享内存、信号量及文件等。
在计算机中加入操作系统来实现多个程序的同时执行,主要是为了:
- 资源利用率。如果在等待某些外部输入的同时能够运行另一个程序,可以提高资源的利用率。
- 公平性。高效的运行方式是通过粗粒度的时间片使多个用户能够共享计算机资源,而不是由一个程序运行到尾、然后再启动另一个程序。
- 便利性。通常在计算多个任务时应该编写多个程序,每个程序执行一个任务并在必要时进行互相通信,这比仅编写一个程序来计算所有任务来说要更容易实现。
在早期的“分时系统”中,每个进程相当于一台“虚拟的”冯诺依曼计算机,它拥有存储指令和数据的内存空间,根据机器语言的语义以串行方式执行指令,并通过一组 IO 指令与外部设备通信。对于每条被执行的指令,都有相应的“下一条指令”,程序中的“控制流”是按照指令集的规则来确定的。当前,几乎所有的主流编程语言都遵循这种“串行编程模型”,并且在这些语言的规范中也都清晰定义了在某个动作之后需要执行的“下一个动作”。
“串行编程模型”的优势在于其直观性和简单性,因为它模仿了人类的工作方式:每次只做一件事情,做完之后再做下一件。在编程语言中,这些现实世界的动作可以被进一步抽象为一组更细粒度的动作。例如,喝早茶的动作可以被进一步细化:打开橱柜,挑选喜欢的茶叶,将一些茶叶倒入杯中,看看茶壶中是否有足够的水,如果没有的话则添加足够的水,将茶壶放到火炉上,点燃火炉,然后等水烧开等等。在最后一步等水烧开的过程中包含了一定程度的“异步性”。当正在烧水时,你可以干等着,也可以做些其他事情,例如开始烤面包(这是一个异步任务)或者看报纸,同时留意茶壶中的水是否已经烧开。茶壶和面包机的生产商都很清楚:用户通常会采用异步方式来使用他们的产品,因此当这些机器完成任务时都会发出声音提示。“但凡做事高效的人,总能在串行性和异步性之间找到合理的平衡,对于程序来说同样如此。”
“这些促使进程出现的因素(资源利用率、公平性、便利性)同样也促使着线程的出现”。“线程允许在同一个进程内同时存在多个程序控制流”。线程会共享进程范围内的资源,但每个线程都有各自的程序计数器、栈、局部变量。线程还提供了一种直观的“分解模式”来充分利用多处理器系统中的“硬件并行性”,而在同一个程序中的多个线程也可以同时被“调度”到多个 CPU 上运行。
“线程被称为轻量级进程”。在大多数现代操作系统中,都是以线程为基本的“调度单位”,而不是进程。如果没有明确的“(线程间的)协同机制”,那么线程将彼此独立的运行。由于同一个进程中的所有线程都将共享进程的内存地址,因此这些线程都能访问相同的变量,并在同一个堆上分配对象,这就需要一种比在进程间共享数据粒度更细的“数据共享机制”。如果没有明确的“同步机制”来协同对共享数据的访问,那么当一个线程正在使用变量时,另一个线程可能同时访问这个变量,这将造成不可预测的结果。
1.2 线程的优势
如果使用得当,线程可以有效降低程序的开发和维护成本,同时提升应用程序的性能。“线程能够将大部分的异步工作流转换为串行工作流”,因此能够更好的模拟人类的工作和交互方式。此外,线程还可以降低代码的复杂度,使代码更容易编写、阅读、维护。
在 GUI 程序中,线程可以提供用户界面的响应灵敏度。而在服务器应用程序中,可以提升资源利用率及系统吞吐量。线程还可以简化 JVM 的实现,垃圾收集器通常在一个或多个专门的线程中运行。在许多重要的 Java 程序中,都在一定程度上使用了线程。
1.2.1 发挥多处理器的强大能力
过去,多处理器系统是非常昂贵和稀少的,通常只有在大型数据中心和科学计算设备中才会使用多处理器系统。但现在,多处理器系统整日益普及,并且价格也在不断的降低,即使在底端服务器和中端桌面系统中,通常也会采用多个处理器。这种趋势还将进一步加快,因为通过提高时钟频率来提升性能已变得越来越困难,处理器生产厂商都开始转而在单个芯片上放置多个处理器核心。所有的主流芯片厂商都开始了这种转变,而我们已经看到了在一些机器上出现了更多的处理器。
由于基本的调度单位是线程,因此如果在程序中只有一个线程,那么最多同时只能在一个处理器上运行。在双处理器系统上,单线程的程序只能使用一半的 CPU 资源,而在拥有 100 个处理器的系统上,将有 99% 的资源无法使用。另一方面,多线程程序可以同时在多个处理器上执行。如果设计正确,多线程程序可以通过提高处理器资源利用率来提升系统吞吐率。
使用多个线程还有助于在单处理器系统上获得更高的吞吐率。如果程序是单线程的,那么当程序等待某个同步 IO 操作完成时,处理器将处于空闲状态。而在多线程程序中,如果一个线程在等待 IO 操作完成,另一个线程可以继续运行,使得程序能够在 IO 阻塞期间继续运行。(这就好比在等待烧水的同时看报纸,而不是等到水烧开之后再开始看报纸)
1.2.2 建模的简单性
通常,当只需要执行一种类型的任务时,在时间管理方面比执行多种类型的任务要简单。当只有一种类型的任务需要完成时,只需要埋头工作,直到完成所有的任务,你不需要花任何精力来琢磨下一步该干什么。而另一方面,如果需要完成多种类型的任务,那么需要管理不同任务之间的优先级和执行时间,并在任务之间进行切换,这将带来额外的开销。
对于软件来说同样如此:如果在程序中仅包含一种类型的任务,那么比包含多种不同类型的任务的程序要更易于编写,错误更少,也更容易测试。如果为模型中每种类型的任务都分配一个专门的线程,那么可以形成一种串行执行的假象,并将程序的执行逻辑与调度机制的细节、交替执行的操作、异步 IO、资源等待等问题分类出来。通过使用线程,可以将复杂并且异步的工作流进一步分解为一组简单且同步的工作流,每个工作流在一个单独的线程中运行,并在特定的同步位置进行交互。
我们可以通过一些现有的框架来实现上述目标,例如 Servlet 和 RMI。框架负责解决一些细节问题,例如请求管理、线程创建、负载均衡,并且在正确的时刻将请求分发给正确的应用线程组件。编写 Servlet 的开发任务不需要了解有多少请求在同一时刻要被处理,也不需要了解套接字的输入输出流是否被阻塞。当调用 Servlet 的 service 方法来响应 Web 请求时,可以以同步的方式来处理这个请求,就好像它是一个单线程程序。这种方式可以简化组件的开发,并缩短掌握这种框架的学习时间。
1.2.3 异步事件的简化处理
服务器应用程序在接收来自多个远程客户端的套接字连接请求时,如果为每个连接都分配其各自的线程且使用同步 IO,那么会降低这类程序的开发难度。
如果某个应用程序对套接字执行读操作,而此时还没有数据到来,那么这个读操作将一直阻塞到有数据抵达。在单线程程序中,这不仅意味着在处理请求的过程中停顿,而且还意味着在该线程被阻塞期间,对所有请求的处理都将停顿。为了避免这个问题,单线程服务器应用程序必须使用非阻塞 IO,这种 IO 的复杂性要远远高于同步 IO,并且很容易出错。然而,如果每个请求都拥有自己的处理线程,那么在处理某个请求时发生的阻塞将不会影响其他请求的处理。
早期的操作系统通常会将进程中可创建的线程数量限制在一个较低的阈值内,大约在数百个甚至更少。因此,操作系统提供了一些高效的方法来实现多路 IO,例如 Unix 的的 select 和 poll 等系统调用,要调用这些方法,Java 类库需要获得一组实现非阻塞 IO 的包(java.nio)。然而,在现代操作系统中,线程数量已经得到极大的提升,这使得在某些平台上,即使有更多的客户端,为每个客户分配一个线程也是可行的。
1.2.4 响应灵敏的用户界面
传统的 GUI 程序都是单线程的,从而在代码的各个位置都需要调用 poll 方法来获得输入事件(这种方式将给代码带来极大的混乱),或者通过一个“主事件循环”来间接的执行应用程序的所有代码。如果在主事件循环中调用的代码需要很长时间才能执行完成,那么用户界面就会“冻结”,直到代码执行完成。这是因为只有当执行控制权返回到主事件循环后,才能处理后续的用户界面事件。
在现代的 GUI 框架中,例如 AWT 和 Swing 等工具,都采用一个事件分发线程来替代主事件循环。当某个用户界面事件发生时(如按下一个按钮),在事件线程中将调用应用程序的事件处理器。由于大多数 GUI 框架都单线程子系统,因此到目前为止仍然存在主事件循环,但其它线程处于 GUI 工具的控制下并在其自己的线程中运行,而不是在应用程序的控制下。
如果在事件线程中执行的任务都是短暂的,那么界面的响应灵敏度就较高,因为事件线程很够很快的处理用户的动作。然而,如果事件线程中的任务需要很长的执行时间,例如对一个大型文档进行拼写检查,或者从网络上获得一个资源,那么界面的响应灵敏度就会降低。如果用户在执行这类任务时触发了某个动作,那么必须等待很长时间才能获得响应,因为事件线程要先执行完该任务。更糟糕的是,不仅界面失去响应,而且即使在界面上包含了“取消”按钮,也无法取消这个长事件执行的任务,因为事件线程只有在执行完该任务后才能响应“取消”按钮的点击事件。然而,如果将这个长时间运行的任务放在一个单独的线程中运行,那么事件线程就能及时的处理其他界面事件,从而使得用户界面具有更高的灵敏度。
1.3 线程带来的风险
Java 对线程的支持其实是一把双刃剑。虽然 Java 提供了相应的语言和库,以及一种明确的“跨平台内存模型”,这些工具简化了并发应用程序的开发,但同时也提高了对开发人员的技术要求,因为在更多的程序中会使用线程。当线程还是一项鲜为人知的技术时,并发性是一个“高深的”话题,但现在,主流开发任务都必须了解线程方面的内容。
1.3.1 安全性问题
线程安全性是非常复杂的,在没有充分同步的情况下,多个线程中的操作执行顺序是不可预测的,甚至会产生奇怪的执行结果。如程序清单 1-1 的 UnsafeSequence 类中将产生一个整数值序列,该序列中的每个值都是唯一的。在这个类中简要说明了多个线程间的交替操作将如何导致出乎预料的结果。在单线程环境中,这个类能够正常工作,但在多线程环境中则不行。
@NotThreadSafe
public class UnsafeSequnce {
private int value;
/* 返回一个唯一的数值 */
public int getNext() {
return value++;
}
}
UnsafeSequnce 的问题在于,如果执行时机不对,那么两个线程在调用 getNext 时会得到相同的值。在图 1-1 中给出了这种错误情况。虽然递增运算 someVariable++
看上去是单个操作,但事实上包含 3 个独立的操作:读取 value、将 value 加一、将结果写入 value。由于运行时可能将多个线程之间的操作交替执行,因此这两个线程可能同时执行读操作,从而使他们得到相同的值,并都将该值加 1。结果就是,在不同线程中的调用返回了相同的数值。
在 UnsafeSequnce 中使用了一个非标准的注解:@NotThreadSafe
。这是在本书中使用的几个自定义注解之一,用于说明类和成员的并发属性。线程安全性注解在许多方面都是有用的。如果使用 @ThreadSafe 类标注某个类,那么开发人员可以放心的在多线程环境中使用该类,维护人员也会发现它能保证线程安全性,而软件分析工具还可以识别出潜在的编码错误。
在 UnsafeSequnce 类中说明的是一种常见的并发安全问题,称为竟态条件(Race Condition)。在多线程环境下,getValue 是否返回唯一的值,要取决于运行时对线程中操作的交替执行方式,这并不是我们希望看到的情况。
由于多线程有共享相同的内存地址空间,并且是并发运行,因此它们可能会访问或修改其他线程正在使用的变量。当然,这是一种极大的便利,因为这种方式比其他线程间通信机制更容易实现数据共享。但它同样也带来的巨大的风险:线程会由于无法预料的数据变化而发生错误。当多个线程同时访问和修改相同的变量时,将会在串行模型编程中引入非串行因素,而这种非串行性是很难分析的。要使多线程程序的行为可以被预测,必须对共享变量的访问操作进行协同,这样才不会在线程之间发生彼此干扰。幸运的是,Java 提供了各种同步机制来协同这种访问。
通过将 getNext 修改为一个同步方法,可以修复 UnsafeSequnce 中的错误,如程序清单 1-2 中的 Sequnce,该类可以防止图 1-1 中错误的交替执行情况。
@ThreadSafe
public class Sequence {
@GuardedBy("this") private int value;
public synchronized int getNext() {
return value++;
}
}
如果没有同步,那么无论是“编译器、硬件、运行时”,都可以随意安排操作的执行时间和顺序,例如对寄存器或处理器中的变量进行缓存,而这些被缓存的变量对于其他线程来说是暂时的(甚至永久)不可见的。虽然这些技术有助于实现更优的性能,并且通常也是值得采用的方法,但这也给开发人员带来了负担,因为开发人员必须找出这些数据在那些位置被多个线程共享,只有这样才能使这些优化措施不破坏线程安全性。
1.3.2 活跃性问题
在编写并发代码时,一定要注意线程安全性是不可破坏的。安全性不仅对于多线程程序很重要,对于单线程程序也是如此。此外,线程还会导致一些在单线程程序中不会出现的问题,比如活跃性问题。
安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注于另一个目标,即“某件正确的事情最终会发生”。当某个操作无法继续执行下去时,就会发生活跃性问题。在串行程序中,活跃性问题的形式之一就是无意中造成的无限循环,从而使循环之后的代码无法得到执行。线程将到来其他一些活跃性问题。如,如果线程 A 在等待线程 B 释放其持有的资源,而线程 B 永远都不释放该资源,那么 A 就会永久的等待下去。与大多数并发性错误一样,导致活跃性问题的错误同样是难以分析的,因为它们依赖于不同线程的事件发生时序,因此在开发或测试中并不总是能够重现。
1.3.3 性能问题
与活跃性密切相关的是性能问题。活跃性意味着某件正确的事情最终会发生。但这还不够好,我们通常希望正确的事情尽快发生。性能问题包括多个方面,如服务时间过长、响应不够灵敏、吞吐率过低、资源消耗过高、可伸缩性较低等。与安全性和活跃性一样,在多线程程序中不仅存在与单线程程序相同的性能问题,而且还存在由于使用线程而引入的其他性能问题。
在设计良好的并发程序中,线程能够提升程序的性能,但无论如何,线程总会带来某种程度的运行时开销。在多线程程序中,当线程调度器临时挂起活跃线程转而运行另一个线程时,就会频繁的出现上下文切换,这种操作将带来极大的开销:保存和恢复执行上下文、丢失现场,并且 CPU 时间将更多的花在线程调度而不是线程运行上。当线程共享数据时,必须使用同步机制,而这些机制往往会抑制某些编译器优化,使内存缓存区中的数据无效,以及增加共享内存总线的同步流量。所有这些因素都将带来额外的性能开销。
1.4 线程无处不在
即使在程序中没有显式的创建线程,但在框架中人可能会创建线程,因此在这些线程中调用的代码同样必须是线程安全的。这将给开发人员在设计和实现上带来沉重负担,因为开发线程安全的类比开发非线程安全的类需要更加谨慎和细致。
每个 Java 程序都会使用线程。当 JVM 启动时,它将为 JVM 的内部任务(如垃圾回收、终结操作)创建后台线程,并创建一个主线程来运行 main 方法。AWT 和 Swing 的用户界面框架将创建线程来管理用户界面事件。Timer 将创建线程来执行延时任务。一些组件框架,如 Servlet 和 RMI,都会创建线程池并调用这些线程中的方法。
如果要使用这行功能,那么就必须熟悉并发性和线程安全性,因为这些框架将创建线程并且在这些线程中调用程序中的代码。虽然将并发性认为是一种“可选的”或“高级的”语言功能固然很理想,但现实情况是,几乎所有的 Java 程序都是多线程的,因此在使用这些框架时仍然需要对应用程序状态的访问进行协同。
当某个框架在应用程序中引入并发性时,通常不可能将并发性仅仅局限于框架代码,因为框架本身会回调应用程序代码,而这些代码将访问应用程序的状态。同样,对线程安全性的需求也不能局限于被调用的代码,而是要延伸到需要访问这些代码所访问的程序状态的所有代码路径。因此,对线程安全性的需求将在程序中蔓延开来。
下面给出的模块都将在应用程序之外的线程中调用应用程序的代码。尽管线程安全性需求可能源自这些模块,但却不会止步于它们,而是会延伸到整个应用程序。
Timer。Timer 类的作用是使任务在稍后的时刻运行,运行一次或周期性的运行。引入 Timer 可能会使串行程序变得复杂,因为 TimerTask 访问了应用程序中其他线程访问的数据,那么不仅 TimerTask 需要以线程安全的方式来访问数据,其他类也必须采用线程安全的方式来访问该数据。通常,要实现这个目标,最简单的方式是确保 TimerTask 访问的对象本身是线程安全的,从而就能把线程安全性封装在共享对象内部。
Servlet 和 JSP。Servlet 框架用于部署网页应用程序以及分来来自 HTTP 客户端的请求。到达服务器的请求可能会通过一个过滤器链被分发到正确的 Servlet 或 JSP。每个 Servlet 都表示一个程序逻辑组件,在高吞吐率的网站中,多个客户端可能同时请求一个 Servlet 的服务。在 Servlet 规范中,Servlet 同样需要满足被多个线程同时调用,换句话说,Servlet 需要是线程安全的。
即使你可以确保每次只有一个线程调用某个 Servlet,但在构建网页应用程序时仍然必须注意线程安全性。Servlet 通常会访问与其他 Servlet 共享的信息,例如应用程序中的对象或者会话中的对象。当一个 Servlet 访问在多个 Servlet 或者请求中共享的对象时,必须正确的协同对这些对象的访问,因为多个请求可能在不同的线程中同时访问这些对象。Servlet 和 JSP,以及在 ServletContext 和 HttpSession 等容器中保存的 Servlet 过滤器和对象等,都必须是线程安全的。
远程方法调用,RMI。RMI 使代码能够调用位于其他 JVM 中运行的对象。当通过 RMI 调用某个远程方法时,传递给方法的参数必须被打包到一个字节流中,通过网络传输给远程 JVM,然后由远程 JVM 拆包并传递给远程方法。
当 RMI 代码调用远程对象时,这个调用将在哪个线程中执行?你并不知道,但肯定不会在你创建的线程中,而是将在一个由 RMI 管理的线程中调用对象。RMI 会创建多给少个线程?同一个对象上的同一个远程方法会不会在多个 RMI 线程中被同时调用?
远程对象必须注意两个线程安全性问题:正确的协同在多个对象中共享的状态,以及对远程对象本身状态的访问。与 Servlet 相同,RMI 对象应用做好被多个线程同时调用的准备,并且必须确保它们自身的线程安全性。
Swing 和 AWT。GUI 程序的一个固有属性就是异步性。用户可以在任何时刻选择执行一个菜单项或按下一个按钮,应用程序会及时响应,即使应用程序当时正在执行其他的任务。Swing 和 AWT 很好的解决了这个问题,他们创建了一个单独的线程来处理用户触发的事件,并对呈现给用户的图形界面进行了更新。
Swing 的一些组件并不是线程安全的,例如 JTable。相反,Swing 程序通过将所有对 GUI 组件的访问局限在事件线程以实现线程安全性。如果某个程序希望在事件线程之外控制 GUI,那么必须将控制 GUI 的代码放在事件线程中运行。
当用户触发某个 UI 动作时,在事件线程中就会有一个事件处理器被调用以执行用户请求的操作。如果事件处理器需要访问由其他线程同时访问的应用程序状态,那么这个事件处理器,以及访问这个状态的所有其他代码,都必须采用一种线程安全的方式来访问该状态。
1.2 - CH02-线程安全性
你或许会感到奇怪,线程或锁在并发编程中的作用,类似于铆钉和工字梁在土木工程中的作用。要建造一座坚固的桥梁,必须正确的使用大量的铆钉和工字梁。同理,在构件稳健的并发程序时,必须正确的使用线程和锁。但这些终归是一些机制。要编写线程安全的代码,“其核心在于对状态访问操作进行管理,特别是对共享的可变状态的访问”。
从非正式的意义上来说,对象的状态是存储在状态变量中的数据。对象的状态可能包括其他依赖对象的域。例如,HashMap 的状态不仅存储在 HashMap 对象本身,还存储在 Map.Entry 对象中。对象的状态中包含了任何可能影响其外部可见行为的数据。
“共享”意味着变量可以被多个线程同时访问,而“可变”则意味着变量的值在其声明周期内可以发生变化。我们将像讨论代码一样来讨论线程安全性,但更侧重于如何防止在数据上发生不受控的并发访问。
一个对象是否需求提供线程安全性,取决于它是否会被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。
当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java 中的主要同步机制是关键字 synchronized,它提供了一种独占的加锁方式,但“同步”这个术语还包括 volatile 类型的变量、显式锁、原子变量。
在上述规则中并不存在一些“想象中的例外情况”。即使在某个程序中省略了必要的同步机制并且看上去似乎能正确执行,而且通过了测试并在随后几年时间里都能正确运行,但程序仍可能在某个时刻发生错误。
如果当多个线程访问同一个可变状态变量时没有使用合适的同步,那么程序会出现错误。有三种方式可以修复这个问题:
- 不在线程之间共享该状态变量。
- 将状态变量修改为不可变变量。
- 在访问状态变量时使用同步。
如果在设计类的时候没有考虑并发访问的情况,那么在采用上述方法时可能需要对设计进行重大修改,因此要修复这个问题可谓是知易行难。如果从一开始就设计一个线程安全的类,那么比在以后再将这个类修改为线程安全的类要容易的多。
在一些大型程序中,要找出多个线程在哪些位置上将访问同一个变量是非常复杂的。幸运的是,面向对象这种技术不仅有助于编写出结构优雅、可维护性高的类,还有助于编写出线程安全的类。访问某个变量的代码越少,就越容易确保对变量的所有访问都正确的实现了同步,同时也更容易找出变量在哪些条件下被访问。Java 语言并没有强制要求将状态都封装在类中,开发人员完全可以将状态保存在某个公开的域(甚至是公开的静态域)中,或者提供一个对内部对象的公开引用。然而,程序状态的封装性越好,就越容易实现程序的线程安全性,并且代码的维护人员也越容易保持这种方式。
当设计线程安全的类时,良好的面向对象技术、不可修改性,以及明晰的不变性规范都能起到一定的帮助作用。
在某些情况下,良好的面向对象设计技术与实际的需求并不一致。在某些情况下,可能需要牺牲一些良好的设计原则,以换取性能或者对遗留代码的向后兼容性。有时候,面向对象中的抽象和封装会降低程序的性能,但是在编写并发应用程序时,一种正确的编程方法就是:“首先使代码正确运行,然后再提高代码的运行速度”。即便如此,最好也只是当性能测试结果和应用需求告诉你必须提高性能,以及测量结果表明这种优化在实际环境中确实能够带来提升时,才进行优化。
如果你必须打破封装,那么也并非不可,你仍然可以实现程序的线程安全性,只是更困难。而且,程序的线程安全性将更加脆弱,不仅增加了成本和风险,而且也增加了维护的成本和风险。
到目前为止,我们使用了“线程安全类”和“线程安全程序”两个术语,二者的含义基本相同。线程安全的程序是否完全由线程安全的类构成?答案是否定的,完全由线程安全的类构成的程序并不一定是一个线程安全的程序,而在线程安全的程序中也可以包含非线程安全的类。在任何情况下,“只有当类中仅包含自己的状态时,线程安全类才是有意义的”。线程安全性是一个在代码上使用的术语,但它只是与状态相关的,因此只能应用于封装其状态的整个代码,这可能是一个对象,也可能是整个程序。
2.1 什么是线程安全性
要对线程安全性给出一个确切的定义是非常复杂的。定义越正式,就越复杂,不仅很难提供有实际意义的指导建议,而且也很难从直观上去理解。因此,下面给出了一些非正式的描述,看上去令人困惑。比如:
- ……可以在多个线程中调用,并且在线程之间不会出现错误的交互。
- ……可以同时被多个线程调用,而调用者无需执行额外的同步动作。
看看这些定义,难怪我们会对线程安全性感到困惑。他们听起来非常像“如果这个类可以在线程中安全的使用,那么他就是一个线程安全的类”。对于这种说法,虽然没有太多的争议,但同样也不会带来太多的帮助。我们如何区分线程安全的类以及非线程安全的类?进一步说,“安全”的含义是什么?
在线程安全性的定义中,最核心的概念是正确性。如果对线程安全的定义是模糊的,那么就是因为缺乏对正确性的清晰定义。
“正确性的定义是,某个类的行为与其规范完全一致”。在良好的规范中通常会定义各种不变性条件来约束对象的状态,以及定义各种后验条件来描述对象操作的结果。由于我们通常不会对类编写详细的规范,那么如何知道这些类是正确的呢?我们无法知道,但这并不妨碍我们在确信“类的代码能够工作”后使用它们。这种“代码可信性”非常接近于我们对正确性的理解,因此我们可以将单线程的正确性近似定义为“所见即所得”。在对“正确性”给出一个较为清晰的定义后,就可以定义线程安全性:“当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。”
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
由于单线程程序也可以被看成是一个多线程程序,如果某个类在单线程环境中都不是正确的,那么它肯定也不是线程安全的。如果正确的实现了某个对象,那么在任何操作中(包括调用对象的公有方法或者对其公有域进行读写操作)都不会违背不变性条件或后验条件。在线程安全类的对象实例上执行的任何串行或并行操作都不会使对象处于无效状态。
在线程安全类中封装了必要的同步机制,因此客户端无需进一步采取同步措施。
示例:一个线程安全的 Servlet
我们在第一章中列出了一组框架,其中每个框架都能创建多个线程并在这些线程中调用你编写的代码,因此你需要保证编写的代码是线程安全的。通常,线程安全性的需求并非来源于线程的直接调用,而是使用像 Servlet 这样的框架。我们来看一个简单的示例——一个基于 Servlet 的因数分解服务,并逐渐扩展它的功能,同时确保它的线程安全性。
程序清单 2-1 给出了一个简单的因素分解 Servlet。这个 Servlet 从请求中提取出数值,执行因数分解,然后将结果封装到该 Servlet 的响应中。
@ThreadSafe
public class StatelessFactorizer implements Servlet {
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp, factors);
}
}
与大多数 Servlet 相同,StatelessFactorizer 是无状态的:它既不包含任何域,也不包含任何对其他类的域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。访问 StatelessFactorizer 的线程不会影响另一个访问同一个 StatelessFactorizer 的线程的计算结果,因为这两个线程并没有共享状态,就好像它们都在访问不同的实例。由于线程访问无状态对象的行为不会影响其他线程中操作的正确性,因此无状态对象是线程安全的。
无状态对象一定是线程安全的。
大多数 Servlet 都是无状态的,从而极大降低了在实现 Servlet 线程安全性时的复杂性。只有当 Servlet 在处理请求时需要保存一些信息,线程安全性才会成为一个问题。
2.2 原子性
当我们在无状态对象中增加一个状态时,会出现什么状况?假设我们希望增加一个“命中计数器”来统计所处理的请求数量。一种直观的方法是在 Servlet 中增加一个 long 类型的域,并且每处理一个请求就将该值加 1,如程序清单 2-2 所示:
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
public long count = 0;
public long getCount() { return count; }
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
++count;
encodeIntoResponse(factors);
}
}
不幸的是,UnsafeCountingFactorizer 并非是线程安全的,尽管它在单线程环境中能正确运行。与前面的 UnsafeSequence 一样,这个类很可能会丢失一些更新操作。虽然递增操作 ++count
是一种紧凑的语法,使其看上去只是一个操作,但是该操作并非是原子的,因而它并不会作为一个不可分割的操作来执行。实际上,它包含了三个独立的操作:读取 count 值、将值加 1、然后将计算结果写入 count。这是一个“读取——修改——写入”的操作序列,并且其结果状态依赖于之前的状态。
图 1-1 给出了两个线程在没有同步的情况下同时对一个计数器执行递增操作时发生的情况。如果计数器的初始值为 9,那么在某些情况下,每个线程读到的值都为 9,接着执行递增操作,并且都将计数器的值设为 10。显然,这并不是我们希望看到的结果,如果有一次递增操作丢失了,命中计数器的值就将偏差 1。
你可能会认为,在基于 Web 的服务中,命中计数器的少量偏差或许是可以接受的,在某些情况下也确实如此。但如果计数器被用来生成数值序列或唯一的对象标识符,那么在多次调用中返回相同的值将导致严重的数据完整性问题。在并发编程中,这种由于不恰当的执行时序而出现不正确结果是一种非常重要的情况,他有一个正式的名字:竟态条件。
2.2.1 竟态条件
在 UnsafeCountingFactorizer 中存在多个竟态条件,从而使结果变得不可靠。当某个计算的正确性取决于多个线程的交替执行时序时,那么就会出现竟态条件。换句话说,就是正确的结果要取决于运气。最常见的竟态条件类型是“先检查后执行(Check-Then-Act)”操作,即通过一个可能失效的观测结果来决定下一步的动作。
在实际情况中经常会遇到竟态条件。例如,假定你计划中午在 University Avenue 的星巴克与一位朋友烩面。但当你达到那里时,发现在 University Avenue 上有两家星巴克,并且你不知道约定的是哪一家。在 12:10 时,你没有在星巴克 A 看到朋友,那么就会去星巴克 B 看看他是否在那里,但他也不在那里。这有几种可能:你的朋友迟到了,还没有抵达任何一家星巴克;你的朋友在你离开后到了星巴克 A;你的朋友在星巴克 B,但是当它去星巴克 A 找你时,你此时正在赶往星巴克 B 的途中。我们假设的最后一种情况最为糟糕。现在是 12:15,你们两个都去过了两家星巴克,并且都开始怀疑对方失约了。现在你会怎么做?回到另一家星巴克?来来回回要走多少次?除非你们之间约定了某种协议,否则你们整天都在 University Avenue 上走来走去,倍感沮丧。
在“我去看看他是否在另一家星巴克”这种方法中,问题在于:当你在街上走时,你的朋友可能已经离开了你要去的星巴克。你首先看了看星巴克 A,发现“他不在”,并且开始去找他。你可以在星巴克 B 中做同样的选择,但不是同时发生。两家星巴克之间有几分钟的路程,而就在这几分钟时间里,系统的状态可能会发生变化。
在星巴克这个示例中说明了一种竟态条件,因为要获得正确的结果(与朋友会面),必须取决于事件的发生时序(当你们到达星巴克时,在离开去另一家星巴克之前会等待剁成事件……)。当你迈出前门时,你在星巴克 A 的观察结果将变得无效,你的朋友可能从后门进来了,而你却不知道。这种观察结果的失效就是大多数竟态条件的本质——“基于一种可能失效的观察结果来做出判断或者执行某个计算”。这种类型的竟态条件被称为“先检查后执行”:首先观察到某个条件为真,然后根据观察结果采用相应的动作,但事实上,在你观察到这个结果以后以及开始执行动作之前,观察结构可能变得无效,从而导致各种问题。
竟态条件这个术语经常与另一个相关术语“数据竞争(Data Race)”相混淆。数据竞争是指,如果在访问共享的非 final 类型的域时没有采用同步来进行协同,那么就会出现数据竞争。当一个线程写入一个变量而另一个线程接下来读取这个变量,或者读取一个之前由另一个线程写入的变量时,并且在这两个线程之间没有使用同步,那么就可能出现数据竞争。在 Java 内存模型中,如果代码中存在数据竞争,那么这段代码就没有确定的语义。并非所有竟态条件都是数据竞争,同样并非所有的数据竞争都是竟态条件,但二者都可能使并发程序失败。
2.2.2 示例:延迟初始化中的竟态条件
使用“先检查后执行”的一种常见情况就是“延迟初始化”。延迟初始化的目的是将对象的初始化操作推迟到第一次实际被使用时才进行,同时要确保只被初始化一次。在程序清单 2-3 中的 LazyInitRace 说明了这种延迟初始化情况。getInstance 方法首先判断 ExpensiveObject 是否已经被初始化,如果已经初始化则返回现有的实例,否则他将创建一个新的实例并返回一个引用,从而在后来的调用中值无需执行这段高开销的代码路径。
@NotThreadSafe
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance() {
if(instance == null)
intstance = new ExpensiveObject();
return instance;
}
}
LazyInitRace 中包含一个竟态条件,他可能会打破这个类的正确性。假定线程 A 和 B 同时执行 getInstance。A 看到 instance 为空,因而创建一个新的 ExpensiveObject 实例。B 同样需要判断 instance 是否为空。此时的 instance 是否为空,要取决于不可预测的时序,包括线程的调度方式,以及 A 需要花多长时间来初始化 ExpensiveObject 并设置 instance。如果当 B 检查时 instance 为空,那么在两次调用 getInstance 时可能会得到不同的结果,即使 getInstance 通常被认为是返回相同的实例。
在 UnsafeCountingFactorizer 的统计命中计数操作中存在另一种竟态条件。在“读取——修改——写入”这种操作中,基于对象之前的状态来定义对象状态的转换。要递增一个计数器,你必须知道它之前的值,并确保在执行更新过程中没有其他线程会修改或使用这个值。
与大多数并发错误一样,竟态条件并不总是会产生错误,还需要某种不恰当的执行时序。然而,竟态条件也可能导致严重的问题。假定 LazyInitRace 被用于初始化应用程序范围内的注册表,如果在多次调用中返回不同的实例,那么要么会丢失部分注册信息,要么多个执行行为对同一个组注册对象表现出不一致的视图。如果将 UnsafeSequence 用于在某个持久化框架中生成对象的标识,那么两个不同的对象最终将获得相同的标识,这就违反了标识的完整性约束条件。
2.2.3 复合操作
LazyInitRace 和 UnsafeCountingFactorizer 都包含一组需要以原子方式执行(不可分割)的操作。要避免竟态条件问题,就必须在某个线程修改变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,从而避免在修改状态的过程中观察到失效状态。
假定两个操作 A 和 B,如果从执行 A 的线程来看,当另一个线程执行 B 时,要么将 B 全部执行完,要么完全不执行 B,那么 A 和 B 对彼此来说是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。
如果 UnsafeSequence 中的递增操作是原子操作,那么图 1-1 中的竟态条件就不会发生,并且递增操作在每次执行时都会把计数器加 1。为了确保线程安全性,“先检查后执行”和“读取—修改-写入”等操作都必须是原子的。我们将“先检查后执行”以及“读取—修改-写入”等操作统称为“复合操作”:“包含了一组必须以原子方式执行的操作以确保线程安全性”。在 2.3 节中,我们将介绍锁机制,这是 Java 中用于确保原子性的内置机制。就目前而言,我们先采用另一种凡事来修复这个问题,即使用一个现有的线程安全类,如程序清单 2-4 中的 CountingFactorizer 所示:
@ThreadSafe
public class CountingFactorizer implements Servlet {
private final AtomicLong count = new AtomicLong(0);
public long getCount() { return count.get(); }
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
count.incrementAndGet();
encodeIntoResponse(factors);
}
}
在 java.util.concurrent.atomic 包中包含了一些原子变量类,用于实现在数值的对象引用上的原子状态转换。通过用 AtomicLong 来代替 long 类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。由于 Servlet 的状态就是计数器的状态,并且计数器是线程安全的,因此这里的 Servlet 也是线程安全的。
我们在因数分解的 Servlet 中增加了一个计数器,并且通过使用线程安全类 AtomicLong 来管理计数器的状态,从而确保了代码的线程安全性。挡在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。然而,在 2.3 节你将看到,当状态变量的数量不再是一个时,并不会像仅有一个状态变量时那么简单。
在实际情况中,应尽可能的使用现有的线程安全对象来管理状态。与非线程安全对象相比,判断线程安全对象的状态空间及其状态迁移情况要更为容易,从而也更容易维护和验证线程安全性。
2.3 加锁机制
当在 Servlet 中添加一个状态变量时,可以通过线程安全的对象来管理 Servlet 的状态以维护 Servlet 的线程安全性。但如果想在 Servlet 中添加更多的状态,那么是否只需要添加更多的线程安全状态变量就足够了?
假设我们希望提升 Servlet 的性能:将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果,而无需重新计算。要实现该策略,需要保存两个状态:最近执行因数分解的数值,和分解结果。
我们曾通过 AtomicLong 以线程安全的方式来管理计数器的状态,那么,在这里是否可以使用类似 AtomicReference 来管理最近执行因数分解的数值及其分解结果吗?在程序清单 2-5 中的 UnsafeCachingFactorizer 实现了这种思想。
@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>();
private fianl AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extactFromRequest(req);
if(i.equals(lastNumber.get())) {
encodeIntoResponse(resp, lastFactors.get());
}
else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors);
encodeIntoResponse(factors);
}
}
}
然而,这种方法并不正确。尽管这些原子引用本身都是线程安全的,但在 UnsafeCachingFactorizer 中存在着竟态条件,这可能产生错误的结果。
在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏。UnsafeCachingFactorizer 的不变性条件之一是:在 lastFactors 中缓存的因数之积应该等于在 lastNumber 中缓存的数值。只有确保了这个不变性条件不被破坏,上面的 Servlet 才是正确的。当在不变性条件中涉及到多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某个变量时,需要在同一个原子操作中对其他变量同时进行更新。
在某些执行时序中,UnsafeCachingFactorizer 可能会破坏这个不变性条件。在使用原子引用的情况下,尽管对 set 方法的每次调用都是原子的,但仍然无法同时更新 lastNumber 和 lastFactors。如果只修改了其中一个变量,那么在两次修改操作之间,其他线程发现不变性条件被破坏了。同样,我们也不能保证会同时获取两个值:在线程 A 获取两个值的过程中,线程 B 可能修改了它们,这样线程 A 就发现不变性条件被破坏了。
要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
2.3.1 内置锁
Java 提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包含两个部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字 synchronized 来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的 synchronized 方法以 Class 对象作为锁。
synchronized (lock) {
// 访问或修改由锁保护的共享状态
}
每个 Java 对象都可以用作一个实现同步的锁,这些所被称为内置锁或监视器锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
Java 的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当线程 A 尝试获取一个右线程 B 持有的锁时,线程 A 必须等待或阻塞,直到线程 B 释放这个锁。如果 B 永远不释放锁,那么 A 也将永远的等下去。
由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会互相干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义——“一组语句作为一个不可分割的单元被执行”。任何一个执行同步代码块的线程,都不可能看到其他线程正在执行由同一个锁保护的同步代码块。
这种同步机制使得要确保因数分解 Servlet 的线程安全性变得更加简单。在程序清单 2-6 中使用了关键字 synchronized 来修饰 service 方法,因此在同一时刻只有一个线程可以执行 service 方法。现在的 SynchronizedFactorizer 是线程安全的。然而,这种方法却过于极端,因为多个客户端无法同时使用因素分解 Servlet,服务的响应性非常低,令人无法接受。这是一个性能问题,而非线程安全问题,我们将在 2.5 中解决该问题。
@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
@GuardBy("this") private BigInteger lastNumber;
@GuardBy("this") private BigInteger[] lastFactors;
public synchronized void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extactFromRequest(req);
if(i.equals(lastNumber)) {
encodeIntoResponse(resp, lastFactors);
}
else {
BigInteger[] factors = factor(i);
lastNumber = i;
lastFactors = factors;
encodeIntoResponse(factors);
}
}
}
2.3.2 重入
当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,“由于内置锁是可重入的”,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”(同一个线程可以发起多次调用)。重入的一种实现方式是,为每个锁关联一个获取计数值和一个拥有者线程。当计数器为 0 时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM 将记录锁的持有者,并且将获取计数值设为 1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应的递减。当计数值为 0 时,这个锁将被释放。
“重入”进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。在程序清单 2-7 的代码中,子类改写了父类的 synchronized 方法,然后调用父类中的方法,此时如果没有可重入锁,那么这段代码将产生死锁。由于 Widget 和 LoggingWidget 中 doSomething 方法都是 synchronized 方法,因此每个 doSomething 方法在执行前都会获取 Widget 上的锁。然而,如果内置锁不是可重入的,那么在调用 super.doSomething 时将无法获得 Widget 上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁。重入则避免了这种死锁的发生。
public class Widget {
public synchronized void doSomething() {...}
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}
2.4 用锁来保护状态
由于锁能使其保护的代码路径以“串行形式”被访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。
访问共享状态的复合操作,例如命中计数器的递增操作(读取-修改-写入)或者延迟初始化(先检查后执行),都必须是原子操作以避免产生竟态条件。如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步块。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。
一种常见的错误是认为,只有在写入共享变量时才需要同步,然而事实并非如此。
对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量由这个锁保护。
在程序清单 2-6 的 SynchronizedFactorizer 中,lastNumber 和 lastFactors 这两个变量都是由 Servlet 对象的内置锁来保护的,在注解 @GuardBy 中也已经说明了这一点。
对象的内置锁与其状态之间没有内置的关联。虽然大多数类都将内置锁用作一种有效的加锁机制,但对象的域并不一定要通过内置锁类保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式的创建锁对象。你需要自行构造加锁协议或者同步策略来实现对共享状态的安全访问,并且在程序中至始至终的使用它们。
每个共享的可变的变量都应该由一个锁来保护,从而使维护人员知道是哪一个锁。
一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有的访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多多线程安全类中都使用了这种模式,例如 Vector 和其他的同步集合类。在这种情况下,对象状态中的所有变量都由对象的内置锁保护起来。然而,这种模式并没有任何特殊之处,编译器或运行时都不会强制实施这种(或其他)模式。如果在添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议会很容易被破坏。
并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。第一章曾介绍过,当添加一个简单的异步事件时,例如 TimerTask,整个程序都需要满足线程安全性要求,尤其是当程序状态的封装性比较糟糕时。考虑一个处理大规模数据的单线程程序,由于任何数据都不会在多个线程之间共享,因此在单线程程序中不需要同步。现在,假设希望添加一个功能,即定期对数据处理进度生成快照,这样当程序崩溃或必须停止时无需再次从头开始。你可能会选择使用 TimerTask,每十分钟触发一次,并将程序状态保存到一个文件中。
由于 TimerTask 在另一个(由 Timer 管理的)线程中调用,因此现在就有两个线程同时访问快照中的数据:程序的主线程与 Timer 线程。这意味着,当访问程序的状态时,不仅 TimerTask 代码必须使用同步,而且程序中所有访问相同数据的代码路径也必须使用同步。原本在程序中不需要使用同步,现在变成了在程序的各个位置都需要使用同步。
当某个变量由锁来保护时,意味着在每次访问这个变量时都需要首先获得锁,这样就确保在同一时刻只有一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时,那么还有另一个需求:在不变性条件中的每个变量都需要由同一把锁来保护。因此可以在单个原子操作中访问或更新所有这些变量,从而确保不变性条件不被破坏。在 SynchronizedFactorizer 类中说明了这条规则:缓存的数值和因数分解结果都由 Servlet 对象的内置锁来保护。
对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
如果同步可以避免竟态条件问题,那么为什么不在每个方法声明时都使用关键字 synchronized ?事实上,如果不加区别的滥用 synchronized,可能导致程序中出现过多的同步。此外,如果只是将每个方法都作为同步方法,例如 Vector,那么并不足以确保 Vector 上复合操作都是原子的:
if(!vector.contains(element))
vector.add(element);
虽然 contains 和 add 等方法都是原子的,但在上面这个“如果不存在则添加(put-if-absent)”的操作中仍然存在竟态条件。虽然 synchronized 方法可以确保单个操作的原子性,但如果要把多个操作合并成一个复合操作,还是需要额外的加锁机制。此外,将每个方法都作为同步方法还可能导致活跃性问题或性能问题,我们在 SynchronizedFactorizer 中已经看到了这些问题。
活跃性与性能
在 UnsafeCachingFactorizer 中,我们通过在因数分解 Servlet 中引入了缓存机制来提升性能。在缓存中需要使用共享状态,因此需要通过同步来维护状态的完整性。然而,如果使用 SynchronizedFactorizer 中的同步方式,那么代码的执行性能将非常糟糕。SynchronizedFactorizer 中采用的同步策略是,通过 Servlet 对象的内置锁来保护每一个状态变量,该策略的实现方式也就是对整个 service 方法进行同步。虽然这种简单且粗粒度的方法能确保线程安全性,但付出的代价却很高。
由于 service 是一个 synchronized 方法,因此每次只有一个线程可以执行。这就背离了 Servlet 的初衷,即 Servlet 需要能够同时处理多个请求,这在负载过高的情况下将给用户带来糟糕的体验。如果 Servlet 在对某个大数值进行因数分解时需要很长的执行时间,那么其他的客户端必须一致等待,知道 Servlet 处理完当前的请求,才能开始另一个新的因数分解运算。如果在系统中有多个 CPU,那么当负载很高时,仍然会有处理器处于空闲状态。即使一些执行时间很短的请求,比如访问缓存的值,仍然需要很长的时间,因为这些请求都必须等待前一个请求执行完成。
图 2-1 给出了当多个请求同时到达因数分解 Servlet 时发生的情况:这些请求将排队等待处理。我们将这种 Web 应用程序称之为不良并发应用程序:可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身架构的限制。幸运的是,通过缩小同步代码块的作用范围,我们很容易做到既确保 Servlet 的并发性,同时又维护线程安全性。要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。
程序清单 2-8 中的 CachedFactorizer 将 Servlet 的代码修改为使用两个独立的同步代码块,每个同步代码块都只包含一小段代码。其中一个同步代码块负责保护判断是否只需返回缓存结果的“先检查后执行”操作序列,另一个同步代码块则负责确保对缓存的数值和因数分解结果进行同步更新。此外,我们还重新引入了“命中计数器”,添加了一个“缓存命中”计数器,并在第一个同步代码块中更新这两个变量。由于这两个计数器也是共享可变状态的一部分,因此必须在所有访问它们的位置上使用同步。位于同步代码块之外的代码将以独占的方式来访问局部(位于栈上)的变量,这些变量不会在多个线程间共享,因此不需要同步。
@ThreadSafe
public class CachedFactorizer implements Servlet {
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors;
@GuardedBy("this") private long hits;
@GuardedBy("this") private long cacheHits;
public synchronized long getHits() { return hits; }
public synchronized double getCacheHitRatio() {
return (double) cacheHits / (double) hits;
}
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = null;
synchronzied (this) {
++hits;
if(i.equals(lastNumber)) {
++cacheHits;
factors = lastFactors.clone();
}
}
if(factors == null) {
factors = factor(i);
synchronized (this) {
lastNumber = i;
lastFactors = factors.clone();
}
}
encodeIntoResponse(resp, factors);
}
}
在 CachedFactorizer 中不再使用 AtomicLong 类型的命中计数器,而是使用了一个 long 类型的变量。当然也可以使用 AtomicLong 类型,但使用 CountingFactorizer 带来的好处更多。对于在单个变量上实现原子操作来说,原子变量是很有用的,但由于我们已经使用了同步代码块来构造原子操作,而使用两种不同的同步机制不仅会带来混乱,而不在性能和安全性上带来任何好处,因此这里不再使用原子变量。
重新构造后的 CachedFactorizer 实现了在简单性(对整个方法进行同步)与并发性(对仅可能短的代码路径进行同步)之间的平衡。在获取与释放锁等操作上需要一定的开销,因此如果将同步代码块分解的过细(例如将 ++hits
分解到它自己的同步代码块中),那么通常并不好,尽管这样做不会破坏原子性。当访问状态变量或者在复合操作的执行期间,CachedFactorizer 需要持有锁,但在执行时间较长的因素分解运算之前要释放锁。这样既能确保线程安全性,也不会过多的影响并发性,而且在每个同步代码块中的代码路径都“足够短”。
要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性(这是必要需求)、简单性、性能。有时候,在简单性与性能之间会发生冲突,但在 CachedFactorizer 中已经说明了,在二者之间通常能够找到某种合理的平衡。
通常,在简单性和性能之间存在相互制约因素。当实现某个同步策略时,一定不要盲目的为了性能而牺牲简单性(这可能会破坏安全性)。
当使用锁时,你应该清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间。无论是执行计算密集的操作,还是在执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能问题。
当执行时间较长的计算或者可能无法快速完成的操作时,如 IO、控制台 IO,一定不要持有锁。
1.3 - CH03-对象共享
第二章的开头曾指出,要编写正确的并发程序,关键问题在于:“在访问共享的可变状态时需要正确的管理”。第二章介绍了如何通过同步来避免多个线程在同一时刻访问相同的数据,而本章将介绍“如何共享和发布对象”,从而使它们能够安全的同时被多个线程访问。这两章合在一起,就形成了构建线程安全类以及通过 JUC 类库来构建并发应用程序的重要基础。
我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解是,认为关键字 synchronized 只能用于实现原子性或者确定“临界区”。同步还有一个重要的方面:“内存可见性”。“我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改状态,而且希望确保当一个线程修改了对象状态之后,其他线程能够看到发生的状态变化”。如果没有同步,那么这种情况就无法实现。你可以通过显式的同步或者类库中内置的同步来保证对象被安全的发布。
3.1 可见性
可见性是一种复杂的属性,因为可见性中的错误总是违背我们的直觉。在单线程环境中,如果想某个变量先写入值,然后在没有其他写入操作的情况下读取这个变量,那么总能得到相同的值。这看起来很自然。然而,当读操作和写操作在不同的线程中执行时,情况却并非如此,这听起来或许有些难以接受。通常,我们无法确保执行读操作的线程能实时的看到其他线程写入的值,有时甚至是不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
在程序清单 3-1 中的 NoVisibility 说明了当多个线程在没有同步的情况下共享数据时出现的错误。在代码中,主线程和读线程都将访问变量 ready 和 number。主线程启动读线程,然后将 number 设为 42,并将 ready 设为 true。读线程一直循环直到发现 ready 的值变为 true,然后输出 number 的值。虽然 NoVisibility 看起来会输出 42,但事实上可能输出 0,或者根本无法终止。这是因为在代码中没有使用足够的同步机制,因此无法保证主线程写入的 ready 值和 number 值对于读线程来说是可见的。
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
while(!ready) {
Thread.yield();
}
System.out.println(number);
}
}
public static void mian(String...args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}
NoVisibility 可能会一致循环下去,因为读线程可能永远也看不到 ready 的值。一种更奇怪的现象是,NoVisibility 可能会输出 0,因为读线程可能看到了写入 ready 的值,但却没有看到只有写入 number 的值,这种现象被称为“重排序”。只要在某个线程中无法检测到重排序的情况(即使在其他线程中可以很明显的看到该线程中的重排序),那么就无法确保线程中的操作将按照程序中指定的顺序来执行。当主线程首先写入 number,然后在没有同步的情况下写入 ready,那么读线程看到的顺序可能与写入的顺序完全相反。
在没有同步的情况下,编译器、处理器以及运行时都有可能对操作的顺序进行一些意想不到的调整。在缺乏同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。
NoVisibility 是一个简单的并发程序,只包含两个线程和两个共享变量,但即便如此,在判断程序的执行结果以及是否会结束时仍然很容易得出错误结论。要对那些缺乏足够同步的并发程序的执行情况进行推断是十分困难的。
这听起来有点恐怖,但实际情况也确实如此。幸运的是,有一种简单的方法能够避免这些复杂的问题:“只要有数据在多个线程间共享,就使用正确的同步”。
3.1.1 失效数据
NoVisibility 展示了在缺乏同步的程序中可能产生错误结果的一种情况:失效数据。当读线程查看 ready 变量时,可能会得到一个已经失效的值。除非在每次访问变量时都使用同步,否则很可能获得该变量的一个失效值。更糟糕的是,失效值可能不会同时出现:“一个线程可能获得某个变量的最新值,而获得另一个变量的失效值”。
通常,当食物过期(失效)时,还是可以食用的,只不过味道差了些。但失效的数据可能会导致更危险的情况。虽然在 Web 应用程序中失效的命中计数器可能不会导致太糟糕的情况,但在其他情况中,失效值可能会导致一些严重的安全问题或者活跃性问题。在 NoVisibility 中,失效数据可能会导致输出错误的值,或者使程序无法结束。如果对象的引用(如链表中的指针)失效,那么情况会更加复杂。失效数据还可能导致一些令人困惑的故障,如意料之外的异常、被破坏的数据结构、不精确的计算以及无线循环等。
程序清单 3-2 中的 MutableInteger 不是线程安全的,因为 get 和 set 都是在没有同步的情况下访问 value 的。与其他问题相比,失效值问题更容易出现:如果某个线程调用了 set,那么另一个正在调用 get 的线程可能会看到更新后的 value 的值,也可能看不到。
@NotThreadSafe
public class MutableInteger {
private int value;
public int get() { return value; }
public void set(int value) { this.value = value; }
}
在程序清单 3-3 的 SynchronizedInteger 中,通过对 get 和 set 等方法进行同步,可以使 MutableInteger 称为一个线程安全的类。“仅对 set 方法进行同步是不够的,调用 get 的线程仍然会看见失效的值”。
@ThreadSafe
public class SynchronizedInteger {
@GuardBy("this") private int value;
public synchronized int get() { return value; }
public synchronized void set(int value) {
this.value = value;
}
}
3.1.2 非原子的 64 位操作
当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为“最低安全性”。
最低安全性适用于绝大多数变量,但是存在一个例外:非 volatile 类型的 64 位数值变量(long/double)。Java 内存模型要求,变量的读取和写入操作都必须是原子的,但对于非 volatile 类型的 long 和 double 变量,JVM 允许将 64 位的读写操作分解为两个对 32 位数值的操作。当读取一个非 volatile 类型的 long 变量时,如果对该变量的读写操作在不同的线程中执行,那么很可能会读取到某个值的高 32 位或另一个值的低 32 位。因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的 long 和 double 等类型的变量也是不安全的,除非用关键字 volatile 来声明它们,或者用锁保护起来。
3.1.3 加锁与可见性
“内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果”,如图 3-1 所示。当线程 A 执行某个同步代码块时,线程 B 随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放之前,A 看到的变量值在 B 获得锁后同样可以由 B 看到。换句话说,当线程 B 执行由锁保护的同步代码块时,可以看到线程 A 之前在同一个同步代码块中的所有操作结果。如果没有同步,那么就无法实现上述保证。
现在,我们可以进一步理解为什么在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的。否则,如果一个线程在未持有正确的锁的情况下,那么读到的可能是一个失效值。
加锁的含义不仅仅局限于互斥访问,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或写操作的线程都必须在同一个锁上同步。
3.1.4 Volatile 变量
Java 语言提供了一种稍弱的同步机制,即 volatile 变量,“用来确保将变量的更新操作通知到其他线程”。当把变量声明为 volatile 类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。
理解 volatile 变量的一种有效方法是,将它们的行为想象成程序清单 3-3 中 SynchronizedInteger 的类似行为,并将 volatile 变量的读写操作分别替换为 get 和 set 方法。然而,在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile 变量一种比 synchronized 关键字更加轻量的同步机制。
volatile 变量对可见性的影响比 volatile 变量本身更为重要。当线程 A 首先写入一个 volatile 变量并且线程 B 随后读取该变量时,在写入 volatile 变量之前对 A 可见的所有变量的值,在 B 读取了 volatile 变量后,对 B 也是可见的。因此,从内存可见性的角度来看,写入 volatile 变量相当于退出同步代码块,而读取 volatile 变量相当于进入同步代码块。然而,我们并不建议过度依赖 volatile 变量提供的可见性。如果在代码中依赖 volatile 变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难以理解。
仅当 volatile 变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不需要使用 volatile 变量。volatile 变量的正确用法包括:确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生,如初始化和关闭。
程序清单 3-4 给出了 volatile 变量的一种典型用法:检查某个状态标记以判断是否退出循环。在这个示例中,线程试图通过一种数绵羊的传统方法进入休眠状态。为了使这个示例能够正确执行,asleep 必须为 volatile 变量。否则,当 volatile 被另一个线程修改时,执行判断的线程却发现不了。我们也可以用锁定来确保 volatile 更新操作的可见性,但这将使代码变得更加复杂。
volatile boolean asleep;
...
while(!asleep) {
countSomeSheep();
}
虽然 volatile 变量很方便,但也存在一些局限性。volatile 变量通常用作某个操作完成、发生中断或状态的标记,例如程序清单 3-4 中的 asleep 标记。尽管 volatile 也可以用于表示其他的状态信息,但在使用时要非常小心。例如,volatile 的语义不足以确保递增操作(count++)的原子性,除非你能确保只有一个线程对变量执行写操作。(原子变量提供了“读-改-写”的原子操作,并且常常用作“更好的 volatile 变量”)
加锁机制既可以确保可见性又可以确保原子性,而 volatile 变量只能确保可见性。
当且仅当满足以下条件时,才应该使用 volatile 变量:
- 对变量的写入不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量不会与其他状态变量一起被纳入不变性条件。
- 在访问变量时不需要加锁。
3.2 发布与逸出
“发布(Publish)” 一个对象的意思是指,是对象能够在当前作用域之外的代码中使用。例如,将一个指向对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。在许多情中,我们要确保对象及其内部状态不会被发布。而在某些情况下,我们又需要发布某个对象,但如果在发布时需要确保线程安全性,则可能需要同步。发布内部状态可能会破坏封装性,并使得程序难以维持不变性条件。例如,如果在对象的构造过程完成之前就发布该对象,就会破坏线程安全性。“当某个不应该发布的对象被发布时,这种情况就被称为逸出。” 3.5 节将介绍安全发布对象的一些方法。现在我们首先来看看一个对象是如何逸出的。
发布对象的最简单方法是将对象的引用保存到一个共有的静态变量中,以便任何类和线程都能看见该对象,如程序清单 2-5 所示。在 initialize 方法中实例化一个新的 HashSet 对象,并将对象的引用保存到 knownSecrets 中以发布该对象。
public static Set<Secret> knownSecrets;
public void initialize() {
knownSecrets = new HashSet<Secret>():
}
当发布某个对象时,可能会间接的发布其他对象。如果将一个 Secret 对象添加到集合 knownSecrets 中,那么同样会发布这个对象,因为任何代码都可以遍历这个集合,并获得对这个新 Secret 对象的引用。同样,如果从非私有方法中返回一个引用,那么同样会发布返回的对象。程序清单 3-6 中的 UnsafeStates 发布了本应为私有的状态数组。
class UnsafeStates {
private String[] states = new String[]{"AK","AL",...}
public String[] getStates() { return states; }
}
如果按照上述方法来发布 states,就会出现问题,因为任何调用者都可以修改这个数组的内容。在这个示例中,数组 states 已经逸出了它所在的作用域,因为这个本应该是私有的变量已经被发布了。
当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他的对象,那么这些对象也都会被发布。
假定有一个类 C,对于 C 来说,“外部方法”是指行为并不完全由 C 来规定的方法,包括其他类中定义的方法以及类 C 中可以被改写的方法(既不是 private 方法也不是 final 方法)。当把一个对象传递给外部方法时,就相当于发布了该对象。你无法知道哪些方法会被执行,也不知道在外部方法中究竟会发布这个对象,还是会保留对象的引用并在随后由另一个线程使用。
无论其他的线程会对已经发布的引用执行任何操作,其实都不重要,因为误用该引用的风险始终存在。当某个对象逸出后,你必须假设有某个类或线程最终可能会误用该对象。这正是需要使用封装的最主要原因:封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得更难。
最后一种发布对象或其内部状态的机制就是发布一个内部的类实例,如程序清单 3-7 的 ThisEscape 所示。当 ThisEscape 发布 EventListener 时,也隐含的发布了 ThisEscape 实例本身,因为在这个内部类的实例中包含了对 ThisEscape 实例的隐含引用。
public class ThisEscape {
public ThisScape(EventSource source) {
source.registerListener(
new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
}
);
}
}
安全的对象构造过程
在 ThisEscape 中给出了逸出的一个特殊示例,即 this 引用在构造函数中逸出。当内部的 EventListener 实例发布时,在外部封装的 ThisEscape 实例也逸出了。当且仅当对象的构造函数返回时,对象才处于可预测的和一致的状态。因此,当从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象。即使发布对象的语句位于构造函数的最后一行也是如此。如果 this 引用在构造过程中逸出,那么这种对象就被认为是不正确的构造。
不要在构造过程中使 this 引用逸出。
在构造过程中使 this 引用逸出的一个常见错误是,在构造函数中启动一个线程。当对象在其构造函数中创建一个线程时,无论是显式创建(通过将它传递给构造函数)还是隐式创建(由于 Thread 或 Runnable 是该对象的一个内部类),this 引用都会被新创建的线程共享。在对象尚未完全构造之前,新的线程就可以看到它。在构造函数中创建线程并没有错误,但是最好不要立即启动它,而是通过一个 start 或 initialize 方法来启动它。在构造函数中调用一个可改写的实例方法时(既不是私有方法、也不是终结方法),同样会导致 this 引用在构成过程中溢出。
如果想在构造函数中注册一个事件监听器或启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法,从而避免不正确的构造过程,如程序清单 3-8 中 SafeListener 所示。
public class SafeListener {
private final EventListener listener;
private SafeListener() {
listener = new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
};
}
public static SafeListener newInstance(EventSource source) {
SafeListener safe = new SafeListener();
source.registerListener(safe.listener);
return safe;
}
}
具体来说,只有当构造函数返回时,this 引用才应该从线程中逸出。构造函数可以将 this 引用保存在某个地方,只要其他线程不会在构造函数完成之前使用它,上面的 SafeListener 就使用了这种技术。
3.3 线程封闭
当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭,它是实现线程安全性的最简单方式之一。当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。
Swing 中大量使用了线程封闭技术。Swing 的可视化组件和数据模型都不是线程安全的,Swing 将它们封闭到 Swing 的事件分发线程中来实现线程安全性。要想正确的使用 Swing,在除了事件线程之外的其他线程中就不能访问这些对象(为了进一步简化 Swing 的使用,Swing 还提供了 inbokeLater 机制,用于将一个 Runnable 实例调度到事件线程中执行)。Swing 程序的许多并发错误都是由于错误的在另一个线程中使用了这些被封闭的对象。
线程封闭技术的另一个常见应用是 JDBC 的 Connection 对象。JDBC 规范并不要求 Connection 对象必须是线程安全的。在典型的服务器应用中,线程从连接池中获得一个 Connection 对象,并且用该对象来处理请求,使用完成后将对象返还给连接池。由于大多数请求都是由单个线程采用同步的方式来处理,并且在 Connection 对象返回之前,连接池也不会再将它分配给其他线程,因此,这种连接管理模式在处理请求时隐含的将 Connection 对象封闭在线程内。
在 Java 语言中并没有强制规定某个变量必须由锁来保护,同样在 Java 语言中也无法强制将对象封闭在某个线程中。线程封闭是在程序设计中的一个考虑因素,必须在程序中实现。Java 语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和 ThreadLoal 类,但即便如此,程序员仍需要负责确保封闭在线程内的对象不会从线程中逸出。
3.3.1 Ad-hoc 线程封闭
Ad-hoc 线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。Ad-hoc 线程封闭是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到目标线程上。事实上,对线程封闭对象(如 GUI 应用程序中的可视化组件或数据模型等)的引用通常保存在公有变量中。
当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。在某些情况下,单线程子系统提供的简单性要胜过 Ad-hoc 线程封闭技术的脆弱性。
在 volatile 变量上存在一种特殊的线程封闭。只要你能确保只有单个线程对共享的 volatile 变量执行写入操作,那么就可以安全的在这些共享的 volatile 变量上执行“读-改-写”操作。在这种情况下,相当于将修改操作封闭在单个线程中以防止发生竟态条件,并且 volatile 变量的可见性确保了其他线程能够看到最新的值。
由于 Ad-hoc 线程封闭技术的脆弱性,因此在程序中尽量少用它,在可能的情况下,应该使用更强的线程封闭技术(如栈封闭或 ThreadLocal 类)。
3.3.2 栈封闭
栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。正如封装能使得代码更容易维持不变性条件那样,同步变量也能使对象更易于封闭在线程中。局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。栈封闭(也被称为线程内部使用或线程局部使用,不要与核心类库中的 ThreadLocal 混淆)比 Ad-hoc 线程封闭更易于维护,也更加健壮。
对于基本类型的局部变量,例如程序清单 3-9 中的 loadTheArk 方法的 numPairs,无论如何都不会破坏栈封闭性。由于任何方法都不会获得基本类型的引用,因此 Java 语言的这种语义就确保了基本类型的局部变量始终封闭在线程内。
public int loadTheArk(Collection<Animal> candidates) {
SortedSet<Animal> animals;
int numPairs = 0;
Animal candidate = null;
// animals 被封闭在方法中,不要使它们逸出
animals = new TreeSet<Animal>(new SpeciesGenderComparator());
animals.addAll(candidates);
for(Animal a: animals) {
if(candidate == null || !candidate.isPotentialMate(a))
candidate = a;
else {
ark.load(new AnimalPari(candidate, a));
++numPairs;
candidate = null;
}
}
return numPairs;
}
在维持对象引用的栈封闭性时,程序员需要多做一些工作以确保被引用的对象不会逸出。在 loadTheArk 中实例化一个 TreeSet 对象,并将指向该对象的一个引用保存到 animals 中。此时,只有一个引用指向集合 animals,这个引用被封装在局部变量中,因此也被封装在执行线程中。然而,如果发布了对象集合 animals(或者该对象中的任何内部数据)的引用,那么封闭性将被破坏,并导致对象 animals 的逸出。
如果在线程内部上下文中使用非线程安全的对象,那么该对象仍然是线程安全的。然而,要小心的是,只有编写代码的开发人员才知道哪些对象需要被封闭到执行线程内,以及被封闭的对象是否是线程安全的。如果没有明确的说明这些需求,那么后续的维护人员很容易错误的使对象逸出。
3.3.3 ThreadLocal
维持线程封闭性的一种更规范的方法是使用 ThreadLocal,该类能使线程中的某个值与保存值的对象关联起来。ThreadLocal 提供了 get 与 set 等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此 get 总是返回由当前执行线程在调用 set 时设置的最新值。
“ThreadLocal 对象通常用于放置对可变的单实例变量或全局变量进行共享”。例如,在单线程应用程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用这个方法时都要传递一个 Connection 对象。由于 JDBC 的连接对象不一定是线程安全的,因此,当多线程程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将 JDBC 的连接保存到 ThreadLocal 对象中,每个线程都会拥有属于自己的连接,如程序清单 3-10 中的 ConnectionHolder 所示。
private static ThreadLocal<Connection> connectionHolder =
new ThreadLocal<Connection>() {
public Connection initialValue() {
return DriverManager.getConnection(DB_URL);
}
};
public static Connection getConnection() {
return conenctionHolder.get();
}
当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都分配该临时对象时,就可以使用这项技术。例如在 Java 5.0 之前,Integer.toString() 方法使用 ThreadLocal 对象来保存一个 12 字节大小的缓冲区,用于对结果进行格式化,而不是使用共享的静态缓冲区(这需要使用锁机制)或者在每次调用时都分配一个新的缓冲区。
当某个线程初次调用 ThreadLocal.get 方法时,就会调用 initialValue 来获取初始值。从概念上来看,你可以将 ThreadLocal
假设你需要将一个单线程应用程序移植到多线程环境中,通过将共享的全局变量转换为 ThreadLocal 对象(如果全局变量的语义允许这么做),可以维持线程安全性。然而,如果将应用程序范围内的缓存转换为线程局部的缓存,就不会有太大作用。
在实现应用程序框架时大量使用了 ThreadLocal。例如,在 EJB 调用期间,J2EE 容器需要将一个事务上下文与某个执行中的线程关联起来。通过将事务上下文保存在静态的 ThreadLocal 对象中,可以很容易的实现这个功能:当框架代码需要判断当前运行的是哪一个事务时,只需要从 ThreadLocal 对象中读取事务上下文。这种机制很方便,因为它避免了在调用每个方法时都要传递执行上下文信息,然而这也将该机制的代码与框架耦合在一起。
开发人员经常滥用 ThreadLocal,例如将所有全局变量都作为 ThreadLocal 对象,或者作为一种“隐藏”方法参数的手段。ThreadLocal 变量类似于全局变量,它会降低代码的可重用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心。
3.4 不变性
满足同步需求的另一种做法是使用不可变对象。到目前为止,我们介绍了许多原子性和可见性相关的问题,例如得到失效数据、丢失更新操作或观察到某个对象处于不一致的状态等等,都与多线程视图同时访问同一个可变的状态相关。如果对象的状态不会改变,那么这些问题与复杂性自然也就消失了。
如果某个对象在被创建后其状态不能被修改,那么这个对象就称为不可变对象。线程安全性是不可变对象的固有属性之一,它们的不变性条件是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。
不可变对象一定是线程安全的。
不可变对象很简单。它们只有一种状态,并且该状态由构造函数来控制。在程序设计中,一个最困难的地方就是判断复杂对象的可能状态。然而,判断不可变对象的状态却很简单。
同样,不可变对象也更加安全。如果将一个可变对象传递给不可信的代码,或者将该对象发布到不可信代码可以访问到的地方,那么就很危险——不可信代码会改变它们的状态,更糟糕的是,在代码中将保留一个对该对象的引用或者有问题的代码破坏,因此可以安全的共享和发布这些对象,而无需创建保护性的副本。
虽然在 Java 语言规范和 Java 内存模型中都没有给出不可变性的正式定义,但不可变性并不等于将对象中所有的域都声明为 final 类型,即使对象中所有的域都是 final 类型的,这个对象仍然是可变的,因为在 final 类型的域中可以保存可变对象的引用。
当满足以下条件时,对象才是不可变的:
- 对象创建以后其状态就不能被修改。
- 对象的所有域都是 final 类型。
- 对象是正确创建的(在对象的创建期间,this 引用没有逸出)。
在不可变对象的内部仍然可以使用可变对象来管理其状态,如程序清单 3-11 中的 ThreeStooges 所示。尽管保存姓名的 Set 对象是可变的,但是从 ThreeStooges 的设计中可以看到,在 Set 对象构造完成后无法对其进行修改。stooges 是一个 final 类型的引用变量,因此所有对象状态都通过一个 final 域来访问。最后一个要求是“正确的构造对象”,这个要求很容易满足,因为构造函数能使该引用由除了构造函数及其调用者之外的代码来访问。
@Immutable
public final class ThreeStooges {
private final Set<String> stooges = new HashSet<>();
public ThreeStooges() {
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
}
public boolean isStooge(String name) {
return stooges.contains(name);
}
}
由于程序的状态总是在不断改变,你可能会认为需要使用不可变对象的地方不多,但实际情况并非如此。在“不可变对象”与“不可变的对象引用”之间存在很多差异。保存在不可变对象中的状态仍然可以更新,即通过将一个保存新状态的实例来“替换”原有的不可变对象。
3.4.1 Final
关键字 final 可以视为 C++ 中 const 机制的一种受限版本,用于构造不可变的对象。final 类型的域是不能被修改的,但如果 final 域所引用的对象是可变的,那么这些被引用的对象是可以修改的。然而,在 Java 内存模型中,final 域还有着特殊的语义。final 域能够确保初始化过程的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时无需同步。
即使对象是可变的,通过将对象的某些域声明为 fianl 类型,仍然可以简化对状态的判断,因此限制对象的可变性也就相当于限制了该对象可能的状态集合。仅包含一个或两个可变状态的“基本不可变”对象仍然比包含多个可变状态的对象简单。通过将域声明为 fianl 类型,也相当于告诉维护人员这些域是不会变化的。
3.4.2 示例:使用 volatile 类型来发布不可变对象
在前面的 UnsafeCachingFactorizer 类中,我们尝试用 AtomicReference 变量来保存最新的数值机器因数分解结果,但这种方式并非线程安全,因为我们无法以原子方式来同时读取或更新两个相关的值。同样,用 volatile 类型的变量来保存这些值也不是线程安全的。然而,在某些情况下,不可变对象能够提供一种弱形式的原子性。
因数分解 Servlet 将执行两个原子操作:更新缓存的结果,以及通过判断缓存中的数值是否等于请求的数值来决定是否直接读取缓存中的因数分解结果。每当需要一组相关数据以原子方式执行某个操作时,就可以考虑创建一个不可变的类来包含这些数据,例如程序清单 3-12 中的 OneValueCache。
@Immutable
class OneValueCache {
private final BigInteger lastNumber;
private final BigInteger[] lastFactors;
public OneValueCache(BitInteger i, BigInteger[] factors) {
lastNumber = i;
lastFactors = Arrays.copy(factors, factors.length);
}
public BigInteger[] getFactors(BigInteger i) {
if(lastNumber == null || !lastNumber.equals(i))
return null;
else
return Arrays.copy(lastFactors, lastFactors.length);
}
}
对于在访问和更新多个相关变量时出现的竟态条件问题,可以通过将所有这些变量保存在一个不可变对象中来消除。如果是一个可变的对象,就必须使用锁来确保原子性。如果是一个不可变的对象,那么当线程获得了对该对象的引用后,就不必担心另一个线程会修改对象的状态。如果要更新这些变量,那么可以创建一个新的容器对象,但其他使用原子变量的线程仍然会看到对象处于一致的状态。
程序清单 3-13 中的 VolatileCachedFactorizer 使用了 OneValueCache 来保存缓存的数值机器因数。当一个线程将 volatile 类型的 cache 设置为引用一个新的 OneValueCache 时,其他线程就会立即看到新缓存的数据。
@ThreadSafe
public class VolatileCachedFactorizer implements Servlet {
private volatile OneValueCache cache =
new OneValueCache(null, null);
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BitInteger[] factors = cache.getFactors(i);
if(factors == null) {
factors = factor(i);
cache = new OneValueCache(i, factors);
}
encodeIntoResponse(resp, factors);
}
}
与 cache 相关的操作不会互相干扰,因为 OneValueCache 是不可变的,并且在每条相应的代码路劲中只会访问它一次。通过使用包含多个状态变量的容器来维持不变性条件,并使用一个 volatile 类型的引用来确保可见性,使得 VolatileCachedFactorizer 在没有使用显式锁的情况下依然是线程安全的。
3.5 安全发布
到目前为止,我们重点讨论的是如何确保对象不会被发布,例如让对象封闭在线程或另一个对象的内部。当然,在某些情况下我们希望在多个线程间共享对象,此时必须确保安全的进行共享。然而,如果只是像程序清单 3-14 那样将对象引用保存到公有域中,那么还不足以安全的发布这个对象。
// 不安全的发布
public Holder holder;
public void initialize() {
holder = new Holder(42);
}
你可能会奇怪,这个看似没有问题的示例为何会运行失败。由于存在可见性问题,其他线程看到的 Holder 对象将处于不一致的状态,即便在该对象的构建函数中已经正确的构建了不变性条件。这种不正确的发布导致其他线程能够看到尚未创建完成的对象。
3.5.1 不正确的发布:正确的对象被发布
你不能指望一个尚未被完全创建的对象拥有完整性。某个观察该对象的线程将看到对象处于不一致的状态,然后看到对象的状态突然发生变化,即使线程在对象发布后还没有修改过它。事实上,如果程序清单 3-15 中的 Holder 使用程序清单 3-14 中的不安全方式发布,那么另一个线程在调用 assertSanity 时将抛出 AssertionError。
public class Holder {
private int n;
public Holder(int n) {this.n = n;}
public void assertSanity() {
if(n != n)
throw new AssertionError()
}
}
由于没有使用同步来确保 Holder 对象对其他线程可见,因此将 Holder 称为“未被正确发布”。在未被正确发布的对象中存在两个问题。首先,除了发布对象的线程外,其他线程可以看到 Holder 域是一个失效值,因此将看到一个空引用或者之前的旧值。然而,更糟糕的情况是,线程看到 Holder 引用的值是最新的,某个线程在第一次读取域时得到失效值,而在此读取这个域时会得到一个更新值,这也是 assertSainty 抛出异常的原因。
如果没有足够的同步,那么当在多个线程间共享数据时将发生一些非常奇怪的事情。
3.5.2 不可变对象与初始化安全性
由于不可变对象是一种非常重要的对象,因此 Java 内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证。我们已经知道,即使某个对象的引用对其他线程是可见的,也并不意味着对象状态对于使用该对象的线程来说一定是可见的。为了确保对象状态呈现出一致的视图,就必须使用同步。
另一方面,即使在发布不可变对象的引用时没有使用同步,也仍然可以安全的访问该对象。为了维持这种初始化安全性保证,必须满足不可变性的所有需求:状态不可修改、所有域都是 final 类型、以及正确的构造过程。
任何线程都可以在不需要额外同步的情况下安全的访问不可变对象,即使在发布这些对象时没有使用同步。
这种保证还将延伸到被正确创建对象中所有 final 类型的域。在没有额外同步的情况下,也可以安全的访问 final 类型的域。然而,如果 final 类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。
3.5.3 安全发布的常用模式
可变对象必须通过安全的方式来发布,这通常意味着在发布和使用该对象的线程时都必须使用同步。现在,我们将重点介绍如何确保使用对象的线程能够看到该对象处于已发布的状态,并稍后介绍如何在对象发布后对其可见性进行修改。
要安全的发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全的发布:
- 在静态初始化函数中初始化一个对象引用。
- 将对象的引用保存到 volatile 类型的域或者 AtomicReference 对象中。
- 将对象的引用保存到某个正确构造对象的 fianl 类域中。
- 将对象的引用保存到一个由锁保护的域中。
在线程安全容器内部的同步意味着,在将对象放入到某个容器,例如 Vector 或者 synchronizedList 时,将满足上述最后一条需求。如果线程 A 将对象 X 放入一个线程安全的容器,随后线程 B 读取这个对象,那么可以确保 B 看到 A 设置的 X 状态,即便在这段 读/写 X 的应用程序代码中没有包含显式的同步。尽管 Javadoc 在这个主题上没有给出清晰的说明,但线程安全库中的容器类提供了以下安全发布保证:
- 通过将一个键或值放入到 Hashtable、synchronizedList 或 ConcurrentMap 中,可以安全的将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)。
- 通过将某个元素放入 Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、SynchronizedList、SynchronizedSet 中,可以将该元素安全的发布到任何从这些容器中访问该元素的线程。 通过将某个元素放入 BlockingQueue 或 ConcurrentLinkedQueue 中,可以将该元素安全的帆布到任何从这些队列中访问该元素的线程。
类库中的其他数据传递机制(如 Future 或 Exchanger)同样能实现安全发布,在介绍这些机制时将讨论它们的安全发布功能。
通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器:
public static Holder holder = new Holder(42);
静态初始化器由 JVM 在类的初始化阶段执行。由于 JVM 内部存在同步机制,因此通过这种方式初始化的任何对象都可以被安全的发布。
3.5.4 事实不可变对象
如果对象在发布后不会被修改,那么对于其他在没有额外同步的情况下安全的访问这些对象的线程来说,安全发布是足够的。所有的安全发布机制都能确保,当对象的引用对所有访问该对象的线程可见时,对象发布时的状态对于所有线程都是可见的,并且如果对象状态不会再改变,那么久足以保证任何访问都是安全的。
如果对象从技术上看是可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象”。这些对象不需要满足 3.4 节中提出的不可变性的严格定义。在这些对象发布后,程序只需要将它们视为不可变对象即可。通过使用事实不可变对象,不仅可以简化开发过程,而且还能由于减少了同步而提高性能。
没有额外的同步的情况下,任何线程都可以安全的使用被安全发布的事实不可变对象。
例如,Date 本身是不可变的,但如果将其作为不可变对象来使用,那么在多个线程之间共享 Date 对象时,就可以省去对锁的使用。假设要维护一个 Map 对象,其中保存了每位用户的最近登录时间:
public Map<String, Date> lastLogin =
Collection.synchronizedMap(new HashMap<>());
如果 Date 对象的值在被放入 Map 后就不再会被改变,那么 synchronizedMap 中的同步机制就足以使 Date 值被安全的发布,并且在访问这些 Date 值时不需要使用额外的同步。
3.5.5 可变对象
如果对象在构造之后可以被修改,那么安全发布只能确保“发布当时”状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。要安全的共享可变对象,这些对象就必须被安全的发布,并且必须是线程安全的或者由某个锁保护起来。
对象的发布需求取决于它的可变性:
- 不可变对象可以通过任意机制来发布。
- 事实不可变对象必须通过安全方式来发布。
- 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。
3.5.6 安全地共享对象
当获得对象的一个引用时,你需要知道在这个引用上可以执行哪些操作。在使用它之前是否要获得一个锁?是否可以修改它的状态?或者只能读取它?许多并发错误都是由于没有理解共享对象的这些“既定规则”而导致的。当发布一个对象时,必须明确的说明对象的访问方式。
在并发编程中使用和共享对象时,可以使用一些实用的策略,包括:
- 线程封闭。线程封闭的对象只能由一个线程拥有,对象对封闭在该线程内,并且只能由这个线程修改。
- 只读共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而无需进一步的同步。
- 保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。
1.4 - CH04-对象组合
到目前为止,我们已经介绍了关于线程安全与同步的一些基础知识。然而,我们并不希望对每一次的内存访问都进行分析以确保程序是线程安全的,而是希望将一些现有的线程安全组件组合为更大规模的组件或程序。本章将介绍一些组合模式,这些模式能够使一个类更容易实现线程安全,并且在维护这些类时不会在无意中破坏类的安全性保证。
4.1 设计线程安全的类
在线程安全的程序中,虽然可以将程序的所有状态都保存在公有的静态域中,但与那些将状态封装起来的程序相比,这些程序的线程安全性更难以得到验证,并且在修改时也更难以始终确保其线程安全性。通过使用封装技术,可以使得在不对整个程序进行分析的情况下就可以判断一个类是否是线程安全的。
在设计线程安全类的过程中,需要包含以下三个基本要素:
- 找出构成对象状态的所有变量。
- 找出约束状态变量的不变性条件。
- 建立对象状态的并发访问管理策略。
要分析对象的状态,首先从对象的域开始。如果对象中所有的域都是基本类型的变量,那么这些域将构成对象的全部状态。程序清单 4-1 中的 Counter 只有一个域——value,因此这个域就是 Counter 的全部状态。对于含有 n 个基本类型的域的对象,其状态就是这些域构成的 n 元组。例如,二维点的状态就是它的坐标值 (x,y)。如果在对象的域中引用了其他对象,那么该对象的状态将包含被引用对象的域。例如,LinkedList 的状态就包括该链表中所有节点对象的状态。
@ThreadSafe
public final class Counter {
@GuardedBy("this") private long value = 0;
public synchronized long getValue() {
return value;
}
public synchronized long increment() {
if(value == Long.MAX_VALUE)
throw new IllegalStateException();
return ++value;
}
}
“同步策略”定义了如何在不违背对象不变性条件或后验条件的情况下对其状态的访问操作进行协同。同步策略规定了如何将不可变性、线程封闭、加锁机制等结合起来以维护线程的安全性,并且还规定了哪些变量由哪些锁来保护。要确保开发人员可以对这个类进行分析和维护,就必须将同步策略写为正式文档。
4.1.1 收集同步需求
要确保类的线程安全性,就需要确保它的不变性条件不会在并发访问的情况下被破坏,这就需要对其状态进行推断。对象与变量都有一个状态空间,即所有可能的取值。状态空间越小,就越容易判断线程的状态。final 类型的域越多,就越能简化对象可能状态的分析过程。(在极端情况下,不可变对象只有唯一的状态)
在许多类中都定义了一些不可变条件,用于判断状态的有效性。Counter 中的 value 域是 long 类型的变量,其状态空间从 Long.MIN_VALUE 到 Long.MAX_VALUE,但 Counter 中 value 在取值范围上存在着一个限制,即不能是负值。
同样,在操作中还会包含一些后验条件来判断状态迁移是否有效。如果 Counter 的当前状态为 17,那么下一个有效状态只能是 18。当下一个状态需要依赖当前状态时,这个操作就必须是一个复合操作。并非所有的操作都会在状态转换上施加限制,例如,当更新一个保存当前温度的变量时,该变量之前的值并不会影响计算结果。
由于不变性条件以及后验条件在状态转换上施加了各种约束,因此就需要额外的同步与封装。如果某些状态是无效的,那么必须对底层的状态变量进行封装,否则客户代码可能会使对象处于无效状态。如果在某个操作中存在无效的状态转换,那么该操作必须是原子的。另外,如果在类中没有施加这种约束,那么就可以放宽封装性或序列化等需求,以便获得更高的灵活性或性能。
在类中可以包含同时约束多个状态变量的不变性条件。在一个表示数值范围的类中可以包含两个状态变量,分别表示范围的上下界。这些变量必须遵循的约束是,下界值应该小于等于上界值。类似于这种包含多个变量的不变性条件将带来原子性需求:这些相关的变量必须在原子操作中进行读取或更新。不能首先更新一个变量值然后释放锁并再次获得锁,然后再更新其他变量。因为释放锁后,可能会使对象处于无效状态。如果在一个不变性条件中包含多个变量,那么在执行任何访问相关变量的操作时,都必须持持有保护这些变量的锁。
如果不了解对象的不变性条件和后验条件,就不能确保线程安全性。要满足在状态变量的有效值或在状态转换上的各种约束条件,就需要借助于原子性和封装性。
4.1.2 依赖状态的操作
类的不变性条件与后验条件约束了在对象上有哪些状态和状态转换是有效的。在某些对象的方法中还包含一些基于状态的先验条件。例如,不能从空队列中移除一个元素,在删除元素前,队列必须处于“非空的”状态。如果在某个操作中包含有基于状态的先验条件,那么这个操作就称为依赖状态的操作。
在单线程程序中,如果某个操作无法满足先验条件,那么就只能失败。但在并发程序中,先验条件可能会由其他线程执行的操作而变为真。在并发程序中要一直等到先验条件为真,然后再执行该操作。
在 Java 中,等待某个条件为真的各种内置机制(包括等待和通知等机制)都与内置加锁机制紧密关联,要想正确的使用它们并不容易。要想实现某个等待先验条件为真时才执行的操作,一种更简单的方法是通过现有库中的类来实现依赖状态的行为。
4.1.3 状态的所有权
4.1 节曾指出,如果以某个对象为根节点构造一张对象图,那么该对象的状态将是对象图中所有对象包含的域的一个子集。为什么是一个子集?在从该对象可以到达的所有域中,需要满足哪些条件才不属于该对象状态的一部分?
在定义将由哪些变量构成对象的状态时,只考虑对象拥有的数据。所有权在 Java 中并没有得到充分的体现,而是属于类设计中的一个要素。如果分配并填充了一个 HashMap 对象,那么就相当于创建了多个对象:HashMap 对象,在 HashMap 对象中包含的多个对象,以及在 Map.Entry 中可能包含的内部对象。HashMap 对象的逻辑状态包含所有的 Map.Entry 对象以及内部对象,即使这些对象都是一些独立的对象。
无论如何,垃圾回收机制使我们避免了如何处理所有权的问题。在 C++ 中,当把一个对象传递给某个方法时,必须认真考虑这种操作是否传递对象的所有权,是短期的所有权还是长期的所有权。在 Java 中同样存在这样的所有权模型,只不过垃圾回收器为我们减少了许多在引用共享方面常见的错误,因此降低了在所有权处理上的开销。
许多情况下,所有权与封装性总是互相关联的:对象封装它拥有的状态,反之也成立,即对它封装的状态拥有所有权。状态变量的所有者将决定采用何种加锁协议来维持变量状态的完整性。所有权意味着控制权。然而,如果发布了某个不可变对象的引用,那么久不再拥有独占的控制权,最多是“共享控制权”。对于从构造函数或者从方法中传递进来的对象,类通常并不拥有这些对象,除非这些方法是专门设计为转义传递进来的对象的所有权的。
容器类通常表现出一种“所有权分离”的形式,其中容器类拥有自身的状态,而客户端代码则拥有容器中各个对象的状态。Servlet 框架中的 ServletContext 就是其中一个示例。ServletContext 为 Servlet 提供了类似于 Map 形式的对象容器服务,在 ServletContext 中可以通过名称来注册或获取应用程序对象。由 Servlet 容器实现的 ServletContext 对象必须是线程安全的,因为它肯定会被多个线程同时访问。当调用 setAttribute 和 getAttribute 时,Servlet 不需要使用同步,但当使用保存在 ServletContext 中的对象时,则可能需要使用同步。这些对象由应用程序拥有,Servlet 容器只是替应用程序保管它们。与所有共享对象一样,它们必须安全的被共享。为了防止多个线程在并发访问同一个对象时产生的相互干扰,这些对象应该要么是线程安全的对象,要么是事实不可变的对象,或者由锁来保护的对象。
4.2 实例封闭
如果某对象不是线程安全的,那么可以通过多种技术使其在多线程程序中安全的使用。你可以确保该对象只能由单个线程访问(线程封闭),或者由锁来保护对该对象的所有访问。
封装简化了线程安全类的实现过程,它实现了一种实例封闭机制,通常简称为“封闭”。当一个对象被封装到另一个对象中时,能够访问被封装对象的所有代码路径都是已知的。与对象可以由整个程序访问的情况相比,更易于对代码进行分析。通过将封闭机制与合适的加锁策略结合起来,可以确保以线程安全的方式来使用非线程安全的对象。
将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。
被封闭对象一定不能超出它们既定的作用域。对象可以封装在类的一个实例(如作为类的私有成员)中,或者封装在一个作用域内(如作为一个局部变量),再或者封闭在线程内(如在某个线程中将对象从一个方法传递到另一个方法,而不是在多个线程间共享该对象)。当然,对象本身不会逸出——发生逸出通常是由于开发人员在发布对象时超出了对象既定的作用域。
程序清单 4-2 中的 PersonSet 说明了如何通过封闭与加锁等机制是一个类称为线程安全的。PersonSet 的状态由 HashSet 来管理,而 HashSet 并非线程安全,但由于 mySet 是私有的且不会逸出,因此 HashSet 被封闭在 PersonSet 中。唯一能访问 mySet 的代码路径是 addPerson 与 containsPerson,在执行它们时都需要获得一个 PersonSet 上的锁。PersonSet 的状态完全由它的内置锁保护,因而 PersonSet 是一个线程安全的类。
@ThreadSafe
public class PersonSet {
@GuardedBy("this")
private final Set<Person> mySet = new HashSet<>();
public synchronized void addPerson(Person p) {
mySet.add(p);
}
public synchronized boolean contains(Person p) {
return mySet.contains(p);
}
}
这个示例并未对 Person 类的线程安全性做任何假设,但如果 Person 类是可变的,那么在访问从 PersonSet 中获得的 Person 对象时,还需要额外的同步。要想安全的使用 Person 对象,最可靠的方法就是使 Person 成为一个线程安全的类。另外,也可以使用锁来保护 Person 对象,并确保所有客户端代码在访问 Person 对象之前都已经获得了正确的锁。
实例封闭是构建线程安全的一个最简单方式,它还是的在锁策略的选择上拥有了更多的灵活性。在 PersonSet 中使用了它的内置锁来保护状态,但对于其他形式的锁来说,只要至始至终都使用同一个锁,就可以保护状态。实例封闭还使得不同的状态变量可以由不同的锁来保护。
在 Java 平台的类库中还有很多线程封闭的实例,其中有些类的唯一用途就是将非线程安全的类转换为线程安全。一些基本容器并非线程安全,例如 ArrayList 和 HashMap,但类库提供了包装工厂方法,使得这些非线程安全的类可以在多线程环境中安全的使用。这些工厂方法通过“装饰器”模式将容器类封装在一个同步的包装器对象中,而包装器能将接口中的每个方法都实现为同步方法,并将调用请求转发到底层的容器对象上。只要包装器对象拥有对底层容器对象的唯一引用(即把底层容器对象封闭在包装器中),那么他就是线程安全的。在这些方法的 Javadoc 中指出,对底层容器对象的所有访问必须通过包装器来进行。
当然,如果将一个本该封闭的对象发布出去,那么也能破坏封装性。如果一个对象本应该封闭在特定的作用域中,那么让该对象逸出作用域就是一个错误。当发布其他对象时,例如迭代器或内部的类实例,可能会间接的发布被封闭的对象,同样会使封闭对象逸出。
封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无需检查整个程序。
4.2.1 Java 监视器模式
从线程封闭原则及其逻辑推论可以得出 Java 监视器模式。遵循 Java 监视器模式的对象会把对象的所有可变状态都封装起来,并由对象的内置锁来保护。
在代码清单 4-1 的 Counter 中给出了这种模式的一个典型示例。在 Counter 中封装了一个状态变量 value,对该变量的所有访问都需要通过 Counter 的方法来执行,并且这些方法都是同步的。
在许多类中都使用了 Java 监视器模式,例如 Vector 和 Hashtable。在某些情况下,程序需要一种更复杂的同步策略。第 11 章将介绍如何通过细粒度的加锁策略来提高可伸缩性。Java 监视器模式的主要优势在于它的简单性。
Java 监视器模式仅仅是一种编写代码的约定,对于任何一种锁对象,只要至始至终的使用该锁对象,都可以用来保护对象的状态。程序清单 4-3 给出了如何使用私有锁来保护状态。
public class PrivateLock {
private final Object myLock = new Object();
@GuardedBy("myLock") Widget widget;
void someMethod() {
synchronized(myLock) {
// 访问或修改 Widget 的状态
}
}
}
使用私有锁对象而不是内置锁(或其他任何可以通过共有方式访问的锁),有许多优点。私有的锁对象可以将锁封装起来,使客户端代码无法获得锁,但客户端可以通过公有方法访问锁,以便参与到它的同步策略中。如果客户端代码错误的获得了另一个对象的锁,那么可能会产生活跃性问题。此外,要想验证某个公有方法的锁在程序中是否本正确的使用,则需要检查整个程序,而不是单个的类。
4.2.2 示例:车辆追踪
程序清单 4-1 中的 Counter 是一个简单但用处不大的 Java 监视器示例。我们来看一个更有用处的示例:一个用于调度车辆的“车辆追踪器”,例如出租车、警车、货车等。首先使用监视器模式来构建追踪器,然后再尝试放宽某些封装性需求同时又保证线程安全性。
每台车都由一个 String 对象来标识,并且拥有一个相应的坐标位置 (x,y)。在 VehicleTracker 类中封装了车辆的标识和位置,因而它非常适合作为基于 MVC 模式的 GUI 应用程序中的数据模型,并且该模型将由一个视图线程和多个执行更新操作的线程共享。视图线程会读取车辆的标识和位置,并将它们展示在界面上:
Map<String,Point> locations = vehicles.getLocations();
for(String key: locations.keySet())
renderVehicle(key, locations.get(key));
类似的,执行更新操作的线程通过从 GPS 设备上获取的数据或者调度员从 GUI 界面上输入的数据来修改车辆的位置。
void vehicleMoved(VehicleMovedEvent evt) {
Point loc = evt.getNewLocation();
vehicles.setLocation(evt.getVehicleId(), loc.x, loc.y);
}
从视图线程与执行更新操作的线程将并发的访问数据模型,因此该模型必须是线程安全的。程序清单 4-4 给出了一个基于 Java 监视器模式实现的“车辆追踪器”,其中使用了程序清单 4-5 中的 MutanlePoint 来表示车辆位置。
@ThreadSafe
public class MonitorVehicleTracker {
@GuardedBy("this")
private final Map<String, MutablePoint> locations;
public MonitorVehicleTracker( Map<String, MutablePoint> locations) {
this.locations = deepCopy(locations);
}
public synchronized Map<String, MutablePoint> getLocations() {
return deepCopy(locations);
}
public synchronized MutablePoint getLocation(String id) {
MutablePoint loc = locations.get(id);
return loc == null ? null : new MutablePoint(loc);
}
public synchronized void setLocation(String id, int x, int y) {
MutablePoint loc = locations.get(id);
if (loc == null)
throw new IllegalArgumentException("No such ID: " + id);
loc.x = x; loc.y = y;
}
private static Map<String, MutablePoint> deepCopy( Map<String, MutablePoint> m) {
Map<String, MutablePoint> result = new HashMap<String, MutablePoint>();
for (String id : m.keySet())
result.put(id, new MutablePoint(m.get(id)));
return Collections.unmodifiableMap(result);
}
}
public class MutablePoint { /* Listing 4.5 */ }
虽然类 MutablePoint 不是线程安全的,但追踪器类是线程安全的。它所包含的 Map 对象和可变的 Point 对象都未曾发布。当需要返回车辆的位置时,通过 MutablePoint 拷贝构造函数或者 deepCopy 方法来复制正确的值,从而生成一个新的 Map 对象,并且该对象中的值与原有 Map 对象中的 key、value 值都相同。
@NotThreadSafe
public class MutablePoint {
public int x, y;
public MutablePoint() { x = 0; y = 0; }
public MutablePoint(MutablePoint p) {
this.x = p.x;
this.y = p.y;
}
}
在某种程度上,这种实现方式是通过在返回客户端代码之前复制可变数据来维持安全性的。通常情况下,这并不存在性能问题,但在车辆容器非常大的情况下将极大降低性能。此外,由于每次调用 getLocation 就要复制数据,因此将出现一种错误的情况——虽然车辆的实际位置发生了改变,但返回的信息却保持不变。这种情况的好坏,取决于你的需求。如果在 location 集合上存在内部的一致性需求,那么这就是有点,在这种情况下返回一致的快照就非常重要。然而,如果调用者需要每辆车的最新信息,那么这就是缺点,因为这需要非常频繁的刷新快照。
4.3 线程安全性的委托
大多数对象都是组合对象。当从头开始构建一个类,或者将多个非线程安全的类组合成一个类时,Java 监视器模式是非常有用的。但是,如果类中的各个组件都已经是线程安全的,会是什么情况呢?我们是否需要再增加一个额外的线程安全层?答案是“视情况而定”。在某些情况下,通过多个线程安全类组合而成的类是线程安全的,而在某些情况下,这仅仅是一个好的开端。
在前面的 CountingFactorizer 类中,我们在一个无状态的类中增加了一个 Atomiclong 类型的域,并且得到的组合对象仍然是线程安全的。由于 CountingFactorizer 的状态就是 AtomicLong 的状态,而 AtomicLong 是线程安全的,因此 CountingFactorizer 不会对 counter 的状态施加额外的有效性约束,所以很容易知道 CountingFactorizer 是线程安全的。我们可以说 CountingFactorizer 将它的线程安全性委托给 AtomicLong 来保证:之所以 CountingFactorizer 是线程安全的,是因为 AtomicLong 是线程安全的。
4.3.1 示例:基于委托的车辆追踪器
下面将介绍一下更实际的委托示例,构造一个委托给线程安全类的车辆追踪器。我们将车辆的位置保存到一个 Map 对象中,因此首先要实现一个线程安全的 Map 类,ConcurrentHashMap。我们还可以用一个不可变的 Point 类来代替 MutablePoint 以保存位置,如程序清单 4-6 所示。
@Immutable
public class Point {
public final int x,y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
由于 Point 类是不可变的,因而它是线程安全的。不可变的值可以被自由的共享与发布,因此在返回 location 时不需要复制。
在程序清单 4-7 的 DelegatingVehicleTracker 中没有使用任何显式的同步,所有对状态的访问都由 ConcurrentHashMap 来管理,而且 Map 所有的键和值都是不可变的。
@ThreadSafe
public class DelegatingVehicleTracker {
private final ConcurrentMap<String,Point> locations;
private final Map<String,Poing> unmodifiableMap;
public DelegatingVehicleTracker(Map<String,Poing> points) {
locaitions = new ConcurrentHashMap<>(points);
unmodifiableMap = Collections.unmodifiableMap(locations);
}
public Map<String,Poing> getLocations() {
return unmodifiableMap;
}
public Point geg Location(String id) {
return locations.get(id);
}
public void setLocation(String id, int x, int y) {
if(locations.replace(id, new Point(x,y)) == null) {
throw new IllegalArgumentException();
}
}
}
如果使用最初的 MutablePoint 而不是 Point 类就会破坏封装性,因为 getLocations 会发布一个指向可变状态的引用,而这个引用不是线程安全的。需要注意的是,我们稍微改变了车辆追踪器的行为。在使用监视器模式的车辆追踪器中返回的是车辆位置的快照,而在使用委托的车辆追踪器中放回的是一个不可修改但却保持实时的车辆位置视图。这意味着,如果线程 A 调用 getLocations,而线程 B 在随后修改了某些点的位置,那么在返回给线程 A 的 Map 中将反应出这些变化。在前面提到过,这可能是一种优点,也可能是一种缺点,取决于具体需求。
如果需要一个不会发生变化的车辆视图,那么 getlocations 可以返回对 locations 这个 Map 对象的一个浅拷贝。由于 Map 的内容是不可变的,因此只需复制 Map 的结构,而不用复制它的内容,如程序清单 4-8 所示。
public Map<Stirng,Point> getLocations() {
return Collections.unmodifiableMap(new HashMap<>(locations));
}
4.3.2 独立的状态变量
到目前为止,这些委托实例都仅仅委托给了单个线程安全的状态变量。我们还可以将线程安全性委托给多个状态变量,只要这些变量是彼此独立的,即“组合而成的类不会在其包含的多个状态变量上增加任何不变性条件”。
程序清单 4-9 中的 VisualConponet 是一个图形组件,允许客户端程序注册鼠标事件和键盘事件的监听器。因为每种类型的事件都备有一个已注册监听器列表,因此当某个事件发生时,就会调用相应的监听器。然而,在鼠标事件监听器与键盘事件监听器之间不存在任何关联,二者是彼此独立的,因此 VisualComponet 可以将其线程安全性委托给这两个线程安全的监听器列表。
public class VisualComponet {
private final List<KeyListener> keyListeners =
new CopyOnWriteArrayList<>();
private final List<MouseListener> mouseListeners =
new CopyOnWriteArrayList<>();
public void addKeyListener(KeyListener listener) {
keyListeners.add(listener);
}
public void addMouseListener(MouseListener listener) {
mouseListeners.add(listener);
}
public void removeKeyListener(KeyListener listener) {
keyListeners.remove(listener);
}
public void removeMouseListener(MouseListener listener) {
mouseListeners.remove(listener);
}
}
VisualComponet 使用 CopyOnWriteArrayList 来保存各个监听器列表。它是一个线程安全的链表,特别适用于管理监听器列表。每个链表都是线程安全的,此外,由于各个状态之间不存在耦合关系,因此 VisualComponet 可以将它的线程安全性委托给 mouseListeners 和 keyListeners。
4.3.3 当委托失效时
大多数组合对象都不会像 VisualComponet 这样简单:在它们的状态变量之间存在着某些不变性条件。程序清单 4-10 中的 NumberRange 使用了两个 AtomicInteger 来管理状态,并且含有一个约束条件,即第一个数值要小于或等于第二个数值。
public class NumberRange {
// 不变性条件:lower <= upper
private final AtomicInteger lower = new AtomicInteger(0);
private final AtomicInteger upper = new AtomicInteger(0);
public void setLower(int i) {
// 注意:不安全的“先检查后执行”
if(i < upper.get()) {
throw new ...
}
lower.set(i);
}
public void setUpper(int i) {
// 注意:不安全的“先检查后执行”
if(i < lower.get) {
throw new ...
}
upper.set(i);
}
}
NumberRange 不是线程安全的,没有维持对上界和下界进行约束的不变性条件。setLower 和 setUpper 等方法都尝试维持不变性条件,但却无法做到。setLower 和 setUpper 都是“先检查后执行”操作,但它们没有使用足够的加锁机制来保证这些操作的原子性。假设取值范围为 (0,10),如果一个线程调用 setLower(5),另一个线程调用 setUpper(4),那么在一些错误的执行时序中,这两个调用都将通过检查,并且都能设置成功。结果得到的取值范围是 (5,4),那么这是一个无效的状态。因此,虽然 AtomicInteger 是线程安全的,但经过组合得到的类却不是。由于状态变量 lower 和 upper 不是彼此独立的,因此 NumberRange 不能将线程安全委托给它的线程安全状态变量。
NumberRange 可以通过加锁机制来维护不变性条件以确保其线程安全性,例如使用一个锁来保护 lower 和 upper。此外,它还必须避免发布 lower 和 upper,从而防止客户端代码破坏其不变性条件。
如果某各类含有复合操作,例如 NumberRange,那么紧靠委托并不足以实现线程安全性。在这种情况下,这个类必须提供自己的加锁机制以保证这些复合操作都是原子操作,除非整个复合操作都可以委托给状态变量。
如果一个类是由多个独立且线程安全的变量组成的,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。
即使 NumberRange 的各个组件部分都是线程安全的,也不能保证 NumberRange 的线程安全性,这种问题非常类似于 3.1.4 节介绍的 volatile 变量规则:仅当一个变量参与到包含其他状态变量的不变性条件时,才可以声明为 volatile 变量。
4.3.4 发布底层的状态变量
当把线程安全性委托给某个对象底层的状态变量时,在什么条件下才可以发布这些变量从而使其他类能够修改它们?答案仍然取决于在类中对这些变量施加了哪些不变性条件。虽然 Counter 中的 value 域可以为任意整数值,但 Counter 施加的约束条件是只能取正整数,此外递增操作同样约束了下一个状态的有效取值范围。如果将 value 声明为一个共有域,那么客户端代码可以将它修改为一个无效值,因此发布 value 会导致这个类出错。另一方面,如果某个变量表示的是当前温度或者最近登录用户的 ID,那么即使另一个类在某个时刻修改了这个值,也不会破坏任何不变性条件,因此发布这个变量也是可以接受的。(这或许不是个好注意,因为发布可变的变量将对下一步的开发和派生子类带来限制,但不会破坏类的线程安全性。)
例如,发布 VisualComponent 中的 mouseListeners 和 keyListeners 等变量是线程安全的。由于 VisualComponet 并没有在其监听器列表的合法状态上施加任何约束,因此这些域可以声明为公有域或者发布,而不会破坏线程安全性。
4.3.5 示例:发布状态的车辆追踪器
我们来构造车辆追踪器的另一个版本,并在这个版本中发布底层的可变状态。我们需要修改接口以适应这种变化,即使用可变且线程安全的 Point 类。
程序清单 4-11 中的 SafePoint 提供的 get 方法同时获得 x 和 y 的值,并将二者放在一个数组中返回。如果 x 和 y 分别提供 get 方法,那么在获得者两个不同坐标的操作之间,x 和 y 的值发生变化,从而导致调用者看到不一致的值:车辆重来没有到达过位置 (x,y)。通过使用 SafePoint,可以构造一个发布其底层可变状态的车辆追踪器,还能确保其线程安全性不被破坏,如程序清单 4-12 中的 PublishingVehicleTracker 类所示。
@ThreadSafe
public class SafePoint {
@GuardedBy("this") private int x, y;
private SafePoint(int[] a) { this(a[0], a[1]); }
public SafePoint(SafePoint p) { this(p.get()); }
public SafePoint(int x, int y) {
this.x = x; this.y = y;
}
public synchronized int[] get() {
return new int[] { x, y };
}
public synchronized void set(int x, int y) {
this.x = x; this.y = y;
}
}
@ThreadSafe
public class PublishingVehicleTracker {
private final Map<String, SafePoint> locations;
private final Map<String, SafePoint> unmodifiableMap;
public PublishingVehicleTracker( Map<String, SafePoint> locations) {
this.locations = new ConcurrentHashMap<String, SafePoint>(locations);
this.unmodifiableMap = Collections.unmodifiableMap(this.locations);
}
public Map<String, SafePoint> getLocations() {
return unmodifiableMap;
}
public SafePoint getLocation(String id) {
return locations.get(id);
}
public void setLocation(String id, int x, int y) {
if (!locations.containsKey(id))
throw new IllegalArgumentException( "invalid vehicle name: " + id);
locations.get(id).set(x, y);
}
}
PublishingVehicleTracker 将其线程安全性委托给底层的 PublishingVehicleTracker,只是 Map 中的元素是线程安全的且可变的 Point,而并非不可变的。getLocation 方法返回底层 Map 对象的一个不可变副本。调用者不能增加或删除车辆,但却可以通过修改返回 Map 中的 SafePoint 值来改变车辆的位置。再次指出,Map 的这种“实时特性”究竟是带来了好处还是坏处,仍然取决于实际的需求。PublishingVehicleTracker 是线程安全的,但如果它在车辆位置的有效值上施加了任何约束,那么就不再是线程安全的了。如果需要对车辆位置的变化进行判断或者当位置变化时执行一些操作,那么 PublishingVehicleTracker 中采用的方式并不合适。
4.4 在现有的线程安全类中添加功能
Java 类库包含许多有用的“基础模块”类。通常,我们应该优先选择重用这些现有的类而不是创建新类:重用能降低开发工作量、开发风险(因为现有的类都已通过测试)以及维护成本。有时候,某个现有的线程安全类能支持我们需要的所有工作,但更多时候,现有的类只能支持大部分的操作,此时就需要在不破坏线程安全性的情况下添加一个新的操作。
例如,假设需要一个线程安全的链表,它需要提供一个原子的“若没有则添加”的操作。同步的 List 类已经实现了大部分的功能,我们可以根据它提供的 contains 方法和 add 方法构造一个这样的操作。
“若没有则添加”的概念很简单,在向容器添加元素前,首先检查该元素是否已经存在,如果存在就不再添加。由于这个类必须是线程安全的,因此就隐含的增加了另一个需求,即“若没有则添加”这个操作必须是原子的。这意味着,如果在链表中没有包含对象 X,那么在执行两次“若没有则添加” X 后,在容器中只能包含一个 X 对象。然而,如果“若没有则添加”并非原子操作,那么在某些执行情况下,有两个线程将看到 X 不再容器中,并且都执行了添加 X 的操作,从而使容器中包含两个相同的 X 对象。
要添加一个新的原子操作,最安全的方式是修改原始类,但这通常无法做到,因为你可能无法访问或修改类的原始代码。要想修改原始的类,就需要理解代码中的同步策略,这样增加的功能才能与原有的设计保持一致。如果直接将新方法添加到类中,那么意味着实现同步策略的所有代码仍然处于一个源代码文件中,从而更容易理解和维护。
另一种方法是扩展这个类,假定在设计这个类的时候考虑了扩展性。程序清单 4-13 中的 BetterVector 对 Vector 进行了扩展,并添加了一个新的 putIfAbsent。扩展 Vector 很简单,但并非所有的类都想 Vector 那样将状态向子类公开,因此也就不适合采用这种方法。
@ThreadSafe
public class BetterVector<E> extends Vector<E> {
public synchronized boolean putIfAbsend(E e) {
boolean absent = !contains(e);
if(absent) add(e);
return absent;
}
}
“扩展方法”比直接将代码添加到类中更加脆弱,因为现在的同步策略实现被分布到多个单独维护的源代码文件中。如果底层的类改变了同步策略并选择了不同的锁来保护它的状态变量,那么子类会被破坏,因为在同步策略改变后它无法再使用正确的锁来控制对基类状态的并发访问。(在 Vector 的规范中定义了它的同步策略,因此 BetterVector 不存在该问题。)
4.4.1 客户端加锁机制
对于由 Collections.synchronizedList 封装的 ArrayList,这两种方法在原始类中添加一个方法或者对类进行扩展都行不通,因为客户端代码并不知道在同步封装器工厂方法中返回的 List 对象的类型。第三中策略是扩展类的功能,但并不扩展类本身,而是将扩展放入一个辅助类。
程序清单 4-14 实现了一个包含“若没有则添加”操作的辅助类,用于对线程安全的 List 执行操作,但其中的代码是错误的。
@NotThreadSafe
public class ListHelper<E> {
public List<E> list =
Collections.synchronizedList(new ArrayList<E>());
...
public synchronized boolean putIfAbsent(E x) {
boolean absent = !list.contains(x);
if(absent) {
list.add(x);
}
return absent;
}
}
为什么这种方式不能实现线程安全性?毕竟,putIfAbsent 已经声明为 synchronized 类型的变量,对不对?问题在于在错误的锁上进行了同步。无论 List 使用哪个锁来保护它的状态,可以确定的是,这个锁不会是 ListHelper 上的锁。ListHelper 只是带来了同步的假象,尽管所有的链表操作都被声明为 synchronized,但却使用了不同的锁,这意味着 putIfAbsent 相对于 List 的其他同步操作来说并不是原子的,因此就无法确保当 putIfAbsent 执行时另一个线程不会修改链表。
要想使这个方法变得有效,必须使 List 在实现客户端加锁或外部加锁时使用同一个锁。客户端加锁指的是,对于使用某个对象 X 的客户端代码,使用 X 本身用于保护其状态的锁来保护这段客户端代码。要使用客户端加锁,你必须知道对象 X 使用的是哪个锁。
在 Vector 和同步封装器类的文档中指出,他们通过使用 Vector 或封装器容器的内置锁来支持客户端加锁。程序清单 4-15 给出了在线程安全的 List 上执行 putIfAbsent 操作,其中使用了正确的客户端加锁。
@ThreadSafe
public class ListHelper<E> {
public List<E> list =
Collections.synchronizedList(new ArrayList<E>());
...
public boolean putIfAbsent(E x) {
synchronized (list) {
boolean absent = !list.contains(x);
if (absent) list.add(x);
return absent;
}
}
}
通过添加一个原子操作来扩展类是脆弱的,因为它将类的加锁代码分布到多个类中。然而,客户端加锁却更加脆弱,因为它将类 C 的加锁代码放到与 C 完全无关的其他类中。当在那些并不承诺遵循加锁策略的类上使用客户端加锁时,要特别小心。
客户端加锁机制与扩展类机制有很多相同点,二者都是将派生类的行为与基类的实现耦合在一起。正如扩展会破坏实现的封装性,客户端加锁同样会破坏同步策略的封装性。
4.4.2 组合
当为现有的类添加一个原子操作时,有一种更好的方法:组合。程序清单 4-16 中的 ImporvedList 通过将 List 对象的操作委托给底层的 List 实例来实现 List 的操作,同时还添加了一个原子的 putIfAbsent 方法。(与 Collections.synchronizedList 和其他容器封装器一样,ImprovedList 假设把某个链表对象传递给构造函数之后,客户端代码就不会再直接使用这个对象,而只能通过 ImprovedList 来访问它。)
@ThreadSafe
public class ImprovedList<T> implements List<T> {
private final List<T> list;
public ImprovedList(List<T> list) {
this.list = list;
}
public synchronized boolean putIfAbsent(T x) {
boolean contains = list.contains(x);
if (contains) list.add(x);
return !contains;
}
public synchronized void clear() { list.clear(); }
// ... similarly delegate other List methods
}
ImprovedList 通过自身的内置锁增加了一层额外的加锁。它并不关心底层的 List 是否是线程安全的,即使 List 不是线程安全的或者修改了它的加锁实现,ImprovedList 也会提供一致性的加锁机制来实现线程安全性。虽然额外的同步层可能导致轻微的性能损失,但与模拟另外一个对象的加锁策略相比,ImprovedList 更加健壮。事实上,我们使用了 Java 监视器模式来封装现有的 List,并且只要在类中拥有指向底层 List 的唯一外部引用,就能确保线程安全性。
4.5 将同步策略文档化
在维护线程安全性时,文档是最强大的工具之一。用户可以通过查阅文档来判断某个类是否是线程安全的,而维护人员也可以通过查阅文档来理解其中的实现策略,避免在维护过程中破坏安全性。然而,通常人们从文档中获取的信息少之又少。
synchronized、volatile 或者任何一个线程安全类都对应一个同步策略,用于在并发访问时保证数据的完整性。这种策略是程序设计的要素之一,因此应该将其文档化。当然,设计阶段是编写设计决策文档的最佳时间。这之后经过数周或数月后,一些设计细节会逐渐变得模糊,因此一定要在忘记之前将他们记录下来。
在设计同步策略时需要考虑多个方面,例如,将哪些变量声明为 volatile 类型,哪些变量用锁来保护,哪些锁保护哪些变量,哪些变量必修是不可变的或者被封闭在线程内,哪些操作必须是原子的等。其中某些方面是严格的实现细节,应该将他们文档化以便日后维护。还有一些方面会影响类中加锁行为的外在表现,也应该将其作为规范的一部分写入文档。
最起码,应该保证类中的线程安全性文档化。它是否是线程安全的?在执行回调时是否持有一个锁?是否有某些特定的锁会影响其行为?不要让客户端冒着风险去猜测。如果你不想支持客户端加锁也是可以的,但一定要明确的指出来。如果你希望客户端代码能够在类中添加新的原子操作,如 4.4 节所示,那么就需要在文档中索命需要获得哪些锁才能实现安全的原子操作。如果使用锁来保护状态,那么也要将其写入文档以便日后维护,这很简单,只需要使用注解 @GuardedBy
即可。如果要使用更复杂的方法来维护线程安全性,那么一定要将它们写入文档,因为维护者们通常很难发现它们。
甚至在平台类库中,线程安全性方面的文档也是很难令人满意。当你阅读某个类的 Javadoc 时,是否曾怀疑过他是否是线程安全的?大多数类都没有给出任何提示。许多正式的 Java 技术规范,例如 Servlet 和 JDBC,也没有在它们的文档中给出线程安全性的保证和需求。
尽管我们不应该对规范之外的行为进行猜测,但有时候处于工作需要,将不得不面对各种糟糕的假设。我们是否应该因为某个对象看上去是线程安全的就假设它是安全的?是否可以假设通过获取对象的锁来确保对象访问的线程安全性?(只有当我们能够控制所有访问该对象的代码时,才能使用这种带有风险的技术,否则,这这能带来线程安全性的假象。)无论做出哪种选择都很难令人满意。
更糟糕的是,我们的直觉通常是错误的:我们认为“可能是线程安全的”的类通常不是线程安全的。例如,java.text.SimpleDateFormat 并不线程安全,但 JDK 1.4 之前的 Javadoc 并没有提到这点。许多开发人员都对这个事实感到惊讶。有多少程序已经错误的生成了这种非线程安全的对象,并在多线程中使用它们?这些程序没有意识到这将在高负载的情况下导致错误的结果。
如果某个类没有明确的声明是线程安全的,那么就不要假设它是线程安全的,从而有效的避免类似于 SimpleDateFormat 中的问题。而另一方面,如果不对容器提供对象(如 HttpSession) 的线程安全性做出某种有问题的假设,也就不可能开发出一个基于 Servlet 的应用程序。不要使你的客户或同事做这样的猜测。
解释含糊的文档
许多 Java 技术规范都没有说明接口的线程安全性,例如 ServletContext、HttpSession、DataSource。这些接口是由容器或者数据库供应商来实现的,而你通常无法通过查看其源码来了解细节功能。此外,你也不希望依赖于某个特定的 JDBC 驱动的实现细节——你希望遵从标准,这样代码可以基于任何一个 JDBC 驱动工作。但在 JDBC 的规范中从未出现“线程”和“并发”这些术语,同样在 Servlet 规范中也很少提到。那么你改做些什么?
你只能取猜测。一个提高猜测准确性的做法是,从实现者的角度去解释规范,而不是从使用者的角度去解释。Servlet 通常是在容器管理的线程中调用的,因此可以安全的假设:如果有多个这样的线程在运行,那么容器是知道这种情况的。Servlet 容器能生成一些为多个 Servlet 提供服务的对象,例如 HttpSession 或 ServletContext。因此,Servlet 容器应该预见到这些对象将被并发访问,因为它创建了多个线程,并且从这些线程中调用像 Servlet.service 这样的方法,而这个方法很可能会访问 ServletContext。
由于这些对象在单线程的上下文中很少是有用的,因此我们不得不假设它们已被实现为线程安全的,即使在规范中没有明确的说明。此外,如果需要客户端加锁,那么客户端代码应该在哪个锁上同步?在文档中没有说明这一点,而要猜测的话也不知从何猜起。在规范和正式手册中给出的如何访问 ServletContext 或 HttpSession 的示例中进一步强调了这种合理的假设,因为么有使用任何客户端同步。
另一方面,通过把 setAttribute 放到 ServletContext 中或者将 HttpSession 的对象由 Web 应用程序拥有,而不是由 Servlet 容器拥有。在 Servlet 规范中没有给出任何机制来协调对这些共享属性的并发访问。因此,由容器代替 Web 应用程序来保存这些属性应用是线程安全的,或者是不可变的。如果容器的工作只是代替 Web 应用程序来保存这些属性,那么当从 Servlet 应用程序代码访问他们时,应该确保他们始终由同一个锁保护。但由于容器可能需要序列化 HttpSession 中的对象复制或者钝化等操作,并且容器不可能知道你的加锁协议,因此你要自己确保这些对象是线程安全的。
1.5 - CH05-基础构建块
第四章介绍了构建线程安全类时采用的一些技术,例如将线程安全性委托给现有的线程安全类。委托是创建线程安全类的一个最有效的策略:只需让现有的线程安全类管理所有的状态即可。
Java 平台类库包含了丰富的并发基础构建块,例如线程安全的容器类以及各种用于协调多个相互协作的线程控制流的同步工具(synchronizer)。本章将介绍其中一些最有用的并发构建模块,特别是在 Java 5 和 6 中引入的新模块,以及在使用这些模块来构建并发应用程序时的一些常用模式。
5.1 同步容器类
同步容器类包括 Vector 和 Hashtable,二者是早期 JDK 的一部分,此外还包括一些在 JDK 1.2 中添加的功能相似的类,这些同步的封装容器类是由 Collections.synchronizedXxx 等工厂方法创建的。这些类实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。
5.1.1 同步容器类的问题
同步容器类都是线程安全的,但在某些情况下可能需要额外的客户端加锁来来保护复合操作。容器上最常见的复合操作包括:迭代、跳转、条件运算,例如“若没有则添加”。在同步容器中,这些复合操作在没有客户端加锁的情况下仍然是线程安全的,但在其他线程并发的修改容器时,它们可能会表现出意料之外的行为。
程序清单 5-1 给出了 Vector 中定义的两个方法:getLast 和 deleteLast,它们都会执行“先检查再运行”。每个方法首先都获得数组的大小,然后通过结果来获取或删除最后一个元素。
public static Object getLast(Vector list) {
int lastIndex = list.size -1;
return list.get(lastIndex);
}
public static void deleteLast(Vector list) {
int lastIndex = list.size() -1;
list.remove(lastIndex);
}
这些方法看似没有任何问题,从某种程度上来看也确实如此——无论多少个线程同时调用它们也不会破坏 Vector。但从这些方法的调用者角度来看,情况就不同了。如果线程 A 在包含 10 个元素的 Vector 上调用 getLast,同时线程 B 在同一个 Vector 上调用 deleteLast,这些操作的交替执行如图 5-1 所示,getLast 将抛出数组索引越界异常。在调用 size 与调用 getLast 这两个操作之间,Vector 变小了,因此在调用 size 时得到的索引将不再有效。这种情况很好的遵循了 Vector 的规范——如果请求一个不存在的元素,那么将抛出一个异常。但这并不是 getLast 的调用者希望得到的结果(即使是在并发修改的情况下也不希望看到),除非 Vector 从一开始就是空的。
由于同步容器类要遵循同步策略,即支持客户端加锁,因此可能会创建一些新的操作,只要我们知道应该使用哪个锁,那么这些操作就会与容器的其他操作一样都是原子的。同步容器类通过其自身的锁来保护它的每个方法。通过获得容器类的锁,我们可以使 getLast 和 deleteLast 成为原子操作,并确保 Vector 的大小在调用 size 和 get 之间不会发生变化,如程序清单 5-2 所示。
public static Object getLast(Vector list) {
synchronized(list){
int lastIndex = list.size -1;
return list.get(lastIndex);
}
}
public static void deleteLast(Vector list) {
synchronized(list){
int lastIndex = list.size() -1;
list.remove(lastIndex);
}
}
在调用 size 和相应的 get 之间,Vector 的长度可能会发生变化,这种风险在于对 Vector 中的元素进行迭代时仍然会出现,如程序清单 5-3 所示。
for(int i=0; i<vector.size(); i++){
doSomething(vector.get(i));
}
这种迭代操作的正确性要依赖运气,即在调用 size 和 get 之间没有线程会修改 Vector。在单线程环境下,这种假设完全成立,但在有其他线程并发的修改 Vector 时,则可能导致麻烦。与 getLast 一样,如果在对 Vector 进行迭代时,另一个线程删除了一个元素,并且这两个操作交替执行,那么这种迭代方法将抛出数组索引越界异常。
虽然在程序清单 5-3 的迭代操作中可能抛出异常,并并不意味着 Vector 就不是线程安全的。Vector 的状态仍然是有效的,而抛出的异常也与其规范保持一致。然而,像在读取最后一个元素或者迭代等这样简单的操作中抛出异常显然不是人们所期望的。
我们可以通过在客户端加锁来解决不可迭代的问题,但要牺牲一些伸缩性。通过在迭代期间持有 Vector 的锁,可以防止其他线程在迭代期间修改 Vector,如程序清单 5-4 所示。然而,着同样会导致其他线程在迭代期间无法访问它,因此降低了并发性。
synchronized (vector) {
for(int i=0; i<vector.size(); i++){
doSomething(vector.get(i));
}
}
5.1.2 迭代器与 ConcurrentModificationException
为了将问题阐述清楚,我们使用了 Vector,虽然这是一个古老的容器类。然而,许多现代的容器类也并没有消除复合操作中的问题。无论直接迭代还是 Java 5.0 引入的 foreach 循环语法中,对容器类迭代的标准方式都是使用 Iterator。然而,如果有其他线程并发的修改容器,那么即使是使用迭代器也无法避免在迭代期间对容器加锁。在设计同步容器类的迭代器时并没有考虑到并发修改的问题,并且它们表现出的行为是“及时失败”的。这意味着,当它们发现容器在迭代过程中被修改时,就会抛出 ConcurrentModificationException。
这种“及时失败”的迭代器并不是一种完备的处理器机制,而只是“善意的”捕获并发错误。因此只能作为并发问题的预警指示器。它们采用的实现方式是,将计数器的变化与容器关联起来:如果迭代器件计数器被修改,那么 hasNext 或 next 将抛出异常。然而,这种检查是在没有同步的情况下进行的,因此可能会看到失效的计数值,而迭代器可能并没有意识到已经发生了修改。这是一种设计上的权衡,从而降低并发修改操作的检测代码对程序性能带来的影响。
程序清单 5-5 说明了如何使用 for-each 循环语法对 List 容器进行迭代。从内部来看,javac 将生成使用 iterator 的代码,返回调用 hasNex 和 next 来迭代 List 对象。与迭代 Vector 一样,要想避免出现并发修改异常,就必须在迭代过程中持有锁。
List<Widget> widgetList =
Collections.synchronizedList(new ArrayList<>());
...
// 可能抛出并发修改异常
for(Widget w: widgetList)
doSomething(w);
然而,有时候开发人员并不希望在迭代期间对容器加锁。例如,某些线程在可以访问容器之前,必须等待迭代过程结束,如果容器的规模很大,或者在每个元素上执行操作的时间很长,那么这些线程将长时间等待。同样,如果容器像程序清单 5-4 中那样的锁,那么在调用 doSomething 时将持有一个锁,还可能会产生死锁。即使不存在饥饿或死锁等风险,长时间对容器加锁也会降低程序的可伸缩性。持有锁的时间越长,那么在锁上的竞争就可能越激烈,如果许多线程都在等待锁被释放,那么将极大的降低吞吐量和 CPU 的利用率。
如果不希望在迭代期间对容器加锁,那么一种替代方法就是“克隆”容器,并在副本上进行迭代。由于副本被封闭在线程内,因此其他线程不会在迭代期间对其进行修改,这样就避免了抛出并发修改异常。在克隆容器时存在显著的性能开销。这种方式的好坏取决于多个因素,包括容器的大小、在每个元素上执行的工作,迭代操作相对于容器其他操作的调用频率,以及在响应时间和吞吐量等方面的需求。
5.1.3 隐藏迭代器
虽然加锁可以放置迭代器抛出并发修改异常,但你必须记住在所有对共享容器进行迭代的地方都需要锁。实际情况要更加复杂,因为在某些情况下,迭代器会隐藏起来,如程序清单 5-6 中的 HiddenIterator 所示。在 HiddenIterator 中没有显式的迭代操作,但在粗体标出的代码中将执行迭代操作。编译器将字符串的连接操作转换为调用 StringBuilder.append(Object),而这个方法又会调用容器的 toString 方法,标准容器的 toString 方法将迭代容器,并在每个元素上调用 toString 来生成容器内容的格式化表示。
public class HiddenIterator {
@GuardedBy("this")
private final Set<Integer> set = new HashSet<>();
public synchronized void add(Integer i) {
set.add(i);
}
public synchronized void remove(Integer i){
set.remove(i);
}
public void addTenThings() {
Random r = new Random();
for(int i=0; i<10; i++)
add(r.nexInt());
System.out.println("DEBUG..." + set);
}
}
addTenThings 方法可能会抛出并发修改异常,因为在生成调试信息的过程中,toString 对容器进行迭代。当然,真正的问题在于 HiddenIterator 不是线程安全的。在使用 printlng 中的 set 之前必须首先获取 HiddenIterator 的锁,但在调试代码和日志代码中通常会忽视这个要求。
这里得到的教训是,如果状态与保护它的代码之间相隔甚远,那么开发人员就越容易忘记在访问状态时使用正确的同步。如果 HiddenIterator 用 synchronizedSet 来包装 HashSet,并且对同步代码进行封装,那么就不会发生这种错误。
正如封装对象的状态有助于维持不变性条件一样,封装对象的同步机制同样有助于确保实施同步策略。
容器的 hashCode 和 equals 等方法也会间接的执行迭代操作,当容器作为另一个容器的元素或键值时,就会出现这种情况。同样,containsAll、removeAll、retainAll 等方法,以及把容器作为参数的构造函数,都会对容器进行迭代。所有这些间接的迭代操作都可能抛出并发修改异常。
5.2 并发容器
Java 5.0 提供了多种并发容器来改进同步容器的性能。同步容器将所有对容器状态的访问都串行化,以实现它们的线程安全性。这种方法的代价是验证降低并发性,当多个线程竞争容器的锁时,吞吐量会严重降低。
另一方面,并发容器是针对多个线程并发访问设计的。在 Java 5.0 中增加了 ConcurrentHashMap,用来代替同步且基于散列的 Map,以及 CopyOnWriteArrayList,用于在遍历操作为主要操作的情况下代替同步的 List。在新的 ConcurrentMap 接口中增加了对一些常见复合操作的支持,例如“若没有则添加”、替换、有条件删除等。
通过并发容器来代替同步容器,可以极大的提高伸缩性并降低风险。
Java 5.0 增加了两种新的容器类型:Queue 和 BlockingQueue。Queue 用来临时保存一组等待处理的元素。它提供了几组实现,包括:ConcurrentLinkedQueue,这是一个传统的 FIFO 队列,以及 PriorityQueue,这是一个(非并发的)优先级队列。Queue 上的操作不会阻塞,如果队列为空,那么获取元素的操作将返回空值。虽然可以用 List 来模拟 Queue 的行为——事实上,正是通过 LinkedList 来实现 Queue 的,但还是需要一个 Queue 的类,因为它能去掉 List 的随机访问需求,从而实现高效的并发。
BlockingQueue 扩展了 Queue,增加了可阻塞的插入和获取等操作。如果队列为空,那么获取元素的操作将一直阻塞,直到队列中出现一个可用的元素。如果队列已满(对于有界队列来说),那么插入元素的操作将一直阻塞,直到队列中出现可用的空间。在“生产者-消费者”这种设计模式中,阻塞队列是非常有用的。
正如 ConcurrentHashMap 用于代替基于散列的同步 Map,Java 6 也引入了 ConcurrentSkipListMap 和 ConcurrentSkipListSet,分别作为同步的 SortedMap 和 SortedSet 的并发替代品。
5.2.1 ConcurrentHashMap
同步容器类在执行每个操作期间都持有一个锁。在一些操作中,例如 HashMap.get 或 List.contains,可能包含大量的工作:当遍历散列桶或链表来查找特定对象时,必须在许多元素上调用 equals(这包含一定的计算量)。在基于散列的容器中,如果 hashCode 不能很均匀的分布散列值,那么容器中的元素就不会均匀的分布在整个容器中。某些情况下,某个糟糕的散列函数还会把一个散列表编程线性链表。当遍历很长的链表并且在某些或全部元素上调用 equals 方法时,会花费很长的时间,而其他线程在这段时间内都不能访问该容器。
与 HashMap 一样,ConcurrentHashMap 也是一个基于散列的 Map,但它使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性。ConcurrentHashMap 并不是将每个方法多在同一个锁上同步并且每次只能有一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更更大程度的共享,这种机制称为“分段锁”。在这种机制中,任意数量的读取线程都可以并发的访问 Map,执行读取操作的线程和执行写入操作的线程可以并发的访问 Map,并且一定数量的写入线程可以并发的修改 Map。ConcurrentHashMap 带来的结果是,在并发访问环境下将实现更高的吞吐量,而在单线程环境中只有非常小的性能损失。
ConcurrentHashMap 与其他并发容器一起增强了同步容器类:它们提供的迭代器不会抛出并发修改异常,因此不需要在迭代过程中对容器加锁。ConcurrentHashMap 返回的迭代器具有弱一致性,而并非“及时失败”。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(但是不保证)在迭代器被构造后将修改操作反映给容器。
尽管有这些改进,但仍存存在一些需要权衡的因素。对于一些需要在整个 Map 上进行计算的方法,比如 size 和 isEmpty,这些方法的语义被略微减弱了,以反映容器的并发特性。由于 size 放回的结果在计算时可能已经过期了,它实际上只是一个估计值,因此允许 size 返回一个近似值而非精确值。虽然看上去有些令人不安,但事实上 size 和 isEmpty 这样的方法在并发环境下的用处很小,因为它们的返回值总是在不断变化。因此,这些操作的需求被弱化了,以换取对其他更重要操作的性能优化,比如 get、put、containsKey、remove 等。
在 ConcurrentHashMap 中没有实现对 Map 加锁以提供独占访问。在 Hashtable 和 synchronizedMap 中,获得 Map 的锁能防止其他线程访问该 Map。在一些不常见的情况中需要这种功能,例如通过原子方式添加一些映射,或者对 Map 迭代若干次并在此期间保持元素顺序相同。然而,总体来说这种权衡是合理的,因为并发容器的内容会持续变化。
与 Hashtable 和 synchronizedMap 相比,ConcurrentHashMap 有着更多的优势以及更少的劣势,因此在大多数情况下,用 ConcurrentHashMap 来代替同步 Map 能进一步提高代码的可伸缩性。只有当应用程序需要加锁 Map 以进行独占访问时,才应该放弃使用 ConcurrentHashMap。
5.2.2 额外的原子 Map 操作
由于 ConcurrentHashMap 中不能被加锁以执行独占访问,因此我们无法使用客户端加锁来创建新的原子操作,例如 4.4.1 节中对 Vector 增加原子操作“若没有则添加”。但是,一些常见的复合操作,例如“若没有则添加”、“若相等则移除”和“若相等则替换”等,都已经实现为原子操作并且在 ConcurrentMap 中声明为接口,如程序清单 5-7 所示。如果你需要在现有的 Map 中添加这样的功能,那么很可能就意味着应该考虑使用 ConcurrentMap 了。
public interface ConcurrentMap<K,V> extends Map<K,V> {
V putIfAbsent(K key, V value);
boolean remove(K key, V value);
boolean replace(K key, V oldValue, V newValue);
V replace(K key, V newValue);
}
5.2.3 CopyOnWriteArrayList
CopyOnWriteArrayList 用于替代同步 List,在某些情况下它提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制。
“写时复制”容器的线程安全性在于,只要正确的发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。“写时复制”容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于他不会被修改,因此在对其进行同步时只需要确保数组内容的可见性。因此,多个线程可以同时对这个容器进行迭代,而不会彼此干扰或者与修改容器的线程相互干扰。“写时复制”容器返回的迭代器不会抛出并发修改异常,并且返回的元素与迭代器创建时的元素保持一致,而不必考虑之后修改操作所带来的影响。
显然,每当修改容器时都会复制底层数组,这需要一定的开销,特别是当容器的规模较大时。仅当迭代操作远远多于修改操作时,才应该使用“写时复制”容器。这个准则很好的描述了许多事件通知系统:在分发通知时需要迭代已注册的监听器列表,并调用每个监听器,在大多数情况下,注册和注销事件监听器的操作远少于接收事件通知的操作。
5.3 阻塞队列和生产者-消费者模式
阻塞队列提供了可阻塞的 put 和 take 方法,以及支持定时的 offer 和 poll 方法。如果队列已经满了,那么 put 方法将持续阻塞直到有空间可用;如果队列为空,那么 take 方法将只需阻塞直到有元素可用。队列可以是有界的或无界的,无界队列永远不会满,因此无界队列上的 put 操作永远不会阻塞。
阻塞队列支持生产者-消费者设计模式。该模式将“找出需要完成的工作”与“执行工作”这两个过程分离,并把工作项放入一个“待完成”列表中以便在随后处理,而不是找出后立即处理。生产者—消费者模式能简化开发过程,因为它消除了生产者类和消费者类之间的代码依赖性,此外,该模式还将生产数据的过程与使用数据的过程解耦开来以简化工作负载的管理,因为这两个过程在处理数据的速率上可以有所不同。
在基于阻塞队列的生产者—消费者设计中,当生产数据时,生产者将数据放入队列,而当消费者准备处理数据时,将从队列中获取数据。生产者不需要知道消费者的标识或数量,或者它们是否是唯一的生产者,而只需要将数据放入队列即可。同样,消费者也不需要知道生产者是谁,或者工作来自何处。BlockingQueue 简化了生产者-消费者设计的实现过程,它支持任意数量的生产者和消费者。一种最常见的生产者-消费者设计模式就是将线程池与工作队列的组合,在 Executor 任务执行框架中体现了这种模式。
一两个人洗盘子为例,二者的劳动分工也是一种生产消费模式:其中一个人把喜好的盘子放在架子上,而另一个人从架子上取出盘子并把它们烘干。在这个实例中,架子相当于阻塞队列。如果架子上没有盘子,那么消费者会一直等待,知道有盘子需要烘干。如果架子上放满了,那么生产者会停止清洗直到架子上有更多的空间。我们可以将这种类比扩展为多个生产者和多个消费者,每个工人只需要与架子打交道。人们不需要知道究竟有多少生产者或消费者,或者是谁生产了某个指定的工作项。
“生产者”和“消费者”的角色是相对的,某种环境中的消费者在另一种不同的环境中可能会成为生产者。烘干盘子的工人将“消费”洗干净的盘子,而产生干燥的盘子。第三个工人把烘干后的盘子整理好,在这种情况下,烘干盘子的工人既是生产者、又是消费者,从而就有了两个共享的工作队列。
阻塞队列简化了消费者程序的编码,因为 take 操作会一直阻塞直到有可用的数据。如果生产者不能尽快产生工作项使消费者保持忙碌,那么消费者就只能保持等待,直到有工作可做。在某些情况下,这种方式是非常合适的,而在其他一些情况下,这也表示需要调整生产者线程数量和消费者线程数量之间的比例,从而实现更高的资源利用率。
如果生产者生成工作的效率比消费者处理工作的效率快,那么工作项会在队列中基类起来,最终耗尽内存。同样,put 方法的阻塞特性也极大地简化了生产者的编码。如果使用有界队列,那么当队列充满时,生产者将阻塞并且不能继续生成工作,而消费者就有时间来赶上工作处理速度。
阻塞队列同样提供了一个 offer 方法,如果数据项不能被添加到队列中,那么将返回一个失败状态。这样你就能够创建更多灵活的策略来处理符合过载的情况,例如减轻负载,将多余的工作项序列化并写入磁盘,减少生产者线程的数量,或者通过某种方式来抑制生产者线程。
在构建可靠的应用程序时,有界队列是一种强大的资源管理工具:它能能抑制并防止产生过多的工作项,使应用程序在符合过载的情况下变得更加健壮。
虽然生产者和消费者模式能够将生产者和消费者的代码彼此解耦,但它们的行为依然会通过共享队列间接耦合在一起。开发人员总会假设消费者处理工作的速率能够赶上生产者生成工作项的速率,因此通常不会为工作队列的大小设置边界,但这将导致在之后需要重新设计系统架构。因此,应该尽早通过阻塞队列在设计中构建资源管理机制——这件事情做得越早就越容易。在许多情况下,阻塞队列能够使这项工作更加简单,如果阻塞队列并不完全符合设计需求,那么还可以通过信号量来创建其他的阻塞数据结构。
在类库中包含了 BlockingQueue 的多种实现,其中 LinkedBlockingQueue 和 ArrayBlockingQueue 是 FIFO 队列,二者分别于 LinkedList 和 ArrayList 类似,但比同步 List 拥有更好的并发性能。PriorityBlockingQueue 是一个按优先级排序的队列,当你希望按照某种顺序而不是 FIFO 来处理元素时,这个队列将非常有用。正如其他有序的容器一样,PriorityBlockingQueue 既可以根据元素的自然顺序来比较元素,也可以自定义 Comparator。
最后一个 BlockingQueue 实现是 SynchronousQueue,实际上他不是一个真正的队列,因为他不会为队列中元素维护存储空间。与其他队列不同的是,它维护一组线程,这些线程在等待着把元素加入或移出队列。如果以洗盘子的比喻为例,那么就相当于没有架子,而是将喜好的盘子直接放入下一个空闲的烘干机中。这种实现队列的方式看似很奇怪,但由于可以直接交付工作,从而降低了间数据从生产者移动到消费者的延迟。直接交付方式还会将更多关于任务状态的信息反馈给生产者。当交付被接受时,它就知道消费者已经得到了任务,而不是把任务简单的放入一个队列——这种区别就好比将文件直接交给同事,还是将文件放到它的邮箱中并希望它尽快拿到文件。因为 SynchronousQueue 没有存储功能,因此 put 和 take 会一直阻塞,知道有另一个线程已经准备好参与到交付工作中。仅当有足够多的消费者,并且总有一个消费者准备好后去交付的工作时,才适合使用同步队列。
5.3.1 示例:桌面搜索
有一种类型的程序适合被分解为生产者和消费者,如代理程序,它将扫描本地驱动器上的文件并建立索引以便随后进行搜索,类似于这些桌面搜索程序或者 Windows 索引服务。在程序清单 5-8 的 DiskCrawler 中给出了一个生产者任务,即在某个文件层次架构中搜索符合索引标准的文件,并将它们的名称放入工作队列。而且,在 Indexer 中还给出了一个消费者任务,即从队列中取出文件名称并对它们建立索引。
public class FileCrawler implements Runnable {
private final BlockingQueue<File> fileQueue;
private final FileFilter fileFilter;
pribate final File root;
...
public void run() {
try {
crawl(root);
} catch (InterruptedException e) {
Thread.currentThread.interrupt();
}
}
private void crawl(File root) thorows InterruptedException {
File[] entries = root.listFiles(fileFilter);
if(entries != null) {
for(File entry : entries){
if(entry.isDierctory())
crawl(entry);
else if(!alreadyIndexed(entry))
fileQueue.put(entry);
}
}
}
}
public class Indexer implements Runnable {
private final BlockingQueue<File> queue;
public Indexer(BlockingQueue<File> queue) {
this.queue = queue;
}
public void run() {
try {
while(true){
indexFile(queue.take());
}
} catch(InterruptedException e) {
Thread.currentThread.interrupt();
}
}
}
生产者消费者模式提供了一种适合线程的方法将桌面搜索问题分解为更简单的组件。将文件遍历与建立索引等功能分解为独立的操作,比将所有功能都放在一个操作中实现有着更高的代码可读性和可重用性:每个操作只需完成一个任务,并且阻塞队列将负责所有的控制流,因此每个功能的代码更加清晰简单。
生产消费模式同样能带来许多性能优势。生产者和消费者可以并发的执行。如果一个是 IO 密集型,另一个是 CPU 密集型,那么并发执行的吞吐率要高于串行执行的吞吐率。如果生产者和消费者的并行度不同,那么将它们紧密耦合在一起会把整体并行度降低为二者中更小的并行度。
在程序清单 5-9 中启动了多个爬虫程序和索引建立程序,每个程序都在各自的线程中运行。前面曾讲,消费者线程永远不会退出,因而程序无法终止,第 7 章将介绍多种技术来解决这个问题。虽然这个示例使用了显式管理的线程,但许多生产消费者设计也可以通过 Executor 任务执行框架来实现,其本身也使用了生产消费模式。
5.3.2 串行线程封闭
在 JUC 中实现的各种阻塞队列都包含了足够的内部同步机制,从而安全的将对象从生产者线程发布到消费者线程。
对于可变对象,生产消费这种设计与阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付给消费者。线程封闭对象只能由单个线程拥有,但可以通过安全的发布该对象来转移所有权。在转移所有权之后,也只有另一个线程能获得这个对象的访问权限,并且发布该对象的线程无法再访问它。这种安全的发布确保了对象状态对于新的所有者来说是可见的,并且由于最初的所有者不会再访问它,因此对象被封闭在新的线程中。新的所有者线程可以对该线程做任意修改,因为它拥有独占的访问权。
对象池利用了串行封闭技术,将对象“借给”一个请求线程。只要对象池包含足够的内部同步来安全的发布池中的对象,并且只要客户端本身不会发布池中的对象,或者在将对象返回给对象池后就不再使用它,那么就可以安全的在线程之间传递所有权。
我们也可以使用其他发布机制来传递可变对象的所有权,但必须确保只有一个线程能接收被转移的对象。阻塞队列简化了这项工作。除此之外,可以通过 ConcurrentMap 的原子方法 remove 或 AtomicReference 的原子方法 compareAndSet 来完成这项工作。
5.3.3 双端队列与工作密取
Java 6 增加了两种容器类型,Deque(读作 “deck”) 和 BlockingQueue,它们分别对 Queue 和 BlockingQueue 进行了扩展。Deque 是一个双端队列,实现了在队列头和队列尾的高效插入和移除。具体实现还包括 ArrayDeque、LinkedBlockingDeque。
正如阻塞队列适用于生产消费模式,双端队列同样适用于另一种相关的模式,即工作窃取(Work Stealing)。在生产消费设计中,所有消费者有一个共享的工作队列,而在工作窃取设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己的双段队列的全部工作,那么它可以从其他消费者双端队列末尾窃取工作。窃取工作模式比传统的的生产消费模式具有更高的可伸缩性,这是因为工作者线程不会在单个共享队列上发生竞争。在大多数时候,它们只是访问自己的双端队列,从而极大减少了竞争。当工作者线程需要访问另一个队列时,它会从队列的尾部而不是从头部获取工作,因此进一步降低了队列上的竞争程度。
工作窃取非常适用于既是消费者又是生产者的问题——当执行某个工作时可能导致出现更多的工作。例如,在网页爬虫程序中处理一个页面时,通常会发现有更多的页面需要处理。类似的还有许多图搜索算法,例如在垃圾回收阶段对堆进行标记,都可以通过工作窃取机制来实现高效并发。当一个工作线程找到新的任务单元时,它会将其放到自己队列的末尾(或者在工作共享设计模式中,放入其他工作者线程的队列中)。当双端队列为空时,它会在另一个线程的队列末尾查找新的任务,从而确保每个线程都保持忙碌状态。
5.4 阻塞方法与中断方法
线程可能会阻塞或暂停执行,原因有多种:等待 IO 操作结束,等待获得一个锁,等待从 Thread.sleep 方法中醒来,或者是等待另一个线程的计算结果。当线程阻塞时,它通常会被挂起,并处于某种阻塞状态(BLOCKED/WAITING/TIMED_WAITING)。阻塞操作与执行时间很长的普通操作的差别在于,被阻塞的线程必须等待某个不受它控制的事件的发生后才能继续执行,比如等待 IO 操作完成,等待某个锁变为可用,或者等待外部计算结束。当某个外部事件发生时,线程被置回 RUNNABLE 状态,并可以再次被调用执行。
BlockingQueue 的 put 和 take 等方法会抛出受检异常 InterruptedException,这与类库中其他一些方法的做法相同,例如 Thread.sleep。当某个方法被声明为会抛出中断异常时,表示该方法是一个阻塞方法,如果这个方法被中断,那么它将努力提前结束阻塞状态。
Thread 提供了 interrupt 方法,用于中断线程或查询线程是否已经被中断。每个线程都有一个布尔值属性,表示线程的中断状态,当中断线程时将设置该状态为 true。
中断是一种协作机制,一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作。当线程 A 中断 B 时,A 仅仅是要求 B 在执行到某个可暂停的地方时停止正在执行的操作——前提是如果线程 B 愿意停下来。虽然在 API 或者语言规范中并没有为中断定义任何特定应用级别的语义,但最常用中断的情况就是取消某个操作。方法对中断请求的响应度越高,就越容易及时取消那些执行时间很长的操作。
当在代码中调用一个将抛出中断异常的方法时,你自己的方法也就变成了一个阻塞方法,并且必须要处理中断的响应。对于库代码来说,有两种方案可供选择:
- 传递中断异常。避开这个异常通常是最明智的选择——只需要把中断异常继续传递给外层调用者。传递中断异常的方法包括,根本不捕获该异常,或者捕获该异常,然后在执行某种简单的轻量工作后再次抛出该异常。
- 恢复中断。有时候不能抛出中断异常,例如当代码是 Runnable 的一部分时。在有些情况下,必须捕获中断异常,并通过调用当前线程上的 interrupt 方法恢复到中断状态,这样在调用栈中更高层的代码将看到引发了一个中断,如程序清单 5-10 所示。
public class TaskRunnable implements Runnable {
BlockingQuque<Task> queue;
public void run() {
try {
process(queue.take());
} catch(InterruptedException e) {
// 恢复被中断的状态
Thread.currentThread().interrupt();
}
}
}
还可以采用一些更复杂的中断处理方法,但上述两种方法已经可以应付大多数场景了。然而在出现中断异常时不应该做的事情是,捕获它但不做任何响应。这将使调用栈上更高层的代码无法对中断采取处理措施,因为线程被中断的证据已经丢失。只有在一种特殊的情况下才可以屏蔽中断,即对 Thread 进行扩展,并且能够控制调用栈上所有更高层的代码。第 7 章将进一步介绍取消和中断操作。
5.5 同步工具类
在容器类中,阻塞队列是一种特殊的类:它们不仅作为保存对象的容器,还能协调生产者和消费者之间的控制流,因为 take 和 put 等方法将阻塞,直到队列达到期望的状态(对既非空也不满)。
同步工具类可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流。阻塞队列可以作为一个同步工具类,其他类型的同步工具类还有信号量(Semaphore)、栅栏(Barrier)、闭锁(Latch)。在平台类库中还包含一些其他同步工具类,如果这些类无法满足需要,那么可以按照第 14 章给出的机制来构建自己的同步工具类。
所有的同步工具类都包含一些特定的结构化属性:它们封装了状态,这些状态将决定执行同步工作类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另一些方法用于高效的等待同步工具类进入预期状态。
5.5.1 闭锁
闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程可以通过,当到达状态时,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态后,将不会在改变状态,因此这扇门将永远保持打开状态。闭锁可以用来确保某些活动直到其他活动都完成后才继续进行,比如:
- 确保某个计算在其需要的所有资源都被初始化之后才继续进行。二元闭锁(包含两个状态)可以用来表示“资源 R 已经被初始化”,而所有需要 R 的操作都必须先在这个闭锁上等待。
- 确保某个服务在其依赖的所有其他服务都已启动之后才启动。每个服务都有一个相关的二元闭锁。当启动服务 S 时,将首先在 S 依赖的其他服务的闭锁上等待,在所有依赖的服务都启动后会释放闭锁 S,这样其他依赖 S 的服务才能继续执行。
- 等待直到某个操作的所有参与者(例如在多玩家游戏中的所有玩家)都就绪再继续执行。在这种情况下,当所有玩家都准备继续时,闭锁将到达结束状态。
CountDownLatch 是一种灵活的闭锁实现,可以在上述各种情况中使用,它可以使一个或多个线程等待一组事件发生。闭锁状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量。countDown 方法将递减计数器,表示已经发生了一个事件,而 await 方法等待计数器到达零,或者等待中的线程中断,或者等待超时。
在程序清单 5-11 中的 TestHarness 中给出了闭锁的两种常见方法。TestHarness 创建一定数量的线程,利用它们并发执行指定的任务。它使用两个闭锁,分别表示“起始门”和“结束门”。起始门的计数器为 1,而结束门的计数器为工作线程的数量。每个工作线程首先要做的事就是在启动门上等待,从而确保所有线程都已就绪后才开始执行。而每个线程要做的最后一件事就是调用结束门的 countDown 方法将计数器递减 1,这能使主线程高效的等待直到所有工作线程都执行完成,因此可以统计所消耗的时间。
public class TestHarness {
public long timeTasks(int nThreads, final Runnable task)
throws InterruptedException {
final CountDownLatch startGate =
new CountDownLatch(1);
final CountDownLatch endGate =
new CountDownLatch(nThreads);
for (int i = 0; i < nThreads; i++) {
Thread t = new Thread() {
public void run() {
try {
startGate.await();
try { task.run(); }
finally { endGate.countDown(); }
} catch (InterruptedException ignored) { }
}
};
t.start();
}
long start = System.nanoTime();
startGate.countDown();
endGate.await();
long end = System.nanoTime();
return end-start;
}
}
这里为什么要使用闭锁,而不是在线程创建后直接启动?或许,我们希望测试 N 个线程并发执行某个任务时需要的时间。如果在创建线程后立即启动,那么先启动的线程将“领先”后启动的线程,并且活跃线程数量会随着时间的推移而增加或减少,蒋政程度也在不断的发生变化。启动门将使得主线程能够同时释放所有都工作线程,而将结束门则使主线程能够等待最后一个线程执行完成,而不是顺序的等待各个线程执行完成。
5.5.2 FutureTask
FutureTask 也可以用作闭锁。FutureTask 实现了 Future 语义,表示一种可生产结果的计算。FutureTask 表示的计算是通过 Callable 来实现的,相当于一种可生成结果的 Runnable,并且可以处于以下三种状态:等待运行、正在运行、运行完成。“执行完成”表示计算的所有可能的结束方式,包括正常结束、由于取消而结束、由于异常而结束等。当 FutureTask 进入完成状态后,它会永远停止在这个状态上。
Future.get 的行为取决于任务的状态。如果任务已经完成,会立即返回结果,否则,get 调用将阻塞直到任务进入完成状态,然后返回结果或抛出异常。FutureTask 将计算结果从执行计算的线程传递到获取结果的线程,而 FutureTask 的规范确保了这种传递过程能实现结果的安全发布。
FutureTask 在 Executor 框架中表示异步任务,此外还可以用于表示一些时间较长的计算,这些计算可以在使用计算结果之前启动。程序清单 5-12 中的 Preloader 就使用了 FutureTask 来执行一个高开销的计算,并且计算结果将在稍后使用。通过提前启动计算,可以减少在等待结束时需要的时间。
public class Preloader {
private final FutureTask<ProductInfo> future =
new FutureTask<ProductInfo>(new Callable<>(){
public ProductInfo call() throws DataLoadException {
return loadProductInfo();
}
});
private final Thread thread = new Thread(future);
public void start() { thread.start(); }
public ProductInfo get() throws ex..{
try {
return future.get();
} catch (ExecutionException e){
Throwable cause = e.getCause();
if (cause instanceof DataLoadException)
throw (DataLoadException) cause;
else throw launderThrowable(cause);
}
}
}
Preloader 创建了一个 FutureTask,其中包含从数据库加载产品信息的任务,以及一个执行运算的线程。由于在构造函数或静态初始化方法中启动线程并不是一种好方法,因此提供了一个 start 方法来启动线程。当程序随后需要 ProductInfo 时,可以调用 get 方法,如果数据已经加载,那么将返回这些数据,否则将等待直到加载完成后再返回。
Callable 表示的任务可以抛出受检异常或未检异常,并且任何代码都可能抛出一个 Error。无论任务代码抛出什么异常,都会被封装到一个 ExecutionException 中,并在 Future.get 中重新被抛出。这将使调用 get 的代码变得复杂,因为不仅要处理可能出现的 ExecutionException,而且还由于 ExecutionException 是座椅个 Throwable 返回的,因此处理起来并不容易。
在 Preloader 中,当 get 方法抛出 ExecutionException 时,可能是以下三种情况之一:Callable 抛出的受检异常、RuntimeException、Error。我们必须对每种情况进行单独处理,但我们将使用程序清单 5-13 中的 launderThrowable 辅助方法来封装一些复杂的异常处理逻辑。在调用 launderThrowable 之前,Preloader 会首先检查已知的受检异常,并重新抛出它们,Preloader 将调用 launderThrowable 并抛出结果。如果 Throwable 传递给 launderThrowable 的是一个 Error,那么 launderThrowable 将直接再次将其抛出;如果不是 RuntimeException,那么将抛出一个非法状态异常表示这是一个逻辑错误。剩下的 RuntimeException,launderThrowable 将把它返回给调用者,而调用者通常会重新抛出这些异常。
/** If the Throwable is an Error, throw it; if it is a
* RuntimeException return it, otherwise throw IllegalStateException
*/
public static RuntimeException launderThrowable(Throwable t) {
if (t instanceof RuntimeException)
return (RuntimeException) t;
else if (t instanceof Error)
throw (Error) t;
else
throw new IllegalStateException("Not unchecked", t); }
5.5.3 信号量
计数信号量用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。
Semaphore 中管理着一组虚拟许可,许可的和数量可以通过构造函数来指定。在执行操作时可以首先获得许可(如果还有剩余的许可),并在使用后释放许可。如果没有许可,那么 acquire 将阻塞直到有许可(或者直到被中断或者操作超时)。release 方法将返回一个许可给信号量。计算信号量的一种简化形式是二值信号量,即初始值为 1 的 Semaphore。二值信号量可以用作互斥体(mutex),并具备不可重入的加锁语义:谁拥有这个唯一的许可,谁就拥有了互斥锁。
Semaphore 可以用于实现资源池,例如数据库连接池。我们可以构造一个固定长度的资源池,当池为空时,请求资源将会失败,但你真正希望看到的行为是阻塞而不是失败,并且当池非空时解除阻塞。如果将 Semaphore 的计数值初始化为池的大小,并在池中获取一个资源之前首先调用 acquire 方法获得一个许可,在将资源返回给池之后调用 release 释放许可,那么 acquire 将一直阻塞直到资源池不为空。在第 12 章的有界缓冲类中将使用这项技术。(在构造阻塞对象池时,一种更简单的方法是使用 BlockingQueue 来保存池的资源。)
同样,你也可以使用 Semaphore 将任何一种容器变为有界阻塞容器,如程序清单 5-14 中的 BoundledHashSet 所示。信号量的计数值会初始化为容器容量的最大值。add 操作在向底层容器中添加一个元素之前,首先要获取一个许可。如果 add 操作没有添加任何一个元素,那么会立刻释放许可。同样,remove 操作释放一个许可,使更多元素能够添加到容器中。底层的 Set 实现并不知道关于边界的任何信息,这是由 BoundedHashSet 来处理的。
public class BoundedHashSet<T> {
private final Set<T> set;
private final Semaphore sem;
public BoundedHashSet(int bound) {
this.set =
Collecitons.synchronizedSet(new HashSet<T>());
this.sem = new Semaphore(bound);
}
public boolean add(T o) throws InterruptedException {
sem.acquire();
boolean wasAdded = false;
try {
wasAdded = set.add(o);
return wasAdded;
} finally {
if(!wasAdded) sem.release();
}
}
public boolean remove(Object o) {
boolean wasRemoved = set.remove(o);
if(wasRemoved)
sem.release();
return wasRemoved;
}
}
5.5.4 栅栏
我们已经看到通过闭锁来启动一组相关的操作,或者等待一组相关的操作结束。闭锁是一次性对象,一旦进入终止状态,就不能再被重置。
栅栏类似于闭锁,它能阻塞一组线程直到某个事件繁盛。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。栅栏用于实现一些协议,例如几个家庭决定在某个地方回合:“所有人 6:00 在麦当劳碰头,到了以后要等其他人,之后再讨论下一步要做的事情。”
CyclicBarrier 可以使用一定数量的参与方反复的在栅栏处汇集,它在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列互相独立的子问题。当线程到达栅栏位置时将调用 await 方法,这个方法将阻塞直到所有线程都到达栅栏位置。如果所有线程都到达了栅栏位置,那么栅栏将打开,此时所有线程都被释放,而栅栏将被重置以便下次使用。如果对 await 的调用超时,或者 await 阻塞的线程被中断,那么栅栏就被认为是打破了,所有阻塞的 await 调用都将终止并抛出 BrokenArrierException。如果成功通过栅栏,那么 await 将为每个线程返回一个唯一的到达索引号,我们可以利用这些索引来“选举”产生一个领导线程,并在下一次迭代中由该领导线程执行一些特殊的工作。CyclicBarrier 还可以使你将一个栅栏操作传递给构造函数,这是一个 Runnable,当成功通过栅栏时会(在一个子任务线程中)执行它,但在阻塞线程被释放之前是不能执行的。
在模拟程序中通常要使用栅栏,例如某个步骤中的计算可以并行执行,但必须等到该步骤中的所有计算都执行完毕才能进入下一个步骤。例如,在 n-body 粒子迷模拟系统中,每个步骤都根据其他粒子的位置和属性来计算各个粒子的新位置。通过在么两次调用之间等待栅栏,能够确保在第 K 步中的所有操作都已经计算完成,才进入第 k+1 步。
在程序清单 5-15 的 CellularAutomata 中给出了如何通过栅栏来计算细胞的自动化模拟,例如 Conway 的生命游戏。在吧模拟过程并行化后,为么个元素(在该例子中相当于一个细胞)分配一个独立的线程是不现实的,因为浙江产生过多的线程,而在协调这些线程上导致的开销将降低计算性能。合理的做法是,将问题分解成一定数量的子问题,为每个子问题分配一个线程来进行求解,之后再将所有的结果合并起来。CellularAutomata 将问题分解为 N(CPU) 个子问题,其中 N 等于 CPU 的数量,并将每个子问题分配给一个线程。在每个步骤中,工作线程都为各自问题中的所有细胞计算新值。但所有工作线程都到达栅栏时,栅栏会把这些新值提交给数据模型。在栅栏的操作执行完成以后,工作线程将开始下一步的计算,包括调用 isDone 方法来判断是否还需要进行下一步迭代。
public class CellularAutomata {
private final Board minBoard;
private final CyclicBarrier barrier;
private final Worker[] workers;
public CellularAutomate(Board board) {
this.mainBoard = board;
int count = Runtime.getRuntime().availiableProcessessors();
this.barrier = new CyclicBarrier(count,
new Runnable() {
public void run() {
mainBoard.commitNewValues();
}});
this.workers = new Wokrer[count];
for(int i=0; i<count; i++)
workers[i] =
new Worker(mainBoard.getSubBoard(count, i));
}
private class Worker implements Runnable {
private final Board board;
public Worker(Board board) {
this.board = board;
}
public void run() {
while(!board.hasConverged()) {
for(int x=0; x<board.getMaxX(); x++)
for(int y=0; y<board.getMaxY(); y++)
board.setNewValue(
x,y,computeValue(x,y));
try {
barrier.await();
} catch (InterruptedException ex) {
return;
} catch (BrokenBarrierException ex){
return;
}
}
}
}
}
另一种形式的栅栏是 Excheanger,它是一种两方栅栏,各方在栅栏位置上交换数据。当两方执行不对称的操作时,Exchanger 会非常有用,比如当一个线程向缓冲区写入数据,而另一个线程从缓冲区中读取数据。这些线程可以使用 Exchanger 来汇合,并将满的缓冲区与空的缓冲区交换。当两个线程通过 Exchanger 交换对象时,这种交换就把这两个对象安全的发布给对方。
数据交换的时机取决于应用程序的响应需求。最简单的方案是,当缓冲区被填满时,由填充任务进行交换,当缓冲区为空时,由清空任务进行交换。这样会把需要交换的次数将至最低,但如果新数据的到达率不可预测,那么一些数据的处理过程就将延迟。另一个方法是,不仅当缓冲区被填满时进行交换,并且当缓冲被填充到一定程度并保持一段时间后,也进行交换。
5.6 构建高效且可伸缩的结果缓存
几乎所有的服务器应用程序都会使用某种形式的缓存。重用之前的计算结果能降低延迟,提供吞吐量,但却需要消耗更多的内存。
像许多重复发明的轮子一样,缓存看上去都非常简单。然而,简单的缓存可能会将性能瓶颈转换为可伸缩性瓶颈,即使缓存是用于提升单线程的性能。本节我们将开发一个高效且可伸缩的缓存,用于改进一个高计算开销的函数。我们首先从简单的 HashMap 开始,然后分析它的并发缺陷性,并讨论如何修复它。
在程序清单 5-16 的 Computable<A,V>
接口中声明了一个函数 Computable,其输入类型为 A,输出类型为 V。在 ExpensiveFunction 中实现的 Conputable,需要很长时间来计算结果,我们将创建一个 Computable 包装器,用于记住之前的计算结果,并将缓存过程封装起来。这被称为记忆化。
public interface Computable<A,V> {
V compute(A arg) throws InterruptedException;
}
public class ExpensiveFunction implements Computable<String,BigInteger> {
public BigInteger compute(String arg) {
// 在经过长时间的计算后
return new BigInteger(arg);
}
}
public class Memoizer1<A,V> implements Computable<A,V> {
@GuardedBy("this")
private fianl Map<A,V> cache = new HashMap<>();
private fianl Computable<A,V> c;
public Memorizer(Computable<A,V> c) {
this.c = c;
}
public synchronized V compute(A arg)
throws InterruptedEx {
V result = cache.get(arg);
if(result == null){
result = c.compute(arg);
cache.put(arg, result);
}
return result;
}
}
在程序清单 5-16 中的 Memorizer1 中给出了第一种尝试:使用 HashMap 来保存之前计算的结果。compute 方法首先将检查需要的结果是否已经在缓存中,如果存在则返回之前计算的结果。否则,将把计算结果缓存在 HashMap,然后再返回。
HashMap 不是线程安全的,因此要确保两个线程不会同时访问 HashMap,Memorizer1 采用了一种保守的方法,即对整个 compute 方法进行同步。这种方法能确保线程安全性,但会带来一种明显的可伸缩性问题:每次只有一个线程能够执行 compute。如果另一个线程正在计算结果,那么其他调用 conpute 的线程可能需要被阻塞很长一段时间。如果有多个线程在排队等待还未计算出的结果,那么 compute 方法的计算时间可能比没有记忆操作的计算时间更长。在图 5-2 中给出了当多个线程使用这种方法中的记忆操作时发生的情况,这显然不是我们希望通过缓存获得的性能提升结果。
程序清单 5-17 中的 Memorizer2 用 ConcurrentHashMap 代替 HashMap 来改进 Memorizer1 中糟糕的并发行文。由于 ConcurrentHashMap 是线程安全的,因此在访问底层 Map 时就不需要进行同步,因而避免了在对 Memorizer1 中的 compute 方法进行同步时带来的串行性。
Memorizer2 比 Memorizer1 有着更好的并发行为:多线程可以并发的使用它。但它在作为缓存时仍然存在一些不足——当两个线程同时调用 compute 时存在一个漏洞,可能会导致计算得到相同的值。在使用 memorization 的情况下,这只会带来低效,因为缓存的作用是避免相同的数据被计算多次。但对于更通用的缓存机制来说,这种情况将更为糟糕。对于只提供单词初始化的对象缓存来说,这个漏洞就会带来安全风险。
public class Memorizer2<A,V> implements Computable<A,V> {
private final Map<A,V> cache = new ConcurrentHashMap<>();
private final Computable<A,V> c;
public Memorizer2(Computable<A,V> c) {
this.c = c;
}
public V compute(A arg) throws InterrputedEx {
V result = cache.get(arg);
if(result == null){
result = c.compute(arg);
cache.put(arg, result);
}
return result;
}
}
Memorizer2 的问题在于,如果某个线程启动了一个开销很大的计算,而其他线程并不知道这个计算正在进行,那么很可能会重复计算,如图 5-3 所示。我们系统通过某种方法来表达“线程 X 正在计算 f(27)” 这种情况,这样当另一个线程查找 f(27) 时,它能够知道最高效的方法是等待线程 X 计算完成,然后再去查询缓存。
我们已经知道有一个类能够实现这个功能:FutureTask。FuturTask 表示一个计算的过程,该过程可能已经计算完成,也可能正在进行。如果有结果可用,那么 FutureTask.get 将立即返回结果,否则它会一直阻塞,直到结果结算出来再将其返回。
程序清单 5-18 中的 Memorizer3 将用于缓存值的 Map 重新定义为 ConcurrentHashMap<A, Future
public class Memorizer3<A, V> implements Computable<A, V> { private final Map<A, Future<V>> cache =
new ConcurrentHashMap<>();
private final Computable<A, V> c;
public Memorizer3(Computable<A, V> c) { this.c = c; }
public V compute(final A arg) throws InterruptedEx { Future<V> f = cache.get(arg);
if (f == null) {
Callable<V> eval = new Callable<V>() {
public V call() throws InterruptedException {
return c.compute(arg);
}
};
FutureTask<V> ft = new FutureTask<V>(eval);
f = ft;
cache.put(arg, ft);
ft.run(); // call to c.compute happens here
}
try {
return f.get();
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
Memorizer3 的实现几乎是完美的:它表现出了非常好的并发性(基本上是源自 ConcurrentHashMap 的并发性),如果结果已经计算出来,那么将立即返回。如果其他线程正在计算该结果,那么新到的线程将一直等待这个结果被计算出来。它只有一个缺陷,即仍然存在两个线程计算出相同值的漏洞。该漏洞这时的发生概率要远小于 Memorizer2,但由于 conpute 方法中的 if 代码块仍然是非原子的“先检查再执行”操作,因此两个线程仍有可能在同一时间内调用 compute 来计算相同的值,即二者都没有在缓存中找到期望的值,因此都开始计算。这个错误的执行时序如图 5-4 所示:
Memorizer3 中存在这个问题的原因是,符合操作是在底层的 Map 对象上执行的,而这个对象无法通过加锁来确保原子性。程序清单 5-19 中的 Memorizer 使用了 ConcurrentMap 中的原子方法 putIfAbsent,避免了 Memorizer3 的漏洞。
public class Memorizer<A, V> implements Computable<A, V> {
private final ConcurrentMap<A, Future<V>> cache =
new ConcurrentHashMap<>();
private final Computable<A, V> c;
public Memorizer(Computable<A, V> c) { this.c = c; }
public V compute(final A arg) throws InterruptedEx {
while (true) {
Future<V> f = cache.get(arg);
if (f == null) {
Callable<V> eval = new Callable<V>() {
public V call() throws InterruptedEx {
return c.compute(arg);
}};
FutureTask<V> ft = new FutureTask<V>(eval);
f = cache.putIfAbsent(arg, ft);
if (f == null) {
f = ft; ft.run();
}
}
try { return f.get(); }
catch (CancellationException e) {
cache.remove(arg, f);
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
}
当缓存的是 Future 而不是值时,将导致缓存污染问题:如果某个计算被取消或失败,那么在计算这个结果时将指明计算过程被取消或失败。为了避免这种情况,如果 Memorizer 发现计算被取消,那么将把 Future 从缓存中删除。如果检测到 RuntimeException,那么也会移除 Future,这样将来的计算结果才能成功。Memorizer 同样没有解决缓存逾期的问题,但它可以通过使用 FutureTask 子类来解决,在子类中为每个结果指定一个预期时间,并定期扫描缓存中逾期的元素。(同样,它没有解决缓存清理的问题,即移除旧的计算结果以便为新的计算腾出空间,从而避免缓存效果过多的内存)。
在完成并发缓存的实现后,就可以为第二章中因式分解 servlet 添加结果缓存。程序清单 5-20 中的 Factorizer 使用 Memorizer 来缓存之前计算的结果,这种方式不仅高效,而且扩展性也高。
@ThreadSafe public class Factorizer implements Servlet {
private final Computable<BigInteger, BigInteger[]> c =
new Computable<BigInteger, BigInteger[]>() {
public BigInteger[] compute(BigInteger arg) {
return factor(arg);
}
};
private final Computable<BigInteger, BigInteger[]> cache =
new Memorizer<BigInteger, BigInteger[]>(c);
public void service(ServletRequest req, ServletResponse resp) {
try {
BigInteger i = extractFromRequest(req);
encodeIntoResponse(resp, cache.compute(i));
} catch (InterruptedException e) {
encodeError(resp, "factorization interrupted");
}
}
}
第一部分小节
- 可变状态是至关重要的。所有的并发问题都可以归结为如何协调对并发状态的访问。可变状态越少,就越容易确保线程安全。
- 尽量将域声明为 final 类型,除非需要它们是可变的。
- 不可变对象一定是线程安全的。不可变对象能极大的降低并发编程的复杂性。它们更为简单且安全,可以任意共享而无需使用加锁或保护性复制等机制。
- 封装有助于管理复杂性。在编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,但为什么还要封装?将数据封装在对象中,更易于维持不变性条件:将同步机制封装在对象中,更易于遵循同步策略。
- 用锁来保护每个可变变量。
- 当保护一个不变形条件中的所有变量时,要使用同一个锁。
- 在执行符合操作期间,要持有锁。
- 如果从多个线程中访问同一个可变变量时没有同步机制,那么程序会出错。
- 不要故作聪明的推断出不需要使用同步。
- 在设计过程中考虑线程安全,或者在文档中明确指出是否是线程安全的。
- 将同步策略文档化。
1.6 - CH06-任务执行
大多数并发应用都是围绕“任务执行”来构造的:任务通常是一些抽象的离散的工作单元。通过把应用程序的工作分解到多个任务中,可以简化程序的组织结构,提供一种自然的事务边界来优化错误恢复过程,以及提供一种自然的并行工作结构来提升并发性。
6.1 在线程中执行任务
当围绕“任务执行”来设计应用程序结构时,第一步就是要找出清晰的任务边界。在理想情况下,各个任务之间是相互独立的:任务并不依赖于其他任务的状态、结果或边界效应。独立性有助于实现并发,因为如果存在足够多的处理资源,那么这些独立的任务都可以并行执行。为了在调度与负载均衡过程中实现更高的灵活性,每项任务还应该表示应用程序的一部分处理能力。
在正常的负载下,服务器应用程序应该同时表现出良好的吞吐量和快速的响应性。应用程序提供商希望程序支持尽可能多的用户,从而降低每用户的服务成本,而用户则希望获得尽快的响应。而且,当符合过载时,应用程序的性能应该是逐渐降低,而不是直接失败。要实现上述目标,应该选择清晰的任务边界以及明确的任务执行策略。
大多数服务器应用程序都提供了一种自然的任务边界选择方式:以独立的客户请求为边界。Web 服务器、邮件服务器、文件服务器、EJB 容器以及数据库服务器等,这些服务器都能通过网络接受远程客户的连接请求。将独立的请求作为任务边界,既可以实现任务的独立性,又可以实现合理的任务规模。例如,在向邮件服务器提交一个消息得到的结果,并不会受其他正在处理的消息的影响,而在处理消息时通常只需要服务器总处理能力的很小一部分。
6.1.1 串行的执行任务
在应用程序中可以通过多种策略来调度任务,而其中一些策略能够更好的利用潜在的并发性。最简单的策略就是在单个线程中串行执行各项任务。程序清单 6-1 中的 SingleThreadWebServer 将串行的处理它的任务。至于如何处理请求的细节问题,在这里并不重要,我们感兴趣的是如何表征不同调度策略的同步特性。
class SingleThreadWebServer {
public static void main(String...args) throws IOEx {
ServerSocket socket = new ServerSocket(80);
while(true) {
Socket connection = socket.accept();
handleRequest(connection);
}
}
}
SingleThreadWebServer 很简单,且在理论上是正确的,但在实际生产环境中的执行性能却很糟糕,因为它每次只能处理一个请求。主线程在接受与处理请求等操作之间不断的交替运行。当服务器正在处理请求时,新到来的连接必须等待直到请求处理完成,然后服务器将再次调用 accept。如果处理请求的速度很快并且 handleRequest 可以立即返回,那么这种方法是可行的,但现实世界中的 Web 服务器的情况却并非如此。
在 Web 请求的处理中包含了一组不同的运算与 IO 操作。服务器必须处理套接字 IO 以读取请求和写回响应,这些操作通常会由于网络拥堵或连通性问题而被阻塞。此外,服务器还可能处理文件 IO 或数据库请求,这些操作同样会阻塞。在单线程的服务器中,阻塞不仅会推迟当前请求的完成时间,而且还将彻底阻止等待中的请求被处理。如果请求阻塞的时间过长,用户将任务服务器是不可用的,因为服务器看似失去了响应。同时,服务器的资源利用率却非常低,因为当单线程在等待 IO 操作完成时,CPU 将处于空闲状态。
在服务器应用程序中,串行处理机制通常无法提供高吞吐率或快速响应性。也有一些例外,例如,当任务数量很少且执行时间很长时,或者当服务器只需要单个用户提供服务,并且该用户每次只发出一个请求——但大多数服务器应用程序并不是按照这种方式工作的。
6.1.2 显式的为任务创建线程
通过为每个请求创建一个新的线程来提供服务,从而实现更好的响应性,如程序清单 6-2 中的 ThreadPerTaskWebServer 所示。
class ThreadPerTaskWebServer {
public static void main(String...args) {
ServerSocket socket = new ServerSocket(80);
while(true) {
final Socket connection = socket.accept();
Runnable task = new Runnable() {
public void run() {
handleRequest(connection);
}
};
new Thread(task).start();
}
}
}
ThreadPerTaskWebServer 在架构上类似于前面的单线程版本——主线程仍然不断的交替执行“接受外部连接”与“分发请求”等操作。区别在于,对于每个连接,主循环都将创建一个新线程来处理请求,而不是在主循环中进行处理。由此可以得出 3 个主要结论:
- 任务处理过程从主线程中分离出来,使得主循环能够更快的重新等待下一个到来的连接。这使得程序在完成前面的请求之前可以接受更多的请求,从而提高响应性。
- 任务可以并行处理,从而能同时服务多个请求。如果有多个服务器,或者任务由于某种原因被阻塞,例如等待 IO 完成、获取锁或资源可用性等,程序的吞吐量将得到提升。
- 任务处理代码必须是线程安全的,因为会有多个任务并发的调用这段代码。
在正常负载情况下,“为每个任务分配一个线程”的方法能提升串行执行的性能。只要请求的到达率不超出服务器的请求处理能力,那么这种方法可以同时带来更快的响应性和更高的吞吐率。
6.1.3 无限制创建线程的不足
在生产环境中,“为每个任务分配一个线程”这种方法是存在缺陷的,尤其是当需要创建大量线程时。
线程生命周期的开销很高。 线程的创建与销毁并不是没有代价的。根据平台的不同,实际的开销也会有所不同,但线程的创建过程都需要时间,延迟处理的请求,并且需要 JVM 和操作系统提供一些辅助操作。如果请求的到达率非常高且处理过程是轻量级的,例如大多数服务器应用程序就是这种情况,那么为么个请求创建一个新的线程将消耗大量计算资源。
资源消耗。 活跃的线程会消耗系统资源,尤其是内存。如果可运行的线程数量对于可用处理器的数量,那么有些线程将闲置。大量空闲的线程会占用许多内存,给垃圾回收器带来压力,而且大量线程在竞争 CPU 资源时还将产生其他的性能开销。如果你已经拥有足够多的线程使所有 CPU 保持忙碌状态,那么再创建更多的线程反而会降低性能。
稳定性。 在可创建线程的数量上存在一个限制。这个限制值将随平台的不同而变化,并且受多个因素影响,包括 JVM 的启动参数、Thread 构造函数中请求的栈大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,那么很可能抛出 OOM 异常,要想从这种错误中恢复过来是非常危险的,更简单的办法设是通过构造程序来避免超出这些限制。
在一定的范围内,增加线程可以提高系统的吞吐率,但如果超出了这个范围,再创建更多的线程只会降低程序的执行速度,并且如果过多的创建一个线程,那么整个应用程序将会崩溃。要想避免这种危险,就应该对应用程序可以创建的线程数量进行限制,并且全面的测试应用程序,从而确保在线程数量到达限制时,程序也不会耗尽资源。
“为每个任务分配一个线程”这种方法的问题在于,他没有限制可创建线程的数量,只限制了远程用户提交 HTTP 请求的速率。与其他的并发危险一样,在原型设计和开发阶段,无限制的创建线程或许还能较好的运行,但在应用程序部署后并处于高负载运行时,才会有问题不断的暴露出来。因此,某个恶意的用户或过多的用户,都会使 Web 服务器的负载达到某个阈值,从而使服务器崩溃。如果服务器需要提供高可用性,并且在高负载情况下能平缓的降低性能,那么这将是一个严重的故障。
6.2 Executor 框架
任务是一组逻辑工作单元,而线程则是使任务异步执行的机制。我们已经分析了两种通过线程来执行任务的策略,即把所有任务都放在单个线程中串行执行,以及将每个任务放在各自的线程中执行。这两种方式都存在一些严格的限制:串行执行的问题在于其糟糕的响应性和吞吐量,而“为每个任务分配一个线程”的问题在于资源管理的复杂性。
在第 5 章,我们介绍了如何通过有界队列来防止高负载的应用程序耗尽内存。线程池简化了线程的管理工作,并且 JUC 提供了一种灵活的线程池实现作为 Executor 框架的一部分。在 Java 类库中,任务执行的只要抽闲不是 Thread,而是 Executor,如程序清单 6-3 所示。
public interface Executor {
void execute(Runnable command);
}
虽然 Executor 是一个简单的接口,但它却为灵活且强大的异步任务执行框架提供了基础,该框架能支持多种不同类型的任务执行策略。它提供了一种标准的方法将任务的提交过程与执行过程解耦开来,并且用 Runnable 来表示任务。Executor 的实现还提供了对生命周期的支持,以及统计信息收集、应用程序管理机制和性能监视器等机制。
Executor 基于生产者——消费者模式,提交的操作相当于生产者(生成待完成的工作单元),执行任务的线程则相当于消费者(执行完这些工作单元)。如果要在程序中实现一个生产消费设计,那么最简单的方式通常就是使用 Executor。
6.2.1 示例:基于 Executor 的 Web 服务器
基于 Executor 来构建 Web 服务器是非常容易的。在程序清单 6-4 中用 Executor 代替了硬编码的线程创建过程。在这种情况下使用了一种标准的 Executor 实现,即一个固定长度的线程池,可以容纳 100 个线程。
class TaskExecutionWebServer {
private static final int NTHREADS = 100;
private static final Executor exec =
Executor.newFixedThreadPool(NTHREADS);
public static void main(String..args) throws IOEx {
ServerSocket socket = new ServerSocket(80);
while(true) {
final Socket connection = socket.accept();
Runnable task = new Runnable() {
public void run() {
handleRequest(connection);
}
};
exec.execute(task);
}
}
}
在 TaskExecutionWebServer 中,通过使用 Executor,将请求处理任务的提交与任务的实际执行解耦开来,并且只需要采用另一种不同的 Executor 实现,就可以改变服务器的行为。改变 Executor 实现或配置所带来的影响要远远小于改变任务提交方式带来的影响。通常,Executor 的配置是一次性的,因此在部署阶段可以完成,而提交任务的代码却会不断的扩散到整个程序中,增加了修改的难度。
我们可以很容易的将 TaskExecutionWebServer 修改为类似 ThreadPerTaskWebServer 的行为,只需要使用一个为每个请求创建新线程的 Executor。编写这样的 Executor 很简单,如程序清单 6-5 中的 ThreadPerTaskExecutor 所示。
public class ThreadPerTaskExecutor implements Executor {
public void execute(Runnable r) {
new Thread(r).start();
}
}
同样,还可以编写一个 Executor 使 TaskExecutionWebServer 的行为类似于单线程的行为,即以同步的方式执行每个任务,然后再返回,如程序清单 6-6 中的 WithinThreadExecutor 所示。
public class WithinThreadExecutor implements Executor {
public void execute(Runnable r) {
r.run();
}
}
6.2.2 执行策略
通过将任务的提交与执行解耦出来,从而无需太大的困难就可以为某种类型的任务指定或修改执行策略。在执行策略中定义了任务执行的 What、Where、When、How 等方面,包括:
- 在什么(What)线程中执行任务?
- 任务按照什么(What)属性怒执行(FIFO/LIFO/优先级)?
- 有多少个(How many)任务并发执行?
- 在队列中有多少个(How many)任务在等待执行?
- 如果系统由于过载而需要拒绝一个任务,那么应该选择拒绝哪个任务?另外,如何通知应用程序有任务被拒绝?
- 在执行一个任务之前或之后,应该进行哪些动作?
各种执行策略都是一种资源管理工具,最佳策略取决于可用的计算资源以及对服务质量的需求。通过限制并发任务的数量,可以确保应用程序不会由资源耗尽而失败,或者由于在稀缺资源上发生竞争而严重影响性能。通过将任务的提交与任务的执行策略分离开来,有助于在部署阶段选择与硬件资源匹配的执行策略。
6.2.3 线程池
线程池,从字面含义来看,是指管理一组同构工作线程的资源池。线程池是与工作队列密切相关的,其中在工作队列保存了所有了所有等待执行的任务。工作者线程的任务很简单:从工作队列中取出一个任务,执行任务,然后返回线程池并等待下一个任务。
“在线程池中执行任务”比“没每个任务分配一个线程”优势更多。通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销。另一个额外的好处是,当请求到达时,工作线程通常已经存在,因此不会犹豫等待创建线程而延迟任务的执行,从而提高了响应性。通过适当调整线程池的大小,可以创建足够的线程以便使处理器保持忙碌状态,同时还可以防止过多线程互相竞争资源而使应用程序耗尽内存或失败。
类库提供了一个灵活的线程池以及一些有用的默认配置。可以通过调用 Executors 中的静态工厂方法之一来创建线程池实例:
newFixedThreadPool 将创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量,这时线程池的规模将不再变化(如果某个线程由于发生了未预期的异常而结束,那么线程池将会补充一个新的线程)。
newCachedThreadPool 将创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制。
newSingleThreadExecutor 是一个单线程的 Executor,它创建单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代。它能确保任务在队列中的顺序来串行执行(如 FIFO/LIFO/优先级)。
newScheduledThreadPool 创建一个固定长度的线程池,而且以延迟或定时的方式来执行任务,类似于 Timer。
newFixedThreadPool/newCachedThreadPool 这两个工厂方法返回通用的 ThreadPoolExecutor 实例,这些实例可以直接用来构造专门用途的 executor。我们将在第八章深入讨论线程池的各个配置项。
TaskExecutionWebServer 中的 Web 服务器使用了一个带有有界线程池的 Executor。通过 execute 方法将任务提交到工作队列中,工作线程反复的从工作队列中取出任务并执行它们。
从“为每任务分配一个线程”策略变成基于线程池的策略,将对应用程序的稳定性产生重大的影响:Web 服务器不会再在高负载情况下失败。由于服务器不会创建数千个线程来争夺有限的 CPU 和内存资源,因此服务器的性能将平缓的降低。通过使用 Executor,可以实现各种调优、管理、监视、记录日志、错误报告和其他功能,如果不使用任务执行框架,那么要增加这些功能是非常困难的。
6.2.4 Executor 的生命周期
我们已经知道如何创建一个 Executor,但并没有讨论如何关闭它。Executor 的实现通常会创建线程来执行任务。但 JVM 只有在所有(非守护)线程全部终止后才会退出。因此,如果无法正确的关闭 Executor,那么 JVM 将无法结束。
由于 Executor 以异步方式来执行任务,因此在任何时刻,之前提交任务的状态不是立即可见的。有些任务可能已经完成,有些可能正在运行,而其他的任务可能在队列中等待执行。当关闭应用程序时,可能采用最平缓的关闭方式(完成所有已启动的任务,并且不再接受任何新的任务),也可能采用最粗暴的关闭方式(直接关闭机房的电源),以及其他各种可能的形式。既然 Executor 是为应用程序提供服务的,因而它们也是可关闭的(无论采用平缓或粗暴的方式),并将在关闭操作中受影响的任务的状态反馈给应用程序。
为了解决执行服务的生命周期问题,Executor 扩展了 ExecutorService 接口,添加了一些用于生命周期管理的方法(同时还有一些用于任务提交的便利方法)。在程序清单 6-7 中给出了 ExecutorService 中的生命周期管理方法。
public interface ExecutorService extends Executor {
void shutdown();
List<Runnable> shutdownNow();
boolean isShutdown();
boolean isTerminated();
boolean awaitTermination(long timeout, TimeUnit unit)
thorws InterruptedException
// 其他用于任务提交的便利方法...
}
ExecutorService 的生命周期有三种状态:运行、关闭、已终止。ExecutorService 在初始化创建时处于运行状态。shutdown 方法将执行平缓的关闭过程:不再接受新任务,同时等待已经提交的任务执行完成——包括那些还未开始执行的任务。shutdownNow 方法将执行粗暴的关闭过程:它将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。
在 ExecutorService 关闭后提交的任务将由“拒绝执行处理器”来处理,它会抛弃任务,或者使得 execute 方法抛出一个未检的 RejectedExecutionException。等所有任务都完成后,ExecutorService 将转入终止状态。可以调用 awaitTermination 来等待 ExecutorService 到达终止状态,或者通过调用 isTerminated 来轮询 ExecutorService 是否已经终止。通常在调用 awaitTermination 之后会立即调用 shutdown,从而产生同步的关闭 ExecutorService 的效果。(第七章将进一步介绍 Executor 的关闭和任务取消等方面的内容)
程序清单 6-8 的 LifecycleWebServer 通过增加生命周期支持来扩展 Web 服务器的功能。可以通过两种方法来关闭 Web 服务器:在程序中调用 stop,或者以客户端请求的形式向 Web 服务器发送一个特定格式的 HTTP 请求。
class LifecycleWebServer {
private final ExecutorService exec = ...;
public void start() throws IOException {
ServerSocker socket = new ServerSocket(80);
while(!exec.isShutdown()) {
try {
final Socket conn = socket.accept();
exec.execute(new Runnable() {
public void run() {
handleRequest(conn);
}
});
} catch (RejectedExecutionException e) {
if(!exec.isShutdown())
log("task submission rejected", e);
}
}
}
public void stop() {
exec.shutdown();
}
void handleRequest(Socket conn) {
Request req = readRequest(conn);
if(isShutdownRequest(req))
stop();
else
dispatchRequest(req);
}
}
6.2.5 延时任务与周期性任务
Timer 类负责管理延迟任务以及周期性任务。然而,Timer 存在一些缺陷,因此应该考虑使用 ScheduledThreadPoolExecutor 来代替它。可以通过 ScheduledThreadPoolExecutor 的构造函数或 newScheduledThreadPool 工厂方法来创建类的对象。
Timer 在执行所有定时任务时只会创建一个线程。如果某个任务的执行实现过长,那么将破坏其他 TimerTask 的定时精确性。例如某个周期 TimerTask 需要每 10ms 执行一次,而另一个 TimerTask 需要执行 40ms,那么这个周期性任务或者在 40ms 任务执行完成后快速连续的调用 4 次,或者彻底丢失 4 次调用(取决于它是基于固定速率来调度还是基于固定延时来调度)。线程池能弥补这个缺陷,它可以提供多个线程来执行延时任务和周期任务。
Timer 的另一个问题是,如果 TimerTask 抛出一个未检异常,那么 Timer 将表现出糟糕的行为。Timer 线程并不捕获异常,因此当 TimerTask 抛出未检异常时将终止定时线程。这种情况下,Timer 也不会恢复线程的执行,而是会错误的任务整个 Timer 被取消了。因此,已经被调度但尚未执行的 TimerTask 将不会再执行,新的任务也不能被调度。该问题被称为线程泄露,7.3 将介绍如何避免该问题。
在程序清单 6-9 的 OutOfTime 中给出了 Timer 中为什么会出现这种问题,以及如何使得视图提交 TimerTask 的调用者出现问题。你可能任务程序会运行 6 秒后退出,但实际情况是运行 1 秒就结束了,并抛出了一个异常消息“Timer already cancelled”。ScheduledThreadPoolExecutor 能正确处理这些表现出错误行为的任务。在 Java 5.0 或更高的 JDK 中,将减少使用 Timer。
public class OutOfTime {
public static void main(String...args) throws Exception {
Timer timer = new Timer();
timer.schedule(new ThrowTask(), 1);
SECONDS.sleep(1);
timer.schedule(new ThrowTask(), 1);
SECONDS.sleep(5);
}
static class ThrowTask extends TimerTask {
public void run() {
throw new RuntimeException();
}
}
}
如果要构建自己的调度服务,那么可以使用 DelayQueue,它实现了 BlockingQueue,并为 ScheduledThreadPoolExecutor 提供调度功能。DelayQueue 管理着一组 Delayed 对象。每个 Delayed 对象都有一个相应的延迟时间:在 DelayQueue 中,只有某个元素逾期后,才能从 DelayQueue 中执行 take 操作。从 DelayQueue 中返回的对象将根据它们的延迟时间进行排序。
6.3 找出可利用的并行性
Executor 框架帮助指定执行策略,但如果要使用 Executor,必须将任务表述为一个 Runnable。在大多数服务器应用程序中都存在一个明显的任务边界:单个客户请求。但有时候,任务边界并非是显而易见的,例如在很多桌面应用程序中。即使是服务器应用程序,在单个客户请求中仍可能存在可发掘的并行性,例如数据库服务器。
本节我们将开发一些不同版本的组件,并且每个版本都实现了不同程度的并发性。该示例组件实现浏览器中的页面渲染功能,它的作用是将 HTML 页面绘制到图像缓存中。为了简便,假设 HTML 页面只包含标签文本,以及预定义大小的图片和 URL。
6.3.1 示例:串行的页面渲染器
最简单的方法是对 HTML 文档进行串行化处理。当遇到文本标签时,将其绘制到图像缓存中。当遇到图像引用时,先通过网络获取它,然后将其绘制到图像缓存中。这很容易实现,程序只需要将输入中的元素处理一次(甚至不需要缓存文档),但这种方法可能会令用户感到烦恼,他们必须等待很长时间,直到显示所有文本。
另一种串行执行方法更好一些,它绘制文本元素,同时为图像预留出矩形的占位空间,在处理完第一遍文本后,程序开始下载图像,并将他们绘制到相应的占位空间中。在程序清单 6-10 的 SingleThreadRenderer 中给出了这种方法的实现。
public class SingleThreadRenderer {
void renaderPage(CharSequence source) {
renaderText(suorce);
List<ImageInfo> imageData = new ArrayList<>();
for(ImageInfo imageInfo : scanFroImageInfo(source)) {
imageData.add(imageInfo.downloadImae());
}
for(ImageData data : imageData) {
renderImage(data);
}
}
}
图像下载过程的大部分时间都是在等待 IO 操作执行完成,在这期间 CPU 几乎不做任何工作。因此,这种串行执行方法没有充分利用 CPU,使得用户在看到最终页面之前需要等待过长的时间。通过将问题分解为多个独立的任务并发执行,能够获得更好的 CPU 利用率和响应灵敏度。
6.3.2 携带结果的任务:Calllable 与 Future
Executor 框架使用 Runnable 作为其基本的任务表示形式。Runnable 是一种局限很大的抽象,虽然 run 能写入到日志文件或者将结果放入某个数据结构,但它不能返回一个值或抛出一个受检异常。
许多任务实际上都是存在延迟的计算——执行数据库查询,从网络上获取资源,或者计算某个复杂的功能。对于这些任务,Callable 是一种更好的抽象:它认为主入口点(call)将返回一个值,并可抛出一个异常。在 Executor 中包含了一些辅助方法能将其他类型的任务封装为一个 Callable,例如 Runnable 和 java.security.PrivilegedAction。
Runnable 和 Callable 描述的都是抽象的计算任务。这些任务通常是有范围的,即都有一个明确的起始点,并且最终会结束。Executor 执行的任务有 4 个生命周期:创建、提交、开始、完成。由于有些任务可能需要执行很长时间,因此通常希望能够取消这些任务。在 Executor 框架中,已提交单尚未开始的任务可以取消,但对于那些已经开始执行的任务,只有当他们能够响应中断时,才能被取消。取消一个已经完成的任务不会有任何影响。
Future 表示一个任务的生命周期,并提供了相应的方法来判断是否已经完成或取消,以及获取任务的结果或取消任务等。在程序清单 6-11 中给出了 Callable 和 Future。在 Future 规范中包含的隐含意义是,任务的生命周期只能前进不能后退,就像 ExecutorService 的生命周期一样。当某个任务完成后,它就永远停留在“完成”状态上。
get 方法的行为取决于任务的状态,如果任务已经完成,那么 get 会立即返回或抛出一个异常,如果任务没有完成,那么 get 将阻塞并直到任务完成。如果任务抛出了异常,那么 get 将该异常封装为 ExecutionException 并重新抛出。如果任务被取消,那么 get 将抛出 CancellationException。如果 get 抛出了 ExecutionException,那么可以通过 getCause 来获得被封装的原始异常。
public interface Callable<V> {
V call() throws Exception;
}
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled(); boolean isDone();
V get() throws
InterruptedException,
ExecutionException,
CancellationException;
V get(long timeout, TimeUnit unit) throws
InterruptedException,
ExecutionException,
CancellationException,
TimeoutException;
}
可以通过多种方法创建一个 Future 来描述任务。ExecutorService 中的所有 submit 方法都将返回个 Future,从而将一个 Runnable 或 Callable 提交给 Executor,并得到一个 Future 用来获得任务的执行结果或取消任务。还可以显式的为某个指定的 Runnable 或 Callable 实例化一个 FutureTask。(由于 FutureTask 实现了 Runnable,因此可以将它提交给 Executor 来执行,或者直接调用它的 run 方法。)
从 Java 6 开始,ExecutorService 实现可以改写 AstractExecutorService 中的 newTaskFor 方法,从而根据已提交的 Runnable 或 Callable 来控制 Future 的实例化过程。在默认实现中仅创建了一个新的 FutureTask,如程序清单 6-12 所示。
protected <T> RunnableFuture<T> newTaskFor(Callable<T> task) {
return new FutureTask<T>(task);
}
在将 Runnable 或 Callable 提交到 Executor 的过程中,包含了一个安全发布过程,即将 Runnable 或 Callable 从提交线程发布到最终执行任务的线程。类似的,在设置 Future 结果的过程也包含了一个安全发布,即将这个结果从计算它的线程发布到任何通过 get 获得它的线程。
6.3.3 示例:使用 Future 实现页面渲染器
为了使页面渲染器实现更高的并发性,首先将渲染过程分解为两个任务,一个是渲染所有文本,另一个是下载所有的图像。(因为其中一个任务是 CPU 密集型,而另一个任务是 IO 密集型,因此这种方法即使应用在单 CPU 系统上也能提升性能)。
Callable 和 Future 有助于表示这些协同任务之间的交互。在程序清单 6-13 的 FutureRenderer 中创建了一个 Callable 来下载所有图像,并将其提交到一个 ExecutorService。这将返回一个描述任务执行情况的 Future。当主任务需要图像时,它会等待 Future.get 的调用结果。如果幸运的话,当开始请求时所有图像就已经下载完成了,即使没有,至少图像的下载任务也已经提前开始了。
public class FutureRenderer {
private final ExecutorService executor = ...;
void renderPage(CharSequence source) {
final List<ImageInfo> imageInfos =
scanForImageInfo(source);
Callable<List<ImageData>> task =
new Callable<List<ImageData>>() {
public List<ImageData> call() {
List<ImageData> result =
new ArrayList<ImageData>();
for (ImageInfo imageInfo : imageInfos)
result.add(imageInfo.downloadImage());
return result;
}
};
Future<List<ImageData>> future = renderText(source);
executor.submit(task);
try {
List<ImageData> imageData = future.get();
for (ImageData data : imageData)
renderImage(data);
} catch (InterruptedException e) {
// Re-assert the thread's interrupted status
Thread.currentThread().interrupt();
// We don't need the result, so cancel the task too
future.cancel(true);
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
get 方法拥有“状态依赖”的内在特性,因而调用者不需要知道任务的状态,此外在任务提交和获得结果中包含的安全发布属性也确保了这个方法是线程安全的。Future.get 的异常处理代码将处理两个可能问题:任务遇到一个异常,或者调用 get 的线程在获得结果之前被中断。
FutureRenderer 使得渲染文本任务与下载图像数据的任务并发的执行。当所有图像下载完成后,会显示到页面上。这将提升用户体验,不仅使用户更快的看到结果,还有效利用了并行性,但我们还可以做得更好。用户不必等到所有的图像都下载完成,而希望看到每当下载一完一副图像时就立即显示出来。
6.3.4 在异构任务并行化中存在的局限
在上个例子中,我们尝试并行的执行两个不同类型的任务——下载图像与渲染页面。然而,通过对异构任务执行并行化来获得重大的性能提升是很困难的。
两个人可以很好的分担洗碗的工作:其中一个人负责清洗,另一个人负责烘干。然而,要将不同类型的任务平均分配给每个工人并不容易。但人数增加时,如何确保他们能帮忙而不是妨碍其他人工作,或者在重新分配工作时,并不是很容易的事情。如果没有在相似的任务之间找出细粒度的并行性,那么这种方法带来的好处将减少。
当在多个工人之间分配异构的任务时,还有一个问题就是各个任务的大小可能完全不同。如果将两个任务 A 和 B 分配给两个工人,但 A 的执行时间是 B 的 10 倍,那么整个过程也只能加速 9%。最后,当在多个工人之间分解任务时,还需要一定的任务协调开销:为了使任务分解能提高性能,这种开销不能高于并行性实现的提升。
FutureRenderer 使用了两个任务,其中一个负责渲染文本,另一个负责下载图像。如果渲染文本的速度远远高于下载图像的速度,那么程序的最终性能与串行执行时的性能差别不大,而代码却更加复杂了。当使用两个线程时,至多能将速度提升一倍。因此,虽然做了许多工作来并发执行异构任务以提高并发度,但从中获得的并发性却是十分有限的。
只有当大量相互独立且同构的任务可以并行处理时,才能体现出将程序的工作负载分配到多个任务中带来的真正性能提升。
6.3.5 CompletionService:Executor 与 BlockingQueue
如果向 Executor 提交一组计算任务,并且希望在计算完成后获得结果,那么可以保留与每个任务关联的 Future,然后反复使用 get 方法,同时将参数 timeout 设定为 0,从而通过轮询来判断任务是否完成。这种方法虽然可行,但却十分繁琐。幸运的是,还有一种更好的方法:CompletionService。
CompletionService 将 Executor 与 BlockingQueue 的功能融合在一起。你可以将 Callable 任务提交给它来执行,然后使用类似于队列操作的 take 和 poll 等方法来获得已完成的结果,而这些结果会在完成时将被封装为 Future。ExecutorCompletionService 实现了 CompletionService,并将计算部分委托给一个 Executor。
ExecutorCompletionService 的实现非常简单。在构造函数中创建一个 BlockingQueue 来保存计算完成的结果。当计算完成时,调用 FutureTask 中的 done 方法。当提交某个任务时,该任务首先将包装为一个 QueueingFuture,这是 FutureTask 的一个子类,然后再改写子类的 done 方法,并将结果放入 BlockingQueue 中,如程序清单 6-14 所示。take 和 poll 方法委托给了 BlockingQueue,这些方法会在得出结果之前阻塞。
private class QueueingFuture<V> extends FutureTask<V> {
QueueingFuture(Callable<V> c) { super(c); }
QueueingFuture(Runnable t, V r) { super(t,r); }
protected void done() {
completionQueue.add(this);
}
}
6.3.6 示例:使用 CompletionService 实现页面渲染器
可以通过 CompletionService 从两个方面来提高页面渲染器的性能:缩短总运行时间以及提高响应性。为每个图像的下载都创建一个独立任务,并在线程池中执行他们,从而将串行的下载过程转换为并行的过程:这将减少下载所有图像的总时间。此外,通过从 CompletionService 中获得结果以及使每张图片在下载完成后立即显示出来,能使用户得到一个更加动态和更高响应性的用户界面。如程序清单 6-15 的 Renderer 所示。
public class Renderer {
private final ExecutorService executor;
Renderer(ExecutorService executor) {
this.executor = executor;
}
void renderPage(CharSequence source) {
final List<ImageInfo> info = scanForImageInfo(source); CompletionService<ImageData> completionService =
new ExecutorCompletionService<>(executor);
for (final ImageInfo imageInfo : info)
completionService.submit(new Callable<>() {
public ImageData call() {
return imageInfo.downloadImage();
}
});
renderText(source);
try {
for (int t = 0, n = info.size(); t < n; t++) {
Future<ImageData> f =
completionService.take();
ImageData imageData = f.get();
renderImage(imageData);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
多个 ExecutorCompletionService 可以共享同一个 Executor,因此可以创建一个对于特定计算私有,又能共享一个公共 Executor 的 ExecutorCompletionService。因此,CompletionService 的作用就相当于一组计算的句柄,这与 Future 作为单个计算的句柄是非常类似的。通过记录提交给 CompletionService 的任务数量,并计算出已经获得的已经完成计算结果的数量,即使使用一个共享的 Executor,也能知道已经获得了所有任务结果的时间。
6.3.7 为任务设置时限
有时候,如果某个任务无法在指定时间内完成,那将不再需要它的结果,此时可以放弃这个任务。例如,某个 Web 应用程序从外部的广告服务器上获取广告信息,但如果该应用程序在两秒钟内得不到响应,那么将显示一个默认的广告,这样即使不能获得广告信息,也不会降低站点的响应性能。类似的,一个门户网站可以从多个数据源并行的获取数据,但可能只会在指定的时间内等待数据,如果超出了等待时间,那么只显示已经获得的数据。
在有限时间内执行任务的主要困难在于,要确保得到答案的时间不会超过限定的时间,或者在限定时间内无法得到答案。在支持时间限制的 Future.get 中支持这种需求:当结果可用时将理解返回,如果在指定的时间内没有计算出结果,那么将抛出 TimeoutException。
在使用限时任务时需要注意,当这些任务超时后应该立即停止执行,从而避免为继续计算一个不再使用的结果而浪费计算资源。要实现这个功能,可以由任务本身来管理它的限定时间,并且在超时后中止执行或取消任务。此时可以再次使用 Futute,如果一个限时的 get 方法抛出了 TimeoutException,那么可以通过 Future 来取消任务。如果编写的任务是可取消的,那么可以提前中止它,以免消耗过多资源。在程序清单 6-13 和 6-16 中的代码使用了这项技术。
程序清单 6-16 给出了限时 Future.get 的一种典型应用。在它生成的页面中包括响应用户请求的内容以及从广告服务器上获得的广告。它将获取广告的任务提交给一个 Executor,然后计算剩余的文本页面的内容,最后等待广告信息,直到超出指定的时间。如果 get 超时,那么将取消广告获取任务,并转而使用默认的广告信息。
Page renderPageWithAd() throws InterrutedException {
long endNanos = System.nanoTime() + TIME_BUDGET;
Future<Ad> f = exec.submit(new FetchAdTask());
// 在等待广告的同时显示页面
Page page = renderPageBody();
Ad ad;
try {
// 等待指定的时长
long timeLeft = endNanos - System.nanoTime();
ad = f.get(timeLeft, NANOSECONDS);
} catch (ExecutionException e) {
ad = DEFAULT_AD;
} catch (TimeoutException e) {
ad = DEFAULT_AD;
f.cancel(true);
}
page.setAd(ad);
return page;
}
6.3.8 示例:旅行预订网站
“预订时间”方法可以很容易的扩展到任意数量的任务上。考虑这样一个旅行预订网站:用户输入旅行的日期和其他要求,网站获取并展示来自多条航线、旅店或汽车租赁公司的报价。在获取不同公司报价的过程中,可能会调用 Web 服务、访问数据库、执行一个 EDI 事务或其他机制。在这种情况下,不宜让页面的响应时间受限于最慢的响应时间,而应该只显示在指定时间内收到的信息。对于没有及时响应的服务提供者,页面可以忽略它们,或者显示一个提示信息。
从一个公司获得报价的过程与从其他公司获得报价的过程无关,因此可以将获取报价的过程当成一个任务,从而使得获取报价的过程并发执行。创建 N 个任务,将其提交到一个线程池,保留 N 个 Future 并使用限时的 get 方法通过 Future 串行的获取每一个结果,这一切都很简单,但还有一个更简单的方法——invokeAll。
程序清单 6-17 使用了支持限时的 invokeAll,将多个任务提交到一个 ExecutorService 并获得结果。InvokeAll 方法参数为一组任务,并返回一组 Future。这两个集合有着相同的结构。invokeAll 按照任务集合中迭代器的顺序将所有的 Future 添加到返回的集合中,从而使调用者能将各个 Future 与其表示的 Callable 关联起来。当所有任务都执行完毕时,或者调用者线程被中断时,又或者超过指定时限时,invokeAll 将返回。当超过执行时限后,任何还未完成的任务都将被取消。当 invokeAll 返回后,每个任务要么正常的完成,要么被取消,而客户端代码可以调用 get 或 isCancelled 来判断具体的情况。
private class QuoteTask implements Callable<TravelQuote> {
private final TravelCompany company;
private final TravelInfo travelInfo;
...
public TravelQuote call() throws Exception {
return company.solicitQuote(travelInfo);
}
}
public List<TravelQuote> getRankedTravelQuotes(
TravelInfo travelInfo,
Set<TravelCompany> companies,
Comparator<TravelQuote> ranking,
long time, TimeUnit unit) throws InterruptedException {
List<QuoteTask> tasks = new ArrayList<QuoteTask>();
for (TravelCompany company : companies)
tasks.add(new QuoteTask(company, travelInfo));
List<Future<TravelQuote>> futures =
exec.invokeAll(tasks, time, unit);
List<TravelQuote> quotes =
new ArrayList<TravelQuote>(tasks.size());
Iterator<QuoteTask> taskIter = tasks.iterator();
for (Future<TravelQuote> f : futures) {
QuoteTask task = taskIter.next();
try {
quotes.add(f.get());
} catch (ExecutionException e) {
quotes.add(task.getFailureQuote(e.getCause()));
} catch (CancellationException e) {
quotes.add(task.getTimeoutQuote(e));
}
}
Collections.sort(quotes, ranking);
return quotes;
}
小结
通过围绕任务执行来设计应用程序,可以简化开发过程,并有助于实现并发。Executor 框架将任务提交与执行策略解耦开来,同时还支持不同类型的执行策略。当需要创建线程来执行任务时,可以考虑使用 Executor。要想在将应用程序分解为不同的任务时获得最大的好处,必须定义清晰的任务边界。某些应用程序中存在着比较明显的任务边界,而在其他一些程序中则需要进一步分析才能揭示出粒度更细的并行性。
1.7 - CH07-取消关闭
任务和线程的启动很容易。在大多数时候,我们都会让它们运行直到结束,或者让它们自行停止。然而,有时候我们希望提前结束任务或线程,或许是因为用户取消了操作,或者应用程序需要被快速关闭。
要使任务和线程能够安全、快速、可靠的停止下来,并不是一件容易的事。Java 没有提供任何机制来安全的终止线程。但它提供了中断,这是一种协作机制,能够使一个线程终止另一个线程的当前工作。
这种协作式的方法是必要的,我们很少希望某个任务、线程或服务立即停止,因为这种立即停止会使共享的数据结构处于不一致的状态。相反,在编写任务和服务时可以使用一种协作的方式:因为任务本身的代码比发出取消请求的代码更清除如何执行清除工作。
生命周期结束的问题会使任务、服务以及程序的设计和实现等过程变得复杂,而这个在程序设计中非常重要的要素却经常被忽略。一个在行为良好的软件与勉强运行的软件之间的最主要区别就是,行为良好的软件很完善的处理失败、关闭和取消等过程。本章将给出各种实现、取消、中断的机制,以及如何编写任务和服务,使它们能对取消请求做出响应。
7.1 任务取消
如果外部代码能在某个操作正常完成之前将其置入“完成”状态,那么这个操作就可以成为可取消的。取消某个操作的原因可能会是:
- 用户请求取消。 用户点击图形界面中的“取消”按钮,或者通过管理接口发出取消请求,例如 JMX。
- 有时限的操作。 例如,应用程序对某个问题空间进行分解并搜索,从而使不同的任务可以搜索问题空间中的不同区域。当其中一个任务找到了解决方案时,所有其他仍在搜索的任务都将被取消。
- 错误。 网页爬虫程序搜索相关的页面,将页面或摘要数据保存到硬盘。当一个爬虫任务发生错误时,那么所有搜索任务都会取消,此时可能会记录它们的当前状态,以便稍后重新启动。
- 关闭。 当一个程序或服务关闭时,必须对正在处理和等待处理的工作执行某种操作。在平缓的关闭过程中,当前正在执行的任务将继续执行直到完成,而在立即关闭过程中,当前的任务则可能取消。
在 Java 中没有一种安全的抢占式的方法来停止线程,因此也就没有安全的抢占式的方法来停止任务。只有一些协作式的机制,使请求取消的任务和代码都遵循一种协商好的协议。
其中一种协作机制能设置某个“已请求取消”标志,而任务将定期来查看该标志。如果设置了这个标志,那么任务将提前结束。程序清单 7-1 中就使用了这项技术,其中的 PrimeGenerator 持续的枚举素数,直到它被取消。cancel 方法将设置 cancelled 标志,并且主循环在搜索下一个素数之前会首先检查这个标志。(为了使该过程可靠的工作,标志 cancelled 必须为 volatile 类型。)
@ThreadSafe
public class PrimeGenerator implements Runnable {
@GuardedBy("this")
private final List<BigInteger> primes = new ArrayList<>();
private volatile boolean cancelled;
public void run() {
BigInteger p = BigInteger.ONE;
while(!cancelled) {
p = p.nextProbablePrime();
synchronized (this) {
primes.add(p);
}
}
}
public void cancel() { cancelled = true; }
public void synchronized List<BigInteger> get() {
return new ArrayList<>(primes);
}
}
程序清单 7-2 给出了这个类的使用示例,即让素数生成器运行 1 秒钟后取消。素数生成器通常并不会刚好在运行一秒钟后停止,因为在请求取消的时刻和 run 方法中循环执行下一次检查之间可能存在延迟。cancel 方法由 finally 块调用,从而确保即使在调用 sleep 时被中断也能取消素数生成器的执行。如果 cancel 没有被调用,那么搜索素数的线程将永远运行下去,不断消耗 CPU 的时钟周期,并使得 JVM 不能正常退出。
List<BigInteger> aSecondOfPrimes() throws InterruptedEx {
primeGenerator generator = new PrimeGenerator();
new Thread(generator).start();
try {
SECONDS.sleep(1);
} finally {
generator.cancel();
}
return generator.get();
}
一个可取消的任务必须拥有取消策略,在这个策略中将详细地定义取消操作的 How、When、What,即其他代码如何请求取消该任务,任务在何时检查是否已经请求了取消,以及在响应取消请求时应该执行哪些操作。
考虑现实世界中停止支付支票的示例。银行通常会规定如何提交一个停止支付的请求,在处理这些请求时需要作出哪些响应性保证,以及当支付终端后需要遵守哪些流程。这些流程和保证放在一起构成了支票支付的取消策略。
PrimeGenerator 使用了一种简单的取消策略:客户代码通过调用 cancel 来请求取消,PrimeGenerator 在每次搜索素数前首先检查是否存在取消请求,如果存在则退出。
7.1.1 中断
PrimeGenerator 中的取消机制最终会使得搜索素数的任务退出,但在退出过程中需要花费一定的时间。然而,如果使用这种方法的任务调用了一个阻塞方法,例如 BlockingQueue.put,那么可能会产生一个更严重的问题——任务永远不会检查取消标志,因此永远不会停止。
在程序清单 7-3 中的 BrokenPrimeProducer 就说明了这个问题。生产者线程生成素数,put 方法也会阻塞。当生产者在 put 方法中阻塞时,如果消费者希望取消生产任务,那么将发生什么情况?它可以调用 cancel 方法来设置 cancelled 标志,但此时生产者却永远不会检查这个标志,因为它无法从阻塞的 put 方法中恢复过来(因为消费者此时已经停止从队列中取出素数,所以 put 方法将一直保持阻塞状态)。
class BrokenPrimeProducer extends Thread {
private final BlockingQueue<BigInteger> queue;
private volatile boolean cancelled = false;
BrokenPrimeProducer(BlockingQueue<BigInteger> queue) {
this.queue = queue;
}
public void run() {
try {
BigInteger p = BigInteger.ONE;
while (!cancelled)
queue.put(p = p.nextProbablePrime());
} catch (InterruptedException consumed) { }
}
public void cancel() { cancelled = true; }
}
void consumePrimes() throws InterruptedException {
BlockingQueue<BigInteger> primes = ...;
BrokenPrimeProducer producer =
new BrokenPrimeProducer(primes);
producer.start();
try {
while (needMorePrimes())
consume(primes.take());
} finally {
producer.cancel();
}
}
第五章曾提到,一些特殊的阻塞库的方法支持中断。线程中断是一种协作机制,线程可以通过这种机制来通知另一个线程,告诉它在合适的或者可能的情况下停止当前工作,并转而执行其他工作。
在 Java 的 API 或语言规范中,并没有将中断与任何取消语义关联起来,但实际上如果在取消之外的其他操作中使用中断,那么都是不合适的,并且很难支撑起更大的应用。
每个线程都有一个 boolean 类型的中断状态。当中断线程时,这个线程的中断状态将被设置为 true。在 Thread 中包含了中断线程以及查询线程中断状态的方法,如程序清单 7-4 所示。interrupt 方法将清除当前线程的中断状态,并返回它之前的值,这也是清除中断状态的唯一方法。
public class Thread {
public void interrupt() {...}
public boolean isInterrupted() {...}
public static boolean interrupted() {...}
...
}
阻塞库方法,例如 Thread.sleep 和 Object.wait 等,都会检查线程何时中断,并且在发现中断时提前返回。它们在响应中断时执行的操作包括:清除中断状态,抛出 InterruptedException,表示阻塞操作由于中断而提前结束。JVM 并不保证阻塞方法检测到中断的速度,但在实际情况中响应速度还是非常快的。
当线程在非阻塞状态下中断时,它的中断状态被设置,然后根据将被取消的操作来检查中断状态以判断发生了中断。通过这样的方法,中断操作将变得“有粘性”——如果不触发中断异常,那么中断状态将一直保持,直到明确的清除中断状态。
调用 interrput 并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。
对中断操作的正确理解是:它并不会真正的中断一个正在运行的线程,而只是发出中断请求,然后由线程在下一个合适的时刻中断自己。(这些时刻也被称为取消点)。有些方法,例如 wait、sleep、join 等,将严格的处理这种请求,当它们收到中断请求或者在开始执行时发现某个已被设置好的中断状态时,将抛出一个异常。设计良好的方法可以完全忽略这种请求,只要它们能使调用者代码对中断请求进行某种处理。设计糟糕的方法可能会屏蔽中断请求,从而导致调用栈中的其他代码无法对中断请求做出响应。
在使用静态的 interrupted 时应该小心,因为它会清除单签线程的中断状态。如果在调用 interrupted 时返回了 true,那么除非你想屏蔽这个中断,否则必须对它进行处理——可以抛出中断异常,或者通过再次调用 interrupt 来恢复中断状态,如程序清单 5-10 所示。
BrokenPromeProducer 说明了一些自定义的取消机制无法与可阻塞的库函数实现良好交互的原因。如果任务代码能够响应中断,那么可以使用中断作为取消机制,并且利用许多库中提供的中断支持。
通常,中断是实现取消的最合理方式。
BrokenPrimeProducer 中的问题可以很容易解决或简化:使用中断而不是 boolean 标志来请求取消,如程序清单 7-5 所示。在每次迭代循环中,有两个位置可以检测出中断:在阻塞的 put 方法调用中,以及在循环开始处查询中断中断状态时。由于调用了阻塞的 put 方法,因此这里并不需要进行显式的检测,但执行检测却会使 PrimeProducer 对中断具有更高的响应性,因为它是在启动寻找素数任务之前检测中断的,而不是在任务完成之后。如果可中断的阻塞方法的调用频率并不高,不足以获得足够的响应性,那么显式的检测中断状态能起到一定的帮助作用。
class PrimeProducer extends Thread {
private final BlockingQueue<BigIngeger> queue;
PrimeProducer(BlockingQueue<BitInteger> queue) {
this.queue = queue;
}
public void run() {
try {
BigInteger p = BigInteger.ONE;
while(!Thread.currentThread().isInterrupted())
queue.put(p=p.nexProbablePrime());
} catch(InterruptedException ignore) {
// 允许线程退出
}
}
public void cancel() { interrupt(); }
}
7.1.2 中断策略
正如任务中应该包含取消策略一样,线程同样应该包含中断策略。中断策略规定线程如何解释某个中断请求——当发现中断请求时,应该做哪些工作(如果需要的话),哪些工作单元对于中断来说是原子操作,以及以多快的速度响应中断。
最合理的中断策略是某种形式的线程级取消操作或服务级取消操作:尽快退出,在必要时进行清理,通知某个所有者该线程已经退出。此外还可以建立其他的中断策略,例如暂停服务或重新开始服务,但对于那些包含非标准中断策略的线程或线程池,只能用于能够知道这些策略的任务中。
区分任务和线程对中断的反应是很重要的。一个中断请求可以有一个或多个接收者——中断线程池中的工作者线程,同时意味着“取消工作者任务”和“关闭工作者线程”。
任务不会在其自己拥有的线程中执行,而是在某个服务(如线程池)拥有的线程中执行。对于非线程持有者的代码来说(如对于线程池而言,任何在线程池以外实现的代码),应该小心的保存中断状态,这样拥有线程的代码才能对中断做出响应,即使“非所有者”代码也可以做出响应。(当你为一户人家打扫房间时,即使主人不在,也不应该把这段时间内收到的邮件扔掉,而应该把邮件收起来,等主人回来后再交给他们处理,尽管你可以阅读他们的杂志)。
这既是为什么大多数可阻塞的库函数只是抛出 InterruptedException 作为终端响应。他们永远不会在某个由自己拥有的线程中运行,因此他们为任务或库代码实现了最合理的取消策略:尽快推出执行流程,并把中断信息传递给调用者,从而使调用栈中的上层代码可以采取进一步的操作。
当检查到中断请求时,任务并不需要放弃所有的操作——它可以推迟处理中断请求,并直到某个更合适的时刻。因此需要记住中断请求,并在完成当前任务后抛出 InterruptedException 或者表示已接收到中断请求。这项技术能够确保在更新过程中发生中断时,数据结构不会被破坏。
任务不应该对执行该任务的线程的中断策略做出任何假设,除非该任务被专门设计为在服务中运行,并且在这些服务中包含特定的中断策略。无论任务把中断视为取消,还是其他某个中断响应操作,都应该小心的保存执行线程的中断状态。如果出了将中断异常传递给调用者外还需要执行其他操作,那么应该在捕获中断异常之后恢复中断状态:
Thread.currentThread().interrupt();
正如任务代码不应该对其执行所在的线程的中断策略做出假设,执行取消操作的代码也不应该对线程的中断策略做出假设。线程应该只能由其所有者中断,所有者可以将线程的中断策略信息封装到某个合适的取消机制中,例如关闭方法。
由于每个线程又有各自的中断策略,因此除非你知道中断对线程的含义,否在就不应该中断这个线程。
批评者层嘲笑 Java 的中断功能,因为它没有提供抢占式中断机制,而且还强迫开发人员必须处理中断异常。然而,通过推迟中断请求的处理,开发人员能够制定更灵活的中断策略,从而使应用程序在响应性和健壮性之间实现合理的平衡。
7.1.3 响应中断
在 5.4 节中,当调用可阻塞的阻塞函数式,例如 Thread.sleep 或 BlockingQueue.put 等,有两种实用策略可用于处理 InterruptedException:
- 传递异常(可能在执行某个特定于任务的清除操作之后),从而使你的方法也成为可中断的阻塞方法。
- 恢复中断状态,从而使调用栈的上层代码能够对其进行处理。
传递中断异常与将中断异常添加到 throws 子句中一样容易,如程序清单 7-6 中的 getNextTask 所示。
BlockingQueue<Task> queue;
...
public Task getNextTask() throws InterruptedException {
return queue.take();
}
如果不想或无法传递中断异常(或许通过 Runnable 来定义任务),那么需要寻找一另一种方式来保存中断请求。一种标准的方法是通过再次调用 interrupt 来恢复中断状态。你不能屏蔽 InterruptedException,例如 catch 块中捕获到异常却不做任何处理,除非在你的代码中实现了线程的中断策略。虽然 PrimeProducer 屏蔽了中断,但这是因为它已经知道线程将要结束,因此在调用栈中已经没有上层代码需要知道中断信息。由于大多数代码并不知道它们将在哪个线程上运行,因此应该保存中断状态。
只有实现了线程中断策略的代码才可以屏蔽中断请求。在常规的任务和库代码中都不应该屏蔽中断请求。
对于一些不支持取消单仍可以调用阻塞方法的操作,他们必须在循环中调用这些方法,并在发现中断后重新尝试。在这种情况下,它们应该在本地保存中断状态,并在返回前恢复状态而不是在捕获中断异常时恢复状态,如程序清单 7-7 所示。如果过早的设置中断状态,就可能引起无限循环,因为大多数可中断的阻塞方法都会在入口检查中断状态,并且当返现该状态已被设置时会立即抛出 InterruptedException。(通常,可中断的方法会在阻塞或进行重要的工作前首先检查中断,从而尽快的响应中断)。
public Task getNextTask(BlockingQueue<Task> queue) {
boolean interrupted = false;
try {
while(true) {
try {
return queue.take();
} catch(InterruptedException e) {
interrupted = true;
//重试
}
}
} finally {
if(interrupted)
Thread.currentThread().interrupt();
}
}
如果代码不会调用可中断的阻塞方法,那么仍然可以通过在任务代码中轮询当前线程的中断状态来响应中断。要选择合适的轮询频率,就需要在效率和响应性之间进行权衡。如果响应性要求较高,那么不应该调用那些执行时间较长并且不影响中断的方法,从而对可调用的库代码进行一些限制。
在取消过程中可能涉及中断状态之外的其他状态。中断可以用来获得线程的注意,并且由中断线程保存的信息,可以为中断的线程提供进一步的指示。(当访问这些信息时,要确保使用同步)
例如,当一个由 ThreadPoolExecutor 拥有的工作线程检测到中断时,它会检查线程是否正在关闭。如果是,它会在结束前执行一些线程池清理工作,否则它可能创建一个新线程以将线程池恢复到合理的规模。
7.1.4 示例:计时运行
许多问题永远也无法解决(如枚举所有的素数),而某些问题,能很快得到答案,也可能永远得不到答案。在这些情况下,如果能够指定“最多花10分钟搜索答案”或者“枚举出在 10 分钟内能找到的答案”,那么将是非常有用的。
程序清单 7-2 中的 aSecondOfPrimes 方法将启动一个 PrimeGenerator,并在 1 秒钟后中断。尽管 PrimeGenerator 可能需要超过 1 秒钟的时间才能停止,但它最终会发现中断,然后停止,并使线程结束。在执行任务时的另一个方面是,你希望知道在任务执行过程中是否会抛出异常。如果 PrimeGenerator 在指定时限内抛出了一个未检异常,那么这个异常可能会被忽略,因为素数生成器在另一个独立的线程中运行,而这个线程并不会显式的处理异常。
在程序清单 7-8 中给出了在指定时间内运行一个任意的 Runnable 的示例。它在调用线程中运行任务,并安排了一个取消任务,在运行指定的时间间隔后中断它。这解决了从任务中抛出未检查异常的问题,因为该异常会被 timedRun 的调用者捕获。
private static final ScheduledExecutorService cancelExec = ...;
public static void timedRun(Runnable r, long timeout, TimeUnit unit) {
final Thread taskThread = Thread.currentThread();
cancelExec.schedule(new Runnable() {
public void run() {
taskThread.interrupt();
}
}, timeout, unit);
r.run();
}
这是一种非常简单的方法,但却破坏了以下原则:在中断线程之前,应该了解它的中断策略。由于 TimeRun 可以从任意一个线程中调用,因此它无法找到这个调用线程的中断策略。如果任务在超时之前完成,那么中断 timedRun 所在线程的取消任务将在 tumedRun 返回到调用者之后启动。我们不知道在这种情况下将运行什么代码,但结果一定不是好的。(可以使用 schedule 返回的 ScheduleFuture 来取消这个取消任务以避免这种风险,这种做法虽然可行,但却非常复杂)
而且,如果任务不响应中断,那么 timedRun 会在任务结束时才返回,此时可能已经超过了指定的时限(或者尚未超过时限)。如果某个限时运行的服务没有在指定时间内返回,那么将对调用者带来负面影响。
在程序清单 7-9 中解决了 aSecondOfPrimes 的异常处理问题以及之前解决方案中的问题。执行任务的线程拥有自己的执行策略,即使任务不响应中断,限时运行的方法仍能返回到它的调用者。在启动任务线程之后,timedRun 将执行一个限时的 join 方法。在 join 返回后,它将检查任务中是否有异常抛出,如果有的话,则会在调用 timedRun 的线程中再次抛出该异常。由于 Throwable 将在两个线程之间共享,因此该变量被声明为 volatile 类型,从而确保线程安全的将其从任务线程中发布到 timedRun 线程。
public static void timedRun(
final Runnable r,
long timeout,
TimeUnit unit) throws InterruptedException {
class RethrowableTask implements Runnable {
private volatile Throwable t;
public void run() {
try { r.run(); }
catch (Throwable t) { this.t = t; }
}
void rethrow() {
if (t != null) throw launderThrowable(t);
}
}
RethrowableTask task = new RethrowableTask();
final Thread taskThread = new Thread(task);
taskThread.start();
cancelExec.schedule(new Runnable() {
public void run() {
taskThread.interrupt();
}, timeout, unit);
taskThread.join(unit.toMillis(timeout));
task.rethrow();
}
在这个示例代码中解决了前面示例中的问题,但由于它依赖于一个限时的 join,因此存在着 join 的不足:无法知道执行控制是因为线程正常退出而返回还是因为 join 超时而返回。
7.1.5 通过 Future 实现取消
我们已经使用了一种抽象机制来管理任务的生命周期、处理异常、实现取消,即 Future。通常,使用现有库中的类比自行编写更好,因此我们将继续使用 Future 和任务执行框架来构建 timedRun。
ExecutorService.submit 将返回一个 Future 来描述任务。Future 拥有一个 cancel 方法,该方法带有一个 boolean 类型的参数 mayInterruptIfRunning,表示取消操作是否成功。(这只是表示任务是否能够接收终端,而不是表示任务是否能检测并处理终端)。如果 mayInterruptIfRunning 为 true 并且任务当前正在某个线程中运行,那么这个线程能被中断,如果这个参数为 false,那么意味着“若任务还没有启动,就不要运行它”,这种方式应该用于那些不处理中断的任务中。
除非你清除线程的中断策略,否则不要中断线程,那么在什么情况下调用 cancel 可以将参数指定为 true 呢?执行任务的线程是由标准的 Executor 创建的,它实现了一种中断策略使得任务可以通过中断被取消,所以如果任务在标准 Executor 中运行,并通过它们的 Future 来取消任务,那么可以设置 mayInterruptIfRunning。当尝试取消某个任务时,不宜直接中断线程池,因为你并不知道当中断请求时要将中断视为一个取消请求的另一个理由:可以通过任务的 Future 来取消它们。
程序清单 7-10 给出了另一个版本的 timedRun:将任务提交给一个 ExecutorService,并通过一个定时的 Future.get 来获得结果。如果 get 在返回时抛出了一个 TimeoutException,那么任务将通过它的 Future 来取消。(为了简化代码,这个版本的 timeRun 在 finally 块中将直接调用 Future.cancel,因为取消一个已经完成的任务不会带来任何影响)。如果任务在被取消前就抛出一个异常,那么该异常将被重新抛出以便由调用者来处理。在程序清单 7-10 中还给出了另一种良好的编程习惯:取消那些不再需要结果的任务。
public static void timedRun(Runnable r, long timeout, TimeUnit unit) throws InterruptedException {
Future<?> task = taskExec.submit(r);
try {
task.get(timeout, unit);
} catch(TimeoutExcetion e) {
// 接下来任务将被取消
} catch(ExecutionException e) {
// 如果在任务中抛出了异常,那么重新抛出该异常
throw launderThrowable(e.getCause());
} finally {
// 如果任务已经结束,执行取消操作不会带来任何影响
// 如果任务正在运行,那么将被中断
task.cancel(true);
}
}
当 Future.get 抛出中断或超时异常时,如果你知道不再需要结果,那么就可以调用 Future.cancel 来取消任务。
7.1.6 处理不可中断的阻塞
在 Java 库中,许多阻塞的方法都是通过提前返回或抛出中断异常来响应中断请求的,从而使开发人员更容易构建出能够响应取消请求的任务。然而,并非所有的可阻塞方法或者阻塞机制都能够响应中断:如果一个线程由于执行同步的 Socket IO 或者等待获得内置锁而阻塞,那么中断请求只能设置线程的中断状态,除此之外没有其他任何作用。对于那些由于执行不可中断操作而被阻塞的线程,可以使用类似于中断的手段来停止这些线程,但这要求我们必须知道线程阻塞的原因。
Java.io 包中的同步 Socket IO,在服务器应用中,最常见的 IO 形式就是对套接字进行读写,虽然 InputStream 和 OutputStream 中的 read 和 write 等方法都不会响应中断,但通过关闭底层的套接字,可以使得由于执行 read 或 write 等方法而被阻塞的线程抛出一个 SocketException。
Java.io 包中的同步 IO,当中断一个正在 InterruptibleChannel 上等待的线程时,将抛出 ClosedByInterruptException 并关闭链路(这还会使得其他在这条链路上阻塞的线程同样抛出该异常)。当关闭一个 InterruptibleChannel 时,将导致所有在链路操作上阻塞的线程都抛出 AsynchronousCloseException。大多数标准的 Channel 都实现了 InterruptibleChannel。
Selector 的异步 IO,如果一个线程在调用 Selector.select 方法时阻塞了,那么调用 close 或 wakeup 方法会使线程抛出 ClosedSelectorException 并提前返回。
获取某个锁,如果一个线程由于等待某个内置锁而阻塞,那么将无法响应中断,因为线程任务它肯定会获得锁,所以不会理会中断请求。但是,在 Lock 类中提供了 lockInterruptibly 方法,该方法允许在等待一个锁的同时仍能响应中断,请参见第 13 章。
程序清单 7-11 的 ReaderThread 给出了如何封装非标准的取消操作。ReaderThread 管理了一个套接字连接,它采用同步方式从该套接字中读取数据,并将接收到的数据传递给 processBuffer。为了结束某个用户的连接或者关闭服务器,ReaderThread 改写了 interrupt 方法,使其既能处理标准的中断,也能关闭底层的套接字。因此,无论 ReaderThread 线程是在 read 方法中阻塞还是在某个可中断的阻塞方法中阻塞,都可以被中断并停止执行当前的工作。
public class ReaderThread extends Thread {
private final Socket socket;
private final InputStream in;
public ReaderThread(Socket socket) throws IOException {
this.socket = socket;
this.in = socket.getInputStream();
}
public void interrupt() {
try {
socket.close();
}
catch(IOException ignored){}
finally {
super.interrupt();
}
}
public void run() {
try {
byte[] buf = new byte[BUFSZ];
while(true) {
int count = in.read(buf);
if(count < 0)
break;
else if(count >0)
pricessBuffer(buf, count);
}
} catch (IOException e) {
// 允许线程退出
}
}
}
7.1.7 采用 newTaskFor 来封装非标准的取消
我们可以通过 newTaskFor 方法来进一步优化 ReaderThread 中封装非标准取消的技术,这是 Java 6 在 ThreadPoolExecutor 中的新增功能。当把一个 Callable 提交给 ExecutorService 时,sunbmit 方法会返回一个 Future,我们可以通过这个 Future 来取消任务。newTaskFor 是一个工厂方法,它将创建 Future 来代表任务。newTaskFor 还能返回一个 RunnableFuture 接口,该接口扩展了 Future 和 Runnable(并由 FutureTask 实现)。
通过定制表示任务的 Future 可以改变 Future.cancel 的行为。例如,定制的取消代码可以实现日志记录或者收集取消操作的统计信息,以及取消一个写不响应中断的操作。通过改写 inturrupt 方法,ReaderThread 可以取消基于套接字的线程。同样,通过改写任务的 Future.cancel 方法可以实现类似的功能。
在程序清单 7-12 的 CancellableTask 中定义一个 CancellableTask 接口,该接口扩展了 Callable,并增加了一个 cancel 方法和一个 newTask 工厂方法来构造 RunnableFuture。cancellingExecutor 扩展了 ThreadPoolExecutor,并通过改写 newTaskFor 使得 CancellableTask 可以创建自己的 Future。
public interface CancellableTask<T> extends Callable<T> {
void cancel();
RunnableFuture<T> newTask();
}
@ThreadSafe
public class CancellingExecutor extends ThreadPoolExecutor {
...
protected<T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
if (callable instanceof CancellableTask)
return ((CancellableTask<T>) callable).newTask();
else return super.newTaskFor(callable);
}
}
public abstract class SocketUsingTask<T>
implements CancellableTask<T> {
@GuardedBy("this") private Socket socket;
protected synchronized void setSocket(Socket s) {
socket = s;
}
public synchronized void cancel() {
try {
if (socket != null) socket.close();
} catch (IOException ignored) { }
}
public RunnableFuture<T> newTask() {
return new FutureTask<T>(this) {
public boolean cancel(
boolean mayInterruptIfRunning) {
try { SocketUsingTask.this.cancel(); }
finally {
return super.cancel(
mayInterruptIfRunning);
}
}
};
}
}
SocketUsingTask 实现了 CancellTask,并定义了 Future.cancel 来关闭套接字和调用 super.cancel。如果 SocketUsingTask 通过自己的 Future 来取消,那么底层的套接字将被关闭且线程将被中断。因此它提高了任务对取消操作的响应性:不仅能够在调用可中断方法的同时确保响应取消操作,而且还能调用可阻塞的套接字 IO 方法。
7.2 停止基于线程的服务
应用程序通常会创建拥有多个线程的服务,比如线程池,并且这些服务的生命周期通常比创建它们的方法的生命周期要长。如果应用程序准备退出,那么这些服务所拥有的线程也需要结束。由于服务通过抢占式的方法来停止线程,因此它们需要自行结束。
正确的封装原则是:除非拥有某个线程,否则不能对该线程继续操控。例如,中断线程或者修改线程的优先级。在线程 API 中,并没有对线程所有权给出正式的定义:线程由 Thread 对象表示,并且像其他对象一样可以被自由共享。然而,线程有一个相应的所有者,即创建该线程的类。因此线程池是其工作者线程的所有者,如果要中断这些线程,那么应该使用线程池。
与其他封装对象一样,线程的所有权是不可传递的:应用程序可以拥有服务,服务也可以拥有工作者线程,但应用程序并不能拥有工作者线程,因此应用程序不能直接停止工作者线程。相反,服务应该提供生命周期方法来关闭它自己以及他所拥有的线程。这样,当应用程序关闭该服务时,服务就可以关闭所有的线程了。在 ExecutorService 中提供了 shutdown 和 shutdownNow 等方法。同样,在其他拥有线程的服务中也应该提供类似的关闭机制。
对于持有线程的服务,只要服务的存在时间大于创建线程的方法存在的时间,那么久应该提供生命周期方法。
示例:日志服务
在大多数服务器应该程序中都会用到日志,例如,在代码中插入 pringln 语句就是一种简单的日志。像 PrintWriter 这样的字符流是线程安全的,因此这种简单的方法不需要显式的同步。然而,在 11.6 节中,我们将看到这种内联日志功能会给一些高容量的应用程序带来一定的性能开销。另外一种替代方法是通过调用 log 方法将日志消息放入某个队列中,并由其他线程来处理。
在程序清单 7-13 的 LogWriter 中给出了一个简单的日志服务示例,其中日志操作在单独的日志线程中执行。产生日志消息的线程并不会将消息直接写入输出流,而是由 LogWriter 通过 BlockingQueue 将消息提交给日志线程,并由日志线程写入。这是一种多生产者单消费者的设计方式:每个调用 log 的操作相当于一个生产者,而后台的日志线程相当于消费者。如果消费者的处理速度低于生产者的生产速度,那么 BlockingQueue 将阻塞生产者,直到日志线程有能力处理新的日志消息。
public class LogWriter {
private final BlockingQueue<String> queue;
private final LoggerThread logger;
public LogWriter(Writer writer) {
this.queue = new LinkedBlockingQueue<String>(CAPACITY);
this.logger = new LoggerThread(writer);
}
public void start() { logger.start(); }
public void log(String msg) throws InterruptedException {
queue.put(msg);
}
private class LoggerThread extends Thread {
private final PrintWriter writer;
...
public void run() {
try {
while (true)
writer.println(queue.take());
} catch(InterruptedException ignored) { }
finally { writer.close(); }
}
}
}
为了使像 LogWriter 这样的服务在软件产品中发挥实际的作用,还需要实现一种终止日志线程的方法,从而避免使 JVM 无法正常关闭。要停止日志线程是很容易的,因为他会反复调用 take,而 take 能响应中断。如果将日志线程修改为当捕获到 InterruptedException 时推出,那么只需中断日志线程就能停止服务。
然而,如果只是日志线程推出,那么还不是一种完备的关闭机制。这种直接关闭的做法会丢失那些正在等待被写入日志的信息,不仅如此,其他线程将在调用 log 时被阻塞,因为日志队列是满的,因此这些线程将无法解除阻塞状态。当取消一个生产者消费者操作时,需要同时取消生产者和消费者。在中断日志线程时会处理消费者,但在这个示例中,由于生产者并不是专门的线程,因此要取消它们非常困难。
另一种关闭 LogWriter 的方式是,设置某个“已请求关闭”标志,以避免进一步提交日志消息,如程序清单 7-14 所示。在收到关闭请求后,消费者会把队列中的所有消息写入日志,并解除所有在调用 log 时阻塞的生产者。然而,在这个方法中存在竟态条件,使得该方法并不可靠。log 的实现是一种“先判断再运行”的代码序列:生产者发现该服务还没有关闭,因此在关闭服务后仍然会将日志消费放入队列,这同样会使得生产者可能在调用 log 时阻塞并且无法解除阻塞状态。可以通过一些技巧来降低这种情况的发生概率(比如在宣布队列被清空之前,让消费者等待数秒钟),但这都没有解决问题的本质,即使很小的概率也可能导致程序故障。
public void log(String msg) throws InterruptedException {
if(!shutdownRequested)
queue.put(msg);
else
throw new IllegalStateException("logger is shutdown");
}
为 LogWriter 提供可靠关闭操作的方法是解决静态条件问题,因为要使日志消息的提交操作称为原子操作。然而,我们不希望在消息加入队列时去持有一把锁,因为 put 方法本身就可以阻塞。我们采用的方法是:通过原子方式来检查关闭请求,并且有条件的递增一个计数器来“保持”提交消息的权利,如程序清单 7-15 中的 LogService 所示。
public class LogService {
private final BlockingQueue<String> queue;
private final LoggerThread loggerThread;
private final PrintWriter writer;
@GuardedBy("this") private boolean isShutdown;
@GuardedBy("this") private int reservations;
public void start() { loggerThread.start(); }
public void stop() {
synchronized (this) { isShutdown = true; }
loggerThread.interrupt();
}
public void log(String msg) throws InterruptedException {
synchronized (this) {
if (isShutdown)
throw new IllegalStateException(...);
++reservations;
}
queue.put(msg);
}
private class LoggerThread extends Thread {
public void run() {
try {
while (true) {
try {
synchronized (this) {
if (isShutdown && reservations == 0) break;
}
String msg = queue.take();
synchronized (this) { --reservations; } writer.println(msg);
} catch (InterruptedException e) {
/* retry*/
}
}
} finally {
writer.close();
}
}
}
}
7.2.2 关闭 ExecutorService
在 6.2.4 节中,我们看到 ExecutorService 提供了两种关闭方法:使用 shutdown 正常关闭,以及使用 shutdownNow 强行关闭。在进行强行关闭时,shutdownNow 首先关闭正在执行的任务,然后返回所有尚未启动的任务清单。
这两种关闭的方式的差别在于各自的安全性和响应性:强行关闭的速度更快,但风险也更大,因为任务很可能在执行到一半时被结束:而正常关闭虽然速度慢,但却更安全,因为 ExecutorService 会一直等到队列中的所有任务都执行完毕才关闭。在其他拥有线程的服务中也应该考虑提供类似的关闭方式以供选择。
简单的程序可以直接在 main 函数中启动和关闭全局的 ExecutorService。而在复杂程序中,通常会将 ExecutorService 封装在某个更高级别的服务中,并且该服务能提供自己的生命周期方法,例如程序清单 7-16 中 LogService 的一种变化形式,它将管理线程的工作委托给一个 ExecutorService,而不是由其自行管理。通过封装 ExecutorService,可以将所有权链(Ownership Chain)从应用程序扩展到服务以及线程,所有权链上的各个成员都将管理它拥有的服务或线程的生命周期。
public class LogService {
private final ExecutorService exec = newSingleThreadExecutor();
...
public void start() {}
public void stop() throws InterruptedException {
try {
exec.shutdown();
exec.awaitTermination(TIMOUT, UNIT);
} finally {
writer.close();
}
}
public void log(String msg) {
try {
exec.execute(new WriteTask(msg));
} catch(RejectedExecutionException ignored){}
}
}
7.2.3 “毒丸”对象
另一种关闭生产者——消费者服务的方式是使用“毒丸(Poison Pill)”对象:毒丸是指一个放在队列中的对象,其含义是“当遇到这个对象时,立即停止”。在 FIFO 队列中,毒丸对象将确保消费者在关闭之前首先完成队列中的所有工作,在提交毒丸对象之前的所有工作都会被处理完毕,而生产者在提交了毒丸之后,将不会再生产任何工作。在程序清单 7-17、7-18、7-19 中给出了一个单生产者单消费者的桌面搜索示例,这个示例中使用了毒丸对象来关闭服务。
public class IndexingService {
private static final File POISON = new File("");
private final IndexerThread consumer =
new IndexerThread();
private final CrawlerThread producer =
new CrawlerThread();
private final BlockingQueue<File> queue;
private final FileFilter fileFilter;
private final File root;
class CrawlerThread extends Thread { /* Listing 7.18 */ }
class IndexerThread extends Thread { /* Listing 7.19 */ }
public void start() {
producer.start();
consumer.start();
}
public void stop() {
producer.interrupt();
}
public void awaitTermination() throws InterruptedException {
consumer.join();
}
}
public class CrawlerThread extends Thread {
public void run() {
try{
crawl(root);
} catch(InterruptedExcetion e) {/* fall through */}
finally {
while(true){
try{
queue.put(POISON);
break;
} catch(InterruptedException e1) {/* retry */}
}
}
}
private void cawal(File root) throws InterruptedException{
// ...
}
}
public class IndexerThread extends Thread {
public void run() {
try {
while(true){
File file = queue.take();
if(file == POISON)
break;
else
indexFile(file);
}
} catch(InterruptedException consumed) {}
}
}
只有生产者和消费者都已知的情况下,才可以使用毒丸对象。在 IndexingService 中采用的解决方案可以扩展到多个生产者:只需要从每个生产者都向队列中放入一个毒丸对象,并且消费者仅当在接收到 N 个毒丸对象时才停止。这种方法也可也扩展到多个消费者的情况,只需生产者将 N 个毒丸对象放入队列。然而,当生产者和消费者的数量较大时,这种方法将变得难以使用。只有在无界队列中,毒丸对象才能可靠的工作。
7.2.4 示例:只执行一次的服务
如果某个方法需要处理一批任务,并且当所有任务都处理完成后才返回,那么可以通过一个私有的 Executor 来简化服务的生命周期管理,其中该 Executor 的生命周期是由这个方法来控制的。(在这种情况下,invokeAll 和 invokeAny 等方法通常会起较大的作用)
程序清单 7-20 中的 checkMail 方法能在多台主机上并行的检查新邮件。它创建一个私有的 Executor,并向每台主机提交一个任务。然后,当所有邮件检查任务都执行完成后,关闭 Executor 并等待结束。
boolean checkMail(Set<String> hosts, long timeout, TimeUnit unit) throws InterruptedException {
final AtomicBoolean hasNewMail = new AtomicBoolean(false);
try {
for(final String host : hosts)
exec.ececute(new Runnable() {
public void run() {
if(checkMail(host))
hasNewMail.set(true);
}
});
} finally {
exec.shutdown();
exec.awaitTermination(timeout, unit);
}
return hasNewMail.get();
}
7.2.5 shutdownNow 的局限性
当通过 shutdownNow 来强行关闭 ExecutorService 时,它会尝试取消正在执行的任务,并返回所有已提交但尚未开始的任务,从而将这些任务写入日志或者保存起来以便之后进行处理。
然而,我们无法通过常规方法来找出哪些任务已经开始但尚未结束。这意味着我们无法在关闭过程中知道正在执行的任务的状态,除非任务本身执行某种检查。要知道哪些任务还没有完成,你不仅需要知道那些任务还没有开始,还需要知道当 Executor 关闭时哪些任务正在执行。
在程序清单 7-21 的 TrackingExecutor 中给出了如何在关闭过程中判断正在执行的任务。通过封装 ExecutorService 并使得 execute (类似的还有 submit 等)记录哪些任务是在关闭后取消的,TackingExecutor 可以找出哪些任务已经开始但还没有正常完成。在 Executor 结束后,getCancelledTasks 返回被取消的任务清单。要使这项技术能发挥作用,任务在返回时必须维持线程的中断状态,在所有设计良好的任务中都会实现这个功能。
public class TrackingExecutor extends AbstractExecutorService {
private final ExecutorService exec;
private final Set<Runnable> tasksCancelledAtShutdown =
Collections.synchronizedSer(new HashSet<>());
...
public List<Runnable> getCancelledTasks() {
if(!exec.isTerminated())
throw new IllegalStateException();
return new ArrayList<>(tasksCancelledAtShutdown);
}
public void execute(final Runnable runnable) {
exec.execute(new Runnable(){
public void run() {
try {
runnable.run();
} finally {
if(isShutdown() &&
Thread.currentThread().isInterrupted())
tasksCancelledAtShutdown.add(runnable);
}
}
});
}
}
在程序清单 7-22 的 WebCrawler 中给出了 TackingExecutor 的用法。网页爬虫的工作通常是无穷尽的,因此当爬虫程序必须关闭时,我们通常希望保存它的状态,以便稍后重新启动。CrawlerTask 提供了一个 getPage 方法,该方法能找出正在处理的页面。当爬虫程序关闭时,无论是还没有开始的任务,还是那些被取消的任务,多将记录它们的 URL,因此爬虫程序重新启动时,就可以将这些 URL 的页面抓取任务加入到任务队列中。
public abstract class WebCrawler {
private volatile TrackingExecutor exec;
@GuardedBy("this")
private final Set<URL> urlsToCrawl = new HashSet<URL>();
...
public synchronized void start() {
exec = new TrackingExecutor(Executors.newCachedThreadPool());
for (URL url : urlsToCrawl)
submitCrawlTask(url);
urlsToCrawl.clear();
}
public synchronized void stop()
throws InterruptedException {
try {
saveUncrawled(exec.shutdownNow());
if (exec.awaitTermination(TIMEOUT, UNIT))
saveUncrawled(exec.getCancelledTasks());
} finally {
exec = null;
}
}
protected abstract List<URL> processPage(URL url);
private void saveUncrawled(List<Runnable> uncrawled) {
for (Runnable task : uncrawled)
urlsToCrawl.add(((CrawlTask) task).getPage());
}
private void submitCrawlTask(URL u) {
exec.execute(new CrawlTask(u));
}
private class CrawlTask implements Runnable {
private final URL url;
...
public void run() {
for (URL link : processPage(url)) {
if (Thread.currentThread().isInterrupted())
return;
submitCrawlTask(link);
}
}
public URL getPage() { return url; }
}
}
在 TrackingExecutor 中存在一个不可避免的竟态条件,从而产生“误报”问题:一些被任务已经取消的任务实际上已经执行完成。这个问题的原因在于,在任务执行最后一条执行以及线程池将任务记录为“结束”的两个时刻之间,线程池可能被关闭。如果任务是幂等的,那么这不会存在问题,在网页爬虫程序中就是这种情况。否则,在应用程序中必须考虑这种风险,并对“误报”问题做好准备。
7.3 处理非正常的线程终止
当单线程的控制台程序由于发生了一个未捕获的异常而终止时,程序将停止运行,并产生与程序正常输出非常不同的栈追踪信息,这种情况是很容易理解的。然而,如果并发程序中的某个线程发生故障,那么通常并不会如此明显。在控制台中可能会输出栈追踪信息,但没有人会观察控制台。此外,当线程发生故障时,应用程序可能看起来仍然在工作,所以这个失败很可能被忽略。幸运的是,我们有可以检测并防止在程序中“遗漏”线程的方法。
导致线程提前死亡的最主要原因就是 RuntimeException。由于这些异常表示出现了某种编程错误或者其他不可修复的错误,因此它们通常不会被捕获。它们不会在调用栈中逐层传递,而是默认的在控制台中输出栈追踪信息,并终止线程。
线程非正常退出的后果可能是良性的,也可能是恶性的,这要取决于线程在应用程序中的作用。虽然在线程池中丢失一个线程可能会带来性能上的影响,但如果程序能在包含 50 个线程的线程池上运行良好,那么在包含 49 个线程的线程池上通常也能良好的运行。然而,如果在 GUI 程序中丢失了事件分派线程,那么造成的影响将非常显著——应用程序将停止处理事件并且 GUI 会因此失去响应。在第六章的 OutOfTime 中给出了由于遗留线程而造成的严重后果:Timer 表示的服务将永远无法使用。
任何代码都可能抛出一个 RuntimeException。每当调用另一个方法时,都要对它的行为保持怀疑,不要盲目的任务它一定会正常返回,或者一定会抛出在方法原型中声明的某个受检异常。对调用的代码约不熟悉,就越应该对其代码行为保持怀疑。
在任务处理线程(如线程池中的工作线程或 Swing 的事件派发线程等)的生命周期中,将通过某种抽象机制(如 Runnable)来调用许多未知的代码,我们应该对在这些线程中执行的代码能否表现出正确的行为保持怀疑。像 Swing 事件线程这样的服务可能只是因为某个编写不当的时间处理器抛出 NPE 而失败,这种情况是非常糟糕的。因此,这些线程应该在 try-catch 代码块中调用这些任务,这样就能捕获那些未检异常了,或者也可以使用 try-finally 代码块来确保框架能够知道线程非正常退出的情况,并作出正确的响应。在这种情况下,你或许会考虑捕获 RuntimeException,即当通过 Runnable 这样的抽象机制来调用未知的和不可信的代码时。
在程序清单 7-23 中给出了如何在线程池内部构建一个工作者线程。如果任务抛出了一个未检异常,那么它将终结线程,但首先会通知框架该线程已经终结。然后,框架可能会用新的线程来代替这个工作线程,也可能不会,因为线程池正在关闭,或者当前已有足够多的线程能够满足需要。ThreadPoolExecutor 和 Swing 都通过这项技术来确保行为糟糕的任务不会影响到后续执行的任务。当编写一个向线程池提交任务的工作者线程类时,或者调用不可信的外部代码时(如动态加载的插件),使用这些方法中的某一种可以避免某个编写的糟糕的任务或插件不会影响调用它的整个线程。
public void run() {
Throwable thrown = null;
try {
while(!isInterrupted()){
runTask(getTaskFromWorkQueue());
}
} catch (Throwable e) {
thrown = e;
} finally {
threadExited(this, thrown);
}
}
未捕获异常的处理
上节介绍了一种主动方法来解决未检异常。在 Thread API 中同样提供了 UncaughtExceptionHandler,它能检测出某个线程由于未捕获异常而终止的情况。这两种方法是互补的,通过将二者结合,就能有效防止线程泄露问题。
当一个线程由于未捕获异常而退出时,JVM 会把这个事件报告给应用程序提供的 UncaughtExceptionHandler 异常处理器。如果没有提供任何异常处理器,那么默认的行为是将栈追踪信息输出到 System.err。
public interface UncaughtExceptionHandler {
void uncaughtException(Thread t, Throwable e);
}
异常处理器处理未捕获异常的方式,取决于对服务质量的需求。最常见的响应方式是将一个错误信息以及相应的栈追踪信息写入应用程序日志中,如程序清单 7-25 所示。异常处理器还可以采取更直接的响应,例如尝试重新启动线程,关闭应用程序,或者执行其他修复或诊断等操作。
public class UEHLogger implements Thread.UncaughtExceptionHandler {
public void uncaughtException(Thread t, Throwable e) {
Logger logger = Logger.getAnonymousLogger();
logger.log(Level.SERVER,
"Thread terminated with exception: " + t.getname(), e);
}
}
在长时间运行的应用程序中,通常会为所有线程的未捕获异常指定同一个异常处理器,并且该处理器至少会将异常信息记录到日志中。
要为线程池中的所有线程设置一个异常处理器,需要为 ThreadPoolExecutor 的构造函数提供一个 ThreadFactory。(与所有线程操控一样,只有线程的所有者能够改变线程的异常处理器)。标准线程池允许当发生未捕获异常时结束线程,但由于使用了一个 try-finally 代码块来接收通知,因此当线程结束时,将有新的线程来代替它。如果没有提供捕获异常处理器或者其他的故障通知机制,那么任务将会悄悄失败,从而导致大面积的混乱,如果你希望在任务由于发生异常而失败时获得通知,并且执行一些特定于任务的恢复操作,那么可以将任务封装在能捕获异常的 Runnable 或 Callable 中,或者改写 ThreadPoolExecutor 的 afterExecute 方法。
令人困惑的是,只有通过 execute 提交的任务,才能将它抛出的异常交给异常处理器,而通过 submit 提交的任务,无论是抛出未检还是受检异常,都将被认为是任务返回状态的一部分。如果一个由 submit 提交的任务由于抛出了异常而结束,那么这个异常将被 Future.get 封装在 ExecutionException 中。
7.4 JVM 关闭
JVM 既可以正常关闭,也可以强行关闭。正常关闭的触发方式有多种,包括:当最后一个“正常(非守护)”线程结束时,或者当调用了 System.exit 时,或者通过其他特定于平台的方法关闭时(例如发送了 SIGINT 信号或键入 Ctrl+C)。虽然可以通过这些标准方法来正常关闭 JVM,但也可以通过调用 Runtime,halt 或者在操作系统中“杀死” JVM 进程(如发送 SIGKILL)来强行关闭 JVM。
关闭钩子
在正常关闭中,JVM 首先调用所有已注册的关闭钩子(ShutdownHook)。关闭钩子是通过 Runtime.addShutdownHook 注册的但尚未开始的线程。JVM 并不能保证关闭钩子的调用顺序。在关闭应用程序线程时,如果有(守护或非守护)线程仍在运行,那么这些(钩子)线程接下来将与关闭线程并发运行。当所有的关闭钩子都执行结束时,如果 runFinalizersOnExit 为 true,那么 JVM 将运行终结器,然后再停止。JVM 并不会停止或中断任何在关闭时仍在运行的应用程序线程。当 JVM 最终结束时,这些线程将被强行结束。如果关闭钩子或终结器没有执行完成,那么正常关闭进程“挂起”并且 JVM 必须被强行关闭。当被强行关闭时,只是关闭 JVM,而不会运行关闭钩子。
关闭钩子应该确保线程安全:它们在访问共享数据时必须使用同步机制,并且小心的避免发生死锁,这与其他并发代码的要求相同。而且,关闭钩子不应该对应用程序的状态(如其他服务是否已经关闭、所有的正常线程是否已经执行完成)或 JVM 的关闭原因作出任何假设,因此在编写关闭钩子的代码时必须考虑周全。最后,关闭钩子应该尽快退出,因为它们的运行会延迟 JVM 的结束时间,而用户可能希望尽快关闭 JVM。
关闭钩子可以用于实现服务或应用程序的清理工作,例如删除临时文件、清除无法由操作系统自行清除的资源。在程序清单 7-26 中给出了如何使用程序清单 7-16 中的 LogService 在其 start 方法中注册一个关闭钩子,从而确保在退出时关闭日志文件。
public void start() {
Runtime.getRuntime.addShutdownHook(new Thread() {
public void run() {
try { LogService.this.stop();}
catch (InterruptedException ignored) {}
}
})
}
由于关闭钩子将并发执行,因此在关闭日志文件时可能导致其他需要日志服务的关闭钩子产生问题。为了避免这种情况,关闭钩子不应该依赖那些可能被应用程序或其他关闭钩子关闭的服务。实现这种功能的一种方式是对所有服务使用同一个关闭钩子(而非为每个服务注册一个不同的关闭钩子),并且在该关闭钩子中执行一系列的关闭操作。这确保了关闭操作在单个线程中串行执行,从而避免了在关闭操作之间出现竟态条件或死锁。无论是否使用关闭钩子,都可以使用这项技术,通过将各个关闭操作串行执行而非并行执行,可以消除许多潜在的故障。当应用程序需要维护多个服务之间的显式依赖信息时,这项技术可以确保关闭操作按照正确的顺序执行。
7.4.2 守护线程
有时候你希望创建一个线程来执行一些辅助工作,但又不希望这个线程阻碍 JVM 的关闭。在这种情况下就需要使用守护线程(Daemon Thread)。
线程可分为两种:普通线程与守护线程。在 JVM 启动时创建的所有线程中,除了主线程以外,其他的线程都是守护线程(如垃圾回收器及其他执行辅助工作的线程)。当创建一个新线程时,新线程将继承创建它的线程的守护状态,因此在默认情况下,主线程创建的所有线程都是普通线程。
普通线程与守护线程的区别仅在于线程退出时发生的操作。当一个线程退出时,JVM 会检查其他正在运行的线程,如果这些线程都是守护线程,那么 JVM 会在正常退出操作。当 JVM 停止时,所有仍然存在的线程都将被抛弃——既不会执行 finally 代码块,也不会执行回卷栈,而是直接退出 JVM。
我们应尽可能少的使用守护线程——很少有操作能够在不进行清理的情况下被安全的抛弃。特别是,如果在守护线程中执行可能包含 IO 操作的任务,那么将是一种危险的行文。守护线程最后用于执行“内部任务”,例如周期性的从内存的缓存中移除逾期数据。
此外,守护线程不能用来替代应用程序管理程序中各个服务的生命周期。
7.4.3 终结器
当不再需要内存资源时,可以通过垃圾回收器来回收他们,但对于其他一些资源,例如文件句柄或套接字句柄,当不再需要它们时,必须显式的交还给操作系统。为了实现这个功能,垃圾回收器对那些定义了 finalize 方法的对象会进行特殊处理:在回收器释放它们后,调用它它们的 finalize 方法,从而保证一些持久化的方法被释放。
由于终结器可以在某个由 JVM 管理的线程中运行,因此终结器访问的任何状态都可能被多个线程访问,这样就必须对其访问操作进行同步。终结器并不能保证它们将在何时运行甚至是否会运行,并且的复杂的终结器通常还会在对象上产生巨大的开销,要编写正确的终结器是非常困难的。在大多数情况下,通过使用 finally 代码块和显式 close 方法,能够比使用终结器更好的管理资源。唯一的例外请求在于:当需要管理对象,并且该对象的资源是通过本地方法获得的。基于这些原因以及其他一些原因,我们要尽量避免编写或使用包含终结器的类(除非是平台类库中的类)。
避免使用终结器。
小结
在任务、线程、服务已经应用程序等模块中的生命周期结束问题,可能会增加它们在设计和实现时的复杂性。Java 并没有提供某种抢占式的机制来取消操作或终结线程。相反,它提供了一种协作式的中断机制来实现取消操作,但这要依赖于如何构建取消操作的协议,以及能否遵循这些协议。通过使用 FutureTask 和 Executor 框架,可以帮助我们构建可以取消的任务和服务。
1.8 - CH08-线程池
第六种介绍了任务执行框架,他不仅简化任务与线程的生命周期管理,而且还提供一种简单灵活的方式将任务的提交与任务的执行策略解耦开来。第七章介绍了在实际应用程序中使用任务执行框架时出现的一些与服务生命周期管理相关的细节问题。本章将对线程池进行配置与调优的一些高级选项,并分析在使用任务执行框架时需要注意的各种危险,以及一些使用 Executor 的高级示例。
8.1 在任务与执行策略之间的隐性耦合
我们已经知道,Executor 框架可以将任务的提交与任务的执行策略解耦。就像许多对复杂过程的解耦操作那样,这种论断多少有些言过其实了。虽然 Executor 框架为指定和修改执行策略都提供了相当大的灵活性,但并非所有的任务都能适用所有的执行策略。有些类型的任务需要明确的执行策略,包括:
- 依赖性任务。大多数行为正确的任务都是独立的:它们不依赖于其他任务的执行时序、执行结果或其他效果。当在线程池中执行独立的任务时,可以随意的改变线程池的大小和配置,这些修改只会对执行性能产生影响。然而,如果提交给线程池的任务需要依赖其他的任务,那么久隐含的给执行策略带来的约束,此时必须小心的维持这些执行策略以避免产生活跃性问题。
- 使用线程封闭机制的任务。与线程池相比,单线程的 Executor 能够对并发性做出更强的承诺。它们能够确保任务不会并发的执行,使你能够放宽代码对线程安全的要求。对象可以封闭在任务线程中,使得在该线程中的任务在访问该对象时不需要同步,即使这些资源不是线程安全的也没问题。这种情形在任务与执行策略之间形成隐式的耦合——任务要求其执行所在的 Executor 是单线程的。如果将 Executor 从单线程环境改为线程池环境,那么将会失去线程安全性。
- 对响应时间敏感的任务。GUI 应用程序对于响应时间是敏感的:如果用户在点击按钮后需要很长延迟才能得到可见的反馈,那么他们会感到不满。如果将一个运行时间较长的任务提交到单线程的 Executor 中,或者将多个运行时间较长的任务提交到一个只包含少量线程的线程池中,那么将降低由该 Executor 管理的所有服务的响应性。
- 使用 ThreadLocal 的任务。ThreadLocal 使每个线程都可以拥有某个变量的一个私有“版本”。然而,只要条件允许,Executor 可以自由的重用这些线程。在标准的 Executor 实现中,当执行需求较低时将回收空闲线程,而当需求增加时将添加新的线程,并且如果从任务中抛出了一个未检异常,那么将用一个新的工作者线程来替代抛出异常的线程。只有当线程本地值的声明周期受限于任务的生命周期时,在线程池中使用 ThreadLocal 才有意义,而在线程池的线程中不应该使用 ThreadLocal 在任务之间传递值。
只有当任务都是同类型的并且是互相独立时,线程池的性能才能达到最佳。如果将运行时间较长的与运行时间较短的任务混合在一起,那么除非线程池很大,否则将可能造成“拥塞”。如果提交的任务依赖于其他任务,那么除非线程池无限大,否则将可能造成死锁。幸运的是,在基于网络的典型服务器应用程序中——网页服务器、邮件服务器、文件服务器,它们的请求通常都是同类型且相互独立的。
在一些任务中,需要拥有或排除某种特定的执行策略。如果某些任务依赖于其他的任务,那么会要求线程池足够大,从而确保它们的依赖任务不会被放入到线程池的等待队列中或被拒绝,而采用线程封闭机制的任务需要串行执行。通常将这些需求写入文档,将来的代码维护人员就不会由于使用了某种不合适的执行策略而破坏安全性和活跃性。
8.1.1 线程饥饿死锁
在线程池中,如果一个任务依赖于其他任务,则可能会产生死锁。在单线程 Executor 中,如果一个任务将另一个任务提交到同一个 Executor,并且等待这个被提交任务的执行结果,那么通常会引发死锁。第二个任务将停留在任务队列中,并等待第一个任务完成以释放线程,而第一个任务又无法完成,因为它在等待第二个任务的完成。在更大线程池中,如果所有正在执行的任务的线程都由于等待其他仍处于工作队列中的任务二阻塞,那么会发生同样的问题。这种现象被称为线程饥饿死锁,只要在线程池中的任务需要无限期的等待一些必须由线程池中其他任务才能提供的资源或条件,例如某个任务等待另一个任务的返回值或执行结果,那么除非线程池足够大,否则将发生线程饥饿死锁。
在程序清单 8-1 中的 ThreadDeadLock 中给出了线程饥饿死锁的示例。RenderPageTask 向 Executor 提交了两个任务来获取网页的页眉和页脚,绘制页面,等待获取页眉和页脚任务的结果,然后将页眉、页面、页脚组合起来并形成最终的页面。如果使用单线程的 Executor,那么 ThreadDeadLock 会经常发生死锁。同样,如果线程池不够大,那么当多个任务通过栅栏机制来彼此协调时,将导致线程饥饿死锁。
public class ThreadDeadLock {
ExecutorService exec = Executors.newSingleThreadExecutor();
public class RenderPageTask implements Callable<String> {
public String call() throws Exception {
Future<String> header, footer;
header = exec.submit(new LoadFileTask("header.html"));
footer = exec.submit(new LoadFileTask("footer.html"));
String page = renderBody();
// Will deadlock -- task waiting for result of subtask
return header.get() + page + footer.get();
}
}
}
每当提交了一个有依赖性的 Executor 任务时,要清楚的知道可能会出现线程“饥饿”死锁,因此需要在代码或配置 Executor 的配置文件中记录线程池的大小限制或配置限制。
除了在线程池大小上的显式限制外,还可能由于其他资源上的约束而存在一些隐式限制。如果应用程序使用一个包含 10 个连接的 JDBC 连接池,并且每个任务需要一个数据库连接,那么线程池就好像只有 10 个线程,因为当超过 10 个任务时,新的任务需要等待其他任务释放连接。
8.1.2 运行时间较长的任务
如果任务阻塞的时间过长,那么即使不出现死锁,线程池的响应性也会变得糟糕。执行时间较长的任务不仅会造成线程池堵塞,甚至还会增加执行时间较短的任务的服务时间。如果线程池中线程的数量远小于在稳定状态下执行时间较长任务的数量,那么到最后所有的线程都会运行这些执行时间较长的任务,从而影响整体的响应性。
有一项技术可以缓解执行时间较长的任务的影响,即限定任务等待资源的时间,而不要无限制的等待。在平台类库的大多数可阻塞方法中,都同时定义了限时版本和无限时版本,例如 Thread.join、BlockingQueue.put、CountDownLatch.await、Selector.select 等。如果等待超时,那么可以把任务标记为失败,然后终止任务或者将任务重新放回队列以便随后执行。这样,无论任务的最终结果是否成功,这种方法都能确保任务总能执行下去,并将线程释放出来以执行一些能更快执行的任务。如果在线程池中总是充满了被阻塞的任务,那么也可能表明线程池的规模确实过小了。
8.2 设置线程池的大小
线程池的理想大小取决于被提交任务的类型以及所部署系统的特性。在代码中通常不会固定线程池的大小,而应该通过某种配置机制来提供,或者根据 Runtime.availableProcessors 来动态计算。
幸运的是,要设置线程池的大小并不困难,只需要避免过大或过小这两种极端情况。如果线程池过大,那么大量的线程将在相对很少的 CPU 和内存资源上发生竞争,这不仅会导致更高的内存占用,甚至可能会耗尽资源。如果线程池过小,那么将导致许多空闲的处理器无法执行工作,从而降低吞吐率。
要想正确的设置线程池的大小,必须分析计算环境、资源预算和任务的特性。在部署的系统中有多少个 CPU?多大的内存?任务是计算密集型还是 IO 密集型还是二者结合?它们是否需要像 JDBC 连接这样的稀缺资源?如果要执行不同类型的任务,并且它们之间的行为相差很大,那么应该考虑使用不同的线程池,从而使每个线程池可以根据各自的工作负载来调整。
对于计算密集型的任务,在拥有 N 个 CPU 的系统上,当线程池的大小为 N+1 时,通常能实现最优的利用率。(即使当计算密集型的线程偶尔由于缺页故障或其他原因而暂停(阻塞)时,这个“额外”的线程也能确保 CPU 的时钟周期不会被浪费)。对于包含 IO 草作者其他阻塞操作的任务,由于线程并不会一直执行,因此线程池的规模应该更大。要正确的设置线程池的大小,你必须估算出任务的等待时间和时间耗时的比值。这种估算不需要很精确,并且可以通过一些分析或监控工具来获得。你还可以通过另一种方法来调节线程池的带下:在某个基准负载下,分别设置不同大小的线程池来运行应用程序,并观察 CPU 利用率的水平。
给定如下定义:
要使处理器达到期望的使用率,线程池的最优大小等于:
可以通过 Runtime 来获得实际 CPU 数量:
int N_CPUS = Runtime.getRuntime().availableProcessors();
当然,CPU 周期并不是唯一影响线程池大小的资源,还包括内存、文件句柄、套接字句柄和数据库连接等。计算这些资源对线程池的约束条件则更加容易:计算每个任务对该资源的需求量,然后用该资源的可用总量除以每个任务的需求量,所得结果就是线程池大小的上限。
当任务需要某种通过资源池来管理的资源时,例如数据库连接,那么线程池和资源池的大小将会互相影响。如果每个任务都需要一个数据库连接,那么连接池的大小就限制的线程池的大小。同样,当线程池中的任务是数据库连接的唯一使用者时,那么线程池的大小又将限制连接池的大小。
8.3 配置 ThreadPoolExecutor
ThreadPoolExecutor 为一些 Executor 提供了基本的实现,这些 Executor 是由 Executors 中的 newCachedThreadPool、newFixedThreadPool、newScheduledThreadExecutor 等工厂方法返回的。ThreadPoolExecutor 是一个灵活稳定的线程池,支持进行各种定制。
如果默认的执行策略不能满足需要,那么可以通过 ThreadPoolExecutor 的构造函数来实例化一个对象,并根据自己的需要来定制,并且可以参考 Executors 的源代码来了解默认配置下的执行策略,然后再以这些执行策略为基础进行修改。ThreadPoolExecutor 定义了很多构造函数,在程序清单 8-2 中给出了最常见的形式。
public ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {...}
)
8.3.1 线程的创建与销毁
线程池的基本大小、最大大小以及存活时间等因素共同负责线程的创建与销毁。基本大小也就是线程池的目标大小,即在没有任务执行线程池的大小、并且只有在工作队列已满的情况下才会创建超出这个数量的线程。线程池的最大大小表示可同时活动的线程数量的上限。如果某个线程的空闲时间超过了存活时间,那么将被标记为可回收,并且当线程池的当前大小超过了基本大小时,这个线程将被终止。
通过调节线程池的基本大小和存活时间,可以帮助线程池回收空闲线程占优的资源,从而使得这些资源可以用于执行其他工作。(显然,这是一种折中:回收线程会产生额外的延迟,因为当需求增加时,必须创建新的线程来满足需求)
newFixedThreadPool 工厂方法将线程池的基本大小和最大大小设置为参数中指定的值,而且创建的线程池不会超时。newCachedThreadPool 工厂方法将线程池的最大大小设置为 Integer.MAX_VALUE,而将基本大小设置为 0,并将超时设置为 1 分钟,这种方法创建出来的线程池可以被无限扩展,并且当需求降低时会自动收缩。其他形式的线程池可以通过显式的 ThreadPoolExecutor 构造函数来构造。
8.3.2 管理队列任务
在有限的线程池中会限制可以并发执行的任务数量。(单线程的 Executor 是一种值得注意的特例:它们能确保不会有任务并发执行,因为它们通过线程封闭来实现线程安全性。)
在 6.1.2 节中曾介绍过,如果无限制的创建线程,那么将导致不稳定性降低,并通过采用固定大小的线程池(而不是每收到一个请求就创建一个线程)来解决这个问题。然而,这个防范并不完整。在高负载的情况下,应用程序仍可能耗尽资源,只是出问题的概率比较小。如果新请求的到达速率超过了线程池的处理速率,那么新到来的请求将累积起来。在线程池中,这些请求会在一个由 Executor 管理的 Runnable 队列中等待,而不会线程那样去竞争 CPU 资源。通过一个 Runnable 和一个链表节点来表现一个等待中的任务,当然比使用线程来表示的开销低很多,但如果客户端提交给服务器的请求的速率超过了服务器的处理速率,那么仍可能会耗尽资源。
即使请求的平均到达率很稳定,也仍然会出现请求突增的情况。尽管队列有助于缓解任务的突增问题,但如果任务持续高速抵达,那么最终还是会抑制请求的到达率以避免耗尽内存。甚至在耗尽内存之前,响应性也随着任务队列的增长而变得越来越糟。
ThreadPoolExecutor 和 newSingleThreadExecutor 在默认情况下使用一个无界的 LinkedBlockingQueue。如果所有工作者线程都处于忙碌状态,那么任务将在队列中等候。如果任务持续快速到达,并且超过了线程池处理任务的速度,那么队列将会无限制的增长。
一种更稳妥的资源管理策略是使用有界队列,如 ArrayBlockingQueue、有界的 LinkedBlockingQueue、PriorityBlockingQueue。有界队列 有助于避免资源耗尽的情况发生,但它又带来了新的问题:当队列填满后,新抵达的任务怎么办?(有许多饱和策略可以解决这个问题,见 8.3.3)。在使用有界队列时,队列的大小与线程池的大小必须一起调节。如果线程池较小而队列较大,那么有助于减少内存占用量,降低 CPU 的使用率,同时还可以减少上下文切换,但付出的代价是可能会限制吞吐量。
对于非常大的或者无界的线程池,可以通过使用 SynchronousQueue 来避免任务排队,以及直接将任务从生产者移交给工作者线程。SynchronousQuque 不是一个真正的队列,而是一种在线程之间进行移交的机制。要将一个元素放入 SynchronousQuque 中,必须有一个线程正在等待着接收这个元素。如果没有正在等待的线程,并且线程池的当前大小小于最大值,那么 ThreadPoolExecutor 将创建一个新的线程,否则根据饱和策略,该任务将被拒绝。使用直接移交将更加高效,因为任务会直接移交给执行它的线程,而不是首先被放在队列中,然后由工作者线程从队列中提取该任务。只有当线程池是无界的或者可以拒绝任务时,SynchronousQuque 才有实际价值。在 newCachedThreadPool 工厂方法中使用了 SynchronousQuque。
当使用像 LinkedBlockingQueue 或 ArrayBlockingQueue 这样的 FIFO 队列时,任务的执行顺序与它们的到达顺序一致。如果想进一步控制任务的执行顺序,还可以使用 PriorityBlockingQueue,这个队列将根据优先级来安排任务。任务的优先级是通过自然顺序或 Comparator 来定义的。
对于 Executor,newCachedThreadPool 工厂方法是一种很好的默认选择,它能提供比固定大小的线程池更好的排队性能。当需要限制当前任务的数量以满足资源管理的需要时,那么可以选择固定大小的线程池,就像在接收网络客户请求的服务器应用程序中,如果不进行限制,那么很容易发生过载问题。
只有当任务互相独立时,为线程池或工作队列设置界限才是合理的。如果任务之间存在依赖性,那么有界的线程池或队列就可能导致线程饥饿死锁问题。此时应该使用无界的线程池,如 newCachedThreadPool。
8.3.3 饱和策略
当有界队列被填满后,饱和策略将开始发挥作用。ThreadPoolExecutor 的跑和策略可以通过调用 setRejectedExecutionHandler 来修改。(如果某个任务被提交到一个已被关闭的 Executor 时,也会用到饱和策略)。JDK 提供了几种不同的 RejectedExecutionHandler 实现,每种实现都包含有不同的饱和策略:AbortPolicy、CallRunsPolicy、DiscardPolicy、DiscardOldestPolicy。
“中止”策略是默认的饱和策略,该策略将抛出未检查的 RejectedExecutionException。调用者可以捕获这个异常,然后根据需求编写自己的处理代码。当新提交的任务无法被保存到队列中等待执行时,“抛弃”策略会悄悄丢弃该任务。“抛弃最旧的”策略会抛弃下一个尚未执行但将被执行的任务,然后尝试重新提交新的任务。(如果工作队列是一个优先级对垒,那么“抛弃最旧的”将会抛弃当前队列中优先级最高的任务,因此最好不要将“抛弃最旧”策略与优先级队列一起使用)。
“调用者运行”策略实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某感谢任务回退到调用者,从而降低新任务的流量。他不会在线程池的某个线程中执行新提交的任务,而是在一个调用了 execute 的线程中执行任务。我们可以将 WebServer 示例修改为使用有界队列和“调用者运行”饱和策略,当线程池中的所有线程都被占用,并且工作队列被填满后,下一个任务会在调用 execute 时在主线程中执行。由于执行任务需要一定的时间,因此主线程至少在一段时间内都不能提交任何任务,从而使得工作者线程有时间来处理正在执行的任务。在这期间,主线程不会调用 accept,因此到达的请求会被保存在 TCP 层的队列中而不是在应用程序的队列中。如果持续过载,那么 TCP 层将最终会发现它的请求队列被填满,因此同样会开始抛弃请求。当服务器过载时,这种过载请求会逐渐向外蔓延开来——从线程池到工作队列到应用程序再到 TCP 层,最终到达客户端,导致服务器在高负载下实现一种平缓的性能降低。
当创建 Executor 时,可以选择饱和策略或者对执行策略进行修改。程序清单 8-3 给出了如何创建一个固定大小的线程池,同时使用“调用者运行”饱和策略。
ThreadPoolExecutor exeuctor = new ThreadPoolExecutor(
N_THREADS,
N_THREADS,
0L,
new LinkedBlockingQueue<Runnable>(CAPACITY)
);
exeuctor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy());
当工作队列被填满时,没有预定义的饱和策略来阻塞 execute。然而,通过使用 Semaphore 来限制任务的到达率,就可以实现这个功能。在程序清单 8-4 中给出了这种方法的实现。该方法使用一个无界对了(因为不能限制队列的大小和任务的到达率),并设置信号量的上界为线程池的大小加上可排队任务的数量,这是因为信号量需要控制正在执行的和等待执行的任务数量。
@ThreadSafe
public class BoundedExecutor {
private final Executor exec;
private fianl Semaphore semaphore;
public BoundedExecutor(Executor exec, int bound) {
this.exec = exec;
this.semaphore = new Semaphore(bound);
}
public void submitTask(final Runnable command)
throws InterruptedException {
semaphore.acquire();
try {
exec.execute(new Runnable() {
public void run() {
try { command.run(); }
finally {
semaphore.release();
}
}
});
} catch (RejectedExecutonException e) {
semaphore.release();
}
}
}
8.3.4 线程工厂
每当线程池需要创建一个线程时,都是线程工厂方法来完成的。默认的线程工厂方法都将创建一个新的、非守护线程,并且不包含特殊的配置信息。通过指定一个线程工厂方法,可以定制线程池的配置信息。在 ThreadFactory 中只定义了一个方法 newThread,每当线程池需要创建新的线程时都会调用该方法。
然而,在许多情况下都需要使用定制的线程工厂方法。例如,你希望为线程池中的线程指定一个 UncaughtExceptionHandler,或者实例化一个定制的 Thread 类用于执行调试信息的记录。你还可能希望修改线程的优先级(这通常并不是一个好主意)或者守护状态(同样不是一个好主意)。或许你只希望给线程取一个更有意义的名字,用来解释线程的转储信息和错误日志。
public interface ThreadFactory {
Thread newThread(Runnable r);
}
在程序清单 8-6 的 MyThreadFactory 中给出了一个自定义的线程工厂。它创建了一个新的 MyAppThread 实例,并将一个特定于线程池的名字传递给 MyAppThread 的构造函数,从而可以在线程转储和错误日志信息中区分来自不同线程池的线程。在应用程序和其他地方也可以使用 MyAppThread,以便所有线程都能使用它的调试功能。
public class MyThreadFactory implements ThreadFactory {
private final String poolName;
public MyThreadFactory(String poolName) {
this.poolName = poolName;
}
public Thread newThread(Runnable runnable) {
return new MyAppThread(runnable, poolName);
}
}
在 MyAppThread 中还可以定制其他行为,如程序清单 8-7 所示,包括:线程名字、设置自定义的 UncaughtExceptionHandler 以向 Logger 写入信息、维护一些统计信息(包括有多少线程被创建和销毁),以及在线程池被创建和终止时把调试信息写入日志。
public class MyAppThread extends Thread {
public static final String DEFAULT_NAME = "MyAppThread";
private static volatile boolean debugLifecycle = false;
private static fianl AtomicInteger created = new AtomicInteger(0);
private static final AtomicInteger alive = new AtomicInteger();
private static final Logger log = Logger.getAnonymousLogger();
public MyAppThread(Runnable r) {
this(r, DEFAULT_NAME);
}
public MyAppThread(Runnable runnable, String name) {
super(runnable, name + "-" + created.incrementAndGet());
setUncaughtExceptionHandler(
new Thread.UncaughtExceptionHandler() {
public void uncaughtException(Thread t, Throwable e) {
log.log(Level.SEVERE, "UNCAUGHT in thread " + t.getName(), e);
}
});
}
public void run() {
// Copy debug flag to ensure consistent value throughout.
boolean debug = debugLifecycle;
if (debug)
log.log(Level.FINE, "Created "+getName());
try {
alive.incrementAndGet();
super.run();
} finally {
alive.decrementAndGet();
if (debug)
log.log(Level.FINE, "Exiting "+getName());
}
}
public static int getThreadsCreated() {
return created.get();
}
public static int getThreadsAlive() {
return alive.get();
}
public static boolean getDebug() {
return debugLifecycle;
}
public static void setDebug(boolean b) {
debugLifecycle = b;
}
}
如果在应程序中需要利用安全策略来控制对某些代码库的访问权限,那么可以通过 Executor 中的 privilegedThreadFactory 工厂来定制自己的线程工厂。通过这种方式创建出来的线程,将与创建 privilegedThreadFactory 的线程拥有相同的访问权限、AccessControlContext 和 contextClassLoader。如果不使用 privilegedThreadFactory,线程池创建的线程将从在需要新线程时调用 execute 或 submit 的客户程序中继承权限,从而导致令人困惑的安全性异常。
8.3.5 在调用构造函数后再定制 ThreadPoolExecutor
在调用完 ThreadPoolExecutor 的构造函数之后,仍然可以通过设置函数来修改大多数传递给构造函数的参数。如果 Executor 是通过 Executors 中的某个工厂方法创建的,那么可以将结果的类型转换为 ThreadPoolExecutor 以访问器设置器,如程序清单 8-8 所示。
ExecutorService exec = Executors.newCachedThreadPool();
if (exec instanceof ThreadPoolExecutor)
((ThreadPoolExecutor) exec).setCorePoolSize(10);
else
throw new AssertionError("Oops, bad assumption");
哎 Executors 中包含一个 unconfigurableExecutorService 工厂方法,该方法对一个现有的 ExecutorService 进行包装,使其仅暴露出 ExecutorService 方法,因此不能对其进行配置。newSingleThreadExecutor 返回按这种方式封装的 ExecutorService,而不是最初的 ThreadPoolExecutor。虽然单线程的 Executor 实际上被实现为一个仅包含单个线程的线程池,但它同样确保了不会并发的执行任务。如果在代码中增加单线程 Executor 的线程池大小,那么将破坏它的执行策略语义。
你可以在自己的 Executor 中用这项技术来放置执行策略被修改。如果将 ExecutorService 暴露给不信任的代码,又不希望被非法修改,就可以通过 unconfigurableExecutorService 将其包装。
8.4 扩展 ThreadPoolExecutor
ThreadPoolExecutor 是可扩展的,它提供了几个可以在子类化中改写的方法:beforeExecute、afterExecute、terminated,这些方法可以用于扩展 ThreadPoolExecutor 的行为。
在执行任务的线程中将调用 beforeExecute 和 afterExecute 等方法,在这些方法中还可以添加日志、计时。监视或统计信息收集等功能。无论任务是从 run 中正常返回,还是抛出一个异常而返回,afterExecute 都会被调用。(让一个一任务在完成后带有一个 Error,那么久不会调用 afterExecute)。如果 beforeExecute 抛出一个 RuntimeException,那么任务将不会被执行,并且 afterExecute 也不会被调用。
在线程池完成关闭操作时调用 termianted,也就是在所有任务都已执行完成并且所有工作者线程都已经关闭后。terminated 可以用来释放 Executor 在其生命周期内分配的各种资源,此外还可以执行发送通知、记录日志或收集 finalize 统计信息等操作。
示例:给线程池添加统计信息
在程序清单 8-9 的 TimingThreadPool 中给出了一个自定义的线程池,它通过 beforeExecute、afterExecute 和 terminated 等方法来添加日志记录和统计信息收集。为了测量任务的运行时间,beforeExecute 必须记录开始时间并将其保存到一个 afterExecute 可以访问的地方。因为这些方法将在执行任务的线程中调用,因此 beforeExecute 可以将值保存到一个 ThreadLocal 变量中,然后由 afterExecute 来读取。在 TimingThreadPool 中使用了两个 AtomicLong 变量,分别用于记录已处理的任务数和总的处理时间,并通过 terminated 来输出包含平均时间的日志消息。
public class TimingThreadPool extends ThreadPoolExecutor {
private final ThreadLocal<Long> startTime
= new ThreadLocal<Long>();
private final Logger log = Logger.getLogger("TimingThreadPool");
private final AtomicLong numTasks = new AtomicLong();
private final AtomicLong totalTime = new AtomicLong();
protected void beforeExecute(Thread t, Runnable r) {
super.beforeExecute(t, r);
log.fine(String.format("Thread %s: start %s", t,r));
startTime.set(System.nanoTime());
}
protected void afterExecute(Runnable r, Throwable t) {
try {
long endTime = System.nanoTime();
long taskTime = endTime - startTime.get();
numTasks.incrementAndGet();
totalTime.addAndGet(taskTime);
log.fine(String.format(
"Thread %s: end %s, time=%dns", t, r, taskTime));
} finally {
super.afterExecute(r, t);
}
}
protected void terminated() {
try {
log.info(String.format(
"Terminated: avg time=%dns", totalTime.get() / numTasks.get()));
} finally {
super.terminated();
}
}
}
8.5 递归算法的并行化
我们对 6.3 节描绘绘制程序进行了一系列的改进以便不断发掘可利用的并行性。第一次是使程序完全串行执行,第二次虽然使用了两个线程,但仍然是串行的现在所有图像:在最后一次实现中将每个图像的下载操作视作一个独立任务,从而实现了更高的并发性。如果在循环体中包含了一些密集计算,或者需要执行可阻塞的 IO 操作,那么只要每次迭代是独立的,都可以对其进行并行化。
如果循环中的迭代操作都是独立的,并且不需要等待所有的迭代操作都完成再继续执行,那么就可以使用 Executor 将串行循环转化为并行循环,在程序清单 8-10 的 processSequentially 和 processInParallel 中给出了这种方法。
void processSequentially(List<Element> elements) {
for(Element e : elements)
process(e);
}
void processInParallel(Executor exec, List<Element> elements) {
for(final Element e : elements){
exec.execute(new Runnable() {
public void run() { process(e); }
});
}
}
调用 processInParallel 比调用 processSequentially 能能快的返回,因为 processInParallel 会在所有任务都进入了 Executor 的队列后就立即返回,而不会等待任务全部完成。如果需要提交一个任务集并等待它们完成,那么可以使用 ExecutorService.invokeAll,并且在所有任务都执行完成后调用 CompletionService 来获得结果,如第六章的 Render 所示。
当串行循环中的各个迭代操作之间彼此独立,并且每个迭代操作执行的工作量比管理一个新任务时带来的开销更多,那么这个串行循环就适合并行化。
在一些递归设计中同样可以采用循环并行化的方法。在递归算法中通常都会存在串行循环,而且这些循环可以按照程序清单 8-10 的方式进行并行化。一种简单的情况是:在每个迭代操作中都不需要来自后续递归迭代的结果。例如,程序清单 8-11 的 sequentialRecursive 用深度优先算法遍历一棵树,在每个节点上执行计算并将结果放入一个集合,而是为每个节点提交一个任务来完成计算。
public <T> void sequentialRecursive(
List<Node<T>> nodes,
Collection<T> results
) {
for(Node<T> n: nodes) {
results.add(n.compute());
sequentialRecursive(n.getChildren, results);
}
}
public <T> void parallelRecursive(
final Executor exec,
List<Node<T>> nodes,
final Collection<T> results
) {
for(final Node<T> n : nodes) {
exec.execute(new Runnable() {
public void run() {
results.add(n.compute());
}
});
parallelRecusive(exec, n.getChildren(), results);
}
}
当 parallelRecursive 返回时,树中的各个节点都已经访问过了(但是遍历过程仍然是串行的,只有 compute 调用才是并行执行的),并且每个节点的计算任务任务也已经放入 Executor 的工作队列。parallelRecursive 的调用者可以通过以下方式等待所有的结果:创建一个特定于遍历过过程的 Executor,并使用 shutdown 和 awaitTermination 等方法,如程序清单 8-12 所示。
public <T> Colleciton<T> getParallelResults(List<Node<T>> nodes) throws InterruptedException {
ExecutorService exec = Executors.newCachedThreadPool();
Queue<T> resultQueue = new ConcuurentLinkedQueue<>();
parallelRecursive(exec, nodes, resultQueue);
exec.shutdown();
exec.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
return resultQueue;
}
示例:谜题框架
这项技术的一种强大应用就是解决一些谜题,这些谜题都需要找出一系列的操作从初始化状态转换到目标状态,例如类似于“搬箱子”、“Hi-Q”、“四色方柱”和其他的棋牌谜题。
我们将“谜题”定义为:包含了一个初始位置,一个目标位置,以及用于判断是否是有效移动的规则集。规则集包含两个部分:计算从指定位置开始的所有合法移动,以及每次移动的结果位置。在程序清单 8-13 给出了表示谜题的抽象类,其中的类型参数 P 和 M 表示位置类和移动类。根据这个接口,我们可以写一个简单的串行求解程序,该程序子在谜题空间(Puzzle Space)中查找,直到找到一个解答或者找遍了整个空间都没有发现答案。
public interface Puzzle<P, M> {
P initialPosition();
boolean isGoal(P position);
Set<M> legalMoves(P position);
P move(P position, M move);
}
程序清单 8-14 中的 Node 代表通过一系列的移动到达的一个位置,其中保存了到达该位置的移动以及前一个 Node。只要沿着 Node 链接逐步回溯,就可以重新构建出到达当前位置的移动序列。
@Immutable
static class Node<P, M> {
final P pos;
final M move;
final Node<P,M> prev;
Node(P pos, M move, Node<P,M> prev) {...}
List<M> asMoveList() {
List<M> solution = new LinkedList<>();
for(Node<P,M> n = this; n.move != null; n = n.prev)
solution.add(0, n.move);
return solution;
}
}
在程序清单 8-15 的 SequentialPuzzleSolver 中给出了谜题框架的串行解决方法,它在谜题空间中执行一个深度优先搜索,当找到解答方案(不一定是最短的解决方案)后结束搜索。
public class SequentialPuzzleSolver<P, M> {
private final Puzzle<P, M> puzzle;
private final Set<P> seen = new HashSet<P>();
public SequentialPuzzleSolver(Puzzle<P, M> puzzle) {
this.puzzle = puzzle;
}
public List<M> solve() {
P pos = puzzle.initialPosition();
return search(new Node<P, M>(pos, null, null));
}
private List<M> search(Node<P, M> node) {
if (!seen.contains(node.pos)) {
seen.add(node.pos);
if (puzzle.isGoal(node.pos))
return node.asMoveList();
for (M move : puzzle.legalMoves(node.pos)) {
P pos = puzzle.move(node.pos, move);
Node<P, M> child = new Node<P, M>(pos, move, node);
List<M> result = search(child);
if (result != null)
return result;
}
}
return null;
}
static class Node<P, M> { /* Listing 8.14 */ }
}
通过修改解决方案以利用并发性,可以以并发方式来计算下一步移动以及目标条件,因为计算某次移动的过程在很大程度上与计算其他移动的过程是相互独立的。(之所以说“在很大的程度上”,是因为在各个任务之间会共享一些可变状态,例如已遍历位置的集合)。如果有多个处理器可用,那么这将减少寻找解决方案所花费的时间。
在程序清单 8-16 的 ConcurrentPuzzleSolver 中使用了一个内部类 SolverTask,这个类扩展了 Node 并实现了 Runnable。大多数工作都是在 run 方法中完成的:首先计算出下一步可能到达的所有位置,并去掉已经到达的位置,然后判断(这个任务或其他某个任务)是否已经成功完成,最后将尚未搜索过的位置交给 Executor。
public class ConcurrentPuzzleSolver<P, M> {
private final Puzzle<P, M> puzzle;
private final ExecutorService exec;
private final ConcurrentMap<P, Boolean> seen;
final ValueLatch<Node<P, M>> solution =
new ValueLatch<Node<P, M>>();
...
public List<M> solve() throws InterruptedException {
try {
P p = puzzle.initialPosition();
exec.execute(newTask(p, null, null));
// block until solution found
Node<P, M> solnNode = solution.getValue();
return (solnNode == null) ? null : solnNode.asMoveList();
} finally { exec.shutdown(); }
}
protected Runnable newTask(P p, M m, Node<P,M> n) {
return new SolverTask(p, m, n);
}
class SolverTask extends Node<P, M> implements Runnable {
...
public void run() {
if (solution.isSet() ||
seen.putIfAbsent(pos, true) != null)
return; // already solved or seen this position
if (puzzle.isGoal(pos))
solution.setValue(this);
else for (M m : puzzle.legalMoves(pos))
exec.execute( newTask(puzzle.move(pos, m), m, this));
}
}
}
为了避免无限循环,在串行版本中引入了一个 Set 对象,其中保存了之前已经搜索过的所有位置。在 ConcurrentPuzzleSovler 中使用了 ConcurrentHashMap 来实现相同的功能。这种做法不仅提供了线程安全性,还避免了在更新共享集合时存在的竟态条件,因为 putIfAbsent 只有在之前没有遍历过的某个位置才会通过原子方式添加到集合中。ConcurrentPuzzleSolver 使用线程池内部工作队列而不是调用栈来保存搜索的状态。
这种并发方法引入了一种新形式的限制去掉了一种原有的限制,新的限制在这个问题域中更合适。串行版本的程序执行深度优先搜索,因此搜索过程将受限于栈的大小。并发版本的程序执行广度优先搜索,因此不会受到栈大小的限制(但如果待搜索的或则已搜索的位置集合大小超过了可用的内存总量,那么仍可能耗尽内存)。
为了在找到某个解答后停止搜索,需要通过某种方法来检查是否有线程已经找到了一个解答。如果需要第一个找到的解答,那么嗨需要在其他任务都没有找到解答时更新答案。这些需求买哦是的是一种闭锁机制,具体的说,是一种包含结果的闭锁。通过使用第 14 章中的技术,可以很容易的构造出一个阻塞的并且可携带结果的闭锁,但更简单且更不容易出错的方式是使用现有库中的类,而不是使用底层的语言机制。在程序清单 8-17 的 ValueLatch 中使用 CountDownLatch 来实现所需的闭锁行为,并且使用多订机制来确保解答只会被设置一次。
@ThreadSafe public class ValueLatch<T> {
@GuardedBy("this") private T value = null;
private final CountDownLatch done = new CountDownLatch(1);
public boolean isSet() { return (done.getCount() == 0); }
public synchronized void setValue(T newValue) {
if (!isSet()) {
value = newValue;
done.countDown();
}
}
public T getValue() throws InterruptedException {
done.await();
synchronized (this) { return value; }
}
}
每个任务首先查询 solution 闭锁,找到一个解答就停止。而在此之前,主线程需要等待,ValueLatch 中的 getValue 将一直阻塞,直到有线程设置了这个值。ValueLatch 提供了一种方式来保存这个值,只有第一次调用才会设置它。调用者能够判断这个值是否已经被设置,以及阻塞并等待后它被设置。在第一次调用 setValue 时,将更新解答方案,并且 CountDownLatch 会递减,从 getValue 中释放主线程。
第一个找到解答的线程还会关闭 Executor,从而阻止接受新的任务。要避免处理 RejectedExecutionException,需要将拒绝执行处理器设置为“抛弃已提交的任务”。然后,所有未完成的任务最终将执行完成,并且在执行任何新任务时都会失败,从而使 Executor 结束。(如果任务运行时间过长,那么可以中断它们而不是等待它们完成)
如果不存在解答,那么 ConcurrentPuzzleSolver 就不能很好的处理这种情况:如果已经遍历的所有移动和位置都没有找到解答,那么在 getSolution 调用中将永远等待下去。当遍历了真个搜索空间时,串行版本的程序将结束,但要结束并发程序会更将困难。其中一个方法是:记录活动任务的数量,当该值为零时将解答设置为 null,如程序清单 8-18 所示。
public class PuzzleSolver<P,M>
extends ConcurrentPuzzleSolver<P,M> {
...
private final AtomicInteger taskCount =
new AtomicInteger(0);
protected Runnable newTask(P p, M m, Node<P,M> n) {
return new CountingSolverTask(p, m, n);
}
class CountingSolverTask extends SolverTask {
CountingSolverTask(P pos, M move, Node<P, M> prev) {
super(pos, move, prev);
taskCount.incrementAndGet();
}
public void run() {
try { super.run(); }
finally {
if (taskCount.decrementAndGet() == 0)
solution.setValue(null);
}
}
}
}
找到解答的时间可能比等待的时间更长,因此在解决器中需要包含几个结束条件。其中一个结束条件是时间限制,这很容易实现:在 ValueLatch 中实现一个限时的 getValue(其中将使用限时版本的 await),如果 getValue 超时,那么关闭 Executor 并声明一个失败。另一个结束条件是某种特定于谜题的标准,例如仅搜索特定数量的位置。此外,还可以提供一种取消机制,由用户来决定何时停止搜索。
总结
对于并发执行任务,Executor 框架是一种强大且灵活的框架。它提供了大量可调节的选项,例如创建线程和关闭线程的策略,处理队列任务的策略,处理过多任务的策略,并且提供了几个钩子方法来扩展它的行为。然而,与大多数功能强大的框架一样,其中有些设置参数并不能很好的工作,某些类型的任务需要特定的执行策略,而一些参数组合则可能产生奇怪的结果。
1.9 - CH09-GUI应用
如果用 Swing 编写过简单的图形用户界面应用程序,那么就应该知道 GUI 应用程序有其奇特的线程问题。为了维护安全性,一些特定的任务必须运行在 Swing 的事件线程中。然而,在事件线程中不应该执行时间较长的操作,以免用户界面失去响应。而且,由于 Swing 的数据结构不是线程安全的,因此必须将它们限制在事件线程中。
几乎所有的 GUI 工具包都被实现为单线程子系统,这意味着所有的 GUI 操作都被限制在单个线程中。如果你不打算编写一个单线程程序,那么就会有部分操作在一个应用程序线程中执行,而其他操作则在事件线程中执行。与其他线程错误一样,即使在这种操作分解中出现了错误,也会导致应用程序立即崩溃,而且程序将在一个难以确定的条件下表现出奇怪的行为。虽然 GUI 框架本身是单线程子系统,但应用程序可能不是单线程的,因此在编写 GUI 代码时仍然需要谨慎的考虑线程问题。
9.1 为什么 GUI 是单线程的
早期的 GUI 程序都是单线程的,并且 GUI 事件在“主事件循环”进行处理。当前的 GUI 框架则使用了一种略有不同的模型:在该模型中创建一个专门事件分发线程(EDT)来处理 GUI 事件。
单线程的 GUI 框架并不仅限于在 Java 中,在 Qt、NexiStep、MaxOS Cocoa、X Windows 以及其他环境中的 GUI 框架都是单线程的。许多人曾经尝试过编写多线程的 GUI 框架,但最终都由于竟态条件和死锁导致的稳定性问题而又重新回到单线程的事件队列模型:采用一个专门的线程从队列中抽取事件,并将它们转发到应用程序定义的事件处理器。(AWT 最初尝试在更大程度上支持多线程访问,而正是基于在 AWT 中得到的经验和教训,Swing 在实现时决定采用单线程模型)。
在多线程的 GUI 框架中更容易发生死锁问题,其部分原因在于,在输入事件的处理过程与 GUI 组件的面向对象模型之间会存在错误的交互。用户引发的动过将通过一种类似于“气泡上升”的方式从操作系统传递给应用程序——操作系统首先检测到一个鼠标点击,然后通过工具包将其转换为“鼠标点击”事件,该事件最终被转换为一个更高层事件(如“鼠标键被按下”事件)转发给应用程序的监听器。另一个方面,应用程序引发的工作有会以“气泡下沉”的方式从应用程序返回到操作系统。例如,在应用程序中引发修改某个组件背景色的请求,该请求将被转发给某个特定的组件类,并最终转发给操作系统进行绘制。因此,一方面这组操作将以完全相反的顺序来访问相同的 GUI 对象;另一方面,又要确保对象都是线程安全的,从而导致不一致的锁定顺序,并引发死锁。这种问题几乎在每次开发 GUI 包时都会重现。
另一个在多线程 GUI 框架中导致死锁的原因就是“模型——视图——控制(MVC)”这种设计模式的广泛应用。通过将用户的交互分解到模型、视图和控制等模块中,能极大的简化 GUI 应用程序的实现,但这却进一步增加了出现不一致锁定顺序的风险。“控制”模块将调用“模型”模块,而“模型”模块将发生的变化通知给“视图”模块。“控制”模块同样可以调用“视图”模块,并调用“模型”模块来查询模型的状态。这将再次导致不一致的锁定顺序并出现死锁。
Sun 公司的前副总裁 Graham Hamilton 在其博客中总结了这些问题,详细阐述了为什么多线程的 GUI 工具包会成为计算机科学史上的又一个“失败的梦想”。
不过,我相信你还是可以成功的编写出多线程的 GUI 工具包,只要做到:非常晋升的设计多线程 GUI 工具包,详尽无遗的公开工具包的锁定方法,以及你非常聪明,非常仔细,并且对工具包的整体结构有着全局理解。然而,如果在上述某个方面稍有偏差,那么即使程序在大多数时候都能正确运行,但在偶尔情况下仍会出现(死锁引起的)挂起或(竞争引起的)运行故障。只有那些深入参与工具包设计的人们才能够正确的使用这种多线程的 GUI 框架。
然而,我并不认为这些特性能够在商业产品中得到广泛应用。可能出现的情况是:大多数普通的开发者发现应用程序无法可靠的运行,而又找不到其中的原因。于是,这些开发者会感到非常不满,并诅咒这些无辜的工具包。
单线程的 GUI 框架通过线程封闭机制来实现线程安全性。所有 GUI 对象,包括可视化组件和数据模型等,都只能在事件线程中访问。当然,这只是将确保线程安全性的一部分工作交给应用程序的开发人员来负责,他们必须确保这些对象被正确的封闭在事件线程中。
9.1.1 串行事件处理
GUI 应用程序需要处理一些细粒度的事件,例如点击鼠标、按下键盘或定时器超时等。事件是另一种类型的任务,而 AWT 和 Swing 提供的事件处理机制在结构上也类似于 Executor。
因为只有单个线程来处理所有的 GUI 任务,因此会采用依次处理的方式——处理完一个任务后再开始下一个任务,在两个任务的处理之间不会重叠。清除了这一点,就可以更容易的编写任务代码,而无需担心其他任务会产生干扰。
串行任务处理的不利之处在于,如果某个任务的处理时间很长,那么其他任务必须等到该任务执行结束。如果这些任务的工作是响应用户输入或者提供可视化界面反馈,那么应该程序看似会失去响应。如果在事件线程中执行时间较长的任务,那么用户甚至无法点击“取消”按钮,因为在该任务完成之前,将无法调用“取消”按钮的监听器。因此,在事件线程中执行的任务必须尽快的把控制权交换给事件线程。要启动一些执行耗时较长的任务,例如对某个大型文档执行拼写检查,在文件系统中执行搜索,或者通过网络获取资源等,必须在另一个线程中执行这些任务,从而尽快的将控制权交还给事件线程。如果要在执行某个事件较长的任务时更新进度标识,或者在任务完成后提高一个可视化的反馈,那么需要再次执行事件线程中的代码。这会很快使程序变得更加复杂。
9.1.2 Swing 中的线程封闭机制
所有 Swing 组件和数据模型对象都被封闭在事件线程中,因此任何访问它们的代码都必须在事件线程中运行。GUI 对象并非通过同步来确保一致性,而是通过线程封闭机制。这种方法的好处在于,当访问表现对象时在事件线程中运行的任务无需担心同步问题,而坏处在于,无法从事件线程之外的线程中访问表现对象。
Swing 的单线程规则是:Swing 中的组件以及模型只能在这个事件分发线程中进行创建、修改、查询。
与所有的规则不同,这个规则也存在一些例外情况。Swing 中只有少数方法可以安全的从其他线程中调用,而在 Javadoc 中已经很清楚的说明了这些方法的线程安全性。单线程规则的其他一些例外情况包括:
- SwingUtilities.isEventDispatchThread,用于判断当前线程是否是事件线程。
- SwingUtilities.invokeLater,该方法可以将一个 Runnable 任务调度到事件线程中执行(可以从任意线程中调用)。
- SwingUtilities.invokeAndWait,该方法可以将一个 Runnable 任务调度到事件线程中执行,并阻塞当前线程直到任务完成(只能从非 GUI 线程中调用)。
- 所有将重绘请求或重生效请求插入队列的方法(可以从任意线程调用)。
- 所有添加或移除监听器的方法(这些方法可以在任意线程中调用,但监听器本身一定要在事件线程中调用)。
invokeLater 和 invokeAndWait 两个方法的作用酷似 Executor。事实上,用单线程的 Executor 来实现 SwingUtilities 中与线程相关的方法是很容易的,如程序清单 9-1 所示。这并非是 SwingUtilities 中的真实实现,因为 Swing 的出现时间要早于 Executor 框架,但如果现在来实现 Swing,或许应该采用这种实现方式。
public class SwingUtilities {
private static final ExecutorService exec =
Executors.newSingleThreadExecutor(new SwingThreadFactory());
private static volatile Thread swingThread;
private static class SwingThreadFactory implements ThreadFactory {
public Thread newThread(Runnable r) {
swingThread = new Thread(r);
return swingThread;
}
}
public static boolean isEventDispatchThread() {
return Thread.currentThread() == swingThread;
}
public static void invokeLater(Runnable task) {
exec.execute(task);
}
public static void invokeAndWait(Runnable task)
throws InterruptedException, InvocationTargetException {
Future f = exec.submit(task);
try { f.get(); }
catch (ExecutionException e) {
throw new InvocationTargetException(e);
}
}
}
可以将 Swing 的事件线程视为一个单线程的 Executor,它处理来自事件队列的任务。与线程池一样,有时候工作者线程会死亡并由另一个新线程来替代,但这一切要对任务透明。如果所有任务的执行时间都很短,或者任务调度的可预见性并不重要,又或者任务不能被并发执行,那么应该采用串行的和单线程的执行策略。
程序清单 9-2 中的 GuiExecutor 是一个 Executor,它将任务委托给 SwingUtilities 来执行。也可以使用其他的 GUI 框架来实现它,例如 SWT 提供的 Display.asyncExec 方法,它类似于 Swing 中的 invokeLater。
public class GuiExecutor extends AbstractExecutorService {
// Singletons have a private constructor and a public factory
private static final GuiExecutor instance = new GuiExecutor();
private GuiExecutor() { }
public static GuiExecutor instance() { return instance; }
public void execute(Runnable r) {
if (SwingUtilities.isEventDispatchThread())
r.run();
else SwingUtilities.invokeLater(r);
}
// Plus trivial implementations of lifecycle methods
}
9.2 短时间的 GUI 任务
在 GUI 应用中,事件在事件线程中产生,并通过“气泡上升”的方式来传递给应用程序提供的监听器,而监听器则根据收到的事件执行一些计算来修改表现对象。为了简便,短时间的任务可以把整个操作都放在事件线程中执行,而对于长时间的任务,则应该讲某些操作放到另一个线程中执行。
final Random random = new Random();
final JButton button = new JButton("change color");
...
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
button.setBackground(new Color(random.nexInt()));
}
})
在这种情况下,表现对象封闭在事件线程中。程序清单 9-3 创建了一个按钮,它的颜色在被按下时会随机的变化。当用户点击按钮时,工具包将事件线程中的一个 ActionEvent 投递给所有已注册的 ActionListener,作为响应,ActionListener 将选择一个新的颜色,并将按钮的背景颜色设置为新颜色。这样,在 GUI 工具包中产生事件,然后发送到应用程序,而应用程序通过修改 GUI 来响应用户的动作。在这期间,执行控制始终不会离开事件线程,如图 9-1所示。
这个示例揭示了 GUI 应用程序和 GUI 工具包之间的主要交互。只要任务是短期的,并且只访问 GUI 对象(或者其他线程封闭的或线程安全的应用程序对象),那么就可以忽略与线程相关的问题,而在时间线程中可以执行任何操作都不会出问题。
图 9-2 给出了一个略微复杂的版本,其中使用了正式的数据模型,例如 TableModel 或 TreeModel。Swing 将大多数可视化组件都分为两个对象,即模型对象和视图对象。在模型对象中保存的是将被显示的数据,而在视图对象中则保存了控制显示方式的规则。模型对象可以通过引发事件来表示模型数据发生了变化,而视图对象则通过“订阅”来接收这些事件。当视图对象收到表示模型数据已经发生变化的事件,将向模型对象查询新的数据,并更新界面显示。因此,在一个修改表格内容的按钮监听器中,事件监听器将更新模型并调用其中一个 fireXxx 方法,这个方法会依次调用视图对象中表格模型监听器,从而更新视图的显示。同样,执行控制权仍然不会离开事件线程。(Swing 数据模型的 fireXxx 方法通常会直接调用模型监听器,而不会向线程队列中提交新的事件,因此 fireXxx 方法只能从事件线程中调用。)
9.3 长时间的 GUI 任务
如果所有任务的执行时间都较短(并且应用中不包含执行时间较长的非 GUI 部分),那么整个应用程序都可以在事件线程内部运行,并且完全不用关心线程。然而,在复杂的 GUI 应用中可能包含一些执行时间较长的任务,并且可能超过了用户可以等待的时间,例如拼写检查、后台编辑或者远程资源获取等。这些任务必须在另一个线程中运行,才能使得 GUI 在运行时保持高响应性。
Swing 使得在事件线程中运行任务很容易,但(在 Java 6 之前)并没有提供任何机制来帮助 GUI 任务执行其他线程中的代码。然而在这里并不需要借助 Swing:可以创建自己的 Executor 来执行长时间的任务。对于长时间的任务,可以使用缓存线程池。只要 GUI 应用程序很少会发起大量的长时间任务,因此即使线程池可以无限制的增长也不会有太大风险。
首先我们来看一个简单的任务,该任务不支持取消操作和进度指示,也不会在完成后更新 GUI,我们之后再将这些功能依次添加进来。在程序清单 9-4 中给出了一个与某个可视化组件绑定的监听器,它将一个长时间的任务提交给一个 Executor。尽管有两个层次的内部类,但通过这种方式使某个 GUI 任务启动另一个任务还是很简单的:在事件线程中调用 UI 动作监听器,然后将一个 Runnable 提交到线程池中执行。
ExecutorService backgroundExec =
Executors.newCachedThreadPool();
...
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
backgroundExec.execute(new Runnable() {
public void run() { doBigComputation(); }
});
}});
这个示例通过 “Fire and Forget” 方式将长时间运行的任务从事件线程中分类出来,这种方式可能不是非常有用。在执行完一个产时间的任务后,通常会产生某种可视化的反馈给用户。但你并不能从后台线程中访问这些表现对象,因此任务在完成时必须向事件线程提交另一个任务来更新用户界面。
程序清单 9-5 给出了如何实现这个功能的方式,但此时已经开始变得复杂了,即已经有了三层内部类。动作监听器首先使按钮无效,并设置一个标签表示正在进行某个计算,然后将一个任务提交给后台的 Executor。当任务完成时,它会在事件下滑才能中增加另一个任务,该任务将重新激活按钮并恢复文本标签文本。
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
button.setEnabled(false);
label.setText("busy");
backgroundExec.execute(new Runnable() {
public void run() {
try { doBigComputation(); }
finally {
GuiExecutor.instance().execute(new Runnable() {
public void run() {
button.setEnabled(true);
label.setText("idle");
}
});
}
}
});
}
});
在按下按钮时触发的任务中包含 3 个连续的子任务,它们将在事件线程与后台线程之间交替运行。第一个子任务更新用户界面,表示一个长时间的操作已经开始,然后在后台线程中启动第二个子任务。当第二个子任务完成时,它把第三个子任务再次提交到事件线程中运行,第三个子任务也会更新用户界面来表示操作已经完成。在 GUI 应程序中,这种“线程接力”是处理长时间任务的典型用法。
9.3.1 取消
当某个任务在线程中运行了过长时间没有结束,用户可能希望取消它。你可以直接通过线程中断来实现取消操作,但是一种更简单的方式是使用 Future,专门用来管理可取消的任务。
如果调用 Future 的 cancel 方法,并将参数 mayInterruptIfRunning 设置为 true,那么这个 Future 可以中断正在执行任务的线程。如果你编写的任务能够正确响应中断,那么当它被取消时就可以提前返回。在程序清单 9-6 给出的任务中,将轮询线程的中断状态,并且在返现中断时提前返回。
Future<?> runningTask = null;
// thread-confined ...
startButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
if (runningTask != null) {
runningTask = backgroundExec.submit(new Runnable() {
public void run() {
while (moreWork()) {
if (Thread.currentThread().isInterrupted()) {
cleanUpPartialWork();
break;
}
doSomeWork();
}
}
});
}
}
}});
cancelButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) {
if (runningTask != null)
runningTask.cancel(true);
}});
由于 runningTask 被封闭在事件线程中,因此在对它进行设置会检查时不需要同步,并且“开始”按钮的监听器可以确保每次只有一个后台任务在运行。然而,当任务完成时最好能通知按钮监听器,例如说可以禁用“取消”按钮。我们将在下一节解决这个问题。
9.3.2 进度标识和完成标识
通过 Future 来表示一个长时间的任务,可以极大的简化取消操作的实现。在 FutureTask 中也有一个 done 方法同样有助于实现完成通知。当后台的 Callable 完成后,将调用 done。通过 done 方法在事件线程中触发一个完成任务,我们能够构造一个 BackgroundTask 类,这个类将提供一个在事件线程中调用 onCompletion 方法,如程序清单 9-7 所示。
abstract class BackgroundTask<V> implements Runnable, Future<V> {
private final FutureTask<V> computation = new Computation();
private class Computation extends FutureTask<V> {
public Computation() {
super(new Callable<V>() {
public V call() throws Exception {
return BackgroundTask.this.compute();
}
});
}
protected final void done() {
GuiExecutor.instance().execute(new Runnable() {
public void run() {
V value = null;
Throwable thrown = null;
boolean cancelled = false;
try { value = get(); }
catch (ExecutionException e) {
thrown = e.getCause();
} catch (CancellationException e) {
cancelled = true;
} catch (InterruptedException consumed) {
} finally {
onCompletion(value, thrown, cancelled);
}
};
});
}
}
protected void setProgress(final int current, final int max) {
GuiExecutor.instance().execute(new Runnable() {
public void run() {
onProgress(current, max);
}
});
}
// Called in the background
thread protected abstract V compute() throws Exception;
// Called in the event thread
protected void onCompletion(V result, Throwable exception, boolean cancelled) { }
protected void onProgress(int current, int max) { }
// Other Future methods forwarded to computation
}
BackgroundTask 还支持进度标识。conpute 方法可以调用 setProgress 方法以数字形式来指示进度。因而在事件线程中调用 onProgress,从而更新用户界面以显示可视化的进度信息。
要想实现 BackgroundTask,只需要实现 compute 方法,该方法将在后台线程中调用。也可以改写 onCompletion 和 onProgress,这两个方法会在事件线程中调用。
基于 FutureTask 构造的 BackgroundTask 还能简化取消操作。Compute 不会检查线程的中断状态,而是调用 Future.isCancelled。程序清单 9-8 通过 BackgroundTask 重新实现了程序清单 9-6 中的示例程序。
public void runInBackground(final Runnable task) {
startButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
class CancelListener implements ActionListener {
BackgroundTask<?> task;
public void actionPerformed(ActionEvent event) {
if (task != null) task.cancel(true);
}
}
final CancelListener listener = new CancelListener();
listener.task = new BackgroundTask<Void>() {
public Void compute() {
while (moreWork() && !isCancelled())
doSomeWork();
return null;
}
public void onCompletion(boolean cancelled, String s, Throwable exception) {
cancelButton.removeActionListener(listener);
label.setText("done");
}
};
cancelButton.addActionListener(listener);
backgroundExec.execute(task);
}
});
}
9.3.3 SwingWorker
我们已经通过 FutureTask 和 Executor 构建了一个简单的框架,它会在后台线程中执行较长耗时的任务,因此不会影响 GUI 的响应性。在任何单线程的 GUI 框架中都可以使用这些技术,而不仅局限于 Swing。在 Swing 中,这类给出的许多特性是由 SwingWorker 类提供的,包括取消、完成通知、进度指示灯。在 “Swing Connection” 和 “The Java Tutorial” 等资料中介绍了不同版本的 SwingWorker,并在 Java 6 中包含了一个更新好的版本。
9.4 共享数据模型
Swing 的表现对象都被封装在事件线程中。在加单的 GUI 程序中,所有的可变状态都被保存在表现对象中,并且除了事件线程之外,唯一的线程就是主线程。要在这些程序中强制实施单线程规则是很容易的:不要从主线程中访问数据模型或表现组件。在一些更复杂的程序中,可能会使用其他线程对持久化的存储进行读写操作以免降低系统的响应性。
最简单的情况是,数据模型中的数据由用户来输入或由应用程序在启动时静态的从文件或其他数据源加载。在这种情况下,除了事件线程之外的任何线程都不可能访问到数据。但在某些情况下,表现模型对象只是一个数据源的视图对象。这时,当数据在应用程序中进出时,有多个线程可以访问这些数据。
例如,你可以使用一个树形空间来显示远程文件系统的内容。在显示树形控件之前,并不需要枚举真个文件系统——那样做会消耗大量的时间和内存。正确的做法是,当树节点被展开时才读取相应的内容。即使只枚举远程卷上的单个目录也可能花费很长的时间,因此你可以考虑在后台线程中执行枚举操作。当后台任务完成后,必须通过某种方式将数据填充到树形模型中。可以使用线程安全的树形模型来实现这个功能:通过 invokeLater 提交一个任务,将数据从后台任务中“推入”事件线程,或者让事件线程通过轮询来查看是否有数据可用。
9.4.1 线程安全的数据模型
只要阻塞操作不会过度影响响应性,那么多个线程操作同一份数据的问题都可以通过线程安全的数据模型来解决。如果数据模型支持细粒度的开发,那么事件线程和后台线程就能共享该数据模型,而不会发生响应性问题。例如,第五章的 DelegatingVehicleTracker 在底层使用了一个 ConcurrentHashMap 来提供高度并发的读写操作。这种方法的缺点在于,ConcurrentHashMap 无法提供一致的数据快照,而这可能是需求的一部分。线程安全的数据模型必须在更新模板时产生事件,这样视图才能在数据发生变化后进行更新。
有时候,在使用版本化数据模型时,例如 CopyOnWriteArrayList,可能要同时获得线程安全性,一致性以及良好的响应性。当获取一个“写实复制”容器的迭代时,这个迭代器将遍历真个容器。然而,只有在遍历操作远远多于修改操作时,“写时复制”容器才能提供更好的性能,例如在车辆追踪应用程序中就不适合采用这种方法。一些特定的数据结构或许可以避免这种限制,但要构建一个既能提供高效并发访问又能在旧数据无效后不再维护它们的数据结构却并不容易,因此只有其他方法都行不通后才应该考虑使用它。
9.4.2 分解数据模型
从 GUI 的角度看,Swing 的表格模型类,例如 TableModel 和 TreeModel,都是保存将要显示的数据的正式方法。然而,这些模型对象本身通常都是应用程序中其他对象的视图。如果在程序中既包含用于表示的数据模型,又包含应用程序特定的数据模型,那么这种应该程序就被称为一种分解模型设计。
在分解模型设计时,表现模型被封闭在事件线程汇总,而其他模型,即共享模型,是线程安全的,因此既可以由事件线程方法,也可以由应用程序线程访问。表现模型会注册共享模型的监听器,从而在更新时得到通知。然后,表示模型可以在共享模型中得到更新:通过将相关状态的快照嵌入到更新消息中,或者由表现模型在收到更新事件时直接从共享模型中获取数据。
快照这种方法虽然简单,但却存在着一些局限。当数据模型很小,更新频率不高,并且这两个模型的结构相似时,他可以工作良好。如果数据模型很大,或者更新频率极高,在分解模型包含的信息中有一方或者双方对另一方不可见,那么更高效的方式是发送增量更新信息而不是发送完整的快照。这种方法将共享模型上的更新操作序列化,并在事件线程中重现。增量更新的另一个好处是,细粒度的变化信息可以提高显示的视觉效果——如果只有一辆车移动,那么只需要更新发生变化的区域,而不用重绘整个显示图形。
如果一个数据模型必须被多个线程共享,而且由于阻塞、一致性或复杂度等原因而无法实现一个线程安全的模型时,可以考虑使用分解模型设计。
9.5 其他形式的单线程子系统
线程封闭不仅仅可以在 GUI 中使用,每当某个工具需要被实现为单线程子系统时,都可以使用这项技术。有时候,当程序员无法避免同步或死锁等问题时,也将不得不使用线程封闭。例如,一些原生库要求:所有对库的访问,甚至通过 System.loadLibiary 来加载库时,都必须在同一个线程中执行。
借鉴 GUI 框架中采用的方法,可以很容易创建一个专门的线程或一个单线程的 Executor 来访问那些库,并提供一个代理对象来拦截所有对线程封闭对象的调用,并将这些调用作为一个任务来提交给专门的线程。将 Future 和 newSingleThreadExecutor 一起使用,可以简化这项工作。在代理方法中可以调用 submit 方法提交任务,然后立即调用 Future.get 来等待结果。(如果在封闭线程的类中实现了一个接口,那么每次可以自动的让方法将一个 Callable 提交给后台线程并通过动态的代理来等待结果。)
小结
所有 GUI 框架基本上都实现为单线程的子系统,其中所有与表现相关的代码都作为任务在事件线程中运行。由于只有一个事件线程,因此运行时间较长的任务会降低 GUI 程序的响应性,所以应该放在后台线程中运行。在一些辅助类(例如 SwingWorker 以及在本章中构建的 BackgroundTask) 中提供了对取消、进度指示以及完成指示的支持,因此对于执行时间较长的任务来说,无论在任务中包含了 GUI 组件还是非 GUI 组件,在开发时都可以的到简化。
1.10 - CH10-活跃性危险
在安全性和活跃性之间通常存在着某种制衡。我们使用加锁机制来确保线程安全,但如果过度的使用锁,则可能导致锁顺序死锁(Lock-Ordering Deadlock)。同样,我们使用线程池和信号量来限制对资源的使用,但这些被限制的行为可能导致资源死锁。Java 应用程序无法从死锁中恢复过来,因此在设计时一定要排除那些可能导致死锁出现的因素。本章将介绍一些导致活跃性故障的原因,以及如何避免它们。
10.1 死锁
经典的“哲学家就餐”问题很好的描述了死锁问题。5 个哲学家去吃中餐,坐在一张圆桌旁。他们有 5 根筷子(而不是 5 双),并且每两个人之间放着一根筷子。哲学家们时而思考、时而进餐。每个人都需要一双筷子才能吃东东,并在吃完后将筷子放回原处继续思考。有些筷子管理算法能够使每个人都能相对及时的吃到东西(例如一个饥饿的哲学家会尝试获取两根临近的筷子,但如果其中一跟正在被他人使用,那么他将放弃已经得到的那根筷子,并等待几分钟之后再次尝试),但有些算法却可能导致一些或者所有哲学家都“饿死”(每个人都立即抓住自己左手边的筷子,然后等待右手边的筷子空出来,但同时又不放下已经拿到的筷子)。后一种情况将产生死锁:每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得所有需要的资源之前都不会放弃已经拥有的资源。
当一个线程永远的持有一把锁,并且其他线程都尝试获得这个锁时,那么他们将永远被阻塞。在线程 A 持有锁 L 并想获得锁 M 的同时,线程 B 持有锁 M 并尝试获得锁 L,那么这两个线程将永远的等待下去。这是最简单的死锁形式(即抱死),其中多个线程由于存在环路的锁依赖关系而永远等待下去。(把每个线程假想为有向图中的一个节点,图中每条边表示的关系是:“线程 A 等待线程 B 所占有的资源”。如果在图中形成了一条环路,那么久存在一个死锁)。
在数据库系统的设计中考虑了检测死锁以及从死锁恢复。在执行一个事务时可能需要同时获得多个锁,并一直持有这些锁直到事务提交。因此在两个事务之间很可能发生死锁,但事实上这种情况并不常见。如果没有外部干涉,这些事务将永远等待下去(在某个事务中持有的锁可能在其他事务中也需要)。但数据库服务器不会让这种情况发生。当他检测到一组事务发生了死锁时(通过在表示等待关系的有向图中搜索依赖循环),将选择牺牲者并放弃该事务。作为牺牲者的事务会释放它所只有的资源,从而使其他事务继续运行。应用程序可以重新执行被强制终止的事务,而这个事务现在可以成功完成,因为所有跟它存在资源竞争的事务都已经完成了。
JVM 在解决死锁问题方面并没有数据库服务那样强大。当一组 Java 线程发生死锁时,“游戏”将到此结束——这些线程将永远无法再使用了。根据线程完成工作的不同,可能造成应用程序完全停止,或者某个特定的子系统停止,或者是性能降低。恢复应用程序的唯一方式就是中止并重启,并希望不要再发生同样的事情。
与所有其他的并发危险一样,死锁造成的影响很少会立即显现出来。如果一个类可能发生死锁,那么并不意味着每次都会发生死锁,而只是表示有可能。当死锁出现时,往往是最糟糕的时候——在高负载情况下。
10.1.1 锁顺序死锁
程序清单 10-1 中过的 LeftRightDeadlock 存在死锁风险。leftRight 和 rightLeft 这两个方法分别获得 left 锁和 right 锁。如果一个线程调用了 leftRight,而另一个线程调用了 rightLeft,并且这两个线程的操作是交错执行的,如图 10-1 所示,那么将发生死锁。
public class LeftRightDeadlock {
private fianl Object left = new Object();
private final Object right = new Object();
public void leftRight() {
synchronized(left) {
synchronized(right) {
doSomething();
}
}
}
public void rightLeft() {
synchronized(right) {
synchronized(left) {
doSomething();
}
}
}
}
在 LeftRightDeadlock 中发生死锁的原因是:两个线程试图以不同的顺序来获得相同的锁。如果按照相同的顺序来请求锁,那么就不会出现循环加锁依赖性,因此也就不会产生死锁。如果每个需要锁 L 和锁 M 的线程都以相同的顺序来获得两个锁,那么就不会发生死锁了。
如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。
要想验证锁顺序的一致性,需要对程序中的加锁顺序进行全局分析。如果只是单独的分析每条获取多个锁的代码路径,那是不够的:leftRight 和 rightLeft 都采用了“合理的”方式来获得锁,它们只是不能互相兼容。当需要加锁时,它们需要知道彼此正在执行什么操作。
10.1.2 动态的锁顺序死锁
有时候,并不能清楚地知道是否在锁顺序上有足够的控制权来避免死锁的发生。考虑程序清单 10-2 中看似无害的代码,它将资金从一个账户转入另一个账户。在开始转账之前,首先要获得这两个 Account 对象的锁,以确保通过原子方式来更新两个账户中的余额,同时又不破坏一些不变性条件,例如“账户的余额不能为负数”。
public void transferMoney(
Account fromAccount,
Account toAccount,
DollarAmount amount) throws InsufficientFundsException{
synchronized (fromAccount) {
synchronized(toAccount) {
if(fromAccount.getBalance().compareTo(amount) <0)
thorw new InsufficientFundsException();
else {
fromAccount.debit(amount);
toAccount.credit(amount);
}
}
}
}
在 transferMoney 中如何发生死锁?所有的线程似乎都是按照相同的顺序来获得锁,但事实上锁的顺序取决于传递给 transferMoney 的参数顺序,而这些参数顺序又取决于外部输入。如果两个线程同时调用 transferMoney,其中一个线程从 X 向 Y 转账,另一个线程从 Y 向 X 转账,那么就会发生死锁:
A: transferMoney(myAccount, yourAccount, 10);
B: transferMoney(yourAccount, myAccount, 20);
如果执行时序不当,那么 A 可能获得 myAccount 的锁并等待 yourAccount 的锁,然后 B 此时持有 yourAccount 的锁,并正在等待 myAccount 的锁。
这种死锁可以采用程序清单 10-1 中的方法来检查——查看是否存在嵌套的锁获取操作。由于我们无法控制参数的顺序,因此要解决该问题,必须定义锁的顺序,并在整个应用程序中都按照这个顺讯来获得锁。
在制定锁的顺序时,可以使用 System.identityHashCode 方法,该方法将返回由 Object.hashCode 返回的值。程序清单 10-3 给出了一另一个版本的 transferMoney,在该版本中使用了 System.identityHashCode 来定义锁的顺序。虽然增加了一些新的代码,但却消除了发生死锁的可能性。
private static final Object tieLock = new Object();
public void transferMoney(
final Account fromAcct,
final Account toAcct,
final DollarAmount amount
) throws InsufficientFundsException {
class Helper {
public void transfer() throws InsufficientFundsException {
if (fromAcct.getBalance().compareTo(amount) < 0)
throw new InsufficientFundsException();
else {
fromAcct.debit(amount);
toAcct.credit(amount);
}
}
}
int fromHash = System.identityHashCode(fromAcct);
int toHash = System.identityHashCode(toAcct);
if (fromHash < toHash) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer();
}
}
} else if (fromHash > toHash) {
synchronized (toAcct) {
synchronized (fromAcct) {
new Helper().transfer();
}
}
} else {
synchronized (tieLock) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer();
}
}
}
}
}
在极少情况下,两个对象可能拥有相同的散列值,此时必须通过某种任意的方法来决定锁的顺序,而这可能又会重新引入死锁。为了避免这种情况,可以使用“加时赛”锁。在获得两个 Account 锁之前,首先获得这个“加时赛”锁,从而每次保证只有一个线程以未知的顺序获得这两个锁,从而消除了死锁发生的可能性(只要一致的使用这种机制)。如果经常会出现散列冲突的情况,那么这种技术可能会成为并发性的一个瓶颈(这类似于在整个程序中只有一个锁的情况),但由于 System.identityHashCode 中出现散列冲突的频率非常低,因此这项技术以最小的代价,换来的最大的安全性。
如果在 Account 中包含一个唯一的、不可变的,并具备可比性的键值,比如账号,那么要指定锁的顺序就更加容易了:通过键值将对象排序,因而不再需要使用“加时赛”锁了。
你或许认为我夸大了死锁的风险,因为所被持有的时间通常很短暂,然而在真实系统中,死锁往往都是很严重的问题。作为商业产品的应用程序每天肯可能要处理数十亿次获取锁——释放锁的操作。只要在这数十亿次操作中出现一次错误,就可能导致程序发生死锁,并且即使使应用程序通过了压力测试也不可能找出所有潜在的死锁。在程序清单 10-4 中的 DemonstrateDeadlock 在大多数系统下都会很快发生死锁。
public class DemonstrateDeadlock {
private static final int NUM_THREADS = 20;
private static final int NUM_ACCOUNTS = 5;
private static final int NUM_ITERATIONS = 1000000;
public static void main(String[] args) {
final Random rnd = new Random();
final Account[] accounts = new Account[NUM_ACCOUNTS];
for (int i = 0; i < accounts.length; i++)
accounts[i] = new Account();
class TransferThread extends Thread {
public void run() {
for (int i=0; i<NUM_ITERATIONS; i++) {
int fromAcct = rnd.nextInt(NUM_ACCOUNTS);
int toAcct = rnd.nextInt(NUM_ACCOUNTS);
DollarAmount amount =
new DollarAmount(rnd.nextInt(1000));
transferMoney(accounts[fromAcct], accounts[toAcct], amount);
}
}
}
for (int i = 0; i < NUM_THREADS; i++)
new TransferThread().start();
}
}
10.1.3 在协作对象之间发生死锁
某些获取多个锁的操作并不像在 LeftRightDeadlock 或 transferMoney 中那么明显,这两个锁并不一定必须在同一个方法中获取。考虑程序清单 10-5 中两个互相协作的类,在出租车调度系统中可能会用到它们。Taxi 代表一个出租车对象,包含位置和目的地两个属性,Dispatcher 代表一个出租车车队。
// Warning: deadlock-prone!
class Taxi {
@GuardedBy("this") private Point location, destination; private final Dispatcher dispatcher;
public Taxi(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public synchronized Point getLocation() {
return location;
}
public synchronized void setLocation(Point location) {
this.location = location;
if (location.equals(destination))
dispatcher.notifyAvailable(this);
}
}
class Dispatcher {
@GuardedBy("this") private final Set<Taxi> taxis;
@GuardedBy("this") private final Set<Taxi> availableTaxis;
public Dispatcher() {
taxis = new HashSet<Taxi>();
availableTaxis = new HashSet<Taxi>();
}
public synchronized void notifyAvailable(Taxi taxi) {
availableTaxis.add(taxi);
}
public synchronized Image getImage() {
Image image = new Image();
for (Taxi t : taxis)
image.drawMarker(t.getLocation());
return image;
}
}
尽管没有任何方法会显式的获取连个锁,但 setLocation 和 getImage 等方法的调用者都会获得两个锁。如果一个线程在收到 GPS 接收器的更新事件时调用 setLocation,那么它将首先更新出租车的位置,然后判断它是否到达了目的地。如果已经到达,它会通知 Dispatcher:它需要一个新的目的地。因为 setLocation 和 notifyAvailable 都是同步方法,因此调用 setLocation 的线程将首先获取 Taxi 的锁,然后获取 Dispatcher 的锁。同样,调用 getImage 的线程将首先获取 Dispatcher 锁,然后在获取一个 Taxi 锁(每次获取一个)。这与 LeftRightDeadlock 中的情况相同,两个线程按照不同的顺序来获取两个锁,因此就可能产生死锁。
在 LeftRightDeadlock 或 transferMoney 中,要查找死锁是比较简单的,只需要找出那些需要获取两个锁的方法即可。然而要在 Taxi 和 Dispatcher 中查找死锁则比较困难:如果在持有锁的情况中调用某个外部方法,那么就需要警惕死锁。
如果在持有锁调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能或获取其他锁(从而可能产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。
10.1.4 开放调用
当然,Taxi 和 Dispatcher 并不知道它们将要陷入死锁,况且它们本来就不应该知道。方法调用相当于一种抽象屏障,因而你无需了解在被调方法内执行的操作。但也正是由于不知道在被调方法内部执行的操作,因此在持有锁的时候对调用某个外部方法将难以进行分析,从而可能出现死锁。
如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用。依赖于开放调用的类通常表现出更好的行为,并且与那些在调用方法时需要持有锁的类相比,也更容易编写。这种通过开放调用来避免死锁的方法,类似于采用封装机制来提供线程安全的方法:虽然在没有封装的情况下也能确保构建线程安全的程序,但对一个使用了封装的程序进行线程安全分析,要比分析那些没有使用封装的程序容易的多。同理,分析一个完全依赖于开放调用的程序的活跃性,要比分析那些不依赖开放调用的程序的活跃性简单。通过尽可能的使用开放调用,将更易于找出那些需要获取多个锁的代码路径,因此也就更容易确保采用一致的顺序来获取锁。
可以很容易的将程序清单 10-5 中的 Taxi 和 Dispatcher 修改为使用开放调用,从而消除发生死锁的风险。这需要使同步代码块仅被用于保护那些涉及共享状态的操作,如程序清单 10-6 所示。通常,如果只是为了语法紧凑或简单性(而不是因为整个方法必须通过一个锁来保护)而使用同步方法(而不是同步代码块),那么就会导致程序清单 10-5 中的问题。(此外,收缩同步代码块的保护范围还可以提高伸缩性,在 11.4.1 节中给出了如何确定同步代码块大小的方法。)
@ThreadSafe class Taxi {
@GuardedBy("this") private Point location, destination; private final Dispatcher dispatcher;
...
public synchronized Point getLocation() {
return location;
}
public synchronized void setLocation(Point location) { boolean reachedDestination; synchronized (this) { this.location = location;
reachedDestination = location.equals(destination);
}
if (reachedDestination)
dispatcher.notifyAvailable(this);
}
}
@ThreadSafe
class Dispatcher {
@GuardedBy("this") private final Set<Taxi> taxis; @GuardedBy("this") private final Set<Taxi> availableTaxis;
...
public synchronized void notifyAvailable(Taxi taxi) {
availableTaxis.add(taxi);
}
public Image getImage() {
Set<Taxi> copy;
synchronized (this) {
copy = new HashSet<Taxi>(taxis);
}
Image image = new Image();
for (Taxi t : copy)
image.drawMarker(t.getLocation());
return image;
}
}
在程序中应尽量使用开放调用。与那些在持有锁时调用外部方法的程序相比,更易于对依赖于开放调用的程序进行死锁分析。
有时候,再重新编写同步代码块以使用开放调用时会产生意想不到的结果,因为这会使得某个原子操作变得不再原子。在许多情况下,是某个操作时区原子性是可以接受的。例如,对于两个操作:更新出租车位置以及通知调用程序这两出租车已经准备好出发去一个新的目的地,这两个操作并不需要实现为一个原子操作。在其他情况中,虽然去掉原子性可能会出现一些值得注意的结果,但这种语义变化让然是可以接受的。在容易产生死锁的版本中,getImage 会生成某个时刻下的整个车队位置的完整快照,而在重新改写的版本中,getImage 将获得每辆出租车不同时刻的位置。
然而,在某些情况下,丢失原子性会引发错误,此时需要通过另一种技术来实现原子性。例如,在构造一个并发对象时,使得每次只有单个线程执行使用了开放调用的代码路径。例如,在关闭某个服务时,你可能希望所有正在运行的操作执行完成以后,再释放这些服务占用的资源。如果在等待操作完成的同时持有该服务的锁,那么将很容易导致死锁,但如果在服务关闭之前就释放服务的锁,则可能导致其他线程开始新的操作。这个问题的解决方法是,在将服务的状态更新为“关闭”之前一直持有锁,这样其他想要开始操作的线程,包括想关闭该服务的其他线程,会发现服务已经不可用,因此也就不会视图开始新的操作。然后,你可以等待关闭操作结束,并且知道当开放调用完成后,只有执行关闭操作的线程才能访问服务的状态,因此,这项技术依赖于构造一些协议(而不是通过加锁)来防止其他线程进入代码的临界区。
10.1.5 资源死锁
正如当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。
假设有两个资源池,例如两个不同数据库的连接池。资源池通常采用信号量来实现当资源为空时的阻塞行为。如果一个任务需要同时连接两个数据库,并且在请求这两个资源时不会始终遵循相同的顺序,那么线程 A 可能与持有数据库 D1 的连接、并等待与数据库 D2 的连接,而线程 B 则持有与 D2 的连接并等待与 D1 的连接。(资源池越大,出现这种情况的可能性也就越小。如果每个资源池都有 N 个连接,那么发生死锁时不仅需要 N 个循环等待的线程,而且还需要大量不恰当的执行顺序)。
另一种基于资源的死锁形式是线程饥饿死锁。8.1.1 节给出了这种危害的一个示例:一个任务提交另一个任务,并等待被提交的任务在单线程的 Executor 中执行完成。这种情况下,第一个任务将永远等待下去,并使得另一个任务以及在这个 Executor 中执行的所有其他任务都停止执行。如果某些任务需要等待其他任务的结果,那么这些任务往往是产生线程饥饿死锁的主要来源,有界线程池/资源池与相互依赖的任务不能结合使用。
10.2 死锁的避免与诊断
如果一个程序每次至多只能获得一个锁,那么就不会产生锁顺序死锁。当然,这种情况通常并不现实,但如果能够避免这种情况,那么就能省去很多工作。如果必须获得多个锁,那么在设计的时候必须考虑锁的顺序:尽量减少潜在的加锁交互次数,将获取锁时需要遵循的协议写入正式的文档并始终遵循这些协议。
在使用细粒度锁的程序中,可以通过使用一种两阶段策略来检查代码中的死锁:首先,找出在什么地方将会获取多个锁(使这个集合尽量小),然后对所有这些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。尽可能的使用开放调用,这能极大的简化分析过程。如果所有的调用都是开放调用,那么要发现获取多个锁的实例是非常简单的,可以通过代码审查,或者借助自动化的源代码分析工具。
10.2.1 支持定时的锁
还有一项技术用于检测死锁和从死锁中恢复,即显式使用 Lock 类中的定时 tryLock 功能来代替内置锁机制。当使用内置锁时,只要没有获得锁,就会永远等待下去,而显式锁可以指定一个超时时限,在等待超过该时限后 tryLock 会返回一个失败信息。如果超时时限比获取锁的时间要长很多,那么就可以在发生某个意外情况后重新获得控制权,(在程序清单 13-3 中给出了 transferMoney 的另一种实现,其中使用了一种轮询的 tryLock 消除了死锁发生的可能性)
当定时锁失败时,你并不需要知道失败的原因。或许是因为发生了死锁,或许某个线程在持有锁时错误的进入了无限循环,还可能是某个操作的执行时间远远超过了你的预期。然而,至少你能记录所发生的失败,以及关于这次操作的其他有用信息,并通过一种更平缓的方式来重新启动计算,而不是关闭整个进程。
即使在整个系统中没有始终使用定时锁,使用定时锁来获取多个锁也能有效的应对死锁问题。如果获取锁时超时,那可可以释放该锁,然后后退并在一段时间后再次尝试,从而消除了死锁发生的条件,使程序恢复过来。(这项技术只有在同时获取两个锁时才有效,如果在嵌套的方法调用中请求多个锁,那么即使你知道已经持有了外层的锁,也无法将其释放)。
10.2.2 通过线程转储信息来分析死锁
虽然防止死锁的主要责任在于你自己,但 JVM 仍然通过线程转储来帮助识别死锁的发生。线程转储包括各个运行中的线程的栈追踪信息,这类似于发生异常时的栈追踪信息。线程转储还包含加锁信息,例如每隔线程持有了哪些锁,在哪些栈帧中获得这些所,以及被阻塞的线程正在等待哪个锁。在生成线程转储之前,JVM 将在等待关系图中通过搜索循环来找出死锁。如果发现了一个死锁,则获取相应的死锁信息,例如在死锁中设计哪些锁和线程,以及这个锁的获取操作位于程序的哪些位置。
要在 UNIX 平台上触发线程转储操作,可以通过向 JVM 的进程发送 SIGQUIT 信号(kill -3
),或者在 UNIX 平台中按下 Ctrl-\
,在 Windows 平台中按下 Ctrl-Break
键。在许多 IDE 中都可以请求线程转储。
如果使用显式的 Lock 类而不是内部锁,那么 Java 5 并不支持与 Lock 相关的转储信息,在线程转储中不会出现显式的 Lock。虽然 Java 6 中包含对显式 Lock 的线程转储和死锁检测等支持,但在这些锁上获得的信息比在内置锁上获得的信息精确度低。内置锁与获得它们所在的线程栈帧是相关联的,而显式的 Lock 只与获得它的线程相关联。
程序清单 10-7 给出了一个 J2EE 应用中获取的部分线程转储信息。在导致死锁的故障中包括 3 个组件:一个 J2EE 应用程序、一个 J2EE 容器、一个 JDBC 驱动程序,分别由不同的生产商提供。这三个组件都是商业产品,并经过了大量的测试,但每个组件都存在一个错误,并且这个错误只有当他们进行交互时才会显现出来,并导致服务器出现一个严重的故障。
Found one Java-level deadlock:
=============================
"ApplicationServerThread":
waiting to lock monitor 0x080f0cdc (a MumbleDBConnection),
which is held by "ApplicationServerThread" "ApplicationServerThread":
waiting to lock monitor 0x080f0ed4 (a
MumbleDBCallableStatement),
which is held by "ApplicationServerThread"
Java stack information for the threads listed above:
"ApplicationServerThread":
at MumbleDBConnection.remove_statement
- waiting to lock <0x650f7f30> (a MumbleDBConnection)
at MumbleDBStatement.close
- locked <0x6024ffb0> (a MumbleDBCallableStatement)
...
"ApplicationServerThread":
at MumbleDBCallableStatement.sendBatch
- waiting to lock <0x6024ffb0> (a MumbleDBCallableStatement)
at MumbleDBConnection.commit
- locked <0x650f7f30> (a MumbleDBConnection)
...
我们只给出了与查找死锁相关的部分线程转储信息。当诊断死锁时,JVM 可以帮我们做许多工作——哪些死锁导致了这个问题,涉及哪些线程,它们持有哪些其他的锁,以及是否间接地给其他下城带来了不利的影响。其中一个线程持有 MumbleDBConnection 上的锁,并等待获得 MumbleDBCallableStatement 上的锁,而另一个线程则持有 MumbleDBCallableStatement 上的锁,并等待 MumbleDBConnection 上的锁。
在这里使用的 JDBC 驱动程序中明显存在一个锁顺序问题:不同的调用链通过 JDBC 驱动程序以不同的顺序获得多个锁。如果不是由于另一个错误,这个问题永远不会显现出来:多个线程视图同时使用同一个 JDBC 连接。这并不是应用程序的设计初衷——开发任务惊讶的发现同一个 Connection 被两个线程并发使用。在 JDBC 规范中并没有要求 Connection 必须是线程安全的,以及 Connection 通常被封装在单个线程中使用,而在这里就采用了这种假设。这个生产商视图提供一个线程安全的 JDBC 驱动,因此在驱动程序代码内部对多个 JDBC 对象施加了同步机制。然而,生产商却没有考虑锁的顺序,因而驱动程序很容易发生死锁,而正是由于这个存在死锁风险的驱动程序与错误共享 Connection 的应用程序发生了交互,才使得这个问题暴露出来。因为单个错误并不会产生死锁,只有这两个错误同时发生时才会出现,即使它们分别进行了大量测试。
10.3 其他活跃性危险
尽管死锁是最常见的活跃性危险,但在并发程序中还存在一些其他的活跃性危险,包括:饥饿、丢失信号、活锁等。
10.3.1 饥饿
当线程无法访问它所需要的资源而不能继续执行时,就发生了饥饿。引发饥饿的崔常见资源就是 CPU 时钟周期。如果在 Java 应用程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构(如无限训话、无限制的等待某个资源),那么也可能导致饥饿,因为其他需要这个锁的线程将无法得到它。
在 Thread API 中定义的线程优先级只是作为线程调度的参考。在 Thread API 中定义了 10 个优先级,JVM 根据需要将他们映射到操作系统的调度优先级。这种映射是与特定平台相关的,因此在某个操作系统中两个不同的 Java 优先级可能被映射到同一个优先级上,而在另一个操作系统中则可能被映射到另一个不同的优先级上。在某些操作系统中,如果优先级的数量少于 10 个,那么有多个 Java 优先级会被映射到同一个优先级上。
操作系统的线程调度器会尽力提供公平的、活跃性良好的调度,甚至超出 Java 语言规范的需求范围。在大多数 Java 程序中,所有线程都具有相同的优先级 Thread.NORMA_PRIORITY。线程优先级并不是一种直观的机制,而通过修改线程优先级所带来的效果通常也不明显。当提高某个线程的优先级时,可能不会起到任何作用,或者也可能使得某个线程的调度优先级高于其他线程,从而导致饥饿。
通常,我们尽量不要改变线程的优先级。只要改变了线程的优先级,程序的行为就将于平台相关,并且会导致发生饥饿问题的风险。你经常能发现某个程序会在一些奇怪的地方抵用 Thread.sleep 或 Thread.yield,这是因为该程序视图克服优先级调整问题或响应性问题,并试图让低优先级的线程执行更多的时间。
Thread.yied (以及 Thread.sleep) 的语义都是未定义的。JVM 既可以将它们实现为空操作,也可以将它们视为线程调度的参考。尤其是,在 UNIX 系统中并不要求它们拥有 sleep 的语义——将当前线程放在与该优先级对应的运行队列的末尾,并将执行权交给拥有相同优先级的其他线程,尽管有些 JVM 是按照这种方式实现 Thread.yied 方法的。
要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在大多数应用程序中,都可以使用默认的线程优先级。
10.3.2 糟糕的响应性
除饥饿意外的另一个问题是糟糕的响应性,如果在 GUI 应用程序中使用了后台线程,那么这种问题是很常见的。在第九章中开发了一个框架,并发运行时间较长的任务放到后台线程中运行,从而不会使用户界面失去响应。但 CPU 密集型的后台任务仍然可能对响应性造成影响,因为它们会与事件线程共同竞争 CPU 时钟周期。在这种情况下就可以发挥线程优先级的作用,此时计算密集型的后台任务将对响应性造成影响。如果由其他线程完成的工作都是后台任务,那么应该降低它们的优先级,从而提高前台程序的响应性。
不良的锁管理也可能导致糟糕的响应性。如果某个线程长时间占有一个锁(或许正在对一个大容器进行迭代,并且对每个元素进行计算密集的处理),而其他想要访问这个容器的线程就必须等待很长时间。
10.3.3 活锁
活锁是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断的重复执行相同的操作,而且总是会失败。活锁通常发生在处理事务消息的应用程序中:如果不能成功的处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。如果消息处理器在处理某个特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递给存在错误的处理器时,都会发生事务回滚。由于这条消息又被放回到队列的开头,因此处理器将被反复调用,并返回相同的结果。(有时候也被称为毒药消息,Poison Message)。虽然处理消息的贤臣并没有阻塞,但也无法继续执行下去。这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误的将不可修复的错误作为可修复的错误。
当多个相互协作的线程都对彼此进行响应而修改各自的状态,并使得任何一个线程都无法继续执行,就发生了活锁。这就像两个过于礼貌的人在半路上面对面的相遇:他们彼此都让出对方的路,然后又在另一条路上相遇了。因此它们就这样反复的避让下去。
要解决这种活锁问题,需要在重试机制中引入随机性。例如,在网络上,如果两台机器都尝试使用相同的载波来发送数据包,那么这些数据包将会冲突。这两台机器都检查到了冲突,并都在稍后再次发送。如果二者都选择了在 1 秒钟之后重试,那么它们会再次冲突,如此往复,因而即使有大量闲置的带宽,也无法发出数据包。为了避免这种情况发生,需要让他们分别等待一段随机的时间。(以太网定义了在重复发生冲突时采用指数方式回退机制,从而降低在多台存在冲突的机器之间发生拥堵而反复失败的风险)。在并发应用中,通过等待随机长度的时间和回退可以有效的避免活锁的发生。
小结
活跃性故障是一个非常严重的问题,因为当出现活跃性故障时,除了终止应用之外没有其他任何机制可以帮助从这种故障中恢复。最常见的活跃性故障就是锁顺序死锁。在设计时应避免产生锁顺序死锁:确保线程在获取多个锁时采用一致的顺序。最好的解决方法是在程序中始终使用开放调用。这将大大减少需要同时持有多个锁的代码路径,也更容易发现有问题的代码路径。
1.11 - CH11-性能与伸缩
线程主要的目的是提高程序的运行性能。线程可以使程序更加充分的发挥系统的可处理能力,从而提高系统的资源利用率。此外,线程还可以使程序在运行现有任务的情况下立即开始处理新的任务,从而提高系统的响应性。
本章将开始介绍各种分析、监测以及提升并发程序性能的技术。然而,许多提升性能的技术同样会增加复杂性,因此也就增加了在安全性和活跃性上发生失败的风险。更糟糕的是,虽然某些技术的初衷是提升性能,但事实上却与最初的目标背道而驰,或者又带来了其他新的性能问题。虽然我们希望获得更好的性能——提升性能总会令人满意,但始终要把安全性放在第一位。首先要保证程序能正确运行,然后仅当程序的性能需求和测试结果要求程序执行的更快时,才应该设法提高它的运行速度。在设计并发的应用程序时,最重要的考虑因素通常并不是将程序的性能提升至极限。
11.1 对性能的思考
提升性能意味着用更少的资源做更多的事。“资源”的含义很广。对于一个给定的操作,通常会缺乏某种特定的资源,例如 CPU 时钟周期、内存、网络带宽、IO 带宽、数据库请求、磁盘空间等其他资源。当操作性能由于某种特定的资源而受到限制时,我们通常将该操作称为资源密集型的操作,例如,CPU 密集型、数据库密集型等。
尽管使用线程的目标是提升整体性能,但与单线程的方法相比,使用多个线程总会引入一些额外的性能开销。造成这些开销的操作包括:线程之间的协调(例如加锁、触发信号以及内存同步等),增加的上下文切换,线程的创建和销毁,以及线程的调度等。如果过度的使用线程,那么这些开销甚至会超过由于提升吞吐量、响应性或计算能力带来的性能提升。另一方面,一个并发设计很糟糕的应用程序,其性能甚至比实现相同功能的串行程序的性能还要差。
要想通过并发来获得更好的性能,需要努力做好两件事情:更有效的利用现有处理资源,以及在出现新的处理资源时使程序尽可能的利用这些新资源。从性能监视的视角来看,CPU 需要尽可能保持忙碌状态。(当然,这并不意味着将 CPU 时钟周期浪费在一些无用的计算上,而是执行一些有用的工作)。如果程序是计算密集型的,那么可以通过增加处理器来提升性能。因为如果程序无法使现有的处理器处于忙碌的状态,那么增加再多的处理器也无济于事。通过将应用程序分解到多个线程上执行,使得每个处理器都执行一些工作,从而使所有 CPU 都保持忙碌状态。
11.1.1 性能与可伸缩性
应用的性能可以通过多个指标来衡量,例如服务时间、延迟时间、吞吐率、效率、可伸缩性以及容量等。其中一些指标(服务时间、等待时间)用于衡量程序的“运行速度”,即某个指定的任务单元需要“多快”才能处理完成。另一些指标(生产量、吞吐量)用于程序的“处理能力”,即在计算资源一定的情况下,能完成多少工作。
可伸缩性指的是:当增加计算资源时(如 CPU、内存、存储容量或 IO 带宽),程序的吞吐量或者处理能力能获得相应的提升。
在并发应用程序中针对可伸缩性进行设计和调整时所采用的方法与传统的性能调优方法截然不同。当进行性能调优时,其目的通常是用更小的代价完成相同的工作,例如通过缓存来重用之前计算的结果,或者采用事件复杂度 O(n^2) 算法来代替复杂度为 O(n log n) 的算法。在进行可伸缩性调优时,其目的是设法将问题进行并行化,从而能利用更多的计算资源来完成更多的任务。
我们熟悉的三层程序模型,即在模型中的表现层、业务逻辑层、持久化层都是彼此独立的,并且可能由不同的系统来处理,这很好的说明了提高可伸缩性通常会造成性能损失的原因。如果把表现层、业务逻辑层和持久化层都融合到单个应用程序汇总,那么在处理第一个工作单元时,其性能肯定要高出将应用分为多个层次并将不同层次划分到多个系统时的性能。这种单一的应用程序避免了在不同层次之间传递任务时存在的网络延迟,同时也不需要将计算过程分解到不同的抽象层次,因此能减少很多开销(如任务排队、线程协调、数据复制)。
然而,当这种单一的系统到达自身处理能力的极限时,会遇到一个严重的问题:要进一步提升它的处理能力将非常困难。因此,我们通常会接受每个工作单元执行更长的时间或消耗更多的计算资源,以换取应用程序在增加更多资源的情况下处理更高的负载。
对于服务器应用程序来说,“多少”这个方面——可伸缩性、吞吐量、生产量,往往比“多快”这个方面更受重视。(在交互式应用程序中,延迟或许更加重要,这样用户就不用等待进度条,并奇怪程序究竟在执行哪些操作)。本章将重点介绍可伸缩性而不是单线程程序的性能。
11.1.2 评估各种性能权衡因素
在几乎所有的工程决策中都会涉及某些形式的权衡。在建设桥梁时,使用更粗的钢筋可以提高桥的负载能力和安全性,但同时也会提高建造成本。尽管在软件工程的决策中通常不会涉及资金以及人身安全,但在做出正确的权衡时通常会缺少相应的信息。例如,“快速排序”算法在大规模数据集上的执行效率非常高,但对于小规模的数据集来说,“冒泡排序”实际上更搞笑。如果要实现一个高效的排序算法,那么就需要知道被处理的数据集的大小,还有衡量优化的指标,包括:平均计算时间、最差时间、可预知性。然而,编写某个库中的排序算法的开发人员通常无法知道这些需求信息。这就是大多数优化措施都不成熟的原因之一:它们通常无法获得一组明确的需求。
避免不成熟的优化。首先使程序正确,然后再提高运行速度——如果它运行的还不够快。
当进行决策时,有时候会通过增加某种形式的成本来降低另一种形式的开销(例如,增加内存使用量以降低服务时间),也会通过增加开销来换取安全性。安全性并不一定就是指对人身安全的威胁,例如桥梁设计的示例。很多性能优化措施都是以牺牲可读性或可维护性为代价——代码越聪明越晦涩,它们又会带来更高的错误风险,因为通常越快的算法就越复杂。(如果你无法找出其中的代价或风险,那么或许还没有对这些优化措施进行彻底的思考和分析)。
在大多数性能决策中都包含多个变量,并且非常依赖于运行环境。在使某个方案比其他方案更快之前,首先问自己一些问题:
- “更快”的含义是什么?
- 该方法在什么条件写运行的更快?在低负载还是高负载的情况下?多数据集还是小数据集?能否通过测试结果来验证你的答案?
- 这些条件在运行环境中的发生概率?能否通过测试结果来验证你的答案?
- 在其他不同条件的环境中是否能使用这里的代码?
- 在实现这种性能提升时需要付出哪些隐含的代价,例如增加开发风险或维护开销?这种权衡是否合适?
在进行任何与性能相关的决策时,都应该考虑哪些问题,本书只介绍并发性方面的内容。我们为什么要推荐这种保守的方法?对性能的提升可能是并发错误的最大来源。有人认为同步机制太慢,因而采用一些看似聪明实则危险的方法来减少同步的使用(比如 16.2.4 节中讨论的双重检查锁),这也通常作为不遵守同步规则的一个常见借口。然而,由于并发错误是最难追踪和消除的错误,因此对于任何可能会引入这类错误的措施,都需要谨慎实施。
更糟糕的是,虽然你的初衷可能是用安全性来换取性能,但最终可能什么也得不到。特别是,当提到并发时,许多开发人员对于哪些地方存在性能问题,哪种方法的运行速度更快,以及哪种方法的可伸缩性更好,往往会存在错误的直觉。因此,在对性能进行调优时,一定要有明确的性能需求(这样才能知道什么时候需要调优,以及什么时候应该停止),此外还需要一个测试程序以及真实的配置和负载均衡等环境。在对性能调优后,你需要再次测量以验证是否达到了预期的性能提升目标。在许多优化措施中带来的安全性和可维护性等风险非常高。如果不是必须的话,你通常不想付出这样的代价,如果无法从这些措施中获得性能提升,那么你肯定不希望付出这种代价。
以测试为基准,不要猜测。
市场上有一些成熟的分析工具可以用于评估性能以及找出性能瓶颈,但你不需要花费太多的资金来找出程序的功能。比如免费的 perfbar 应用可以给出 CPU 的忙碌程度信息,而我们通常的目标是使 CPU 保持忙碌状态,因此这个功能可以有效的评估是否需要进行性能调优或者已经实现的调优效果如何。
11.2 Amdahl 定律
在有些问题中,如果可用资源越多,那么问题的解决速度就越快。例如,如果参与收割庄稼工人越多,那么就能越快的完成收割工作。而有些任务本质上是串行的,例如,即使增加再多的工人也无法增加作物的生长速度。如果使用线程主要是为了发挥多个处理器的处理能力,那么就必须对问题进行合理的并行分解,并使得程序能有效的使用这种潜在的并行能力。
大多数并发程序都与农业耕作有着形似之处,它们都是由一系列的并行工作和串行工作组成的。Amdahl 定律描述的是:在增加计算资源的情况下,程序在理论上就能够实现最高加速比,这个取值取决于程序中可并行组件与串行组件所占的比重。假定 F 是必须被串行的部分,那么根据 Amdahl 定律,在包含 N 个处理器的机器中,最高的加速比为:
当 N 趋近于无穷大时,最大的加速比趋近于 1/F。因此,如果程序有 50% 的计算需要串行执行,那么最高的加速比只能是 2(而无论有多少个线程可用);如果程序中有 10% 的计算需要串行执行,那么最高的加速比接近于 10。Amdahl 定律还量化了串行化的效率开销。在拥有 10 个处理器的系统中,如果程序中有 10% 的部分需要串行执行,那么最高加速比为 5.3(53%的使用率),在拥有 100 个处理器的系统中,加速比可以达到 9.2(9%的使用率)。即使拥有无限多个 CPU,加速比也不可能为 10。
图 11-1 给出了处理器利用率在不同串行比例以及处理器数量的情况下的变化曲率。(利用率定义为:加速比除以处理器的数量)。随着处理器数量的增加,可以明显的看到,即使串行部分所占的比例很小,也会极大的限制当增加计算资源时能够提升的吞吐率。
第六章介绍了如何识别任务的逻辑边界并将应用程序分解为多个子任务。然而,要预测应用程序在某个多处理器系统中将实现多大的加速比,还需要找出任务的串行部分。
假设应用程序中 N 个线程正在执行程序清单 11-1 中的 doWork,这些线程从一个共享的工作队列中取出任务并进行处理,而且这里的任务都不依赖于任何其他的执行结果或影响。暂时先不考虑任务是如何进入这个队列的,如果增加处理器,那么应用程序的性能是否会相应的发生变化?初看上去,这个程序似乎能完全并行化:各个任务之间不会互相等待,因此处理器越多,能够并发处理的任务也就越多。然而,在这个过程中包含了一个串行的部分——从队列中获取任务。所有工作者线程都共享同一个工作队列,因此在对该队列进行并发访问时需要采用某种同步机制来维持队列的完整性。如果通过加锁来保护队列的状态,那么当一个线程从队列中取出一个任务时,其他需要获取下一个任务的线程就必须等待,这就是任务处理过程中的串行部分。
public class WorkerThread extends Thread {
private final BlockingQueue<Runnable> queue;
public WorkerThread(BlockingQueue<Runnable> queue) { this.queue = queue;
}
public void run() {
while (true) {
try {
Runnable task = queue.take();
task.run();
} catch (InterruptedException e) {
break; /* Allow thread to exit */
}
}
}
}
单个任务的处理时间不仅包括执行任务 Runnable 的时间,还包括从共享队列中取出任务的时间。如果使用 LinkedBlockingQueue 作为工作队列,那么出列操作被阻塞的可能性将小于使用同步 LinkedList 发生阻塞的可能性,因为 BlockingQueue 使用了一种可伸缩性更高的算法。然而,无论访问何种共享数据结构,基本上都会为程序引入一个串行的部分。
这个示例还忽略了另一种常见的串行操作:对结果进行处理。所有有用的计算都会生成某种结果或产生某种副作用——如果不会,那么可以将它们作为无用代码删除掉。由于 Runnable 没有提供明确的结果处理过程,因此这些任务一定会产生某种副作用,例如将它们的结果写入日志或保存到某个数据结构。通常,日志文件和结果容器都会由多个工作者线程共享,因此这也是串行的一部分。如果所有线程都将各自的计算结果保存到自行维护的数据结构中,并且在所有任务都执行完成后在合并所有结果,那么这种合并操作也是一个串行部分。
在所有并发程序中都包含一些串行部分。
11.2.1 示例:在各种框架中隐藏的串行部分
要想知道串行部分是如何隐藏在应用程序的框架中的,可以比较当增加线程时吞吐量的变化,并根据观察到的可伸缩线变化来推断串行部分中的差异。图 11-2 给出了一个简单的应用程序,其中多个线程反复从一个共享 Queue 中取出元素进行处理,这与程序清单 11-1 很相似。处理步骤只需执行线程本地的计算。如果某个线程发现队列为空,那么他将一个新元素放入队列,因而其他线程在下一次访问时不会出现没有元素可供处理。在访问共享队列的过程中显然存在着一定程度的串行操作,但处理步骤完全可以并行执行,因为它不会访问共享数据。
图 11-2 的曲线对两个线程安全的 Queue 的吞吐率进行了对比:其中一个是采用 synchronizedList 封装的 LinkedList,另一个是 ConcurrentLinkedQueue。这些测试在 8 路 Sparc V880 系统上运行,操作系统为 Solaris。尽管每次运行都表示相同的“工作量”,但我们可以看到,只需要改变队列的实现方式,就能对伸缩性产生明显的影响。
ConcurrentLinkedQueue 的吞吐量不断提升,直到达到处理器的数量上限,之后将基本保持不变。另一方面,当线程数量小于 3 时,同步 LinkedList 的吞吐量也会有某种程度的提升,但是之后会由于同步开销而骤然下跌。当线程数量达到 4 个或 5 个时,竞争将非常激烈,甚至每次访问队列都会在锁上发生竞争,此时的吞吐量主要受到上下文切换的限制。
吞吐量的差异主要来源于两个队列中不同比例的串行部分。同步 LinkedList 采用单个锁来保护整个队列的状态,并且在 offer 和 remover 等方法的调用期间都将持有该锁。ConcurrentLinkedQueue 使用了一种更加复杂的非阻塞队列算法,该算法使用原子引用来更新各个链接指针。在第一个队列中,整个的插入和删除操作都将串行执行,而在第二个队列中,只有对指针的更新操作才需要串行执行。
11.2.2 Amdahl 定律的应用
如果能准确估算出串行部分所占的比例,那么 Amdahl 定律就能量化增加计算资源时的加锁比。虽然要直接测量串行部分的比例非常困难,但即使在不进行测试的情况下该定律仍然是有用的。
因为我们的思维通常会受周围环境的影响,因此很多人都习惯的认为在多处理器系统中会包含 2 个或 4 个处理器,甚至更多,因为这种技术在近年来被广泛使用。但随着多个 CPU 逐渐成为主流,系统可能拥有数百个或者数千个处理器。一些在 4 路系统中看似具有可伸缩性的算法,却可能含有一个隐藏的可伸缩性瓶颈,只是还没有遇到而已。
在评估一个算法时,要考虑算法在数百个或数千个处理器的情况下的性能表现,从而对可能出现的可伸缩性局限有一定程度的认识。例如,在 11.4.2 节或 11.4.3 节中介绍了两种降低所粒度的技术:锁分解(将一个锁分解为两个锁)和锁分段(将一个锁分解为多个锁)。当通过 Amdahl 定律来分析这两项技术时,我们会发现,如果将一个锁分解为两个锁,似乎并不能充分利用多处理器的能力。锁分段技术似乎更有前途,因为分段的数量可随着处理器数量的增加而增加。(当然,性能优化应该考虑实际的性能需求,在某些情况下,应用锁分解就够了)。
11.3 线程引入的开销
单线程程序即不存在线程调度、也不存在同步开销、而且也不需要使用锁来保证数据结构的一致性。在多个线程的调度和协调过程中都需要一定的性能开销:对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过引入线程的开销。
11.3.1 上下文切换
如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于 CPU 的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使得其他线程能够使用 CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的上下文设置为当前上下文。
切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和 JVM 共享的数据结构。应用程序、操作系统以及 JVM 都使用一组相同的 CPU。在 JVM 和操作系统的代码中消耗越多的 CPU 时钟周期,应用程序的可用 CPU 时钟周期就越少。但上下文切换的开销并不只是包含 JVM 和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不再当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有许多其他的线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。
当线程由于等待某个发生竞争的锁而被阻塞时,JVM 通常会将这个线程挂起,并允许它被交换出去。如果线程频繁发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞 IO、等待获取发生竞争的锁、在条件变量上等待),与 CPU 密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。(无阻塞算法同样有助于减少上下文切换,详见第 15 章)。
上下文切换的开销会随着平台的不同而变化,然而按照经验来看:在大多数同样的处理器中,上下文切换的开销相当于 5000~10000 个时钟周期,也就是几微秒。
UNIX 系统中的 vmstat 命令和 Windows 系统的 perfmon 工具都能包括上下文切换的次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(超过 10%),那么通常表示调度活动发生得很频繁,这很可能是由 IO 或竞争锁导致的阻塞引起的。
11.3.2 内存同步
同步操作的性能开销包括多个方面。在 synchronized 和 volatile 提供的可见性保证中可能会使用一些特殊指令,即内存屏障。内存屏障可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存屏障可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存屏障中,大多数操作都不能被重排序。
在评估同步操作带来的性能影响时,区分有竞争的同步和无竞争的同步非常重要。synchronized 机制针对无竞争的同步进行了优化(volatile 通常是非竞争的),而在编写本书时,一个快速通道(Fast-Path)的非竞争同步将消耗 20~250 个时钟周期。虽然非竞争同步的开销不为零,但它对应用程序整体性能的影响微乎其微,而另一种方法不仅会破坏安全性,而且会使你(或后续开发人员)经历非常痛苦的除错过程。
现代的 JVM 能通过优化来去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁的对象只能由当前线程访问,那么 JVM 就可以通过优化来去掉这个加锁操作,因为另一个线程无法与当前线程在这个锁上发生同步。比如,JVM 通常都会去掉程序清单 11-2 中的加锁操作。
synchronized (new Object()) {
// some operations
}
一些更加完备的 JVM 会通过逃逸分析来找出不会被发布到堆的对象引用(因此这个对象是线程本地的)。在程序清单 11-3 的 getStoogeNames 中,对 List 的唯一引用就是局部变量 stooges,并且所有封闭在栈中的变量都会自动称为线程本地变量。在 getStoogeNames 的执行过程中,至少会将 Vector 上的锁获取、释放 4 次,每次调用 add 或 toString 时都会执行一次。然而,一个智能的运行时编译器通常会分析这些调用,从而使 stooges 及其内部状态不会益处,因此可以去掉这 4 次对锁的获取操作。
public String getStoogeNames() {
List<String> stooges = new Vector<String>();
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
return stooges.toString();
}
即使不进行逃逸分析,编译器也可以执行锁粒度粗化操作,即将临近的同步代码块用同一个锁合并起来。在 getStoogeNames 中,如果 JVM 进行锁粗粒度化,那么可能会把 3 个 add 操作和 1 个 toString 调用合并为单个加解锁操作,并采用启发式方法来评估同步代码块中采用同步操作以及指令之间的相对开销。这不仅减少了同步的开销,同时还能使优化器处理更大的代码块,从而实现更进一步的优化。
不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快了,并且 JVM 还能进行额外的优化进一步降低或消除开销。因此,我们应该将优化重点放到那些发生锁竞争的地方。
某个线程中的同步可能会影响其他线程的性能。同步会增加共享内存总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。
11.3.3 阻塞
非竞争同步可以完全在 JVM 中进行处理(而不涉及操作系统),而竞争的同步可能需要操作系统的介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM 在实现阻塞的行为时,可以采用自旋等待(Spin-Waiting,指通过循环不断的尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待的时间很短,则适合采用自旋等待的方式,而如果等待时间很长,则适合采用线程挂起的方式。有些 JVM 将根据对历史等待时间的分析数据在这两者之间进行选择,但是大多数 JVM 在等待锁时都只是将线程挂起。
当线程无法获取某个锁或者由于在某个条件等待或在 IO 操作上阻塞时,需要被挂起,在这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作:被阻塞的线程在其执行时间片还未用完之前就被交换出去,而在随后当要获取的锁或者其他资源可用时,又再次被切换回来。(由于锁竞争而导致锁阻塞时,线程在持有锁时将存在一定的开销:当它释放锁时,必须告诉操作系统恢复运行阻塞的线程)。
11.4 减少锁的竞争
我们已经看到,串行操作会降低可伸缩线,并且上下文切换也会降低性能。在锁上发生竞争时将同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。
在对由某个独占锁保护的资源进行访问时,将采用串行方式——每次只有一个线程能访问它。当然,我们有很好的理由来使用锁,例如避免数据被破坏,但获得这种安全性是需要付出代价的。如果在锁上持续发生竞争,那么将限制代码的可伸缩性。
在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。
有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重影响。然而,如果在锁上的请求量很高,那么需要获取该锁的线程将被阻塞并等待。在极端情况下,即使仍有大量工作等待完成,处理器也会被闲置。
有三种方式可以降低锁的竞争程度:
- 减少持有锁的时间。
- 降低对锁的请求频率。
- 使用带有协调机制的独占锁,这些机制支持更高的并发性。
11.4.1 缩小锁的范围——“快进快出”
降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。例如,可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,例如 IO 操作。
我们都知道,如果将一个“高度竞争”的锁持有过长的时间,那么会限制可伸缩性,例如在第二章中介绍的 SynchronizedFactorizer 的示例。如果某个操作持有锁的时间超过 2 毫秒并且所有操作都需要这个锁,那么无论拥有多个个空闲的处理器,吞吐量也不会超过每秒 500 个操作。如果将这个锁的持有时间降低为 1 毫秒,那么能够将这个锁对应的吞吐量提高到每秒 1000 个操作。
程序清单 11-4 给出了一个示例,其中锁被持有过长的时间。userLocationMatches 方法在一个 Map 对象中查找用户的位置,并使用正则表达式进行匹配以判断结果值是否匹配所提供的模式。整个 userLocationMatches 方法都是用了 synchronized 来修饰,但只有 Map.get 这个调用才真正需要锁。
@ThreadSafe public class AttributeStore {
@GuardedBy("this")
private final Map<String, String> attributes =
new HashMap<String, String>();
public synchronized boolean userLocationMatches(
String name, String regexp) {
String key = "users." + name + ".location";
String location = attributes.get(key);
if (location == null)
return false;
else
return Pattern.matches(regexp, location);
}
}
在程序清单 11-5 的 BetterAttributeStore 中重新编写了 AttributeStore,从而大大降低了锁的持有时间。第一个步骤是构建 Map 中与用户位置相关联的键值,这是一个字符串,形式为 user.name.location。这个步骤还包括实例化一个 StringBuilder 对象,向其添加几个字符串,并将结果转化为一个 String 对象。在获得了位置之后,就可以将正则表达式与位置字符串进行匹配。由于在构建键值字符串以及处理正则表达式等过程中不需要访问共享状态,因此在执行时不需要持有锁。通过 BetterAttributeStore 中将这些步骤提取出来并放到同步代码块之外,从而减少了锁被持有的时间。
@ThreadSafe public class BetterAttributeStore {
@GuardedBy("this")
private final Map<String, String> attributes =
new HashMap<String, String>();
public boolean userLocationMatches(String name, String regexp) {
String key = "users." + name + ".location";
String location; synchronized (this) {
location = attributes.get(key);
}
if (location == null)
return false;
else
return Pattern.matches(regexp, location);
}
}
通过缩小 userLocationMatches 方法中所的作用范围,能极大的减少在持有锁时需要执行的指令数量。根据 Amdahl 定律,这样消除了限制可伸缩性的一个因素,因为串行代码的总量减少了。
由于 AttributeStore 中只有一个状态变量 attributes,因此可以通过将线程安全性委托给其他的类来进一步提升它的性能。通过使用线程安全的 Map 来代替 attributes,AttributeStore 可以将确保线程安全性为任务委托给顶层的线程安全容器来实现,这样就无需在 AttributeStore 中采用显式锁的同步,缩小在访问 Map 期间锁的范围,并降低了将来的代码维护者无意破坏线程安全性的风险(比如在访问 attributes 之前忘记获得对应的锁)。
尽管缩小同步代码块能够提高可伸缩性,但同步代码块也不能过小——一些采用原子方式执行的操作(如对某个不变性条件中的多个变量进行更新)必须包含在一个同步块中。此外,同步需要获得一定的开销,当把一个同步块分解为多个同步块代码块时(在确保正确性的情况下),反而会对性能提升产生负面的影响。在分解同步代码时,理想的平衡点将与平台相关,但在实际情况中,仅当可以将一些“大量”的计算或阻塞操作作为同步代码块中移出时,才应该考虑同步代码块的大小。
11.4.2 减小锁的粒度
另一种减少锁持有时间的方式是降低线程请求锁的频率(从而减少发生竞争的可能性)。这可以通过锁分解和锁分段技术来实现,在这些技术中将采用多个互相独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个变量来保护的情况。这些技术能减少锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,发生死锁的风险也越高。
设想一下,如果在整个应用中只有一个锁,而不是为每个对象分配一个独立的锁,那么,所有同步代码块的执行就会变成串行化执行,而不考虑各个同步块中的锁。由于很多线程将竞争同一个全局锁,因此两个线程同时请求这个锁的概率将剧增,从而导致更严重的竞争。所以如果将这些锁请求分布到更多的锁上,那么能有效的降低竞争程度。由于等待锁而被阻塞的线程将更少,因此可伸缩性将提高。
如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁仅保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。
在程序清单 11-6 的 ServerStatus 中给出了某个数据库服务器的部分监视接口,该数据库维护了当前已登录的用户以及正在执行的请求。当一个用户登录、注销、开始查询或结束查询时,都会调用对应的 add 和 remove 方法来更新 ServerStatus 对象。这两种类型的信息是完全独立的,ServerStatus 甚至可以被分解为两个类,同时确保不会丢失功能。
@ThreadSafe public class ServerStatus {
@GuardedBy("this")
public final Set<String> users;
@GuardedBy("this")
public final Set<String> queries;
...
public synchronized void addUser(String u) {
users.add(u);
}
public synchronized void addQuery(String q) {
queries.add(q);
}
public synchronized void removeUser(String u) {
users.remove(u);
}
public synchronized void removeQuery(String q) {
queries.remove(q);
}
}
在代码中不是用 ServerStatus 锁来保护用户状态和查询状态,而是每个状态都通过一个锁来保护,如程序清单 11-7 所示。在对锁进行分解后,每个新的粒度锁上的访问量将比最初的访问量少。(通过将用户状态和查询状态委托给一个线程安全的 Set,而不是使用显式的同步,能隐含的对锁进行分解,因为每个 Set 都会使用一个不同的锁来保护其状态。)
@ThreadSafe public class ServerStatus {
@GuardedBy("users") public final Set<String> users;
@GuardedBy("queries") public final Set<String> queries;
...
public void addUser(String u) {
synchronized (users) {
users.add(u);
}
}
public void addQuery(String q) {
synchronized (queries) {
queries.add(q);
}
}
// remove methods similarly refactored to use split locks
}
如果在锁上存在适中而非激烈的竞争时,通过将一个锁分解为两个锁,能最大限度的提升性能。如果对竞争并不激烈的锁进行分解,那么在性能和吞吐量等方面带来的提升将非常有限,但是也会提高性能随着竞争提高而下降的拐点。对竞争适中的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效的提高性能和可伸缩性。
11.4.3 锁分段
把一个竞争激烈的锁分解为两个锁时,这两个锁可能都存在激烈的竞争。虽然采用两个线程并发执行能提高一部分性能,但是在一个拥有多个处理器的系统中,仍然无法给可伸缩性带来极大的提高。在 ServerStatus 类的锁分解实例中,并不能进一步多锁进行分解。
在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。例如,在 ConcurrentHashMap 的实现中使用了一个包含 16 的锁的数组,每个锁保护所有散列通的 1/16,其中第 N 个散列桶由第 (N mod 16) 个锁来保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的 1/16。这是这项技术使得 ConcurrentHashMap 能够支持多达 16 个并发的写入器。(要使得拥有大量处理器的系统在高访问量的情况下实现更高的并发性,还可以进一步增加锁的数量,但仅当你能证明并发写入线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过默认的 16 个。)
锁分段的一个劣势在于:与采用单个锁来实现的独占性相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需获取一个锁,但在某些情况下需要对整个容器加锁,例如当 ConcurrentHashMap 需要扩展容器映射范围时,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段分段锁集合中所有的锁。
在程序清单 11-8 的 StripedMap 中给出了基于散列的 Map 实现,其中使用了锁分段技术。它拥有 N_LOCKS 个锁,并且每个锁保护散列桶的一个子集。大多数方法,例如 get,都只需要获得一个锁,而有些方法则需要获得所有的锁,但并不要求同时获得,例如 clear 方法的实现。
@ThreadSafe public class StripedMap {
// Synchronization policy: buckets[n] guarded by locks[n%N_LOCKS]
private static final int N_LOCKS = 16;
private final Node[] buckets;
private final Object[] locks;
private static class Node { ... }
public StripedMap(int numBuckets) {
buckets = new Node[numBuckets];
locks = new Object[N_LOCKS];
for (int i = 0; i < N_LOCKS; i++)
locks[i] = new Object();
}
private final int hash(Object key) {
return Math.abs(key.hashCode() % buckets.length);
}
public Object get(Object key) {
int hash = hash(key);
synchronized (locks[hash % N_LOCKS]) {
for (Node m = buckets[hash]; m != null; m = m.next)
if (m.key.equals(key))
return m.value;
}
return null;
}
public void clear() {
for (int i = 0; i < buckets.length; i++) {
synchronized (locks[i % N_LOCKS]) {
buckets[i] = null;
}
}
}
...
}
11.4.4 避免热点域
锁分解和锁分段技术都能提高可伸缩性,因为它们都能使不同的线程在不同的数据(或者同一个数据的不同部分)上操作,而不会互相干扰。如果程序采用锁分段技术,那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。如果一个锁保护两个独立变量 X 和 Y,并且线程 A 想要访问 X,而线程 B 想要访问 Y(这类似于在 ServerStatus 中,一个线程调用 addUser,而另一个线程调用 addQuery),那么这两个线程不会在任何数据上发生竞争,即使它们会在同一个锁上发生竞争。
当每个操作都访问多个变量时,锁的粒度将很难降低。这是在性能和可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些“热点域(Hot Field)”,而这些热点域往往会限制可伸缩性。
当实现 HashMap 时,你需要考虑如何在 size 方法中计算 Map 中的元素数量。最简单的方法就是,在每次调用时都统计一次元素的数量。一种常见的优化措施是,在插入和移除元素时更新一个计数器,虽然这在 put 和 remove 等方法中略微增加了一些开销,以确保计数器是最新的值,但这将 size 方法的开销从 O(n) 降低到了 O(1)。
在单线程或采用完全同步的实现中,使用一个独立的就计数能很好的提高类似 size 和 isEmpty 这些方法的执行速度,但却导致更加难以提升实现的可伸缩性,因为每个修改 Map 的操作都需要跟新这个计数器。即使使用锁分段即使来实现散列链,那么在对计数器的访问进行同步时,也会重新导致在使用独占锁时存在的可伸缩性问题。一个看似性能优化的措施——缓存 size 操作的结果,已经变成了一个可伸缩性问题。在这种情况下,计数器也被称为热点域,因为每个导致元素数量发生变化的操作都需要访问它。
为了避免该问题,ConcurrentHashMap 中的 size 将对每个分段进行枚举并将每个分段中的元素数量增加,而不是维护一个全局计数。为了避免枚举每个元素,ConcurrentHashMap 为每个分段都维护了一个独立的计数,并通过每个分段的锁来维护这个值。
11.4.5 一些替代独占锁的方法
第三种降低竞争锁的影响的技术就是放弃独占所,从而有助于使用一种友好并发的方式来管理共享状态。例如,使用并发容器、读-写锁、不可变对象以及原子变量。
ReadWriteLock 实现了一种在多个读取操作以及单个写入操作情况下的加锁规则:如果有多个读取操作都不会修改共享资源,那么这些读操作可以同时访问该共享资源,但在执行写入操作时必须以独占的方式来获取锁,对于读取操作占多数的数据结构,ReadWriteLock 能够提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不可变性可以完全不需要加锁操作。
源自变量提供了一种方式来降低更新“热点域”时的开销,例如静态计数器、序列生成器、或者对链表数据结构中头节点的引用。原子变量类提供了在整数或对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(比如 CAS)。如果在类中只包含少量的热点域,并且这些域不会与其他变量参与到不变性条件中,那么用原子变量来代替它们提高可伸缩性。(通过减少算法中的热点域,可以提高可伸缩性——虽然原子变量能降低热点域的更新开销,但并不能完全消除。)
11.4.6 检测 CPU 的利用率
当测试可伸缩性时,通常要确保处理器得到充分利用。一些工具,例如 UNIX 系统上的 vastat 和 mpstat,或者 Windows 系统的 perfmon,都能给出处理器的忙碌状态。
如果所有 CPU 的利用率并不均匀(有些 CPU 在忙碌的运行,而其他 CPU 却并非如此)。那么你的首要目标就是进一步找出程序中的并行性。不均匀的利用率表名大多数计算都是由一小组线程完成的,并且应用程序没有利用上其他的处理器。
如果 CPU 没有得到充分的利用,那么需要找出其中的原因,通常有以下几种原因:
负载不充足。测试的程序中可能没有足够的负载,因而可以在测试时增加负载,并检查利用率、响应时间和服务时间等指标的变化。如果产生足够多的负载使应用程序达到饱和,那么可能需要大量的计算机能耗,并且问题可能在于客户端系统是否具有足够的能力,而不是被测试的系统。
IO 密集。可以通过 iostat 或 perfmon 来判断某个应用程序是否是磁盘 IO 密集型的,或者通过监控网络的通信流量级别来判断它是否需要高带宽。
外部限制。如果应用程序依赖于外部服务,例如数据库或者 Web 服务,那么性能瓶颈可能并不在你自己的代码中。可以使用某个分析工具或数据库管理工具来判断在等待外部的结果时需要的时间。
锁竞争。使用分析工具可以知道在程序中存在何种程度的锁竞争,以及在哪些锁上存在“激烈的竞争”。然而,也可以通过其他一些方式来获得相同的信息,例如随机取样,触发一些线程转储并在其中查找在锁上发生竞争的线程。如果线程由于等待某个锁而被阻塞,那么在线程转储信息中将存在相应的栈帧,其中包含的信息形如“waiting to lock monitor…”。非竞争的锁很少会出现在线程转储中,而对于竞争激烈的锁,通常至少会有一个线程正在等待获取它,因此将在线程转储中频繁的出现。
如果应用程序正在使 CPU 保持忙碌的状态,那么可以使用检测工具来判断是否能通过增加额外的 CPU 来提升程序的性能。如果一个程序只有 4 个线程,那么可以充分利用一个 4 路系统的计算能力,但当移植到 8 路系统上时,却未必能获得性能提升,因为可能需要更多的线程才会有效利用剩余的处理器。(可以通过重新配置程序将负载分配给更多的线程,例如调整线程池的大小)。在 vmstat 命令的输出中,有一栏信息是当前处于可运行状态但并没有运行(由于没有足够的 CPU)的线程数量。如果 CPU 的利用率很高,并且总会有可运行的线程在等待 CPU,那么当增加更多的处理器时,程序的性能可能会得到提升。
11.4.7 向对象池说“不”
在 JVM 早期的版本中,对象分配和垃圾回收等操作的执行速度非常慢,但在后续的版本中,这些操作的性能得到了很大的提升。事实上,现在 Java 的分配操作已经比 C 语言的 malloc 调用更快:在 HotSpot 1.4.x 和 5.0 中,“new Object”的代码大约包含 10 条机器指令。
为了解决“缓慢的”对象生命周期问题,许多开发人员都选择使用对象池技术,在对象池中,对象能够被循环使用,而不是由垃圾收集器回收并在需要时再重新分配。在单线程程序中,尽管对象池技术能降低垃圾收集器的开销,但对于高开销对象以外的其他对象来说,仍然存在性能缺失(对于轻量级和中量级的对象来说,这种损失更为严重)。
在并发应用程序中,对象池的表现更加糟糕。当线程分配新的对象时,基本上不需要在线程之间进行协调,因为对象分配器通常会使用线程本地的内存块,所以不需要在堆数据结构上进行同步。然而,如果这些线程从对象池中请求一个对象,那么久需要通过某种同步来协调对对象池数据结构的访问,从而可能使某个线程被阻塞。如果某个线程由于锁竞争而被阻塞,那么这种阻塞的开销将是内存分配操作开销的数百倍,因此即使对象池带来的竞争很小,也可能形成一个可伸缩的性能瓶颈。(即使是一个非竞争同步,所导致的开销也会被分配一个对象的开销大)。虽然这看上去是一种性能优化技术,但实际上却会导致可伸缩性问题。对象池有其特定的用途,但对于性能优化来说,用途是有限的。
通常,对象分配操作的开销比同步的开销更低。
11.5 示例:比较 Map 的性能
在单线程环境下,ConcurrentHashMap 的性能比同步的 HashMap 的性能略好一些,但在并发环境中则要好的多。在 ConcurrentHashMap 的实现中假设,大多数常用的操作都是获取某个已经存在的值,因此它对各种 get 操作进行了优化从而提高性能和并发性。
在同步 Map 的实现中,可伸缩性的最主要阻碍在于整个 Map 中只有一个锁,因此每次只有一个线程能能够访问这个 Map。不同的是,ConcurrentHashMap 对于大多数读操作都不会加锁,并且在写入操作以及其他一些需要锁的读操作中使用了锁分段技术。因此,多个线程能并发的访问这个 Map 而不会发生阻塞。
图 11-3 给出了几种 Map 实现在可伸缩性上的差异:ConcurrentHashMap、ConcurrentSkipListMap,以及通过 synchronizedMap 来包装的 HashMap 和 TreeMap。前两种 Map 是线程安全的,而后两个 Map 则通过同步封装器来确保线程安全性。每次运行时,将有 N 个线程并发的执行一个紧凑的循环:选择一个随机的键值,并尝试获取与这个键值相对应的值。如果不存在相应的值,那么将这个值增加到 Map 的概率为 p=0.6,如果存在相应的值,那么删除这个值的概率为 p=0.02。这个测试在 8路 Sparc V880 系统上运行,基于 Java 6 环境,并且在图中给出了将 ConcurrentHashMap 归一化为单个线程时的吞吐量。(并发容器与同步容器在可伸缩性上的差异比在 Java 5.0 中更明显)。
ConcurrentHashMap 和 ConcurrentSkipListMap 的数据显示,它们在线程数量增加时能表现出更好的可伸缩性,并且吞吐量会随着线程的增加而增加。虽然图 11-3 中的线程数量并不大,但与普通的应用程序相比,这个测试程序在每个线程上生成更多的竞争,因为它除了向 Map 施加压力以外几乎没有执行任何其他操作,而实际的应用程序通常会在每次迭代中进行一些线程本地工作。
同步容器的数量并非越多越好。单线程情况下的性能与 ConcurrentHashMap 的性能基本相当,但当负载情况由于非竞争性转变为竞争性时——这里是两个线程,同步容器的性能将变得非常糟糕。在伸缩性收到锁竞争限制的代码中,这种情况很常见。只要竞争程度不高,那么每个操作消耗的时间基本上即使实执行工作的时间,并且吞吐量会因为线程数的增加而增加。当竞争变得激烈时,每个操作消耗的时间大部分都用于上下文切换和调度延迟,而再加入更多的线程也不会提高太多的吞吐量。
11.6 减少上下文切换的开销
在许多人物中都包含一些可能被阻塞的操作。当任务在运行和阻塞这连个状态之间转换时,就相当于一次上下文切换。在服务器应用中,发生阻塞的原因之一就是在处理请求时产生各种日志消息。为了说明如何通过减少上下文切换的次数来提高吞吐量,我们将对两种日志方法的调度行为进行分析。
在大多数日志框架中都是简单的对 println 进行包装,但需要记录某个消息时,只需要将其写入日志文件。在第七章中的 LogWriter 中给出了另一种方法:记录日志的工作由一个专门的后台线程完成,而不是由发出请求的线程。从开发人员的角度来看,这两种方法基本上是相同的。但二者在性能上可能存在一些差异,这取决于日志操作的工作量,即有多少线程正在记录日志,以及其他一些因素,例如上下文切换的开销等。
日志操作的服务时间包括与 IO 流类相关的计算时间,如果 IO 操作被阻塞,那么还会包括线程被阻塞的时间。操作系统将这个被阻塞的线程从调度队列中移出并直到 IO 操作结束,这将比实际阻塞的时间更长。当 IO 操作结束时,可能有其他线程正在执行它们的带哦度时间片,并且在调度队列中有些线程位于被阻塞线程之前,从而进一步增加服务时间。如果有多个线程在同时记录日志,那么还可能在输出流上的锁上发生竞争,这种情况的结果与阻塞 IO 的情况一样——线程被阻塞并等待锁,然后被线程调度器调度出去。在这种日志操作中包含了 IO 操作和加锁操作,从而导致上下文切换次数的增多,以及服务时间的增加。
请求服务的时间不应该过长,主要有以下原因。首先,服务时间将影响服务质量:服务时间越长,就意味着有程序在获得结果时需要等待更多的时间。但更重要的是,服务是将越长,也就意味着存在越多的锁竞争。11.4.1 节中的“快进快出”原则告诉我们,锁被持有的时间应该尽可能的短,因为锁的持有时间越长,那么在这个锁上发生竞争的可能性就越大。如果一个线程由于等待 IO 操作完成而被阻塞,同时它还持有一个锁,那么在这期间很可能会有另一个线程想要获得这个锁。如果在大多数的锁获取操作上不存在竞争,那么并发系统就能执行得更好,因为在锁获取操作上发生竞争时将导致更多的上下文切换。在代码中造成的上下文切换次数越多,吞吐量就越低。
通过将 IO 操作从处理请求的线程分离出来,可以缩短处理请求的平均服务时间。调用 log 方法的线程将不会再因为等待输出流的锁或者 IO 完成而被阻塞,它们只需要将消息放入队列,然后就返回到各自的任务中。另一方面,虽然在消息队列上可能发生竞争,但 put 操作相对于记录日志的 IO 操作(可能需要执行系统调用)是一种更为轻量级的操作,因此在实际使用中发生阻塞的概率更小(只要队列未满)。由于发出日志请求的线程现在被阻塞的概率降低,因此该线程在处理请求时被交换出去的概率也会降低。我们所做的工作就是把一条包含 IO 操作和锁竞争的复杂且不确定的代码路径变成一条简单的代码路径。
从某种意义上讲,我们只是将工作分散开来,并将 IO 操作移到了另一个用户感知不到开销的线程上(这本身就已经获得了成功)。通过把所有记录日志的 IO 转移到一个线程,还消除了输出流上的竞争,因去掉了一个竞争来源。这将提升整体的吞吐量,因为在调度中消耗的资源更少,上下文切换次数更少,并且锁的管理也更简单。
通过把 IO 操作从处理请求的线程转移到一个专门的线程,类似于两种不同救火方案之间的差异:第一种方案是所有人排成一队,通过传递水桶来救火;第二种方案是每个人都拿着一个水桶去救火。在第二种方案中,每个人都可能在水源点和着火点上存在很大的竞争(结果导致了只能将更少的水传递到着火点),此外救火的效率也更低,因为每个人都在不停的切换模式(装水、跑步、倒水、跑步…)。在第一种方案中,水不断的从水源传递到燃烧的建筑物,人么付出更少的体力却传递了更多的水,并且每个人从头到尾只需要做一项工作。正如中断会干扰人们的工作并降低效率一样,阻塞和上下文切换同样会干扰线程的正常执行。
小结
由于使用线程常常是为了充分利用多个处理器的计算能力,因此在并发程序性能的讨论中,通常更多的将侧重点放在吞吐量和可伸缩性上,而不是服务时间。Amdahl 定律告诉我们,程序的可伸缩性取决于在所有代码中必须被串行执行的代码的比例。因为 Java 程序中串行执行的主要来源是独占方式的资源锁,因此通常可以通过以下方式来提升伸缩性:减少锁的持有时间、降低锁的粒度、以及采用非独占的锁或者非阻塞锁来代替独占锁。
1.12 - CH12-测试
在编写并发程序时,可以采用与串行程序相同的设计原则和设计模式。二者的差异在于,并发程序存在一定程度的不确定性,而在串行程序中不存在这个问题。这种不确定性将增加不同交互模式及故障模式的数量,因此在设计并发程序时必须对这些模式进行分析。
同样,在测试开发程序时,将会使用并扩展很多在测试串行程序时用到的方法。在测试串行程序正确性与性能方面所采用的技术,同样可以用于测试并发程序,但对于并发程序而言,可能出错的地方要远比串行程序多。要在测试中将这些故障暴露出来,就需要比普通的串行程序测试覆盖更广的范围并且执行更长的时间。
并发测试大致分为两类,即安全性测试与活跃性测试。在第一章,我们将安全性定义为“不发生任何错误的行为”,而降活跃性定义为“某个良好的行为终究会发生”。
在进行安全性测试时,通常会采用测试不变性条件的形式,即判断某个类的行为是否与其规范保持一致。例如,假设有一个链表,在它每次被修改时将其大小缓存下来,那其中一项安全性测试就是比较在缓存中保存的大小值与链表中实际元素的数目是否相等。这种测试在单线程程序中很简单,因为在测试时链表的内容不会发生变化。但在并发程序中,这种测试将可能由于竞争而失败,除非能将访问计数器的操作和统计元素数据的操作合并为单个原子操作。要实现这一点,可以对链表加锁以实现独占访问,然后采用链表中提供的某种“原子快照”功能,或者在某些“测试点”上采用原子方式来判断不变性条件或者执行测试代码。
在本书中,我们曾通过执行时序图来说明“错误的”交互操作,这些操作将在未被正确构造的类中导致各种故障,而测试程序将努力在足够大的状态空间中查找这些地方。然而,测试代码同样会对执行时序或同步操作带来影响,这些影响可能会掩盖一些本可以暴露的错误。
测试活跃性本身也存在问题。活跃性测试包括进展测试和无进展测试两个方面,这些都是很难量化的——如何验证某个方法是被阻塞了。而不是运行缓慢?同样,如何测试某个算法不会发生死锁?要等待多久才能宣告发生了故障?
与活跃性测试相关的是性能测试。性能可以通过多个方面来衡量,包括:
- 吞吐量。指一组并发任务中已经成任务所占的比例。
- 响应性。指请求从发出到完成之间的时间,也称延迟。
- 可伸缩性。指在增加更多资源的情况下(如 CPU),吞吐量(或者缓解短缺)的提升情况。
12.1 正确性测试
在为某个并发类设计单元测试时,首先需要执行与测试串行代码类时相同的分析——找出需要检查的不变性条件和后验条件,而在剩下的时间里,当编写测试时将不断发现新的规范。
为了进一步说明,接下来我们将构建一组测试用例来测试一个有界缓存。程序清单 12-1 给出了 BoundedBuffer 的实现,其中使用 Semaphore 来实现缓存的有界属性和阻塞行文。
@ThreadSafe public class BoundedBuffer<E> {
private final Semaphore availableItems, availableSpaces;
@GuardedBy("this")
private final E[] items;
@GuardedBy("this")
private int putPosition = 0, takePosition = 0;
public BoundedBuffer(int capacity) {
availableItems = new Semaphore(0);
availableSpaces = new Semaphore(capacity);
items = (E[]) new Object[capacity];
}
public boolean isEmpty() {
return availableItems.availablePermits() == 0;
}
public boolean isFull() {
return availableSpaces.availablePermits() == 0;
}
public void put(E x) throws InterruptedException {
availableSpaces.acquire();
doInsert(x);
availableItems.release();
}
public E take() throws InterruptedException {
availableItems.acquire();
E item = doExtract();
availableSpaces.release();
return item;
}
private synchronized void doInsert(E x) {
int i = putPosition;
items[i] = x;
putPosition = (++i == items.length)? 0 : i;
}
private synchronized E doExtract() {
int i = takePosition;
E x = items[i];
items[i] = null;
takePosition = (++i == items.length)? 0 : i;
return x;
}
}
BoundedBuffer 实现了一个固定长度的队列,其中定义了可阻塞的 put 和 take 方法,并通过两个计数信号量进行控制。信号量 availableItems 表示可以从缓存中删除的元素个数,它的初始值为 0(因为缓存的初始状态为空)。同样,信号量 availableSpaces 表示可以插入到缓存的元素个数,它的初始值等于缓存的大小。
take 操作首先请求从 availableItems 中获得一个许可(Permit)。如果缓存不为空,那么这个请求会立即成功,否则请求将被阻塞直到缓存不再为空。在获得一个许可后,take 方法将删除缓存中的下一个元素,并返回一个许可到 availableSpaces 信号量。put 方法的执行属顺序则刚好相反,因此无论是从 put 方法还是从 take 方法中退出,这两个信号量计数值的和都会等于缓存的大小。(在实际情况中,如果需要一个有界缓存,应该直接使用 ArrayBlockingQueue 或 LinkedBlockingQueue,而不是自己编写,但这里用于说明如何对添加和删除等方法进行控制的技术,在其他数据结构中也同样适用。)
12.1.1 基本的单元测试
BoundedBuffer 的最基本单元测试类似于在串行上下文中执行的测试。首先创建一个有界缓存,然后调用它的各个方法,并验证它的后验条件和不变性条件。我们很快会想到一些不变性条件:新建立的缓存应该是空的,而不是满的。另一个略显复杂的安全测试是,将 N 个元素插入到容量为 N 的缓存中(整个过程应该可以成功且不会阻塞),然后测试缓存是否已经填满(不为空)。程序清单 12-2 给出了这些属性的 JUnit 测试方法。
class BoundedBufferTest extends TestCase {
void testIsEmptyWhenConstructed() {
BoundedBuffer<Integer> bb = new BoundedBuffer<Integer>(10);
assertTrue(bb.isEmpty());
assertFalse(bb.isFull());
}
void testIsFullAfterPuts() throws InterruptedException {
BoundedBuffer<Integer> bb = new BoundedBuffer<Integer>(10);
for (int i = 0; i < 10; i++)
bb.put(i);
assertTrue(bb.isFull());
assertFalse(bb.isEmpty());
}
}
这些简单的测试方法都是串行的。在测试集中包含一组串行测试通常是有益的,因为他们有助于在开始分析竞争之前就找出与并发性无关的问题。
12.1.2 测试阻塞操作
在测试并发的基本属性时,需要引入多线程。大多数测试框架并不能很好的支持并发性测试:它们很少会包含相应的工具来创建线程或监视线程,以确保它们不会意外结束。如果在某个测试用例创建的辅助线程中发现了一个错误,那么框架通常无法得知与这个线程相关的是哪一个测试,所以需要通过一些工作将成功或失败信息传递会主测试线程,从而能够将对应的信息报告出来。
在 JUC 的一致性测试中,一定要将故障与特定的测试明确的关联起来。因此 JSR 166 转件组创建了一个基类,其中定义了一些方法可以将 tearDown 期间传递或报告失败信息,并遵循一个约定:每个测试必须等待它所创建的全部线程结束后才能完成。你不需要考虑这么深入,关键的需求在于,能否通过这些测试,以及是否在某个地方报告了失败信息以便于诊断问题。
如果某方法需要在某些特定条件下阻塞,那么当测试这种行为时,只有当线程不再继续执行时,测试才是成功的。要测试一个方法的阻塞行为,类似于测试一个抛出异常的方法:如果这个方法可以正常返回,那么就意味着测试失败。
在测试方法的阻塞行为时,将引入额外的复杂性:当方法被成功的阻塞后,还必须使方法解除阻塞。实现这个功能的一种简单方式是使用中断——在一个单独的线程中启动一个阻塞操作,等到线程阻塞后再中断它,然后宣告阻塞操作成功。当然,这要求阻塞方法通过提前返回或者抛出中断异常来响应中断。
“等待并直到线程阻塞后”这句话说起来简单,做起来难。实际上,你必须估计执行这些指令可能需要多长时间,并且等待的时间会更长。如果估计的时间不准确(在这种情况下,你会看到伪测试失败),那么应该增大这个值。
程序清单 12-3 给出了一种测试阻塞操作的方法。这种方法会创建一个“获取线程”,该线程将尝试从缓存中获取一个元素。如果 take 方法成功,那么表示测试失败。执行测试的线程启动“获取”线程,等待一段时间,然后中断该线程。如果“获取”线程正确的在 take 方法中阻塞,那么将抛出中断异常,而捕获到该异常的 catch 块将把这个异常视为测试成功,并让线程退出。然后,主测线程会尝试与“获取”线程合并,通过调用 Thread.isAlive 来验证 join 方法是否返回成功,如果“获取”线程可以响应中断,那么 join 能很快完成。
void testTakeBlocksWhenEmpty() {
final BoundedBuffer<Integer> bb = new BoundedBuffer<Integer>(10);
Thread taker = new Thread() {
public void run() {
try {
int unused = bb.take();
fail(); // if we get here, it's an error
} catch (InterruptedException success) { }
}
};
try {
taker.start();
Thread.sleep(LOCKUP_DETECT_TIMEOUT);
taker.interrupt();
taker.join(LOCKUP_DETECT_TIMEOUT);
assertFalse(taker.isAlive());
} catch (Exception unexpected) {
fail();
}
}
如果 take 操作由于某种意料之外的原因停滞了,那么支持限时的 join 方法能确保测试最终完成。该测试验证了 take 方法的多种属性——不仅能阻塞,而且在中断后还能抛出中断异常。在这种情况下,最好是对 Thread 进行子类化而不是使用线程池中的 Runnable,即通过 join 来正确的结束测试。当主线程将一个元素放入队列后,“获取”线程应该解除阻塞状态,要测试这种行为,可以使用相同的方法。
开发人员会尝试使用 Thread.getState 来验证线程能否在一个条件等待上阻塞,但这种方法并不可靠。被阻塞线程并不需要进入 WAITING 或 TIMED_WAITING 等状态,因此 JVM 可以选择通过自旋锁等待来实现阻塞。类似的,由于在 Object.wait 或 Condition.await 等方法上存在伪唤醒,因此,即使一个线程等待的条件尚未成真,也可能从 WAITING 或 TIMED_WAITING 等待状态临时性的转换到 RUNNABLE 状态。即使忽略这些不同实现之间的差异,目标线程在进入阻塞状态时也会消耗一定的时间。Thread.getState 的返回结果不能用于并发控制,它将限制测试的有效性——其主要的作用要是作为调试信息。
12.1.3 安全性测试
程序清单 12-2、12-3 的测试用例验证了有界缓存的一些重要属性,但它们却无法发现由于数据竞争而引发的错误。要想测试一个并发类在不可预测的并发访问情况下能否正确执行,需要创建多个线程来分别执行 put 和 take 操作,并在执行一段时间后判断在测试中是否会出现问题。
如果要构造一些测试来发现并发类中的安全性错误,那么这实际上是一个“先有蛋还是先有鸡”的问题:测试程序自身就是并发程序。要开发一个良好的并发测试程序,或许比开发这些要被测试的类更加困难。
在构建对并发类的安全性测试中,需要解决的关键问题在于,要找出那些容易检查的属性,这些属性在发生错误的情况下极有可能失败,同时又不会使得错误检查代码认为的限制并发性。理想的情况是,在测试属性中不需要任何同步机制。
要测试在生产消费模式中使用的类,一种有效的方法就是检查被放入队列中的、和从队列中取出的各个元素。这种方法的一种简单实现是,当元素被插入到队列时,同时将其插入到一个“影子”列表,当从队列中删除该元素时,同时也从“影子”列表中删除,然后在测试程序运行完成以后判断“影子”列表是否为空。然而,这种方法可能会干扰测试线程的调度,因为在修改“影子”列表时需要同步,并可能会阻塞。
一种更好的方法是,通过一个对顺序敏感的校验和计算函数来计算所有入列元素以及出列元素的校验和,并进行比较。如果二者相等,那么测试就是成功的。如果只有一个生产者将元素放入缓存,同时也只有一个消费者从中取出元素,那么这种方法能发挥最大的作用,因为它不仅能测试出是否取出了正确的元素,而且还能测试出元素被取出的顺序是否正确。
如果要将这种方法扩展到多生产者——多消费者的情况,就需要一个对元素的入列、出列顺序不敏感的校验和函数,从而在测试程序运行完成以后,可以将多个校验和以不同的顺序组合起来。如果不是这样,多个线程就需要访问同一个共享的校验和变量,因此就需要同步,这将成为一个并发瓶颈或者破坏被测代码的执行时序。(任何具备可交换性的操作,例如加法或 XOR,都符合这些需求)。
要确保测试程序正确的测试所有要点,就一定不能让编译器可以预先猜到校验和的值。使用连续的整数作为测试数据并不是一个好办法,因为得到的结果是相同的,而一个智能的编译器通常可以预先计算出这个结果。
要避免这种问题,应该采用随机方式生成的测试数据,但如果选择了一种不合适的随机数生成器,那么会对许多其他的测试造成影响。由于大多数随机数生成器都是线程安全的,并且会代码额外的同步开销,因此在随机数生成过程中,可能会在这些类与执行时序之间产生耦合关系。如果每个线程都拥有各自的生成器,那么生成器就无需在意线程安全性。
与其使用一个通用的随机数生成器,还不如使用一些简单的伪随机函数。你并不需要某种高质量的随机性,而只需要确保在不同的测试运行中都有不同的数字。在程序清单 12-4 的 xorShift 函数是最符合这个需求的随机函数之一。该函数基于 hashCode 和 nanoTime 来生成随机数,所得的结果是不可预测的,而且基本上每次运行都会不同。
static int xorShift(int y) {
y ^= (y << 6);
y ^= (y >>> 21);
y ^= (y << 7);
return y;
}
在程序清单 12-5 和程序清单 12-6 的 PutTakeTest 中启动了 N 个生产者线程来生成元素并把它们插入到队列,同时还启动了 N 个消费者线程从队列中取出元素。当元素进出队列时,每个线程都会更新对这些元素计算得到的校验和,每个线程都拥有一个校验和,并在测试结束后将它们合并,从而在测试缓存时就不会引入过多的同步或竞争。
public class PutTakeTest {
private static final ExecutorService pool = Executors.newCachedThreadPool();
private final AtomicInteger putSum = new AtomicInteger(0);
private final AtomicInteger takeSum = new AtomicInteger(0);
private final CyclicBarrier barrier;
private final BoundedBuffer<Integer> bb;
private final int nTrials, nPairs;
public static void main(String[] args) {
new PutTakeTest(10, 10, 100000).test(); // sample parameters
pool.shutdown();
}
PutTakeTest(int capacity, int npairs, int ntrials) {
this.bb = new BoundedBuffer<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);
}
}
class Producer implements Runnable { /* Listing 12.6*/ }
class Consumer implements Runnable { /* Listing 12.6 */ }
}
/* inner classes of PutTakeTest (Listing 12.5) */
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);
}
}
}
根据系统平台的不同,创建线程与启动线程等操作可能需要较大的开销。如果线程的执行时间很短,并且在循环中启动了大量的这种线程,那么最坏的情况就是,这些线程将会串行执行而不是并发执行。即使在一些不太糟糕的情况下,第一个线程仍然比其他线程具有“领先优势”。因此这可能无法获得预想预想中的交替执行:第一个线程先运行一段时间,然后前两个线程会并发的执行一段时间,只有到了最后,所有线程才会一起并发执行。(在线程结束运行时也存在同样的问题:首先启动的线程将提前完成)。
在 5.5.1 中接收了一项可以缓解该问题的技术,即使用两个 CountDownLatch,一个作为开始阀门,一个作为结束阀门。使用 CyclicBarrier 也可以获得同样的效果:在初始化 CyclicBarrier 时将计数值指定为工作者线程的数量再加 1,并在运行开始和结束时,使工作者线程和测试线程都在这个栅栏处等待。这能确保所有线程在开始执行任何工作之前,都首先执行到同一个位置。PutTakeTest 使用这项技术来协调工作者现货出呢个的启动和停止,从而产生更多的并发交替操作。我们仍然无法确保调度器不会采用串行方式来执行每个线程,但只要这些线程的执行时间足够长,就能降低调度机制对结果的不利影响。
PutTakeTest 使用了一个确定性的结束条件,从而在判断测试何时完成时就不需要在线程之间执行额外的协调。test 方法将启动相同数量的生产者和消费者线程,它们将分别插入和取出相同数量的元素,因此添加和删除的总数相同。
想 PutTakeTest 这种测试能很好的发现安全性问题。例如,在实现由限号量控制的缓存时,一个常见的错误就是在执行插入和取出的代码中忘记实现互斥行为(可以使用 synchronized 或 ReentrantLock)。如果在 PutTakeTest 使用的 BoundedBuffer 中忘记将 doInsert 和 doExtract 声明为 synchronized,那么在运行 PutTakeTest 时就会立即失败。通过多个线程来运行 PutTakeTest,并且使这些线程在不同系统上的不同容量的缓存上迭代数百万次,是我们能进一步确定在 put 和 take 方法中不存在数据破坏问题。
这些测试应该放在多处理器系统上运行,从而进一步测试更多形式的交替运行。然而,CPU 的数量越多并不一定会使测试更加高效。要最大程度的检测出一些对执行时序敏感的数据竞争,那么测试中的线程数量应该多于 CPU 数量,这样在任意时刻都会有一些线程在运行,而另一些被交换出去,从而可以检查线程间交替行为的可测试性。
有一些测试中通常要求执行完一定数量的操作后才能停止运行,如果在测试代码中出现了一个错误并抛出了一个异常,那么这个测试将永远不会停止。最常见的解决方法是:让测试框架放弃那个没有在规定时间内完成的测试,具体要等待多长时间,则要凭借经验来确定,并且要对故障进行分析以确保所出现的问题并不是由于没有等待足够的时长而造成的。(这个问题并不仅限于对并发类的测试,在串行测试中也必须区分长时间的运行和死循环)。
12.1.4 资源管理测试
到目前为止,所有的测试都侧重于类与其设计规范的一致程度——在类中应该实现规范中定义的功能。测试的另一个方面就是要判断类中是否没有做它应该做的事情,例如资源泄露。对于任何持有或管理其他对象的对象,都应该在不需要这些对象时销毁对它们的引用。这种存储资源泄露不仅会妨碍垃圾回收期回收内存(或者线程、文件句柄、套接字、数据库连接或其他有限资源),而且还会导致资源耗尽甚至应用程序失败。
对于像 BoundedBuffer 这样的类来说,资源管理的问题尤为重要。之所以有限制缓存的带下,其原因就是要防止由于资源耗尽而导致应用程序发生故障,例如生产者的速度远远高于消费者的处理速度。通过对缓存进行限制,将使得生产力过剩的生产者被阻塞,因为它们就不会继续创建更多的工作来消耗越来越多的内存以及其他资源。
通过一些测量应用程序中内存使用情况的堆检查工具,可以很容易的测试出对内存的不合理占用,许多商业和开源的堆分析工具都支持这种功能。在程序清单 12-7 的 testLeak 方法中包含了一些堆分析工具用于抓取堆的快照,这将强制执行一次垃圾回收,然后记录堆大小和内存用量的信息。
class Big {
double[] data = new double[100000];
}
void testLeak() throws InterruptedException {
BoundedBuffer<Big> bb = new BoundedBuffer<Big>(CAPACITY);
int heapSize1 = /* snapshot heap */ ;
for (int i = 0; i < CAPACITY; i++)
bb.put(new Big());
for (int i = 0; i < CAPACITY; i++)
bb.take();
int heapSize2 = /* snapshot heap */ ;
assertTrue(Math.abs(heapSize1-heapSize2) < THRESHOLD);
}
testLeak 方法将多个大型对象插入到一个有界缓存中,然后再将他们移除。第二个堆快照中的内存用量应该与第一个堆快照中的内存用量基本相同。然而,doExtract 如果忘记将返回元素的引用置为空(intems[i]=null
),那么在两次快中中报告的内存用量将明显不同。(这是为数不多的集中需要显式的将变量置空的情况之一。在大多数情况下,这种做法不仅不会带来帮助,甚至还会带来负面作用)。
12.1.5 使用回调
在构造测试案例时,对客户提供的代码进行回调是非常有帮助的。回调函数的执行通常是在对象生命周期的一些已知位置上,并且在这些位置上非常适合判断不变性条件是否被破坏。例如,在 ThreadPoolExecutor 中将调用任务的 Runnable 和 ThreadFactory。
在测试线程池时,需要测试执行策略的多个方面:在需要更多的线程时创建新线程,在不需要时不创建,以及当需要回收空闲线程时执行回收操作等。要构造一个全面的测试方案是很困难的,但其中许多方面的测试都可以单独进行。
通过使用自定义的线程工厂,可以对线程的创建过程进行控制。在程序清单 12-8 的 TestingThreadFactory 中将记录已创建的线程数量。这样,在测试过程中,测试方案可以验证已创建线程的数量。我们还可以对 TestingThreadFactory 进行扩展,使其返回一个自定义的 Thread,并且该对象可以记录自己在何时结束,从而在测试方案中验证线程在被回收时是否与执行策略一致。
class TestingThreadFactory implements ThreadFactory {
public final AtomicInteger numCreated = new AtomicInteger();
private final ThreadFactory factory =
Executors.defaultThreadFactory();
public Thread newThread(Runnable r) {
numCreated.incrementAndGet();
return factory.newThread(r);
}
}
如果线程池的基本大小小于最大值,那么线程池会根据执行需求做对应的增长。当把一些运行时间较长的任务提交给线程池时,线程池中的任务数量在长时间内都不会变化,这就可以进行一些判断,例如测试线程池是否能按照预期的方式扩展,如程序清单 12-9 所示。
public void testPoolExpansion() throws InterruptedException {
int MAX_SIZE = 10;
ExecutorService exec = Executors.newFixedThreadPool(MAX_SIZE);
for (int i = 0; i < 10* MAX_SIZE; i++)
exec.execute(new Runnable() {
public void run() {
try {
Thread.sleep(Long.MAX_VALUE);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
for (int i = 0;i < 20 && threadFactory.numCreated.get() < MAX_SIZE;i++)
Thread.sleep(100);
assertEquals(threadFactory.numCreated.get(), MAX_SIZE);
exec.shutdownNow();
}
12.1.6 产生更多的交替操作
由于并发代码中的大多数错误都是一些低概率事件,因此在测试并发错误时需要反复的执行多次,但有些方法可以提高发现这些错误的概率。在前面提高过,在多处理器系统上,如果处理器数量少于活动线程的数量,那么与单处理器系统或者包含多处理器的系统相比,将能产生更多的交替行为。同样,如果在不同的处理器数量、操作系统以及处理器架构的系统上进行测试,就可以发现那些在特定运行环境中才会出现的问题。
有一种有用的方法可以提高交替操作的数量,以便能够更有效的探索程序的状态空间:在访问共享状态的操作中,使用 Thread.yield 将产生更多的上下文切换。(该方法的有效性与具体的平台有关,因为 JVM 可以将 Thread.yield 实现为一个空操作。如果使用一个睡眠时间较短的 sleep,那么虽然更慢,但却更可靠)。程序清单 12-10 中的方法在两个账户之间执行转账操作,在两次更新操作之间,像“所有账户的总和应等于零”这样的一些不变性条件可能会被破坏。当代码在访问状态时没有使用足够的同步,将存在一些对执行时序敏感的错误,通过在某个操作的执行过程中调用 yield 方法,可以将这些错误暴露出来。这种方法需要在测试中添加一些调用并且在正式产品中删除这些调用,这将给开发人员带来不便,通过使用面向切面编程(AOP)工具,可以降低这种不便性。
public synchronized void transferCredits(Account from, Account to, int amount) {
from.setBalance(from.getBalance() - amount);
if (random.nextInt(1000) > THRESHOLD)
Thread.yield();
to.setBalance(to.getBalance() + amount);
}
12.2 性能测试
性能测试通常是功能测试的延伸。事实上,在性能测试中应该包含一些基本的功能测试,从而确保不会对错误代码进行性能测试。
虽然在性能测试与功能测试之间肯定会存在重叠之处,但它们的目标是不同的。性能测试将衡量典型测试用例中的端到端性能。通常,要活的一组合理的使用场景并不容易,理想情况下,在测试中应该反映出被测试对象在应用程序中的实际用法。
在某些情况下,也存在某种显而易见的测试场景。在生产者-消费者设计中通常都会用到有界缓存,因此显然需要测试生产者向消费者提供数据时的吞吐量。对 PutTakeTest 进行扩展,使其成对针对该应用场景的性能测试。
性能测试的第二个目标是根据经验值来调整各个不同的限值,比如线程数量、缓存容量等。这些限值可能依赖具体平台的特性(如处理器类型、处理器的步进级别、CPU 数量、内存大小等),因此需要进行动态的配置,而我们通常需要合理的选择这些值,从而使程序能够在更多的系统上良好的运行。
12.2.1 在 PutTakeTest 中增加计时功能
之前对 PutTakeTest 的主要扩展计时测量运行一次需要的时间。现在,我们不测量单个操作的时间,而是实现一种更精确的测量方式:记录整个运行过程的时间,然后除以总操作的次数,从而得到每次操作的运行时间。之前使用了 CyclicBarrier 来启动和结束工作者线程,因此可以对其进行扩展:使用一个栅栏动作来测量启动和技术时间,如程序清单 12-11 所示。
this.timer = new BarrierTimer();
this.barrier = new CyclicBarrier(npairs * 2 + 1, timer);
public class BarrierTimer implements Runnable {
private boolean started; private long startTime, endTime;
public synchronized void run() {
long t = System.nanoTime();
if (!started) {
started = true;
startTime = t;
}
else endTime = t;
}
public synchronized void clear() {
started = false;
}
public synchronized long getTime() {
return endTime - startTime;
}
}
我们可以将栅栏的初始化过程修改为使用这种栅栏动作,即使用能接收栅栏动作的 CyclicBarrier 构造函数。
在修改后的 test 方法中使用了基于栅栏的计时器,如程序清单 12-12 所示。
public void test() {
try {
timer.clear();
for (int i = 0; i < nPairs; i++) {
pool.execute(new Producer());
pool.execute(new 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);
}
}
我们可以从 TimedPutTakeTest 的运行中学到一些东西。第一,生产者-消费者模式在不同参数组合下的吞吐率。第二,有界缓存在不同线程数量下的可伸缩性。第三,如果选择缓存的大小。要回答这些问题,需要对不同的参数组合以进行测试,因此我们需要一个主测试程序,如程序清单 12-13 所示。
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);
}
}
pool.shutdown();
}
图 12-1 给出在 4 路机器上的一些测试结果,缓存容量分别为 1、10、100、1000。我们可以看到,当缓存大小为 1 时,吞吐率非常糟糕,这是因为每个线程在阻塞并等待另一个线程之前,所取得的进展是非常有限的。当把缓存大小提高到 10 时,吞吐率得到了几大提高:但在超过 10 之后,所得到的收益又开始降低。
初看起来可能会感到困惑:当增加更多的贤臣时,性能却略有下降。其中的原因很难中数据中看出来,但可以在运行测试时使用 CPU 性能工具:虽然有许多的线程,但却没有足够的计算量,并且大部分时间都消耗在线程的阻塞和解除阻塞等操作上。线程有足够多的 CPU 空闲时钟周期来做相同的事情,因此不会过多的降低性能。
然而,要谨慎对待从上面的数据中得出的结论,即在使用有界缓存的生产消费程序中总是可以添加更多的线程。这个测试在模拟应该程序时忽略了许多实际的因素,例如生产者几乎不需要任何工作就可以生成一个元素并将其放入队列,如果工作者线程需要通过执行一些复杂的操作来生产和获取各个元素条目(通常就是这种情况),那么之前那种 CPU 空闲状态将消失,并且由于线程过多而导致的影响将变得非常明显。这个测试的主要目的是,测量生产者和消费者在通过有界缓存传递数据时,哪些约束条件将对整体吞吐量产生影响。
12.2.2 多种算法的比较
虽然 BoundedBuffer 是一种非常合理的实现,并且他的性能还不错,但还是没有 ArrayBlockingQueue 或 LinkedBlockingQueue 那样好(这也解释了为什么这种缓存算法没有被选入标准库)。JUC 中的算法已经通过类似的测试进行了调优,其性能也已经达到了我们已知的最佳状态。此外,这些算法还能提供更多的功能。BoundedBuffer 运行效率不高的主要原因是:在 put 和 get 方法中都包含多个可能发生竞争的操作,比如获取一个信号量、获取一个锁、释放信号量等。而在其他实现中,可能发生竞争的位置将少很多。
图 12-2 给出了一个在双核超线程机器上对这三个类的吞吐量的测试结果,在测试中使用了一个包含 256 个元素的缓存,以及相应版本的 TimedPutTakeTest。测试结果表明,LinkedBlockingQueue 的可伸缩性要高于 ArrayBlockingQueue。初看起来,这个结果有些奇怪:链表队列在每次插入元素时,都必须分配一个链表节点对象,这似乎比基于数组的队列执行了更多的工作。然而,虽然它拥有更好的内存分配和 GC 等开销,但与基于数组的队列相比,链表队列的 put 和 take 等方法支持并发性更高的访问,因为一些优化后的链表队列算法能够将队列头结点的更新操作与尾节点的更新操作分离开来。由于内存分配操作通常是线程本地的,因此如果算法能够通过多执行一些内存分配操作来降低竞争程度,那么这种算法通常具有更高的可伸缩性。(这种情况再次证明了,基于传统性能调优的直觉与提升可伸缩性的实际需求是背道而驰的)。
响应性衡量
到目前为止,我们的重点是对吞吐量的测试,这通常是并发程序中最重要的性能指标。但有时候,我们还需要知道某个动作经过长时间才能执行完成,这时就好测量服务时间的变化情况。而且,如果能获得更小的服务时间变动性,那么更长的平均服务时间是有意义的,“可预测性”同样是一个非常有价值的性能特征。通过测量变动性,使我们能回答一些关于服务质量的问题,如“操作在 100 毫秒内成功执行的百分比是多少?”
通过表示任务完成时间的直方图,最能看出服务时间的变动。服务时间变动的测量比平均值的测量要略困难一些——除了总共完成时间外,还要记录每个任务的完成时间。因为计数器的粒度通常是测量任务时间的一个主要因素(任务的执行时间可能小于或接近于最小“定时器计时单位”,这将影响测量结果的精确性),为了避免测量过程中的认为影响,我们可以测量一组 put 和 take 方法的运行时间。
图 12-3 给出在不同 TimedPutTakeTest 中每个任务的完成时间,其中使用了一个大小为 1000 的缓存,有 256 个并发任务,并且每个任务都将使用非公平的信号量(隐蔽栅栏,Shaded Bars)和公平信号量(开发栅栏,Open Bars)来迭代这 1000 个元素。非公平信号量完成时间的变动范围为 104 到 8714 毫秒,相差超过 80 倍。通过在同步控制中实现为更高的公平性,可以缩小这种变动范围,通过在 BoundedBuffer 中将信号量初始化为公平模式,可以很容易实现这个功能。如图 12-3 所示,这种方法能成功的降低变动性(现在的变动范围为 38194 到 38207 毫秒),然而,该方法会极大的降低吞吐量。(如果在运行时间较长的测试中执行更多种任务,那么吞吐量的下降程度可能更大。)
前面讨论过,如果缓存过小,那么将导致非常多的上下文切换次数,这即使是在非公平模式中也会导致很低的吞吐量,因此在几乎每个操作上都会执行上下文切换。为了说明非公平性开销主要是由于线程阻塞而造成的,我们可以将缓存大小设置为 1,然后重新运行这个测试,从而可以看出此时非公平信号量与公平信号量的执行性能基本相当。如图 12-4 所示,这种情况下公平性并不会使平均完成时间变长,或者使变动性变小。
因此,除非线程由于密集的同步需求而被持续的阻塞,否则非公平的信号量通常能实现更好的吞吐量,而公平的信号量则实现更低的变动性。因为这些结果之间的差异非常大,所以 Semaphore 要求客户选择针对哪一个特性进行优化。
12.3 避免性能测试的陷阱
理论上,编写性能测试程序是很容易的——找出一个典型应用场景,比那些一段程序并多次执行这种场景,同时统计程序的运行时间。但在实际情况中,你必须提防多种编码陷阱,它们会使性能测试变得毫无意义。
12.3.1 垃圾回收
垃圾回收的执行时机是无法预测的,因此在执行测试时,垃圾回收器可能在任何时刻运行。如果测试程序执行了 N 次迭代都没有触发垃圾回收操作,但在第 N+1 次迭代时触发了垃圾回收操作,那么即使运行次数相差不大,仍可能在最终测试的每次迭代时间上带来很大的(却是虚假的)影响。
有两种策略可以防止垃圾回收操作对测试结果产生偏差。第一种策略是,确保垃圾回收操作在测试运行的整个期间都不会执行(可以在启动 JVM 时指定 -vervose:gc
来判断是否执行了垃圾回收操作)。第二种策略是,确保垃圾回收操作在测试期间执行多次,这样测试程序就能充分反映出运行期间的内存分配与垃圾回收等开销。通常第二种策略更好,它要求更长的测试时间,斌跟那个更有可能反映实际环境下的性能。
在大多数采用生产者消费者设计的应用程序中,都会执行一定数量的内存分配与垃圾回收等操作——生产者分配新对象,然后被消费者使用并丢弃。如果将有界缓存测试运行足够长的时间,那么将引发多次垃圾回收,从而得到更精确的结果。
12.3.2 动态编译
与静态编译语言(如 C/C++)相比,编写动态编译语言(如 Java)的性能基准测试要困难的多。在 HotSpot JVM (以及其他现代 JVM)中将字节码的解释与动态编辑结合起来使用。当某个类第一次没加载时,JVM 会通过解释字节码的方式来执行它。在某个时刻,如果一个方法被运行的次数足够多,那么动态编译器会将它编译为机器代码,当编译完成之后,代码的执行方式将从即使执行变成直接执行。
这种编译的执行时机是无法预测的。只有在所有代码都编译完成以后,才应该统计测试的运行时间。测量采用解释执行的速度是没有意义的,因为大多数程序在运行足够长时间后,所有频繁执行的代码路径都会被编译。如果编译器可以在测试期间运行,那么将在两个方面对测试结果带来偏差:在编译过程中将消耗 CPU 资源,并且,如果在测量的代码中包含解释执行的代码,又包含编译执行的代码,那么通过测试这种混合代码得到的性能指标没有太大意义。图 12-5 给出了动态编译在测试结果上带来的偏差。这 3 条时间线表示执行了相同次数的迭代:时间线 A 表示所有代码都采用解释执行,时间线 B 表示在运行过程中间开始转向编译执行,而时间线 C 表示从较早时刻就尅是采用编译执行。编译执行的开始时刻会对每次操作的运行时间产生极大的影响。
基于各种原因,代码还可能被反编译(退回到解释执行)以及重新编译,比如加载了一个会使编译假设无效的类,或者在收集了足够的分析信息后,决定采用不同的优化措施来重新编译某条代码路径。
有种方式可以放置动态编译对测试结果产生偏差,即使使程序运行足够长的时间(至少数分钟),这样编译过程以及解释执行都只是总运行时间的很小一部分。另一种方法是使代码预先运行一段时间并且不测试这段时间内的代码性能,这样在开始计时前代码就已经被完全编译了。在 HotSpot 中,如果在运行时使用命令行选项 -xx:+PrintCompilation
,那么当动态编译运行时将输出一条信息,你可以通过这条信息来验证动态编译是在测试运行前将机执行,而不是在运行过程中执行。
通过在同一个 JVM 中将相同的测试运行多次,可以验证测试方法的有效性。第一组结果应该作为“预先执行”的结果而丢弃,如果在剩下的结果中仍然存在不一致的地方,那么就需要进一步对测试进行分析,从而找出结果不可重复的原因。
JVM 会使用不同的后台线程来执行辅助任务。当在单次运行中测试多个不相关的计算密集型操作时,一种好的做法是在不同操作的测试之间插入显式的暂停,从而使 JVM 能够与后台任务保持步调一致,同时将被测试任务的干扰将至最低。(然而,当测量多个相关操作时,例如将相同测试运行多次,如果按照这种方式来排除 JVM 后台任务,那么可能会得出不真实的结果)。
12.3.3 对代码路径不真实采样
运行时编译器根据收集到的信息对已编译的代码进行优化。JVM 可以与执行过程特定的信息来生成更优的代码,这意味着在编译某个程序的方法 M 时生成的代码,将可能与编译另一个不同程序中的方法 M 时生成的代码不同。在某些情况下,JVM 可能会基于一些只是临时有效的假设进行优化,并在这些假设失效时丢弃已编译的代码。
因此,测试程序不仅要大致判断某个典型应用程序的使用模式,还需要尽量覆盖在该应用程序中将要指定的代码路劲的集合。否则,动态编译器可能会针对一个单线程测试程序进行一些专门优化,但只要在真实的应用程序中略微包含一些并行,都是使这些优化不复存在。因此,即使你只是想测试单线程的性能,也应该将单线程的性能与多线程的性能测试结合在一起。(在 TimedPutTakeTest 中不会出现这个问题,因为即使在最小的测试用例中都使用了两个线程。)
12.3.4 不真实的竞争程度
并发的应用程序可以交替执行两种不同类型的工作:访问共享数据(例如从共享工作对垒中取出下一个任务)以及执行线程本地的计算(如,执行任务,并假设任务本身不会访问共享数据)。根据两种不同类型工作的相关程度,在应用程序中出现不同程度的竞争,并发现出不同的性能与可伸缩性。
如果有 N 个线程从共享对垒中获取任务并执行,并且这些任务都是计算密集型的且运行时间较长(但不会频繁的访问共享数据),那么在这种情况下几乎不存在竞争,吞吐量仅受限于 CPU 资源的可用性。然而,如果任务的生命周期很短,俺么在工作队列上将会存在验证的竞争,此时的吞吐量将受限于同步的开销。
要获得有实际意义的结果,在并发测试中应该尽量模拟典型应用程序中的线程本地计算量以及并发协调开销。如果在真实应用程序的各个任务中执行的工作,与测试程序中执行的工作截然不同,那么测试出的性能瓶颈位置将是不准确的。在 11.5 节看到过,对于基于锁的类,比如同步 Map 实现,在访问锁时是否存在高度的竞争将会对吞吐量产生巨大的影响。本节的测试除了不断访问 Map 之外没有执行其他操作,因此,虽然又两个线程,但在所有对 Map 的访问操作中都存在竞争。然而,如果应用程序在每次访问共享数据结构时执行大量的线程本地计算,那么可以极大的降低竞争程度并提供更好的性能。
从这个角度来看,TimedPutTakeTest 对于某些应用程序来说不是一种好模式。由于工作者线程没有执行太多的工作,因此吞吐量将主要受限于线程之间的协调开销,并且对所有通过有界缓存的生产者和消费者之间交换数据的应用程序来说,并不都是这种情况。
12.3.5 无用代码的消除
在编写优秀的基准测试程序时,一个需要面对的挑战是:优化编译器能找出并消除那些不会对输出结果产生任何影响的无用代码。由于基准测试通常不会执行任何计算,因此它们很容易在编译器的优化过程中被消除。在大多数情况下,编译器从程序中删除无用代码都是一种优化措施,但对于基准测试程序来说却是一个大问题,因为这将使得被测试的内容变得更少。如果幸运的话,编译器将删除整个程序中无用的代码,从而得到一份明显虚假的测试数据。但如果不幸运的话,编译器在消除无用代码后将提高程序的执行速度,从而是你做出错误的结论。
对于静态编译语言中的基准测试,编译器在消除无用代码时也存在问题,但要检测出编译器是否消除了测试基准是很容易的,因为可以通过机器码来发现是否缺失了部分程序。但在动态编译语言中,要获得这种信息则更加困难。
在 HotSpot 中,许多基准测试在 “-server” 模式下都能比在 “-client” 模式下运行的更好,这不仅是因为 “-server” 模式下的编译器能产生更有效的代码,而且这种模式更易于通过优化消除无用代码。然而,对于将执行一定操作的代码来说,无用代码消除优化却不会去掉它们。在多处理器系统上,无论在正式产品还是测试版本中,都应该选择 “-server” 模式而不是 “-client” 模式——只是在测试程序时必须保证它们不会受到无用代码消除带来的影响。
要编写有效的性能测试程序,就需要告诉编译器不要将基准测试当做无用代码优化掉,这就要求在程序中对每个计算结果都要通过某种方式使用,这种方式不需要同步或者大量的计算。
在 PutTakeTest 中,我们计算了在队列中被添加和删除的所有元素的校验和,但如果在程序中没有用到这个校验和,那么计算校验和的操作仍有可能被优化掉。幸好我们需要通过校验和来验证算法的正确性,然而你也可以通过输出这个值来确保它被用到。但是,你需要避免在运行测试时执行 IO 操作,以避免运行时间的测试结果产生偏差。
有一个简单的技巧可以避免运算被优化掉而又不引入过高的开销:即计算某个派生对象中域的散列值,并将它与一个任意值进行比较,比如 System.nanoTime 的当前值,如果二者碰巧相等,那么就输出一个无用且可以被忽略的消息:
if(foo.x.hashCode() == System.nanoTime())
System.out.print(" ");
这个比较操作很少很成功,即使成功了,它的唯一作用就是在输出中插入一个无害的空字符。(在 print 方法中把输出结果缓存起来,并直到调用 println 才真正执行输出操作,因此即使 hashCode 和 System.nanoTime 的返回值碰巧相等,也不会真正的执行 IO 操作)。
不仅每个计算结果都应该被使用,而且还应该是不可预测的。否则,一个智能的动态优化编译器将用预先计算的结果来代替计算过程。虽然在 PutTakeTest 的构造过程中解决了这个问题,但如果测试程序的输入参数为静态数据,那么都会受到这种优化措施的影响。
12.4 其他的测试方法
虽然我们希望一个测试程序能够“找出所有的错误”,但这是一个不切实际的目标。NASA 在测试中投入的资源比任何商业集团投入的都要多,但他们生产的代码仍然是存在缺陷的。在一些复杂的程序中,即使再多的测试也无法找出所有的错误。
测试的目标不是更多的发现错误,而是提高代码能按照预期方式工作的可信度。由于找出所有的错误是不现实的,所以质量保证(QA)目标应该是在给定的测试资源下实现最高的可信度。到目前为止,我们介绍了如何构造有限的单元测试和性能测试。在构建并发类能否表现出正确行为的可信度时,测试是一种非常重要的首选,但并不是唯一可用的 QA 方法。
还有其他一些 QA 方法,他们在找出某些类型的错误时非常有效,而在找出其他类型的错误时则相对低效。通过使用一些补充的测试方法,比如代码审查和静态分析等,可以获得比在使用任何单一方法更多的可信度。
12.4.1 代码审查
正如单元测试和压力测试在查找并发错误时是非常有效和重要的手段,多人参与的代码审查通常是不可替代的。虽然你可以在设计测试方法时使其能最大限度的发现安全性错误,以及反复的运行这些测试,但同样应该需要有代码编写者之外的其他人来仔细的审查并发代码。即使并发专家也有犯错的时候,花一定的时间由其他人来审查代码总是物有所值的。并发专家能够比大多数测试程序更有效的发现一些微秒的竞争问题。(此外,一些平台问题,比如 JVM 的实现细节或处理器的内存模型等,都会屏蔽一些只有在特定硬件或软件配置下才会出现的错误)。代码审查还有其他的好处,它不仅能发现错误,通常还能提高描述实现细节的注释质量,因此可以降低后期维护的成本和风险。
12.4.2 静态分析工具
在编写本书时,一些静态分析工具正在迅速的称为正式测试和代码审查的有效补充。静态代码分析是指在进行分析时不需要运行代码,而代码审查工具可以分类类中是否存在一些常见错误模式。在一些静态分析工具(如 FindBugs)中包含了许多错误模式检查器,能检查出多种常见的编码错误,其中许多错误都很容易在测试或代码审查中遗漏。
静态分析工具能生成一个告警列表,其中包含的警告信息必须通过手工方式进行检查,从而确定这些警告是否表示真正的错误。曾经有一些工具(如 lint)会产生很多伪警告信息,使得开发人员望而却步,但现在的一些工具已经在这方面有所改进,并且产生的伪警告很少。虽然静态分析工具仍然显得有些原始(尤其在它们与开发工具和开发生命周期的集成过程中),但却足以成为对测试过程的一种有效补充。
在编写本书时,FindBugs 包含的检查器中可以发现以下与并发相关的错误模式,而且一直在不断的增加新的检查器:
不一致的同步。许多对象遵循的同步策略是,使用对象的内置锁来保护所有的变量。如果某个域被频繁的访问,但并不是在每次访问时都持有相同的锁,那么这就可能表示没有一致的遵循这个策略。
分析工具必须对同步策略进行猜测,因为在 Java 类中并没有正式的同步规范。将来,如果 @GuardedBy
注解可以被标准化,那么核查工具就能解析这些注解,而无需猜测变量与锁之间的关系,从而提高分析质量。
调用 Thread.run。在 Thread 中实现了 Runnable,因此包含了一个 run 方法。然而,如果直接调用 Thread.run,那么通常是错误的,而应该调用 Thread.start。
未被释放的锁。与内置锁不同的是,执行控制流在退出显式锁的作用域时,通常不会自动释放它们。标准的做法是在一个 finally 块中释放显式锁,否则,当发生 Execution 事件时,锁仍然处于未被释放的状态。
空的同步块。虽然在 Java 内存模型中,空同步块具有一定的语义,但它们总是被不正确的使用,无论开发人员尝试通过空同步块来解决哪种问题,通常都存在一些更好的替代方案。
双重检查锁。双检锁所是一种错误的习惯用法,其初衷是为了降低延迟初始化过程中的同步开销,该用法在读取一个共享的可变域时缺少正确的同步。
在构造函数中启动线程。如果在构造函数中启动线程,那么将可能带来子类化问题,同时还会导致 this 引用从构造函数中溢出。
通知错误。notify 和 notifyAll 方法都表示,某个对象的可变状态可能以某个方式发生了变化,并且这种方式将在相关条件队列上被阻塞的线程恢复执行。只有在与条件队列相关的状态发生改变后,才应该调用这些方法。如果在一个同步块中条用了 notify 或 notifyAll,但没有修改任何状态,那么就可能出错。
条件等待中的错误。当在一个条件队列上等待时,Object.wait 和 Condition.await 方法应该在检查了状态谓词之后,在某个循环中调用,同时需要持有正确的锁。如果在调用 Object.wait 和 Condition.await 方法时没有持有锁,或者不在某个循环中,或者没有检查某些状态谓词,那么通常都是一个错误。
对 Lock 和 Condition 的无用。将 Lock 作为同步块来使用通常是一种错误的用法,正如调用 Condition.wait 而不是调用 await(后者能够通过测试被发现,因此在第一次调用它的将抛出 IllegalMonitorStateException)。
在休眠或等待的同时持有一个锁。如果在调用 Thread.sleep 时持有一个锁,那么将导致其他线程在很长一段时间内无法执行,因此可能导致严重的活跃性问题。如果在调用 Object.wait 或 Condition.await 时持有两个锁,那么也可能导致同样的问题。
自旋循环。如果在代码中除了通过自旋(忙于等待)来检查某个域的值以外不做任何事情,那么将浪费 CPU 时钟周期,并且如果这个域不是 volatile 类型,那么将无法保证这种自旋过程能结束。当等待某个状态转换发生时,闭锁或条件等待通常是一种更好的技术。
12.4.3 面向方面的测试技术
在编写本书时,面向方面编程(AOP)技术在并发领域的应用是非常有限的,因为大多数主流的 AOP 工具还不能支持在同步位置处的“切入点”。然而,AOP 还可以用来确保不变型条件不被破坏,获取与同步策略的某些方面保持一致。例如,在(Laddad, 2003)中给出了一个示例,其中使用了一个切面将所有对非线程安全的 Swing 方法调用都封装在一个断言中,该断言确保这个调用是在事件线程中执行的。由于不需要修改代码,因此该技术很容易使用,并且可以发现一些复杂的发布错误和线程封闭错误。
12.4.4 分析与检测工具
大多数商业分析工具都支持线程。这些工具在功能与执行效率上存在差异,但通常都能给出队程序内部的详细信息(虽然分析工具通常采用侵入式实现,因此可能对程序的执行时序和行为产生极大的影响)。大多数分析工作通常还为每个线程提供了一个时间线显示,并且用颜色来区分不同的线程状态(可运行、由于等待某个锁而阻塞、由于等待 IO 操作而阻塞等等)。从这些显示信息中可以看出程序对可用 CPU 资源的利用率,以及当程序表现糟糕时,该从何处查找原因。(许多分析工具还生成能够找出哪些锁导致了竞争,但在实际情况中,这些功能与人们期望的加锁行为分析能力之间存在一定的差距)。
内置的 JMX 代理同样提供了一些有限的功能来监测线程的行为。在 ThreadInfo 类中包含了线程的当前状态,并且当线程被阻塞时,它还会包含发生阻塞所在的锁或者条件对了。如果启动了“线程竞争监测”功能(在默认情况下为了不影响性能会被关闭),那么在 ThreadInfo 中还会包括线程由于等待一个锁或者通知而被阻塞的次数,以及等待的累积时间。
小结
要测试并发程序的正确性可能非常困难,因为并发程序的许多故障模式都是一些低概率事件,它们对于执行时序、负载情况以及其他难以重现的条件都非常敏感。而且,在测试程序中还会引入额外的同步或执行时序限制,这些因素都将掩盖被测试代码中的一些并发问题。要测试并发程序的性能同样非常困难,与使用静态编译语言(C/C++)编写的程序相比,用 Java 编写的的程序在测试起来会更加困难,因为动态编译、垃圾回收以及自动优化等操作都会影响与时间相关的测试结果。
要想尽可能的发现潜在的错误以及避免它们在正式正式产品中暴露出来,我们需要将传统的测试技术与代码审查和自动化分析工具结合起来,每项技术都可以找出其他方式忽略的问题。
1.13 - CH13-显式锁
在 Java 5.0 之前,在协调对共享对象访问时仅能使用 synchronized 和 volatile 机制。Java 5.0 增加了一种新的机制:ReentrantLock。与之前提到的机制相反,ReentrantLock 并非一种替代内置锁的方法,而是当内置锁机制不适用时,作为一种可供选择的高级功能。
13.1 Lock 与 ReentrantLock
在程序清单 13-1 给出的 Lock 接口中定义了一组抽象的加锁操作。有内置加锁机制不同的是,Lock 提供了一种无条件的、可轮询的、定时的、可中断的加锁操作,所有加锁和解锁的方法都是显式的。在 Lock 的实现中必须提供与内置锁相同的内存可见性语义,但在加锁语义、调度算法、顺序保证以及性能特性等方面可以有所不同。
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException;
void unlock();
Condition newCondition();
}
ReentrantLock 实现了 Lock 接口,并提供了与 synchronized 相同的互斥性和内存可见性。在获取 ReentrantLock 时,有着与进入同步代码块相同的内存语义,在释放 ReentrantLock 时,有着与退出同步代码块相同的内存语义。此外,与 synchronized 一样,ReentrantLock 还提供了可重入的加锁语义。ReentrantLock 支持在 Lock 接口中定义的所有加锁模式,并且与 synchronized 相比,它还为处理锁的不可用性问题提供更高的灵活性。
为什么要创造一种与内置锁如此类似的新加锁机制?在大多数情况下,内置锁能很好的工作,但在功能上存在一些局限性,例如,无法中断一个正在等待获取锁的线程,或者无法在请求获取一个锁时无限的等待下去。内置锁必须在获取该锁的代码块中释放,这就简化了编码工作,并且与异常处理操作实现了很好的交互,但却无法实现非阻塞结构的加锁规则。这些都是使用 synchronized 的原因,但在某些情况下,一种更灵活的加锁机制通常能提供更好的活跃性或性能。
程序清单 13-2 给出了 Lock 接口的标准使用形式。这种形式比使用内置锁复杂一些:必须在 finally 块中释放锁。否则,如果在被保护的代码中抛出了异常,那么这个锁永远都无法释放。当使用加锁时,还必须考虑更多的 try-catch 或 try-finally 代码块。(当使用某种形式的加锁时,包括内置锁,都应该考虑在出现异常时的情况。)
Lock lock = new ReentrantLock();
...
lock.lock();
try {
// update object state
// catch exceptions and restore invariants if necessary
} finally {
lock.unlock();
}
如果没有使用 finally 块来释放 Lock,那么就相当于启动了一个定时炸弹。当“炸弹爆炸”时,将会很难追踪到最初发生错误的位置,因为没有记录应该释放锁的位置和时间。这就是 ReentrantLock 无法完全替代 synchronized 的原因:它更加危险,因为当程序的执行控制离开被保护的代码时,不会自动清除锁。虽然在 finally 块中释放锁并不困难,但可能会被忘记。
13.1.1 轮询锁与定时锁
可定时的与可轮询的加锁模式是由 tryLock 方法实现的,与无条件的加锁模式相比,它具有更完善的错误恢复机制。在内置锁中,死锁是一个严重的问题,恢复程序的唯一方法是重新启动程序,而放置死锁的唯一方法就是在构造程序时避免出现不一致的锁顺序。可定时的与可轮询的锁提供了另一种选择:避免死锁的发生。
如果不能获得所有需要的锁,那么可以使用可定时的或可轮询的加锁方式,从而使你重新获得控制权,这会释放已经获得的锁,然后尝试重新获取所有锁(或者至少会将这次失败记录到日志,并采取其他措施)。程序清单 13-3 给出了另一种方法来解决 10.1.2 节中动态顺序死锁的问题:使用 tryLock 来获取两个锁,如果不能同时获得,那么就会退并重新尝试。在休眠时间中包括固定部分和随机部分,从而降低发生活锁的可能性。如果在指定时间内不能获得所有需要的锁,那么 transferMoney 将返回一个失败状态,从而使该操作平缓的失败。
public boolean transferMoney(Account fromAcct,
Account toAcct,
DollarAmount amount,
long timeout,
TimeUnit unit)
throws InsufficientFundsException, InterruptedException {
long fixedDelay = getFixedDelayComponentNanos(timeout, unit);
long randMod = getRandomDelayModulusNanos(timeout, unit);
long stopTime = System.nanoTime() + unit.toNanos(timeout);
while (true) {
if (fromAcct.lock.tryLock()) {
try {
if (toAcct.lock.tryLock()) {
try {
if (fromAcct.getBalance().compareTo(amount) < 0)
throw new InsufficientFundsException();
else {
fromAcct.debit(amount);
toAcct.credit(amount);
return true;
}
} finally {
toAcct.lock.unlock();
}
}
} finally {
fromAcct.lock.unlock();
}
}
if (System.nanoTime() < stopTime)
return false;
NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod);
}
}
在实现具有时间限制的操作时,定时锁同样非常有用。挡在带有时间限制的操作中调用了一个阻塞方法时,它能根据剩余时间来提供一个时限。如果操作不能在指定的时间内给出结果,那么就会使程序提前结束。当使用内置锁时,在开始请求锁后,这个操作将无法取消,因此内置锁很难实现带有时间限制的操作。
在程序清单 6-17 的旅游门户网站示例中,为询价的每个汽车租赁公司都创建了一个独立的任务。询价操作包含某种基于网络的请求机制,例如 Web 服务请求。但在询价操作中同样可能需要实现对紧缺资源的独占访问,例如通过向公司的直连通信线路。
9.5 节中介绍了确保对资源进行串行访问的方法:一个单线程的 Executor。另一种方法是使用一个独占锁来保护对资源的访问。程序清单 13-4 视图在 Lock 保护的共享通信线路上发送一条消息,如果不能在指定时间内完成,代码就会失败。定时的 tryLock 能够在这种带有时间限制的操作中实现独占加锁行为。
public boolean trySendOnSharedLine(String message,
long timeout,
TimeUnit unit)
throws InterruptedException { long nanosToLock = unit.toNanos(timeout) - estimatedNanosToSend(message);
if (!lock.tryLock(nanosToLock, NANOSECONDS))
return false;
try {
return sendOnSharedLine(message);
} finally {
lock.unlock();
}
}
13.1.2 可中断的加锁操作
正如定时加锁操作能在带有时间限制的操作中使用独占锁,可中断的锁获取操作同样能在可取消的操作中使用加锁。7.1.6 节给出了集中不能响应中断的机制,例如请求内置锁。这些不可中断的阻塞机制将使得实现可取消的任务变得复杂。lockInterruptibly 方法能够在获得锁的同时保持对中断的响应,并且由于它包含在 Lock 中,因此无需创建其他类型的不可中断阻塞机制。
可中断的锁获取操作的标准结构比普通的锁线程获取操作略微复杂一些,因为需要两个 try 块。(如果在可中断的锁获取操作中抛出了 InterruptedException,那么可以使用标准的 try-finally 加锁模式。)在程序清单 13-5 中使用了 lockInterruptily 来实现程序清单 13-4 中的 sendOnSharedLine,以便在一个可取消的任务中调用它。定时的 tryLock 同样能响应中断,因此当需要实现一个定时的和可中断的锁获取操作时,可以使用 tryLock 方法。
public boolean sendOnSharedLine(String message)
throws InterruptedException {
lock.lockInterruptibly();
try {
return cancellableSendOnSharedLine(message);
} finally {
lock.unlock();
}
}
private boolean cancellableSendOnSharedLine(String message) throws InterruptedException { ... }
13.1.3 非块结构的加锁
在内置锁中,锁的获取和释放等操作都是基于代码块的——释放锁的操作总是与获取锁的操作处于同一个代码块,而不考虑控制权如何退出该代码块。自动的锁释放操作简化了对程序的分析,避免了可能的编码错误,但有时候需要更灵活的加锁规则。
在第 11 章中,我们看到了通过降低锁的粒度可以提供代码的可伸缩性。锁分段技术在基于散列的容器中实现了不同的散列链,以便使用不同的锁。我们可以通过采用类似的原则来降低链表中所的粒度,即为每个链表节点使用一个独立的锁,使不同的线程能独立的对链表的不同部分进行操作。每个节点的锁将保护链接指针以及在该节点中存储的数据,因此当遍历或修改链表时,我们必须持有该节点上的这个锁,直到获得了下一个节点的锁,只有这样,才能是方法前一个节点上的锁。在 CPJ 2.5.1.4
中介绍了使用这项技术的一个示例,并称之为连锁式加锁(Hand-Over-Hand Locking)或者耦合锁(Lock Coupling)。
13.2 性能考虑因素
当把 ReentrantLock 添加到 Java 5.0 时,它能比内置锁提供更好的竞争性能。对于同步原语来说,竞争性是可伸缩性的关键要素:如果有越多的资源被耗费在锁的管理和调度上,那么应用程序得到的资源就越少。锁的实现方式越好,将需要越少的系统调用和上下文切换,并且在共享内存总线上的内存同步通信量也越少,而一些耗时的操作将占用应用程序的计算资源。
Java 6 使用了改进后的算法来管理内置锁,与在 ReentrantLock 中使用的算法类似,该算法有效的提供了可伸缩性。图 13-1 给出了在 Java 5 和 Java 6 版本中,内置锁与 ReentrantLock 之间的性能差异,测试程序的运行环境是 4 路的 Opteron 系统,操作系统为 Solaris。图中的曲线表示在某个 JVM 版本中 ReentrantLock 相对于内置锁的“加速比”。在 Java 5 中,ReentrantLock 能提供更高的吞吐量,但在 Java 6 中,二者非常接近。这里使用了与 11.5 节相同的测试程序,这次比较的是通过一个 HashMap 在由内置锁保护以及由 ReentrantLock 保护的情况下的吞吐量。
在 Java 5.0 中,当从单线程(无竞争)变化到多线程时,内置锁的性能急剧下降,而 ReentrantLock 的性能下降则更为平缓,因为它具有更好的可伸缩性。但在 Java 6 中,情况就完全不同了,内置锁的性能不会犹豫竞争而急剧下降,并且两者的可伸缩性也基本相当。
图 13-1 的曲线图告诉我们,像 “X 比 Y 更快”这样的表述大多是短暂的。性能和可伸缩性对于具体平台等因素较为敏感,例如 CPU、处理器数量、缓存带下以及 JVM 特性等,所有这些因素都可能会随着时间而发生变化。
性能是一个不断变化的指标。如果在昨天的测试基准中发现 X 比 Y 要快,那么在今天就可能已经过时了。
13.3 公平性
在 ReentrantLock 的构造函数中提供了两种公平性选择:创建一个非公平的锁或一个公平的锁。在公平的锁上,线程将按照它们发出请求的顺序来获得锁,但在非公平的锁上,则允许“插队”:当一个线程请求非公平的锁时,如果在发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有的等待线程并获得这个锁。(在 Semaphore 中同样可以选择采用公平或非公平的获取顺序)。非公平的 ReentrantLock 并不提倡插队行为,但无法防止某个线程在合适的时候进行插队。在公平的锁中,如果有另一个线程持有这个锁或者有其他线程在队列中等待这个锁,那么新发出请求的线程将被放入队列中。在非公平的锁中,只有当锁被某个线程持有时,新发出请求的线程才会被放入队列中。
我们为什么不希望所有的锁都是公平的?毕竟,公平是一种好的行为,而不公平是一种不好的行为,对不对?当执行加锁操作时,公平性将由于在挂起线程和恢复线程时存在的开销而极大的降低性能。在实际情况中,统计上的公平性保证——确保被阻塞的线程能最终获得锁,通常已经够用了,并且实际开销也小的多。有些算法依赖于公平的排队算法以确保他们的正确性,但这些算法并不常见。在大多数情况下,非公平锁的性能要高于公平锁的性能。
图 13-2 给出了 Map 的性能测试,并比较由公平的以及非公平的 ReentrantLock 包装的 HashMap 的性能,测试程序在一个 4 路的 Opteron 系统上运行,操作系统为 Solaris,在绘制结果曲线时采用了对数缩放比例。从图中可以看出,公平性把性能指标降低了约两个数量级。不必要的话,不要为公平性付出代价。
在竞争激烈的情况下,非公平锁的性能高于公平锁的性能的一个原因是:在恢复一个被挂起的线程与该线程真正开始运行之间存在着严重的延迟。假设线程 A 持有一个锁,并且线程 B 请求这个锁。由于该锁已经被线程 A 持有,因此线程 B 被挂起。当 A 释放时,B 将被唤醒,因此会再次尝试获取锁。与此同时,如果 C 也在请求该锁,那么 C 可能会在 B 被唤醒之前获得、使用以及释放这个锁。这样的情况是一种双赢的局面:B 获得锁的时刻并没有被推迟,C 更早的获得了锁,并且吞吐量也获得了提升。
当持有锁的时间相对较长,或者请求锁的平均时间间隔较长,那么应该使用公平锁。在这些情况下,插队带来的吞吐量提升(但锁处于可用状态时,线程却还处于被唤醒的过程中)则可能不会出现。
与默认的 ReentrantLock 一样,内置锁并不会提供确定的公平性保证,但在大多情况下,在锁实现统计上的公平性保证已经足够了。Java 语言规范并没有要求 JVM 以公平的方式来实现内置锁,而在各种 JVM 中也没有这么做。ReentrantLock 并没有进一步降低锁的公平性,而只是使一些已经存在的内容更明显。
在 synchronized 和 ReentrantLock 之间选择
ReentrantLock 在加锁和内存上提供的语义与内置锁相同,此外还提供了一些其他功能,包括定时的锁等待、可中断的锁等待、公平性,以及实现非块结构的加锁。ReentrantLock 在性能上似乎优于内置锁,其中在 Java 6 中略有胜出,而在 Java 5.0 中则是远远胜出。那么为什么不放弃 synchronized,并在所有新的并发代码中都使用 ReentrantLock?事实上有些作者已经建议这么做了,将 synchronized 作为一种“遗留”结构,但这会将好事情变坏。
与显式锁相比,内置锁仍然具有很大的优势。内置锁为许多开发人员所熟悉,并且间接紧凑,而且在很多现有的程序中都使用了内置锁——如果将这两种机制混用,那么不仅会容易令人迷惑,也容易发生错误。ReentrantLock 的危险性比同步机制要高,如果忘记在 finally 块中调用 unlock,那么虽然代码表面上能继续正常运行,但实际上已经买下了一颗定时炸弹,并很有可能伤及其他代码。仅当内置锁不能满足需求时,才可以考虑使用 ReentrantLock。
在一些内置锁无法满足需求的情况下,ReentrantLock 可以作为一种高级工具。当需要一些高级功能时才应该使用 ReentrantLock,这些功能包括:可定时的、可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用内置锁。
在 Java 5.0 中,内置锁与 ReentrantLock 相比还有一个优点:在线程转储中能够给出在哪些调用帧中获得了哪些锁,并能够检测和识别发生死锁的线程。JVM 并不知道哪些线程持有 ReentrantLock,因此咋调试使用 ReentrantLock 的线程问题时,将起不到帮助作用。Java 6 解决了这个问题,它提供了一个管理和调试接口,锁可以通过该接口进行注册,从而与 ReentrantLock 相关的加锁信息就能出现在线程转储中,并通过其他的管理接口和调试接口来访问。与 synchronized 相比,这些调试消息是一种重要的优势,即便它们大部分都是临时性消息,线程转储中的加锁能够给很多程序员的带来帮助。ReentrantLock 的非块结构特性仍然意味着,获取锁的操作不能与特定的帧栈关联起来,而内置锁却可以。
未来可能会提升 synchronized 而不是 ReentrantLock 的性能。因为 synchronized 是 JVM 内置的属性,它能执行一些优化,例如对线程封闭的锁对象的锁执行消除优化,通过增加锁的粒度来消除内置锁的同步,而如果通过基于类库的锁来实现这些功能,则可能性不大。除非将来需要在 Java 5 上部署应用程序,并且在该平台上确实需要 ReentrantLock 包含的伸缩性优势,否则就性能方面来说,应该选择内置锁而不是 ReentrantLock。
13.5 读写锁
ReentrantLock 实现了一种标准的互斥锁:每次最多有一个线程能持有 ReentrantLock。但对于维护数据的完整性来说,互斥通常是一种过于强硬的加锁规则,因此也就不必要的限制了并发性。互斥是一种保守的加锁策略,虽然可以避免“写写”冲突和“读写”冲突,但同时也避免了“读读”冲突。在许多情况下,数据结构上的操作大多是“读”操作——虽然他们也是可变的并且在某些情况下会被修改,但其中大多数访问操作都是读操作。此时,如果能够放宽加锁需求,允许多个执行读操作的线程同时访问数据结构,那么将提升程序的性能。只要每个线程都能确保读取到最新的数据,并且在读取数据时不会有其他的线程修改数据,那么就不会出现问题。在这种情况下就可以使用读写锁:一个资源可以被多个读操作同时访问,或者被一个写操作独占访问,但读写不能同时进行。
在程序清单 13-6 的 ReadWritLock 中暴露了两个 Lock 对象,其中一个用于读操作,另一个用于写操作。要读取由 ReadWriteLock 保护的数据,必须首先获得读取锁。尽管这两个锁看上去彼此独立,但读锁和写锁这是整个读写锁的不同视图。
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
在读写锁实现的加锁策略中,允许多个读操作同时进行,但每次只允许一个写操作。与 Lock 一样,ReadWriteLock 可以采用多种不同的实现方式,这些方式在性能、调度保证、获取优先性、公平性以及加锁语义等方面可能有所不同。
读写锁是一种性能优化措施,在一些特定的情况下能实现更高的并发性。在实际情况中,对于在多处理器系统上被频繁读取的数据结构,读写锁能够提高性能。而在其他情况下,读写锁的性能比独占锁的性能要略差一些,这是因为他们的复杂性很高。如果要判断在某种情况下使用读写锁是否会带来性能提升,最好对程序进行分析。由于 ReadWriteLock 使用 Lock 来实现锁的读写部分,因此如果分析结果表明读写锁没有提高性能,那么可以很容易的将读写锁换位独占锁。
在读锁和写锁之间的交互可以采用多种实现方式。ReadWriteLock 中的一些可选实现包括:
释放优先。当一个写入操作释放写入锁时,并且队列中同时存在读线程和写线程,那么应该优先选择读线程、写线程、还是最先发出请求的线程?
读线程插队。如果锁由读线程持有,但有写线程正在等待,那么新到达的读线程能否立即获得访问权,还是应该在写线程后面等待?如果允许读线程插队到写线程之前,呢么将提高并发性,但却可能造成写线程发生饥饿问题。
重入性。读锁和写锁是否是可重入的?
降级。如果一个线程持有写锁,那么它能否在不释放该锁的情况下获得读锁?这可能会使得写锁被降级为读锁,同时不允许其他写线程修改被保护的资源。
升级。读锁能否有限于其他正在等待的读、写线程而升级为一个写入锁?在大多数的读-写锁实现中并不支持升级,因为如果没有显式的升级操作,那么很容易造成死锁。(如果两个读线程视图同时升级为写入锁,那么二者都不会释放读取所)。
ReentrantReadWriteLock 为这两种锁都提供了可重入的加锁语义。与 ReentrantLock 类似,ReentrantReadWriteLock 在构造时可以选择公平性。在公平的锁时等待时间最长的线程将优先获得锁。如果这个锁由度线程持有,而另一个线程请求写入锁,那么其他读线程都不能获得读锁,直到写线程使用完并释放了写锁。在非公平的锁中,线程获得访问许可的顺序是不确定的。写线程降级为读线程是可以的,但从读线程升级为写线程是不可以的(这样做会导致死锁)。
与 ReentrantLock 类似的是:ReentrantReadWriteLock 中的写锁是独占的,并且只能由获得该锁的线程来释放。在 Java 5 中,读锁的行为更类似于一个 Semaphore 而不是锁,它只维护活跃的读线程的数量,而不考虑它们的标识。在 Java 6 中修改了这个行为:将会记录哪些线程已经获得了读锁。
当锁的持有时间较长且大部分操作都不会修改被保护的资源时,那么读写锁能提高并发性。在程序清单 13-7 的 ReadWriteMap 中使用了 ReentrantReadWriteLock 来包装 Map,从而使它能在读线程之间被安全的共享,并且仍然能够避免“读写”或“写写”冲突。在实现中,ConcurrentHashMap 的性能已经很好了,因此如果只是需要一个并发的基于散列的映射,那么就可以使用 ConcurrentHashMap 来代替这种方法,但如果需要对另一种 Map 实现(如 LinkedHashMap)提供并发性更高的访问,那么可以使用这种技术。
public class ReadWriteMap<K,V> {
private final Map<K,V> map;
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock r = lock.readLock();
private final Lock w = lock.writeLock();
public ReadWriteMap(Map<K,V> map) {
this.map = map;
}
public V put(K key, V value) {
w.lock();
try {
return map.put(key, value);
} finally {
w.unlock();
}
}
// Do the same for remove(), putAll(), clear()
public V get(Object key) {
r.lock();
try {
return map.get(key);
} finally {
r.unlock();
}
}
// Do the same for other read-only Map methods
}
图 13-3 给出了分别用 ReentrantLock 和 ReadWriteLock 来封装 ArrayList 的吞吐量比较,测试程序在 4 路的 Opteron 系统上运行,操作系统为 Solaris。这里使用的测试程序与本书使用的 Map 性能测试基本类似——每个操作都随机选择一个值并在容器中查找该值,并且只有少量的操作会修改这个容器中的内容。
小结
与内置锁相比,显式 Lock 提供了一些扩展功能,在处理锁的不可用性方面有着更高的灵活性,并且对队列行有着更好的控制。但 ReentrantLock 不能完全替代内置锁,只有在内置锁无法满足需求时,才应该使用它。
读写锁允许多个读线程并发的访问被保护的对象,当访问以读取操作为主的数据结构时,它能提高程序的可伸缩性。
1.14 - CH14-自定义扩展
类库中包含了很多存在状态依赖的类,比如 FutureTask、Semaphore、BlockingQueue 等。在这些类的操作中有着基于状态的前提条件,比如,不能从一个空队列中删除元素,或者获取一个尚未结束的任务的计算结果,在这些操作可以执行之前,必须等到对了进入“非空”状态,或者任务进入“已完成”状态。
创建状态依赖类的最简单方式通常是在类库中现有的状态依赖类的基础上进行构建。比如,在第 8 章的 ValueLatch 中就采用了这种方法,其中使用了 CountDownLatch 来提供所需的阻塞行为。但如果类库中没有提供所需的功能,那么还可以使用 Java 语言和类库提供的底层机制来构造自己的同步机制,包括内置的条件队列、显式的 Condition 对象以及 AbstractQueuedSynchronizer 框架。本章将介绍实现状态依赖性的各种选择,以及在使用平台提供的状态依赖性机制时需要遵守的各项规则。
14.1 状态依赖性的管理
在单线程程序中调用一个方法时,如果某个基于状态的前置条件未得到满足(比如“连接池必须为空”),那么这个条件将永远无法为真。因此,在编写串行程序中的类时,要使得这些类在他们的前提条件未满足时就失败。但在并发程序中,基于状态的条件可能会由于其他线程的操作而改变:一个资源池可能在几条指令之前还是空的,但稍后却被填充,因为另一个线程可能会返回一个元素到资源池。对于并发对象上依赖状态的方法,虽然有时候在前提条件不满足的情况下也不会失败,但通常有一种更好的选择,即等待前提提交转变为真。
依赖状态的操作可以一直阻塞到可以继续执行,这比使它们先失败再实现起来要更为方便且不易出错。内置的条件对了可以使线程一直阻塞,直到对象进入某个进程可以继续执行的状态,并且当被阻塞的线程可以执行时再唤醒它们。我们将在 14.2 节介绍条件队列的详细内容,但为了突出高效的条件等待机制的价值,我们将首先介绍如何通过轮询与休眠等方式来(勉强的)解决状态依赖问题。
可阻塞的状态依赖操作的形式如程序清单 14-1 所示。这种加锁模式有些不同寻常,因为锁是在操作的执行过程中被释放并重新获取的。构成前提条件的状态标量必须由对象的锁来保护,从而使它们在检查前提条件的同时保持不变。如果前提条件尚未满足,就必须释放锁,以便其他线程可以修改对象的状态,否则,前提条件就永远无法被转变为真。在再次检查前提条件之前,又必须重新获得锁。
void blockingAction() throws InterruptedException {
acquire lock on object state
while (precondition does not hold) {
release lock
wait until precondition might hold
optionally fail if interrupted or timeout expires
reacquire lock
}
perform action
}
在生成消费设计中经常会使用像 ArrayBlockingQueue 这样的有界缓存。在有界缓存提供的 put 和 take 操作中都包含一个前提条件:不能从空缓存中获取元素,也不能讲元素放入已满的缓存中。当前提条件未满足时,依赖状态的操作可以抛出一个异常或者返回一个错误状态(使其成为调用者的一个问题),也可以保持阻塞直到对象进入正确的状态。
接下来介绍有界缓存的几种实现,其中将采用不同的方法来处理前提条件失败的问题。在每种实现中都扩展了程序清单 14-2 中的 BaseBoundedBuffer,在这个类中实现了一个基于数组的循环缓存,其中各个缓存状态变量均由缓存的内置锁来保护。它还提供了同步的 doPut 和 doTake 方法,并在子类中通过这些方法来实现 put 和 take 操作,底层的状态将对子类隐藏。
@ThreadSafe public abstract class BaseBoundedBuffer<V> {
@GuardedBy("this") private final V[] buf;
@GuardedBy("this") private int tail;
@GuardedBy("this") private int head;
@GuardedBy("this") private int count;
protected BaseBoundedBuffer(int capacity) {
this.buf = (V[]) new Object[capacity];
}
protected synchronized final void doPut(V v) {
buf[tail] = v;
if (++tail == buf.length)
tail = 0;
++count;
}
protected synchronized final V doTake() {
V v = buf[head];
buf[head] = null;
if (++head == buf.length)
head = 0;
--count;
return v;
}
public synchronized final boolean isFull() {
return count == buf.length;
}
public synchronized final boolean isEmpty() {
return count == 0;
}
}
14.1.1 示例:将前提条件的失败传递给调用者
程序清单 14-3 的 GrumpyBoundedBuffer 是第一个简单的有界缓存实现。put 和 take 方法都进行了同步以确保实现对缓存状态的独占访问,因为这两个方法都进行了同步以确保实现对缓存状态的独占访问,因为这两个方法在访问缓存时都采用“先检查再运行”的逻辑策略。
@ThreadSafe public class GrumpyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
public GrumpyBoundedBuffer(int size) { super(size); }
public synchronized void put(V v) throws BufferFullException {
if (isFull())
throw new BufferFullException();
doPut(v);
}
public synchronized V take() throws BufferEmptyException {
if (isEmpty())
throw new BufferEmptyException();
return doTake();
}
}
尽管这种方法实现起来很简单,但使用起来却并非如此。异常应该用于真正发生异常条件的场景。“缓存已满”并不不是有界缓存的一个异常条件,就像“红灯”并不表示交通信号灯出现了异常。在实现缓存时得到的简化(直接抛出异常,由使调用者管理状态依赖性)并不能抵消在使用时存在的复杂性,因为现在调用者必须做好捕获异常的准备,并且在每次缓存操作时都需要重试。程序清单 14-4 给出了对 take 的调用——并不是很漂亮,尤其是当程序中很多地方都要调用 put 和 take 方法时。
while(true) {
try {
V item = buffer.take();
// 对 item 执行一些操作
break;
} catch(BufferEmptyException e) {
Thread.sleep(SLEEP_GRANULARITY);
}
}
这种方法的一种变化形式是,当缓存处于一种错误的状态时返回一个错误值。这是一种改进,因为并没有放弃异常机制,抛出的异常意味着“对不起,请再试一次”。但这种方法并没有解决根本问题:调用者需要自行处理前置条件失败的情况。
程序清单 14-4 中的客户端代码并非实现重试的唯一方式。调用者可以不用进入休眠状态,而直接重新调用 take 方法,这种方式被称为忙等待或自旋等待。如果缓存的状态在很长一段时间内都不会发生变化,那么使用这种方式将会消耗大量的 CPU 时间。但是,调用者也可以进入休眠状态来避免消耗过多的 CPU 时间,但如果缓存的状态在刚调用完 sleep 就立即发生了变化,那么将不必要的休眠一段时间。因此,客户端代码必须在二者之间进行选择:要么容忍自旋导致的 CPU 时钟周期浪费,要么容忍由于休眠而导致的低响应性。(除了忙等待与休眠之外,还有一种选择是调用 Thread.yield,这相当于给调度器一个提示:现在需要让出一定的 CPU 时间给别的线程运行。假设正在等待另一个线程执行工作,那么如果选择让出处理器而不是消耗完整个 CPU 调度时间片,那么可以使整体的执行过程变快。)
14.1.2 示例:通过轮询与休眠来实现简单的阻塞
程序清单 14-5 中的 SleepyBoundedBuffer 尝试通过 put 和 take 方法来实现一种简单的“轮询与休眠”重试机制,从而使调用者无需在每次调用时都实现重试逻辑。如果缓存为空,那么 take 将休眠直到另一个线程向缓存中放入数据;如果缓存是满的,那么 put 将休眠直到另一个线程从缓存中取出一些数据,以便有空间容纳新的数据。这种方法将前置条件的管理操作封装了起来,并简化了对缓存的作用——这正是朝着正确的改进方向迈进了一步。
@ThreadSafe
public class SleepyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
public SleepyBoundedBuffer(int size) { super(size); }
public void put(V v) throws InterruptedException {
while (true) {
synchronized (this) {
if (!isFull()) {
doPut(v);
return;
}
}
Thread.sleep(SLEEP_GRANULARITY);
}
}
public V take() throws InterruptedException {
while (true) {
synchronized (this) {
if (!isEmpty())
return doTake();
}
Thread.sleep(SLEEP_GRANULARITY);
}
}
}
SleepyBoundedBuffer 的实现远比之前的实现要复杂。缓存代码必须在持有缓存锁的时候才能测试相应的状态条件,因为表示状态条件的变量是由缓存锁保护的。如果检查失败,那么当前执行的线程首先释放锁并休眠一段时间,从而使其他线程能够访问缓存。当线程醒来时,它将重新请求锁并再次尝试执行操作,因而线程将反复的在休眠以及测试条件等过程之间进行切换,直到可以执行操作为止。
从调用者的角度来看,这种方法能很好的运行,如果某个操作可以执行,那么就立即执行,否则就阻塞,调用者无需处理失败和重试。要选择合适的休眠时间间隔,就需要在响应性与 CPU 使用率之间进行权衡。休眠的间隔越小,响应性就越高,但消耗的 CPU 资源也越高。图 14-1 给出了休眠间隔对响应性的影响:在缓存中出现可用空间的时刻与线程醒来并再次执行检查的时刻之间可能存在延迟。
SleepyBoundedBuffer 给调用者提出了一个新的需求:处理中断异常。当一个方法由于等待某个条件为真而阻塞时,需要提供一种取消机制。与大多数具备良好行为的阻塞库方法一样,SleepyBoundedBuffer 通过中断来支持取消,如果该方法被中断,那么将提前返回并抛出中断异常。
这种通过轮询与休眠来实现阻塞操作的过程需要付出大量的努力。如果存在某种挂起线程的方法,并且这种方法能够确保当某个条件为真时线程会立即醒来,那么将极大的简化实现工作。这正是条件队列实现的功能。
14.1.3 条件队列
条件队列就好像烤面包机中通知“面包已烤好”的铃声。如果你注意听着铃声,那么当面包烤好后可以立即得到通知,然后放下手头的事情(或者先把手头的事情做完,例如先看完报纸)开始品尝面包。如果没有听见铃声(可能出去拿报纸了),那么会错过通知消息,但回到厨房时还可以观察烤面包机的状态,如果已经烤好,那么就取出面包,如果尚未烤好,就再次开始留意铃声。
“条件队列”这个名字的来源是:它使得一组线程(称为等待线程集合)能够通过某种方式来等待特定的条件变为真。传统队列的元素是一个个数据,与之不同的是,条件队列中的元素是一个个正在等待相关条件的线程。
正如每个 Java 对象都可以作为一个锁,每个对象同样可以作为一个条件队列,并且 Object 中的 wait、notify、notifyAll 方法就构成了内部条件队列的 API。对象的内置锁与其内部条件队列是相互关联的,要调用对象 X 中条件队列的任何一个方法,必须持有对象 X 上的锁。这是因为“等待由状态构成的条件”与“维护状态一致性”这两种机制必须紧密的被绑定在一起:只有能对状态进行检查时,才能在某个条件上等待,并且只有能修改状态时,才能从条件等待中释放另一个线程。
Object.wait 会自动释放锁,并请求操作系统挂起当前线程,从而使其他线程能够获得这个锁并修改对象的状态。当被挂起的线程醒来时,它将在返回之前重新获取锁。从直观上来理解,调用 wait 意味着“我要去休息了”,但当发生特定的事情时唤醒为,而调用通知方法就意味着“特定的事情发生了”。
在程序清单 14-6 中的 BoundedBuffer 使用了 wait 和 notifyAll 来实现一个有界缓存。这比使用“休眠”的有界缓存更加简单,并且更加高效(当缓存状态没有发生变化时,线程醒来的次数将更少),响应性也更高(当发生特定状态变化时将立即醒来)。这是一个较大的改进,但要注意:与使用“休眠”的有界缓存相比,条件队列并没有改变原来的语义。它只是在多个方面进行了优化:CPU 效率、上下文切换开销和响应性等。如果某个功能无法通过“轮询与休眠”来实现,那么使用条件队列也无法实现,但条件队列使得在表达和管理状态依赖性时更加简单和高效。
@ThreadSafe
public class BoundedBuffer<V> extends BaseBoundedBuffer<V> {
// CONDITION PREDICATE: not-full (!isFull())
// CONDITION PREDICATE: not-empty (!isEmpty())
public BoundedBuffer(int size) { super(size); }
// BLOCKS-UNTIL: not-full
public synchronized void put(V v) throws InterruptedException {
while (isFull())
wait();
doPut(v);
notifyAll();
}
// BLOCKS-UNTIL: not-empty
public synchronized V take() throws InterruptedException {
while (isEmpty())
wait();
V v = doTake();
notifyAll();
return v;
}
}
最终,BoundedBuffer 变得足够好了,不仅简单易用,而且实现了明确的状态依赖性管理。在产品的正式版本中还应该包括限时版本的 put 和 take,这样当阻塞操作不能在预期的时间内完成时,可以因超时而返回。通过使用定时版本的 Object.wait,可以很容易实现这些方法。
14.2 使用条件队列
条件队列使构建高效及高可响应性的状态依赖类变得更容易,但同时也很容易被误用。虽然很多规则都能确保正确的使用条件队列,但在编译器或系统平台上却并没有强制要求遵循这些规则。(这也是为什么要尽量基于 LinkedBlockingQueue、Latch、Semaphore 和 FutureTask 等类来构造程序的原因之一,如果能避免使用条件队列,那么实现起来将容易很多。)
14.2.1 条件谓词
要想正确的使用条件队列,关键是找出对象在哪个条件谓词上等待。条件谓词将在等待与通知等过程中引起很多困惑,因为在 API 中没有对条件谓词进行实例化的方法,并且在 Java 语言规范或 JVM 实现中也没有任何信息可以确保正确的使用它们。事实上,在 Java 语言规范或 Javadoc 中根本没有直接提到过它。但如果没有条件谓词,条件等待机制将无法发挥作用。
条件谓词是使某个操作成为状态依赖操作的前提条件。在有界缓存中,只有当缓存不为空时,take 方法才能执行,否则必须等待。对 take 方法来说,它的条件谓词就是“缓存不为空”,take 方法必须在执行之前必须首先测试条件谓词。同样,put 方法的条件谓词是“缓存不满”。条件谓词是由类中各个状态变量构成的表达式。BaseBoundedBuffer 在测试“缓存不为空”时将把 count 与 0 进行比较,在测试“缓存不满”时将 count 与缓存的大小进行比较。
将与条件队列相关联的条件谓词以及在这些条件谓词上等待的操作都写入文档。
在条件等待中存在一种重要的三元关系:加锁、wait 方法、和一个条件谓词。在条件谓词中包含多个状态变量,而状态变量由一个锁来保护,因此在测试条件谓词之前必须先持有这个锁。锁对象与条件队列对象(即调用 wait 和 notify 等方法所在的对象)必须是同一个对象。
在 BoundedBuffer 中,缓存的状态由缓存锁保护,并且缓存对象被用作条件队列。take 方法将获取请求缓存锁,然后对条件谓词(缓存非空)进行测试。如果缓存非空,那么它会移除一个原色,之所以能这么做,是因为 take 此时仍然持有保护缓存状态的锁。
如果条件谓词不为真(缓存为空),那么 take 方法必须等待直到另一个线程在缓存中放入一个对象。take 将在缓存的内置条件队列上条用 wait 方法,这需要持有条件队列对象上的锁。这是一种严谨的设计,因为 take 方法已经持有在测试条件谓词时(并且如果条件谓词为真,那么在同一个原子操作中修改缓存的状态)需要的锁。wait 方法将释放锁,阻塞当前线程,并等待直到超时,然后线程被中断或者通过一个通知被唤醒。在唤醒进程后,wait 在返回前还要重新获取锁。当线程从 wait 方法中被唤醒时,它在重新请求锁时不具有任何特殊的优先级,而要与任何其他尝试进入同步代码块的线程一起正常的在锁上进行竞争。
每一次 wait 调用都会隐式的与特定的条件谓词关联起来。当调用某个特定条件谓词的 wait 时,调用者必须已经持有与条件队列相关的锁,并且这个锁必须保护着构成条件谓词的状态变量。
14.2.2 过早唤醒
虽然在锁、条件谓词和条件队列之间的三元关系并不复杂,但 wait 方法的返回并不一定意味着线程正在等待的条件谓词已经变味真了。
内置条件队列可以与多个条件谓词一起使用。当一个线程由于调用 notifyAll 而醒来时,并不意味着该线程正在等待的条件谓词已经为真了。(这就像烤面包机和咖啡机公用一个铃声,而响铃后,你必须检查是哪个设备发出的铃声)。另外,wait 方法还可以“假装”返回,而不是由于某个线程条用了 notify。
当执行控制重新进入调用 wait 的代码时,它已经重新获取与跳进队列相关的锁。现在条件谓词是不是已经为真了呢?或许,在发出通知的线程调用 notifyAll 时,条件谓词可能已经变为真,但在重新获取锁时将再次变为假。在线程被唤醒到 wait 重新获取锁的这段时间内,可能有其他线程已经获取过这个锁,并修改了对象的状态。或者,条件谓词从调用 wait 起根本就没有变为真。你并不知道另一个线程为什么会调用 notifyAll 或 notify,也许是因为与同一个条件队列相关的另一个条件谓词变为了真。“一个条件队列与多个条件谓词相关”是一种很常见的情况——在 BoundedBuffer 中使用的条件队列与“非满”和“非空”两个条件谓词相关。
基于所有这些原因,每当线程从 wait 中醒来时,都必须再次测试条件谓词,如果条件谓词不为真,那么就继续等待(或者失败)。由于线程在条件谓词不为真的情况下也可以反复的醒来,因此必须在一个循环中调用 wait,并在每次迭代中都测试条件谓词。程序清单 14-7 给出了条件等待的标准形式。
void stateDependentMethod() throws InterruptedException {
// condition predicate must be guarded by lock
synchronized(lock) {
while (!conditionPredicate())
lock.wait();
// object is now in desired state
}
}
当使用条件等待时(如 Object.wait 或 Condition.await):
- 通常都有一个条件谓词——包括一些对象状态的测试,线程在执行前必须首先通过这些测试。
- 在调用 wait 之前测试条件谓词,并且从 wait 中返回时再次进行测试。
- 在一个循环中调用 wait。
- 确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量。
- 当调用 wait、notify、notifyAll 方法时,一定要持有与条件队列相关的锁。
- 在检查条件谓词之后又以及开始执行相应的操作之前,不要释放锁。
14.2.3 丢失的信号
第 10 章曾经介讨论过活跃性故障,比如死锁和活锁。另一种形式的活跃性故障是丢失的信号。指的是:线程必须等待一个已经为真的条件,但在开始等待之前没有检查条件谓词。现在,线程将等待一个已经发出的事件。这就好比在启动了烤面包机之后出去拿报纸,当你在屋外时烤面包机的铃声响了,但你没有听到,因此还会坐在厨房的桌子前等待烤面包机的铃声。你可能会等待很长时间。通知并不像你涂在面包上的果酱,它没有“黏附性”。如果线程 A 通知了一个条件队列,而线程 B 随后在这个条件队列上等待,那么线程 B 将不会立即醒来,而是需要另一个通知来唤醒它。像上述程序清单中警示之类的编码错误(比如,没有在调用 wait 之前检测条件谓词)就会导致信号的丢失。如果按照程序清单 14-7 的方式来设计条件等待,那么就不会发生信号丢失事件。
14.2.4 通知
到目前为止,我们介绍了条件等待的前一半内容:等待。另一半内容则是通知。在有界缓存中,如果缓存为空,那么在调用 take 时将阻塞。在缓存变为非空时,为了使 take 解除阻塞,必须确保在每条使缓存变为非空的代码路径中发出一个通知。在 BoundedBuffer 中,只有一条代码路径,即在 put 方法之后。因此,put 在成功的将一个元素添加到缓存后,将调用 notifyAll。同样,take 在移除一个元素之后将调用 notifyAll,向任何正在等待“非满”条件的线程发出通知:缓存现在有可用的空间了。
每当在等待一个条件时,一定要确保在条件谓词变为真时通过某种方式发出通知。
在条件队列 API 中有两个发出通知的方法,即 notifyAll 和 notify。无论调用哪一个,都必须持有与条件队列对象相关联的锁。在调用 notify 时,JVM 会从这个条件对了上等待的多个线程中选择一个来唤醒,而调用 notifyAll 则会唤醒所有在这个条件队列上等待的线程。由于在调用 notify 和 notifyAll 时必须持有条件队列对象的锁,而如果这些等待中的线程此时不能重新获得锁,那么无法从 wait 返回,因此发出通知的线程应该尽快的释放锁,从而确保正在等待的线程尽可能快的解除阻塞。
由于多个线程可以基于不同的条件谓词在同一个条件队列上等待,因此如果使用 notify 而不是 notifyAll,那么将是一种危险的动作,因为单一的通知很容易导致类似信号丢失的问题。
在 BoundedBuffer 中很好的说明了为什么在大多数情况下应该优先使用 notifyAll 而不是单个的 notify。这里的条件队列用于两个不同的条件谓词:“非空”和“非满”。假设线程 A 在条件队列上等待条件谓词 PA,同时线程 B 在同一个条件队列上等待条件谓词 PB。现在,假设 PB 变为真,并且线程 C 执行了一个 notify:JVM 将从它拥有的众多线程中选择一个并唤醒。如果选择了线程 A,那么 A 将被唤醒,并且看到 PA 尚未变为真,因此将继续等待。同时,线程 B 本可以开始执行,却没有被唤醒。这并不是严格意义上的“丢失信号”,而更像是一种“被劫持的”信号,但导致的问题是相同的:线程正在等待一个已经(或者本应该)发生过的信号。
只有同时满足以下两个条件时,才能用单一的 notify 而不是 notifyAll:
- 所有等待线程的类型都相同。只有一个条件谓词与条件队列相关,并且每个线程在从 wait 返回后将执行相同的操作。
- 单进单出。在条件变量上的每次通知,最多只能唤醒一个线程来执行。
BoundedBuffer 满足“单进单出”的条件,但不满足“所有等待线程的类型都相同”,因此正在等待的线程可能是在等待“非满”,也可能是在等待“非空”。例如第 5 章的 TestHarness 中使用的“开始阀门”闭锁(单个事件释放一组线程)并不满足“单进单出”的需求,因为这个“开始阀门”将使得多个线程开始执行。
由于大多数类并不满足这些需求,因此普遍认可的做法是优先使用 notifyAll 而不是 notify。虽然 notifyAll 可能比 notify 更低效,但却更容易确保类的行为是正确的。
有些开发人员并不赞同这种“普遍认可的做法”。当只有一个线程可以执行时,如果使用 notifyAll,那么将是低效的,这种低效情况带来的影响有时候很小,但有时候却非常大。如果有 10 个线程在一个条件队列上等待,那么调用 notifyAll 将唤醒每个线程,并使得他们在锁上发生竞争。然后,他们中的大多数或者全部又都回到休眠状态。因而,在每个线程执行一个事件的同时,将出现大量的上下文切换操作以及发生竞争的加锁操作。(最坏的情况是,在使用 notifyAll 时将导致 O(n^2)次唤醒操作,而实际上只需要 n 次唤醒操作就足够了)。这是“性能考虑因素与安全考虑因素互相矛盾”的另一种情况。
在 BoundedBuffer 的 put 和 take 方法中采用的通知机制是保守的:每当将一个对象放入缓存或者从缓存中移走一个对象时,就执行一次通知。我们可以对其进行优化:首先,仅当缓存从空变为非空,或者从满变为非满时,才需要释放一个线程。并且,仅当 put 和 take 影响到这些状态转换时,才发出通知。这也被称为“条件通知”。虽然“条件通知”可以提升性能,但却很难正确的实现(而且还会使子类的实现变得复杂),因此在使用时应当谨慎。程序清单 14-8 给出了如何在 BoundedBuffer.put 中使用“条件通知”。
public synchronized void put(V v) throws InterruptedException {
while (isFull())
wait();
boolean wasEmpty = isEmpty();
doPut(v);
if (wasEmpty)
notifyAll();
}
单次通知和条件通知都属于优化措施。通常,在使用这些优化措施时,应该遵循“首先使程序正确的运行,然后再使其运行的更快”这个原则。如果不正确的使用这些优化措施,那么很容易在程序中引入奇怪的活跃性故障。
14.2.5 示例:阀门类
在第 5 章的 TestHarness 中使用的“开始阀门闭锁”在初始化时指定的参数为 1,从而创建了一个二元闭锁:它只有两种状态,即初始状态和结束状态。闭锁能阻止线程通过开始阀门,并直到阀门被打开,此时所有的线程都可以通过该阀门。虽然闭锁机制通常能满足需求,但在某些情况下存在一些缺陷:按照这种方式构造的阀门在打开后无法重新关闭。
通过使用条件等待,可以很容易的实现一个可重新打开关闭的 TreadGate 类,如程序清单 14-9 所示。ThreadGate 可以打开和关闭阀门,并提供一个 await 方法,该方法能一直阻塞直到阀门被打开。在 open 方法中使用 notifyAll,这是因为这个类的语义不满足单次通知的“单进单出”测试。
@ThreadSafe public class ThreadGate {
// CONDITION-PREDICATE: opened-since(n) (isOpen || generation>n)
@GuardedBy("this") private boolean isOpen;
@GuardedBy("this") private int generation;
public synchronized void close() {
isOpen = false;
}
public synchronized void open() {
++generation;
isOpen = true;
notifyAll();
}
// BLOCKS-UNTIL: opened-since(generation on entry)
public synchronized void await() throws InterruptedException {
int arrivalGeneration = generation;
while (!isOpen && arrivalGeneration == generation)
wait();
}
}
在 wait 中使用的条件谓词比测试 isOpen 复杂的多。这种条件谓词是必须的,因为如果当阀门打开时有 N 个线程正在等待它,那么这些线程都应该被允许执行。然而,如果阀门在打开后又非常快速的关闭了,并且 await 方法只能检查 isOpen,那么所有的线程都可能无法释放:当所有线程收到通知时,将重新请求锁并退出 wait,而此时的阀门可能已经再次关闭了。因此,在 ThreadGate 中使用了一个更复杂的条件谓词:每次阀门关闭时,递增一个 “Generation” 计数器,如果阀门现在是打开的,或者阀门自从该线程到达后就一直是打开的,那么线程就可以通过 wait。
由于 ThreadGate 只支持等待打开阀门,因此只有在 open 中执行通知。要想既支持“等待打开”又支持“等待关闭”,那么 ThreadGate 必须在 open 和 close 中都进行通知。这很好的说明了为什么在维护状态依赖的类时是非常困难的——当增加一个新的状态依赖操作时,可能需要多多条修改对象的代码路径进行调整,才能正确的执行通知。
14.2.6 子类的安全问题
在使用条件通知或单次通知时,一些约束条件使的子类化过程变得更加复杂。要想支持子类化,那么在设计时就需要保证:如果在实施子类化时违背了条件通知或单次通知的某个需求,那么在子类中可以增加合适的通知机制来代表基类。
对于状态依赖的类,要么将其等待和通知协议完全向子类公开并写入正式文档,要么完全阻止子类参与到等待和通知等过程中。(这是对“要么围绕着继承来设计和子类化,要么禁止使用继承”这条规则的一种扩展)。当设计一个可以被继承的状态依赖类时,至少需要公开条件队列和锁,并将条件谓词和同步策略写入文档。此外,还可能需要公开一些底层的状态变量。(最糟糕的情况是,一个状态依赖的类虽然将其状态向子类公开,但却没有将相应的等待和通知等协议写入文档,这就类似于虽然公开了它的状态变量,但却没有将其不变性写入文档一样。)
另外一种选择是完全禁止子类化,比如将类声明为 final 类型,或者将条件队列、锁和状态变量等都隐藏依赖,使子类无法看到。否则,如果子类破坏了在基类中使用 notify 的方式,那么基类就需要修复这种破坏。考虑一个无界的可阻塞栈,当栈为空时,pop 操作将其阻塞,但 push 操作通常可以执行。这就满足了使用单次通知的需求。如果在这个类中使用了单次通知,并且在其中一个子类中添加了一个阻塞的“弹出连续两个元素”方法,那么就会出现两种类型的等待线程:等待弹出一个元素的线程和等待弹出连个元素的线程。但如果基类将条件队列公开出来,并且将使用该条件队列的协议也写入文档,那么子类就可以将 push 方法改写为执行 notifyAll,而重新确保安全性。
14.2.7 封装条件队列
通常,我们应该把条件队列封装起来,因而消除了使用条件队列的类,就不能在其他地方访问它。否则,调用者会自以为理解类在等待和通知上使用的协议,并且采用一种违背设计的方式来使用条件队列。(除非条件队列对象对于你无法控制的代码来说是不可访问的,否则就不可能要求在单次通知中的所有等待线程都是同一类型的。如果外部代码错误的在条件对了上等待,那么可能通知协议,并导致一个“被劫持的”信号)。
不幸的是,这条建议——将条件队列对象封装起来,与线程安全类的最常见设计模式并不一致,在这种模式中建议使用对象的内置锁来保护对象自身的状态。在 BoundedBuffer 中给出了这种常见的模式,即缓存对象自身即为锁、又是条件队列。然而,可以很容易将 BoundedBuffer 重新设计为使用私有的锁对象和条件队列,唯一的不同之处在于,新的 BoundedBuffer 不再支持任何形式的客户端加锁。
14.2.8 入口协议与出口协议
Wellings 通过“入口协议和出口协议”来描述 wait 和 notify 方法的正确使用。对于每个依赖状态的操作,以及每个修改其他操作依赖状态的操作,都应该定义一个入口协议和出口协议。入口协议就是该操作的条件谓词,出口协议则包括:检查被该操作修改的所有状态变量,并确认它们是否使某个其他的条件谓词变为真,如果是,则通知相关的条件队列。
在 AbstractQueuedSynchronizer (JUC 中大多数依赖状态的类都是基于这个类构建的)中使用出口协议。这个类并不是由同步器类执行自己的通知,而是要求同步器方法返回一个值来表示该类的操作是否已经解除了一个或多个等待线程的阻塞状态。这种明确的 API 调用需求使得难以“忘记”在某些状态转换发生时通知。
14.3 显式的 Condition 对象
第 13 章曾经介绍过,在某些情况下,当内置锁过于灵活时,可以使用显式锁。正如 Lock 是一种广义的内置锁,Condition 也是一种广义的内置条件队列。
public interface Condition {
void await() throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
long awaitNanos(long nanosTimeout) throws InterruptedException;
void awaitUninterruptibly();
boolean awaitUntil(Date deadline) throws InterruptedException;
void signal();
void signalAll();
}
内置条件队列存在一些缺陷。每个内置锁都只能有一个相关联的条件队列,因为在像 BoundedBuffer 这种类中,多个线程可能在同一个条件队列上等待不同的条件谓词,并且在最常见的加锁模式下公开条件队列对象。这些因素都是的无法满足在使用 notifyAll 时所有等待线程为同一类型的需求。如果想要编写一个带有多个条件谓词的并发对象,或者想获得出列条件队列可见性之外的更多控制权,就可以使用显式的 Lock 和 Condition 而不是内置锁和条件队列,这是一种更灵活的选择。
一个 Condition 和一个 Lock 关联在一起,就像一个条件队列和一个内置锁相关联一样。要创建一个 Condition,可以在相关联的 Lock 上调用 Lock.newCondition 方法。正如 Lock 比内置加锁提供了更为丰富的功能,Condition 同样比内置条件队列提供了更丰富的功能:在每个锁上可存在多个等待、条件等待可以是可中断的或不可中断的、基于时限的等待,以及公平或非公平的队列操作。
与内置条件队列不同的是,对于每个 Lock,可以有任意数量的 Condition 对象。Condition 对象继承了相关 Lock 对象的公平性,对于公平的锁,线程会依照 FIFO 顺序从 Condition.await 中释放。
特别注意:在 Condition 对象中,与 wait、notify、notifyAll 方法对应的分别是 await、singal、signalAll。但是,Condition 继承了 Object,因而它也拥有 wait 和 notify 方法。一定要确保使用正确的方法——await 和 signal。
程序清单 14-11 给出了有界缓存的另一种实现,即使用两个 Condition,分别为 notFull 和 notEmpty,用于表示“非满”与“非空”两个条件谓词。当缓存为空时,take 将阻塞并等待 notEmpty,此时 put 向 notEmpty 发送信号,可以解除任何在 take 中阻塞的线程。
@ThreadSafe public class ConditionBoundedBuffer<T> {
protected final Lock lock = new ReentrantLock();
// CONDITION PREDICATE: notFull (count < items.length)
private final Condition notFull = lock.newCondition();
// CONDITION PREDICATE: notEmpty (count > 0)
private final Condition notEmpty = lock.newCondition();
@GuardedBy("lock")
private final T[] items = (T[]) new Object[BUFFER_SIZE];
@GuardedBy("lock")
private int tail, head, count;
// BLOCKS-UNTIL: notFull
public void put(T x) throws InterruptedException {
lock.lock();
try {
while (count == items.length)
notFull.await();
items[tail] = x;
if (++tail == items.length)
tail = 0;
++count;
notEmpty.signal();
} finally {
lock.unlock();
}
}
// BLOCKS-UNTIL: notEmpty
public T take() throws InterruptedException {
lock.lock();
try {
while (count == 0)
notEmpty.await();
T x = items[head];
items[head] = null;
if (++head == items.length)
head = 0;
--count;
notFull.signal();
return x;
} finally {
lock.unlock();
}
}
}
ConditionBoundedBuffer 的行为和 BoundedBuffer 相同,但他对条件队列的使用方式更易理解——在分析使用了多个 Condition 的类时,比分析一个使用单一内部队列加上多个条件谓词的类简单的多。通过将连个条件谓词分开并放到两个等待线程集中,Condition 使其更容易满足单次通知的需求。signal 比 signalAll 更高效,它能极大的减少在每次缓存操作中发生的上下文切换与锁请求次数。
与内置锁和条件队列一样,当使用显式的 Lock 和 Condition 时,也必须满足锁、条件谓词和条件变量之间的三元关系。在条件谓词中包含的变量必须由 Lock 来保护,并且在检查条件谓词以及调用 await 和 signal 时,必须持有 Lock 对象。
在使用显式的 Condition 和内置条件对了之间进行选择时,与在 ReentrantLock 和 synchronized 之间进行选择是一样的:如果需要一些高级功能,例如使用公平的队列操作或者在每个锁上对应多个等待线程集,那么应该优先使用 Condition 而非内置条件队列。
14.4 Synchronizer 剖析
在 ReentrantLock 和 Semaphore 这两个接口之间存在许多共同点。这两个类都可以用作一个阀门,即每次只允许一定数量的线程通过,并当在线程到达阀门时,可以通过(在调用 lock 或 acquire 时成功返回),也可以等待(在调用 lock 或 acquire 时阻塞),还可以取消(在调用 tryLock 或 tryAcquire 时返回 false,表示在指定的时间内锁是不可用的或者无法获得许可)。而且,这两个接口都支持可中断的、不可中断的以及限时的获取操作,并且也都支持等待线程执行公平或非公平的队列操作。
列出了这种共性后,你或许会认为 Semaphore 是基于 ReentrantLock 实现的,或者认为 ReentrantLock 实际上是带有一个许可的 Semaphore。这些实现方式都是可行的,一个很常见的练习就是,证明可以通过锁来实现计数信号量(如程序清单 14-12 中的 SemaphoreOnLock 所示),以及可以通过计数信号量来实现锁。
// Not really how java.util.concurrent.Semaphore is implemented @ThreadSafe
public class SemaphoreOnLock {
private final Lock lock = new ReentrantLock();
// CONDITION PREDICATE: permitsAvailable (permits > 0)
private final Condition permitsAvailable = lock.newCondition();
@GuardedBy("lock") private int permits;
SemaphoreOnLock(int initialPermits) {
lock.lock();
try {
permits = initialPermits;
} finally {
lock.unlock();
}
}
// BLOCKS-UNTIL: permitsAvailable
public void acquire() throws InterruptedException {
lock.lock();
try {
while (permits <= 0)
permitsAvailable.await();
--permits;
} finally {
lock.unlock();
}
}
public void release() {
lock.lock();
try {
++permits;
permitsAvailable.signal();
} finally {
lock.unlock();
}
}
}
事实上,它们在实现时都基于共同的基类,即 AQS,这个类也是其他许多同步类的基类。AQS 是一个用于构建锁和同步器的框架,许多同步容器都可以通过 AQS 很容易并且高效的构造出来。不仅 ReentrantLock 和 Semaphore 是基于 AQS 构建的,还包括 CountDownLatch、ReentrantReadWriteLock、SynchronousQueue 和 FutureTask。
AQS 解决了在实现同步器时涉及的大量细节问题,例如等待线程采用 FIFO 队列操作书序。在不同的同步器中还可以定义一些灵活的标准来判断某个线程是应该通过还是需要等待。
基于 AQS 来构建同步器能带来很多好处。它不仅能极大的减少实现工作,而且也不必处理在多个位置上发生的竞争问题(这是在没有使用 AQS 来构建同步器时的情况)。在 SemaphoreOnLock 中,获取许可的操作可能在两个时刻阻塞——当锁保护信号量状态时,或者当许可不可用时。在基于 AQS 构建的同步容器中,只可能在一个时刻发生阻塞,从而降低上下文切换的开销,并提高吞吐量。在设计 AQS 时充分考虑了可伸缩性,因此 JUC 中所有基于 AQS 构建的同步器都能获得这种优势。
14.5 AQS:AbstractQueuedSynchronizer
大多数开发者都不会直接使用 AQS,标准同步器的集合能够满足绝大多数的需求。但如果能了解标准同步器类的实现方式,那么对于理解它们的工作原理将会非常有帮助。
在基于 AQS 构建的同步容器中,最基本的操作包括各种形式的获取和释放操作。获取操作是一种状态依赖操作,并且通常会阻塞。当使用锁或信号量时,“获取”操作的含义就很直观,即获取的是锁或许可,并且调用者可能会一直等待直到同步器类处于可被获取的状态。在使用 CountDownLatch 时,“获取”操作意味着“等待直到闭锁到达结束状态”,而在使用 FutureTask 时,则意味着“等待直到任务已经完成”。“释放”并不是一个可阻塞的操作,当执行“释放”操作时,所有在请求时被阻塞的线程都会开始执行。
如果一个类想成为状态依赖的类,那么它必须拥有一些状态。AQS 负责管理同步容器类中的状态,它管理了一个整数状态信息,可以通过 getState、setState 以及 compareAndSwap 等 protected 方法来进行操作。这个整数可以用于表示任务状态。比如,ReentrantLock 用它来表示所有者线程已经重复获取锁的次数,Semaphore 用它来表示剩余的许可数量,FutureTask 用它来表示任务的状态(尚未开始、正在运行、已完成、已取消)。在同步容器中还可以自行管理一些额外的状态变量,比如,ReentrantLock 保存了锁的当前所有者信息,这样就能区分某个操作是重入的还是竞争的。
程序清单 14-13 给出了 AQS 中获取和释放操作的形式。根据同步器的不同,获取操作可以是一种独占操作(如 ReentrantLock),也可以是一种非独占操作(如 Semaphore 和 CountDownLatch)。一个获取操作包含两个部分。首先,同步器判断当前状态十分允许获取操作,如果是,则允许线程执行,否则获取操作将阻塞或失败。这种判断是由同步器语义来决定的。例如,对于所来说,如果它没有被某个线程持有,那么就能被成功的获取,而对于闭锁来说,如果它处于结束状态,那么也能被成功的获取。
boolean acquire() throws InterruptedException {
while (state does not permit acquire) {
if (blocking acquisition requested) {
enqueue current thread if not already queued
block current thread
}
else return failure
}
possibly update synchronization state
dequeue thread if it was queued
return success
}
void release() {
update synchronization state
if (new state may permit a blocked thread to acquire)
unblock one or more queued threads
}
其次,就是更新同步器的状态,获取同步器的某个线程可能会对其他线程能够也获取该同步器造成影响。比如,当获取一个锁后,锁的状态将从“未被持有”变成“已被持有”,而从 Semaphore 中获得一个许可后,将把剩余许可的数量减去 1。然而,当一个线程获取闭锁时,并不会影响其他线程能否获取它,因此获取闭锁的操作不会改变闭锁的状态。
如果某个同步器支持独占的获取操作,那么需要实现一些保护方法,包括 tryAcquire、tryRelease 和 isHeldExclusively 等,而对于支持共享获取的同步器,则应该实现 tryAcquireShared 和 tryReleaseShared 等方法。AQS 中的 accuire、acquireShared、release 和 releaseShared 等方法都将调用这些方法在子类中带有前缀 try- 的版本来判断某个操作是否能够执行。在同步器的子类中,可以根据其获取操作和释放操作的语义,使用 getState、setState 以及 compareAndSetState 来检查和更新状态,并通过返回的状态值来告知基类“获取”和“释放”同步器的操作是否成功。例如,如果 tryAcquireShared 返回一个值,那么表示获取操作失败,返回零值表示同步器通过独占方式被获取,返回正值则表示同步器通过非独占方式被获取。对于 tryRelease 和 tryReleaseShared 方法来说,如果释放操作使得所有在获取同步器时被阻塞的线程恢复执行,那么这两个方法应该返回 true。
为了使支持条件队列的锁(如 ReentrantLock)实现起来更简单,AQS 还提供了一些机制来构造与同步器相关联的条件变量。
一个简单的闭锁
程序清单 14-14 中的 OneSlotLatch 是一个使用 AQS 实现的二元闭锁。它包括两个公有方法:await 和 signal,分别对应获取操作和释放操作。起初,闭锁是关闭的,任何调用 await 的线程都将阻塞并直到闭锁被打开。当通过调用 signal 打开闭锁时,所有等待中的线程都将被释放,并且后续到达闭锁的线程也被允许执行。
@ThreadSafe
public class OneShotLatch {
private final Sync sync = new Sync();
public void signal() {
sync.releaseShared(0);
}
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(0);
}
private class Sync extends AbstractQueuedSynchronizer {
protected int tryAcquireShared(int ignored) {
// Succeed if latch is open (state == 1), else fail
return (getState() == 1) ? 1 : -1;
}
protected boolean tryReleaseShared(int ignored) {
setState(1);
// Latch is now open
return true;
// Other threads may now be able to acquire
}
}
}
在 OneShotLatch 中,AQS 状态用来表示闭锁状态——关闭(0)或者打开(1)。await 方法调用 AQS 的 acquireSharedInterruptibly,然后接着调用 OneShotLatch 中的 tryAcquireShared 方法。在 tryAcquireShared 的实现中必须返回一个值来表示该获取操作能否执行。如果之间已经打开了闭锁,那么 tryAcquireShared 将返回成功并允许线程通过,否则就会返回一个表示获取操作失败的值。acquireSharedInterruptibly 方法在处理失败的方式,是把这个线程放入等待线程队列中。类似的,signal 将调用 releaseShared,接下来又会调用 tryReleaseShared。在 tryReleaseShared 中将无条件的将闭锁的状态设置为打开,(通过返回值)表示该同步器处于完全释放的状态。因而 AQS 让所有等待中的线程都尝试重新请求该同步器,并且由于 tryAcquireShared 将返回成功,因此现在的请求操作将成功。
OneShotLatch 是一个功能全面的、可用的、性能较好的同步器,并且仅使用了大约 20 多行代码就实现了。当然,它缺少了一些有用的特性,比如限时的请求操作或检查闭锁状态的操作,但这些功能实现起来同样简单,因为 AQS 提供了限时版本的获取方法,以及一些在常见检查中使用的辅助方法。
OneShotLatch 也可以通过扩展 AQS 来实现,而不是将一些功能委托给 AQS,但这种做法并不合理,原因有很多。这样做将破坏 OneShotLatch 接口(只有两个方法)的简洁性,并且虽然 AQS 的公共方法不允许调用者破坏闭锁的状态,但调用者仍可以很容易的误用它们。JUC 中的所有同步器类都没有直接扩展 AQS,而是都将它们的相应功能委托给私有的 AQS 子类来实现。
14.6 JUC 同步类中的 AQS
JUC 中的很多课阻塞类,如 ReentrantLock、Semaphore、ReentrantReadWriteLock、CountDownLatch、SynchronousQueue 和 FutureTask 等,都是基于 AQS 构建的。我们快速的浏览一下每个类是如何使用 AQS 的,不需要过于深入了解细节。
14.6.1 ReentrantLock
ReentrantLock 仅支持独占方式的获取操作,因此它实现了 tryAcquire、tryRelease 和 isHeledExclusively,程序清单 14-15 给出了非公平版本的 tryAcquire。ReentrantLock 将同步状态用于保存加锁操作的次数,并且还维护了一个 owner 变量来保存当前所有者线程的标示符,只有在当前线程刚刚获取到锁,或者正要释放锁的时候,才会修改这个变量。在 tryRelease 中检查 owner 域,从而确保当前线程在执行 unlock 操作之前确实已经获得了锁:在 tryAcquire 中将使用这个域来区分获取操作是重入的还是竞争的。
protected boolean tryAcquire(int ignored) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, 1)) {
owner = current;
return true;
}
}
else if (current == owner) {
setState(c+1);
return true;
}
return false;
}
当一个线程尝试获取锁时,tryAcquire 将首先检查所的状态。如果锁未被持有,那么它将尝试更新锁的状态以表示锁已经被持有。由于状态可能在检查后被立即修改,因此 tryAcquire 使用 compareAndSetState 来原子的更新状态,表示这个锁已经被占有,并确保状态在最后一次检查以后就没有被修改过。(请参考 15.3 节中对 compareAndSet 的描述)。如果锁状态表示已经被持有,并且如果当前线程是锁的拥有者,那么获取计数会被递增,如果当前线程不是锁的拥有者,那么获取操作将失败。
ReentrantLock 还利用了 AQS 对多个条件变量和多个等待线程集的内置支持。Lock.newCondition 将返回一个新的 ConditionObject 实例,这是 AQS 的一个内部类。
14.6.2 Semaphore 与 CountDownLatch
Semaphore 将 AQS 的同步状态用于保存当前可用许可的数量。tryAcquireShared 方法(见程序清单 14-16)首先计算剩余许可的数量,如果没有足够的许可,那么会翻译个值表示获取操作失败。如果还有剩余的许可,那么 tryAcquireShared 会通过 compareAndSetState 以原子方式来降低许可的计数。如果这个操作成功(这意味着许可的计数自从上一次读取后就没有被修改过),那么将返回一个值来表示获取操作成功。在返回值中包含了表示其他共享获取操作能否成功的信息,如果成功,那么其他等待的线程同样会解除阻塞。
protected int tryAcquireShared(int acquires) {
while (true) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 || compareAndSetState(available, remaining))
return remaining;
}
}
protected boolean tryReleaseShared(int releases) {
while (true) {
int p = getState();
if (compareAndSetState(p, p + releases))
return true;
}
}
当没有足够的许可,或者当 tryAcquireShared 可以通过原子方式来更新许可的计数以响应获取操作时,while 循环将终止。虽然对 compareAndSetState 的调用可能由于与另一个线程发生竞争而失败(请参考 15.3 节),使其重新尝试,但在经过了一定次数的重试操作之后,在两个结束条件中有一个会变为真。同样,tryReleaseShare 将增加许可计数,这可能会截除等待中线程的阻塞状态,并且不断的重试直到操作成功。tryReleaseShared 的返回值表示在这次释放操作中解除了其他线程的阻塞。
CountDownLatch 使用 AQS 的方式与 Semaphore 很相似:在同步状态中保存的是当前的计数值。countDown 方法将调用 release,从而导致计数值递减,并且当计数值为零时解除所有等待线程的阻塞。await 调用 acquire,当计数器为 0 时,acquire 将立即返回,否则将阻塞。
14.6.3 FutureTask
咋一看,FutureTask 甚至不像一个容器,但 Future.get 的语义非常类似于闭锁的语义——如果发生了某件事(由 FutureTask 表示的任务执行完成或被取消),那么线程就可以恢复执行,否则这些线程将停留在队列中直到该事件发生。
在 FutureTask 中,AQS 同步状态被用来保存任务的状态,如正在运行、已完成或已取消。FutureTask 还维护一写额外的状态变量,用来保存计算结果或抛出的异常。此外,它还为了一个引用,指向正在执行计算任务的线程(如果当该线程还处于运行状态时),因而如果任务取消,该线程就会被中断。
14.6.4 ReentrantReadWriteLock
ReadWriteLock 接口表示存在两个锁:一个读锁一个写锁,但在基于 AQS 实现的 ReentrantReadWriteLock 中,单个 AQS 子类将同时管理读取加锁和写入加锁。ReentrantReadWriteLock 使用了一个 16 位的状态来表示写入锁的计数,并且使用了另一个 16 位的状态来表示读取锁的计数。在读取锁上操作将使用共享的获取方法与释放方法,在写入锁上的操作将使用独占的获取方法与释放方法。
AQS 在内部维护一个等待线程队列,其中记录了某个线程请求的是独占访问还是共享访问。在 ReentrantReadWriteLock 中,当锁可用时,如果位于对了头部的线程执行写入操作,那么线程会得到该锁,如果位于队列头部的线程执行的是获取访问,那么队列在第一个写入线程之前的所有线程都将获得这个锁。
小结
要实现一个依赖状态的类——如果没有满足依赖状态的前提条件,那么这个类的方法必须阻塞,那么最好的方式是基于现有的类库来构建,比如 Semaphore、BlockingQueue 或 CountDownLatch。如第八章的 ValueLatch 所示。然而,有时候现有类库不能提供足够的功能,在这种情况下,可以使用内置的条件队列、显式的 Condition 对象或者 AQS 来构建自己的同步器。内置条件队列与内置锁是紧密绑定在一起的,这是因为管理状态依赖性的机制必须与确保状态一致性的机制关联起来。同样,显式的 Condition 与显式的 Lock 也是紧密的绑定在一起的,并且与内置条件队列相比,还提供了一个扩展的功能集,包括每个锁对于多个等待线程集,可中断或不可中断的条件等待,公平或非公平的队列操作,以及基于时限的等待。
1.15 - CH15-原子与非阻塞同步
在 JUC 包的许多类中,如 Semaphore 和 ConcurrentLinkedQueue,都提供了比 synchronized 机制更高的性能和可伸缩性。本章将介绍这种性能提升的主要来源:原子变量和非阻塞同步机制。
近年来,在并发算法领域的大多数研究都侧重于非阻塞算法,这种算法用底层的原子机器指令(如 CAS)代替锁来确保数据在并发访问中的一致性。非阻塞算法被广泛的用于在操作系统和 JVM 中实现线程/进程调度机制、垃圾回收机制、锁和其他并发数据结构。
与基于锁的方法相比,非阻塞算法在设计和实现上都要复杂的多,但它们在可伸缩性和活跃性上却拥有巨大的优势。由于非阻塞算法可以使多个线程在竞争相同的数据时不会发生阻塞,因此它能在粒度更细的层次上进行协调,并且极大的减少调度开销。而且,在非阻塞算法中不存在死锁和其他活跃性问题。在基于锁的算法中,如果一个线程在休眠或自旋的同时持有一个锁,那么其他线程都无法执行下去,而非阻塞算法不会受到单个线程失败的影响。从 Java 5.0 开始,可以使用原子变量类(如 AtomicInteger)来构建高效的非阻塞算法。
即使原子变量没有用于非阻塞算法的开发,他们也可以被用作一种“更好的 volatile 变量”。原子变量提供了与 volatile 变量相同的内存语义,此处还支持原子更新操作,从而使它们更加适用于实现计数器、序列发生器和统计数据收集等,同时还能比基于锁的方法提供更高的可伸缩性。
15.1 锁的劣势
通过使用一致的锁协议来协调对共享状态的访问,可以确保无论哪个线程持有守护变量的锁,都能采用独占的方式来访问这些变量,并且对变量的任何修改对后续获得这个锁的其他线程都是可见的。
现代的许多 JVM 都给非竞争的加解锁操作进行极大的优化,但如果有多个线程同时加锁,那么 JVM 就需要借助操作系统的能力。如果出现了这种情况,那么一些线程将被挂起并且在稍后恢复运行。当线程恢复执行时,必须等待其他线程执行完成它们的时间片以后,才能被调度执行。在挂起和恢复线程等过程中存在很大的开销,并且通常存在着较长时间的中断。如果在基于锁的类中包含细粒度的操作(如同步容器类,在其大多数情况中仅包含了少量操作),那么当在锁上存在着激烈的竞争时,调度开销将大大超出工作开销。
与锁相比,volatile 变量是一种更轻量级的同步机制,因为在使用这些变量时不会发生上下文切换或线程调度等操作。然而,volatile 变量同样存在一些局限:虽然它提供了相似的可见性保证,但不能用于构建原子的复合操作。因此,在一个变量依赖其他的变量时,或者当变量的新值依赖于旧值时,就无法使用 volatile 变量。这都限制了 volatile 变量的使用范围,因此他们不能用来实现一些常见的工具,如计数器或互斥体(mutex)。
比如,虽然自增操作看起来像是一个原子操作,但事实上却包含了 3 个独立的操作——获取变量的值、将该值加 1、写入新值到变量。为了确保更新操作不会丢失,整个的读-改-写操作都必须是原子的。到目前为止,我们实现这种原子操作的唯一途径就是使用锁定的方式,如第二章的 Counter 所示。
Counter 是线程安全的,并且在没有竞争的情况下运行良好。但在竞争的情况下,其性能会由于上下文切换的开销和调度延迟而降低。如果锁的持有时间非常短,俺么挡在不恰当的时间请求锁时,使线程休眠将付出很高的代价。
锁定还存在一些其他缺点。当一个线程正在等待锁时,他不能做任何其他事情。如果一个线程在持有锁的情况下被延迟执行(如发生内存缺页、调度延迟等),那么所有需要该锁的线程都将无法继续执行。如果被阻塞的线程的优先级很高,而持有锁的线程优先级较低,那么这将是一个严重的问题——也被称为优先级反转。即使高优先级的线程可抢先执行,但仍然需要等待锁被释放,从而导致它的优先级会降至低优先级线程的级别。如果持有锁的线程被永久的阻塞(如出现了无限循环、死锁、活锁或其他活跃性故障),所有等待该锁的线程都将永远无法继续执行。
即使忽略这种风险,锁定方式对于细粒度的操作(如递增计数器)来说仍然是一种高开销的机制。在管理线程之间的竞争时应该有一种粒度更细的技术,类似于 volatile 变量的机制,同时还要支持原子的更新操作。幸运的是,在现代的处理器中提供了这种机制。
15.2 硬件对并发的支持
独占锁是一项悲观技术——它假设最坏的情况(如果不锁门,那么捣蛋鬼就会闯入并搞破坏),并且只有在确保其他线程不会干扰(通过获取正确的锁)的情况下才能执行下去。
对于细粒度的操作,还有另外一种更高效的方法,也是一种乐观的方法,通过这种方法可以在不发生干扰的情况下完成更新操作。这种方法需要借助冲突检测机制来判断在更新过程中是否存在来自其他线程的干扰,如果存在,该操作将失败,但可以选择是否重试。这种乐观方式就好像一句谚语:“原谅比准许更易获得”,其中“更易”在这里相当于“更高效”。
在针对多处理器操作而设计的处理器中提供了一些特殊指令,用于管理共享数据的并发访问。在早期的处理器中支持原子的测试并设置(TestAndSet)、获取并递增(FetchAndIncrement)、交换(Swap)等指令,这些指令足以实现各种互斥体,而这些互斥体又可以实现一些更复杂的并发对象。现在,几乎所有的现代处理器中都包含了某种形式的原子读-该-写指令,如比较并交换(CAS)、关联加载/条件存储(LoadLinked/StoreConditional)。操作系统和 JVM 使用这些指令来实现锁和并发数据结构,但在 Java 5.0 之前,在 Java 类中还不能直接使用这些指令。
CAS
在大多数处理架构中采用的方法是实现一个 CAS 指令。CAS 包含 3 个操作数——需要读写的内存位置 V、进行比较的值 A、拟写入的新值 B。当且仅当 V 的值等于 A 时,CAS 才会通过原子方式用新值 B 来更新 V 的值,否则不会执行任何操作。无论 V 的值是否等于 A,都将返回 V 原有的值。(这种变化形式被称为“比较并设置”,无论操作是否成功都将放回)。CAS 的含义是:“我认为 V 的值应该是 A,如果是,那么将 V 的值更新为 B,否则不修改,并告诉我 V 的实际值是什么”。CAS 是一种乐观技术,它希望能成功的执行更新操作,并且如果有另一个线程在最近一次检查后更新了该变量,那么 CAS 能检测到这个错误。程序清单 15-1 中的 SimulatedCAS 说明了 CAS 语义(并非实现和性能)。
@ThreadSafe public class SimulatedCAS {
@GuardedBy("this") private int value;
public synchronized int get() {
return value;
}
public synchronized int compareAndSwap(int expectedValue, int newValue) {
int oldValue = value;
if (oldValue == expectedValue)
value = newValue;
return oldValue;
}
public synchronized boolean compareAndSet(int expectedValue, int newValue) {
return (expectedValue == compareAndSwap(expectedValue, newValue));
}
}
当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程更够成功更新变量的值,而其他线程都将失败。然而,失败的线程并不会被挂起(这与获取锁的情况不同,当获取锁失败时线程将被挂起),而是被告知在这次竞争中失败,并可以再次尝试。有一个线程在竞争 CAS 时失败不会阻塞,因此它可以决定是否进行重试,或者执行一些恢复动作,也或者不执行任何操作。这种灵活性就大大减少了与锁相关的活跃性风险(尽管在一些不常见的情况下仍然存在活锁风险——见 10.3.3 节)。
CAS 的典型使用模式是:首先从 V 中读取值 A,并根据 A 值计算新值 B,然后通过 CAS 以原子方式将 V 中的值由 A 变成 B(只要在这期间没有任何线程将 V 的值修改为其他值)。由于 CAS 能检测到来自其他线程的干扰,因此即使不适用锁也能实现原子的读-该-写操作序列。
15.2.2 非阻塞计数器
程序清单 15-2 中的 CasCounter 使用 CAS 实现了一个线程安全的计数器。递增操作采用了标准形式——读取旧值,根据旧值计算出新值(+1),并使用 CAS 来设置新值。如果 CAS 失败,那么该操作将立即重试。通常,返回重试是一种合理的策略,但在一些竞争很激烈的情况下,更好的方式是在重试之前首先等待一段时间或回退,从而避免造成活锁问题。
@ThreadSafe public class CasCounter {
private SimulatedCAS value;
public int getValue() {
return value.get();
}
public int increment() {
int v;
do {
v = value.get();
} while (v != value.compareAndSwap(v, v + 1));
return v + 1;
}
}
CasCounter 不会阻塞,但如果其他线程同时更新计数器,那么会多次执行重试操作。(在实际情况中,如果仅需要一个计数器或序列生成器,那么可以直接使用 AtomicInteger 或 AtomicLong,它们能提供原子的递增方法和其他一些算术方法)。
咋一看,基于 CAS 的计数器似乎比基于锁的计数器在性能上会更差一些,因为它需要执行更多的操作和更复杂的控制流,并且还依赖于看似复杂的 CAS 操作。但实际上,当竞争程度不高时,基于 CAS 的计数器在性能上远远超过基于锁的计数器,而在没有竞争时甚至更高。如果要快速获取无竞争的锁,那么至少需要一次 CAS 操作再加上与其他锁相关的操作,因此基于所的计数器即使在最好的情况下也会比基于 CAS 的计数器在一般情况下能执行更多的操作。由于 CAS 在大多数情况下都能成功执行(假设竞争程度不高),因此硬件能够正确的越策 while 循环中的分支,从而把复杂控制逻辑的开销将至最低。
虽然 Java 语言的锁定语法比较简单,但 JVM 和操作在管理锁时需要完成的工作却并不简单。在实现锁定时需要遍历 JVM 中一条非常复杂的代码路径,并可能导致操作系统级的锁定、线程挂起、上下文切换等操作。在最好的情况下,在锁定时至少需要一次 CAS,因此虽然在使用锁时没有用到 CAS,但实际上也无法节约任何执行开销。另一方面,在程序内部执行 CAS 时不需要执行 JVM 代码、系统调用或线程调度操作。在应用级看起来越长的代码路径,如果加上 JVM 和操作系统中的代码调用,那么事实上却变得更短。CAS 的主要缺点是,它将使调用者处理竞争问题(通过重试、回退、放弃),而在锁中能自动处理竞争问题(线程在获得锁之前将一直阻塞)。
CAS 的性能会随着处理器数量的不同而变化很大。在单 CPU 系统中,CAS 通常只需要很少的时钟周期,因为不需要处理器之间的同步。在编写本书时,非竞争的 CAS 在多 CPU 系统中需要 10 到 150 个时钟周期的开销。CAS 的执行性能不仅在不同的体系架构之间变化很大,甚至在相同处理器的不同版本之间也会发生变化。生产厂商迫于竞争的压力,在接下来的几年内还会继续提高 CAS 的性能。一个很有效的经验法则是:在大多数处理器上,在无竞争的加解锁“快速代码路径”上的开销,大约是 CAS 开销的两倍。
15.2.3 JVM 对 CAS 的支持
那么,Java 代码如何确保处理器执行 CAS 操作呢?在 Java 5.0 之前,如果不编写明确的代码,那么就无法执行 CAS。在 Java 5.0 中引入了底层的支持,在 int、long 和对象的引用类型上都公开了 CAS 操作,并且 JVM 把他们编译为底层硬件提供的最有效方法。在支持 CAS 的平台上,运行时再把这些方法编译为对应的(多条)机器指令。在最坏的情况下,如果不支持 CAS 指令,那么 JVM 将使用自旋锁。在原子类变量(AtomicXxx)中使用了这些底层的 JVM 支持为数字类型和引用类型提供了一种高效的 CAS 操作,而在 JUC 中的大多数类在实现时则直接或间接的引用了这些原子变量类。
15.3 原子变量类
原子变量比锁的粒度更细、更加轻量级,这对于在处理器系统上实现高性能并发代码来说是非常关键的。原子变量将发生竞争的范围缩小到单个变量上,这是你获得的粒度最细的情况(假设算法能够基于这种细粒度来实现)。更新原子变量的快速(非竞争)路径不会比获取锁的路径慢,并且通常会更快,而它的慢速路径肯定比锁的慢速路径要快,因为它不需要挂起或重新调度线程。在使用基于原子变量而非锁的算法中,线程在执行时更不易出现延迟,并且如果遇到竞争,也更容易恢复过来。
原子变量类相当于一种泛化的 volatile 变量,能够支持原子的和有条件的读-该-写操作。AtomicInteger 表示一种 int 类型的值,并提供了 get 和 set 方法,这些 volatile 类型的 int 变量在读取和写入上有着相同的内存语义。它还提供了一个原子的 compareAndSet 方法(如果该方法执行成功,那么将实现与读取/写入一个 volatile 变量相同的内存效果),以及原子的添加、递增和递减等方法。AtomicInteger 表面上非常像一个扩展的 Counter 类,但在发生竞争的情况下能够提供更高的可伸缩性,因为它直接利用了硬件对并发的支持。
共有 12 个原子变量类,可分为 4 组:标量类(Scalar)、更新器类、数组类、符合变量类。最常用的原子变量就是标量类:AtomicInteger、AtomicLong、AtomicBoolean、AtomicReference。所有这些类都支持 CAS,此外,AtomicInteger 和 AtomicLong 还支持算术运算。(要想模拟其他基本类型的原子变量,可以将 short 或 byte 等类型与 int 类型进行转换,以及使用 floatToIntBits 或 doubleToLongBits 来转换浮点数)。
原子数组类(仅支持 Integer、Long、Reference 版本)中的元素可以实现原子更新。原子数组类为数组的元素提供了 volatile 类型的访问语义,这是普通数组所不具备的特性——volatile 类型的数组仅在数组引用上具有 volatile 语义,而在其元素上则没有。
尽管原子标量类扩展了 Number 类,但并没有扩展一些基本类型的包装类,例如 Integer 或 Long。事实上,它们也不能进行扩展:基本类型的包装类是不可修改的,而原子变量类是可修改的。在原子变量类中同样没有定义 hashCode 和 equals 方法,每个实例都是不同的。与其他可变对象相同,它们也不宜用作基于散列的容器中的键值。
15.3.1 原子变量是一种更好的 volatile
在 3.4.2 节中,我们使用了一个指向不可变对象的 volatile 引用来原子的更新多个状态变量。这个示例依赖于“先检查再运行”,但在这种特殊的情况下,竞争是无害的,因为我们并不关心是否会遇到偶尔的丢失更新操作。而在大多数情况下,这种“先检查再运行”不会是无害的,并且可能会破坏数据的一致性。例如,在第四章中的 NumberRange 既不能使用指向不可变对象的 volatile 引用来安全的实现上界和下界,也不能使用原子的整数来保存这两个边界。由于有一个不变性条件限制了两个数值,并且它们无法在同时更新时还维持该不变性条件,因此如果在数值范围类中使用 volatile 引用或多个原子整数,那么将出现不安全的“先检查再运行”操作序列。
可以将 OneValueCache 中的技术与原子引用结合起来,并通过对指向不可变对象(其中保存了上界和下界)的引用进行原子更新以避免竟态条件。在程序清单 15-3 的 CasNumgerRange 中使用了 AtomicReference 和 IntPair 来保存状态,并通过使用 compareAndSet,使它在更新上界或下界时能避免 NumberRange 的竟态条件。
public class CasNumberRange {
@Immutable private static class IntPair {
final int lower; // Invariant: lower <= upper
final int upper;
...
}
private final AtomicReference<IntPair> values =
new AtomicReference<IntPair>(new IntPair(0, 0));
public int getLower() {
return values.get().lower;
}
public int getUpper() {
return values.get().upper;
}
public void setLower(int i) {
while (true) {
IntPair oldv = values.get();
if (i > oldv.upper)
throw new IllegalArgumentException( "Can't set lower to " + i + " > upper");
IntPair newv = new IntPair(i, oldv.upper);
if (values.compareAndSet(oldv, newv))
return;
}
}
// similarly for setUpper
}
15.3.2 性能比较:锁与原子变量
为了说明锁和原子变量之间的可伸缩性差异,我们构造了一个基准测试,其中将比较伪随机数生成器(PRNG)的集中不同实现。在 PRNG 中,当生产下一个随机数时需要用到上一个数字,所以在 PRNG 中必须记录上一个数值并将其作为状态的一部分。
程序清单 15-4 和程序清单 15-5 给出了线程安全的 PRNG 的两种实现,一种使用 ReentrantLock,另一种使用 AtomicInteger。测试程序将返回调用它们,在每次迭代中将生成一个伪随机数(在此过程中将读取并修改共享的 seed 状态),并执行一些仅在线程本地数据上执行的“繁忙”迭代,这种方式模拟了一些典型操作,以及一些在共享状态以及线程本地状态上的操作。
@ThreadSafe
public class ReentrantLockPseudoRandom extends PseudoRandom {
private final Lock lock = new ReentrantLock(false);
private int seed;
ReentrantLockPseudoRandom(int seed) {
this.seed = seed;
}
public int nextInt(int n) {
lock.lock();
try {
int s = seed;
seed = calculateNext(s);
int remainder = s % n;
return remainder > 0 ? remainder : remainder + n;
} finally {
lock.unlock();
}
}
}
@ThreadSafe
public class AtomicPseudoRandom extends PseudoRandom {
private AtomicInteger seed;
AtomicPseudoRandom(int seed) {
this.seed = new AtomicInteger(seed);
}
public int nextInt(int n) {
while (true) {
int s = seed.get();
int nextSeed = calculateNext(s);
if (seed.compareAndSet(s, nextSeed)) {
int remainder = s % n;
return remainder > 0 ? remainder : remainder + n;
}
}
}
}
图 15-1 和图 15-2 给出了在每次迭代中工作量较低以及适中情况下的吞吐量。如果线程本地的计算量较少,那么在锁和原子变量上的竞争将非常激烈,如果线程本地的计算量较多,那么在锁和原子变量上的竞争会降低,因为线程访问锁和原子变量的频率将会降低。
从这些图中可以看出,在高度竞争的情况下,锁的性能将超过原子变量的性能,但是在更真实的竞争情况下,原子变量的性能则会超过锁的性能。这是因为锁在发生竞争时会挂起线程,从而降低了 CPU 的使用率和共享内存总线上的同步通信量。(这类似于在生产者消费者设计中的可阻塞生产者,它能降低消费者上的工作负担,使消费者的处理速度赶上生产者的处理速度)。另一方面,如果使用原子变量,那么发出调用的类负责对竞争进行管理。与大多数基于 CAS 的算法一样,AtomicPseudoRandom 在遇到竞争时会立即重试,这通常是一种正确的做法,但在激烈竞争的环境下却导致了更多的竞争。
在批评 AtomicPseudoRandom 写得太糟糕或者原子变量比锁更糟糕之前,应该意识到图 15-1 中竞争级别过高而有些不切实际:任何一个真实的程序都不会除了竞争锁或原子变量,其他什么工作都不做。在实际情况中,原子变量在可伸缩性上要高于锁,因为在应对常见的竞争程度时,原子变量的效率会更高。
锁与原子变量在不同竞争程度上的性能差异很好的说明了各自的优势和劣势。在中低程度的竞争下,原子变量能够提供更好的可伸缩性,而在高轻度的竞争下,锁能够更有效的避免竞争。(在单 CPU 系统中,基于 CAS 算法在性能上同样会超过基于所的算法,因为 CAS 在单 CPU 的系统上通常能执行成功,只有在偶然情况下,线程才会在执行读-改-写的操作过程中被其他线程抢占执行)。
在图 15-1 和图 15-2 中都包含了第三条曲线,它是一个使用 ThreadLocal 来保存 PRNG 状态的 RseudoRandom。这种实现方法改变了类的行为,即每个线程都只能看到自己私有的伪随机数序列,而不是所有线程共享同一个随机数序列,这说明了,如果能够避免使用共享状态,那么开销将会更小。我们可以通过提供处理竞争的效率来提高可伸缩性,但只有完全消除竞争,才能实现真正的可伸缩性。
15.4 非阻塞算法
在基于所的算法中可能会发生各种活跃性故障。如果线程在持有锁时由于阻塞 IO、内存缺页、或其他延迟而导致推迟执行,那么很可能所有线程都不能继续执行下去。如果在某种算法中,一个线程的失败或挂起不会导致其他线程也失败或挂起,那么这种算法就被称为非阻塞算法。如果在算法的每个步骤中都存在某个线程能够执行下去,那么这种算法也被称为无锁(lock-free)算法。如果在算法中仅将 CAS 用于协调线程之间的操作,并且能够正确的实现,那么它既是一种非阻塞算法,又是一种无锁算法。无竞争的 CAS 通常都能执行成功,并且如果有多个线程竞争同一个 CAS,那么总会有一个线程在竞争中胜出并执行下去。在非阻塞算法中通常不会出现死锁和优先级反转问题(但可能会出现饥饿和活锁问题,因为在算法中会反复的出现重试)。到目前为止,我们已经看到了一个非阻塞算法:CasCounter。在许多常见的数据结构中都可以使用非阻塞算法,包括栈、队列、优先队列、以及散列表等,而要设计一些新的这种数据结构,最好还是由专家们来完成。
15.4.1 非阻塞的栈
在实现相同功能的前提下,非阻塞算法通常比基于锁的算法更为复杂。创建非阻塞算法的关键在于,找出如何将原子修改的范围缩小到单个变量上,同时还要维护数据的一致性。在链式容器类中,有时候无需将状态转换操作表示为对节点链接的修改,也无需使用 AtomicReference 来表示每个必须采用原子操作来更新链接。
栈是最简单的链式数据结构:每个元素仅指向一个元素,并且每个元素也只被一个元素引用。在程序清单 15-6 的 ConcurrentStack 中给出了如何通过原子引用来构建栈的示例。栈是由 Node 元素构成的一个链表,其中栈顶作为根节点,并且在每个元素中都包含了一个值以及指向下一个元素的链接。put 方法创建一个新的节点,该节点的 next 域指向当前的栈顶,然后使用 CAS 把这个新节点放入栈顶。如果在开始插入节点时,位于栈顶的节点没有发生变化,那么 CAS 就会成功,如果栈顶节点发生了变化(比如由于其他线程在本线程开始之前插入或移除了元素),那么 CAS 将会失败,而 push 方法会根据栈的当前状态来更新节点,并且再次尝试。无论哪种情况,在 CAS 执行完成后,栈仍会处于一致的状态。
@ThreadSafe
public class ConcurrentStack <E> {
AtomicReference<Node<E>> top = new AtomicReference<Node<E>>();
public void push(E item) {
Node<E> newHead = new Node<E>(item);
Node<E> oldHead;
do {
oldHead = top.get();
newHead.next = oldHead;
} while (!top.compareAndSet(oldHead, newHead));
}
public E pop() {
Node<E> oldHead;
Node<E> newHead;
do {
oldHead = top.get();
if (oldHead == null)
return null;
newHead = oldHead.next;
} while (!top.compareAndSet(oldHead, newHead));
return oldHead.item;
}
private static class Node <E> {
public final E item; public Node<E> next;
public Node(E item) { this.item = item; }
}
}
在 CasCounter 和 ConcurrentStack 中说明了非阻塞算法的所有特性:某项工作的完成具有不确定性,必须重新执行。在 ConcurrentStack 中,当构造表示新元素的 Node 时,我们系统当把这个新节点压入到栈时,其 next 引用的值仍然是正确的,同时也准备好在发生竞争时的清下重新尝试。
在像 ConcurrentStack 这样的非阻塞算法中都能确保线程安全性,因为 compareAndSet 像锁定机制一样,技能提高原子性,又能提高可见性。当一个线程需要改变栈的状态时,将调用 compareAndSet,这个方法与写入 volatile 变量一样有着相同的内存效果。当线程检查站的状态时,将在同一个 AtomicReference 上调用 get 方法,该方法与读取 volatile 变量有着相同的内存效果。因此,一个线程执行的任何修改结构都可以安全的发布给其他正在查看状态的线程。并且,这个栈是通过 compareAndSet 来修改的,因此将采用原子操作来更新 top 的引用,或者在发现存在其他线程干扰的情况下,修改操作将失败。
15.4.2 非阻塞链表
到目前为止,我们已经看到了两个非阻塞算法,计数器和栈,它们很好的说明了 CAS 的基本使用状态:在更新某个值时存在不确定性,以及在更新失败时重新尝试。构建非阻塞算法的技巧在于:将执行原子修改的范围缩小到单个变量上。这在计数器中很容易实现,在栈中也很简单,但对于一些更复杂的数据结构来说,如队列、散列表、树,这也要复杂的多。
连接队列比栈更为复杂,因为他必须支持对头结点和尾节点的快速访问。因此,它需要单独维护头指针和尾指针。有两个指针指向位于尾部的节点:当前最后一个元素的 next 指针,以及尾节点。当成功的插入一个新元素时,这两个指针都需要采用原子操作来更新。初看起来,这个操作无法通过原子变量来实现。在更新这两个指针时需要不同的 CAS 操作,并且如果第一个 CAS 成功,但第二个 CAS 失败,那么队列将处于不一致的状态。并且,即使这两个 CAS 都成功了,那么在执行这两个 CAS 之间,让可能有另一个线程会访问队列。因此,在为链表队列构建非阻塞算法时,需要考虑到这两种情况。
我们需要使用一种技巧。第一个技巧是,即使在一个包含多个步骤的更新过程中,也要确保数据结构总是处于一致的状态。这样,当线程 B 到达时,如果发现线程 A 正在执行更新,那么线程 B 就可以知道有一个操作已经部分完成,并且不能立即开始自己的更新操作。然后,B 可以等待(通过反复检查队列的状态)并直到 A 完成更新,从而使两个线程不会互相干扰。
虽然这种方法能够使不同的线程“轮流”访问数据结构,并且不会造成破坏,但如果一个线程在更新操作中失败了,那么其他线程都无法再访问队列。要使得该算法成为一个非阻塞算法,必须确保当一个线程失败时不会妨碍其他线程继续执行下去。因此,第二个技巧在于,如果当 B 到达时发现 A 正在修改数据结构,那么在数据结构中应该有足够多的信息,使得 B 能够完成 A 的更新操作。如果 B “帮助” A 完成了更新操作,那么 B 就可以执行自己的操作,而不用等待 A 的操作完成。当 A 恢复后再试图完成其操作时,会发现 B 已经替他完成了。
在程序清单 15-7 中的 LinkedQueue 中给出了 Michael-Scott 提出的非阻塞链接队列算法中的插入部分,在 ConcurrentLinkedQueue 中使用的正式该算法。在许多队列算法中,空队列通常都包含一个“哨兵节点”或者“哑节点”,并且头节点和尾节点在初始化时都指向该哨兵节点。尾节点通常要么指向哨兵节点(如果队列为空),即队列的最后一个元素;要么指向(当有操作正在进行更新时)指向倒数第二个元素。图 15-3 给出了一个处于正常状态(或者说稳定状态)的包含两个元素的队列。
@ThreadSafe
public class LinkedQueue <E> {
private static class Node <E> {
final E item;
final AtomicReference<Node<E>> next;
public Node(E item, Node<E> next) {
this.item = item;
this.next = new AtomicReference<Node<E>>(next);
}
}
private final Node<E> dummy = new Node<E>(null, null);
private final AtomicReference<Node<E>> head =
new AtomicReference<Node<E>>(dummy);
private final AtomicReference<Node<E>> tail =
new AtomicReference<Node<E>>(dummy);
public boolean put(E item) {
Node<E> newNode = new Node<E>(item, null);
while (true) {
Node<E> curTail = tail.get();
Node<E> tailNext = curTail.next.get();
if (curTail == tail.get()) {
if (tailNext != null) {
// Queue in intermediate state, advance tail
tail.compareAndSet(curTail, tailNext);
} else {
// In quiescent state, try inserting new node
if (curTail.next.compareAndSet(null, newNode)) {
// Insertion succeeded, try advancing tail
tail.compareAndSet(curTail, newNode);
return true;
}
}
}
}
}
}
当插入一个新的元素时,需要更新两个指针。首先更新当前最后一个元素的 nex 指针,将新节点连接到列表队尾,然后更新尾节点,将其指向这个新元素。在这两个操作之间,队列处于一种中间状态,如图 15-4 所示。在第二次更新完成后,队列将再次处于稳定状态,如图 15-5 所示。
实现这两个技巧时的关键点在于:当队列处于稳定状态时,尾节点的 next 域将为空,如果队列处于中间状态,呢么 tail.next 将为非空。因此,任何线程队能够通过检查 tail.next 来获取队列的当前状态。而且,当队列处于中间状态时,可以通过将尾节点向前移动一个节点,从而结束其他线程正在执行的插入元素操作,并使得队列恢复为稳定状态。
LinkedQueue.put 方法在插入新元素之前,将首先检查队列是否处于中间状态(步骤 A)。如果是,那么有另一个线程正在插入元素(在步骤 C 和 D 之间)。此时当前线程不会等待其他线程执行完成,而是帮助它完成操作,并将尾节点向前推进一个节点(步骤 B)。然后,它将重新恢复执行这种检查,以免另一个线程已经开始插入新元素,并继续推进尾节点,知道它发现队列处于稳定状态之后,才会开始执行自己的插入操作。
由于步骤 C 中的 CAS 将把新节点链接到队列尾部,因此如果两个线程同时插入元素,那么这个 CAS 将失败。在这样的情况下,并不会造成破坏:不会发生任何变化,并且当前的线程只需要重新读取尾节点并再次重试。如果步骤 C 成功了,那么插入操作将生效,第二个 CAS(步骤 D)被认为是一个“清理操作”,因为它既可以由执行插入操作的线程来执行,也可以由其他任何线程来执行。如果步骤 D 失败了,那么执行插入操作的线程将返回,而不是重新执行 CAS,因为不再需要重试——另一个线程已经在步骤 B 中完成了这个工作。这种方式能够工作,因为在任何线程尝试将一个新节点插入到队列之前,都会首先通过检查 tail.next 是否为空来判断是否需要执行清理工作。如果是,它首先会推进尾节点(可能需要执行多次),知道队列处于稳定状态。
15.4.3 原子的域更新器
程序清单 15-7 说明了在 ConcurrentLinkedQueue 中使用的算法,但在实际的实现中略有区别。在 ConcurrentLinkedQueue 中没有使用原子引用来表示每个 Node,而是使用普通的 volatile 类型应用,并通过基于反射的 AtomicReferenceFieldUpdater 来进行更新,如程序清单 15-8 所示。
private class Node<E> {
private final E item; private volatile Node<E> next;
public Node(E item) {
this.item = item;
}
}
private static AtomicReferenceFieldUpdater<Node, Node> nextUpdater =
AtomicReferenceFieldUpdater.newUpdater( Node.class, Node.class, "next");
原子的类更新器类表示现有的 volatile 域的一种基于反射的视图,从而能够在已有的 volatile 域上使用 CAS。在更新器类中没有构造函数,要创建一个更新器对象,可以调用 newUpdater 工厂方法,并指定类和域的名字。域更新器类没有与某个特定的实例关联在一起,因而可以更新目标类的任意实例中的指定域。更新器提供的原子性保证比普通原子类更弱一些,因为无法保证底层的域不被直接修改——compareAndSet 以及其他算法方法只能确保其他使用原子域更新器方法的线程的原子性。
在 ConcurrentLinkedQueue 中,使用 nextUpdater 的 compareAndSet 方法来更新 Node 的 next 域。这个方法有点繁琐,但完全是为了提升性能。对于一些频繁分配并且生命周期很短的对象,如队列的链接节点,如果能去掉每个 Node 的 AtomicReference 创建过程,那么将极大的降低插入操作的开销。然而,几乎在所有情况下,普通原子变量的性能都很不错,只有在很少的情况下才需要使用原子的域更新器。(如果在执行原子更新的同时还需要维持现有类的串行化形式,那么原子的域更新器将非常有用)。
15.4.4 ABA 问题
ABA 问题是一种异常现象:如果在算法中的节点可以被循环使用,那么在使用 CAS 指令时就可能出现这种问题(主要在没有垃圾回收机制的环境中)。在 CAS 操作中将判断 “V 的值是否仍然为 A?”,并且如果是的话就继续执行更新操作。在大多数情况下,包括本章给出的示例,这种判断是完全足够的。然而,有时候还是需要知道“自从上次看到 V 的值为 A 以来,这个值是否发生过变化?”。在某些算法中,如果 V 的值首先由 A 变为 B,再由 B 变为 A,那么仍然被认为是发生了变化,并需要重新执行算法中的某些步骤。
如果在算法中采用自己的方式来管理节点对象的内存,那么可能出现 ABA 问题。在这种情况下,即使链表的头结点仍然指向之前观察到的节点,那么也不足以说明链表的内容没有发生改变。如果通过垃圾回收器来管理链表节点仍然无法避免 ABA 问题,那么还有一个相对简单的解决方案:不是更新某个引用的值,而是更新两个值,包括一个引用和一个版本号。即使这个值由 A 变为 B,然后又变为 A,版本号也将是不同的。AtomicStampedReference(以及 AtomicMarkableReference)支持在两个变量上执行原子的条件更新。AtomicStampedReference 将更新一个“对象-引用”二元组,通过在引用上加上“版本号”,从而避免 ABA 问题。类似的,AtomicMarkableReference 将更新一个“对象引用-布尔值”二元组,在某些算法中将通过这种二元组使节点保存在链表中同时又将其标记为“已删除的节点”。
小结
非阻塞算法通过底层的并发原语(如 CAS 而不是锁)来维持线程安全性。这些底层的原语通过原子变量类向外公开,这些类也用作一种“更好的 volatile 变量”,从而为整数和对象引用提供原子的更新操作。
非阻塞算法在设计和实现时非常困难,但通常能够提供更高的可伸缩性,并能更好的防止活跃性故障的发生。在 JVM 从一个版本升级到下一个版本的过程中,并发性能的主要提升都来自于(在 JVM 内部以及平台类库中)对非阻塞算法的使用。
1.16 - CH16-内存模型
本书中,我们尽可能的避开了 Java 内存模型(JMM)的底层细节,而将重点放在一些高层次的设计问题,如安全发布、同步策略的规范以及一致性等。它们的安全性都来自于 JMM,并且当你理解了这些机制的工作原理之后,就能更容易的使用它们。本章将介绍 JMM 的底层需求以及它提供的保证,此外还将介绍在本书给出的一些高层设计原则背后的原理。
16.1 什么是内存模型,为什么需要它
假设一个线程为变量 aVariable 赋值:
aVariable = 3;
内存模型需要解决一个问题:“在什么条件下,读取 aVariable 的线程将看到它的值为 3?”。这听起来似乎是一个愚蠢的问题,但如果缺少同步,那么将会发生很多因素使得线程无法立即甚至永远看到另一个线程的操作结果。在编译器中生成的指令顺序,可以与源码中的顺序不同,此外编译器还会把变量保存在寄存器而非内存中:处理器可以采用乱序执行或并行等方式来执行指令;缓存可能会改变将写入变量提交到主存的次序;而且,保存在处理器本地缓存中的值,对于其他处理器是不可见的。这些因素都会使得一个线程无法看到变量的最新值,并且会导致其他线程中的内存操作似乎在乱序执行——如果没有使用正确的同步。
在单线程环境中,我们无法看到所有这些底层技术,它们除了提高程序的执行速度外,不会产生太大的影响。Java 语言规范要求 JVM 在线程中维护一种类似串行的语义:只要程序的最终结果与在严格串行环境中执行的结果相同,那么上述所有操作都是允许的。这确实是一件好事情,因为在最近几年中,计算性能的提升在很大程度上要归功于这些重排序措施。当然,时钟频率的提升同样提升了性能,此外还有不断提升的并行性——采用流水线的超标量执行单元、动态指令调度、猜测执行以及完备的多级缓存。随着处理器变得越来越强大,编译器也不断地在改进:通过对指令重排序来实现执行优化,以及使用成熟的全局寄存器分配算法。由于时钟频率越来越难以提高,因此许多处理器制造厂商都开始转而生产多核处理器,因为能够提高的只有硬件的并行性。
在多线程环境中,维护程序的串行性将导致很大的性能开销。对于开发应用程序中的线程来说,他们在大部分时间里都执行各自的任务,因此在线程之间的协调操作只会降低应用程序的运行速度,而不会带来任何好处。只有当多个线程要共享数据时,才必须协调它们之间的操作,并且 JVM 依赖程序通过通过操作来找出这些协调操作将会在何时发生。
JMM 规定了 JVM 必须遵循一组最小保证,这组保证规定了对变量的写入操作在何时将对于其他线程可见。JMM 在设计是就在可预测性和程序的易于开发性之间进行了权衡,从而在各种主流的处理器体系架构上能够实现高性能的 JVM。如果你不了解现代处理器和编译器中使用的程序性能优化措施,那么在刚刚接触 JMM 的某些方面时会感到困惑。
16.1.1 平台的内存模型
在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期的与主内存进行协调。在不同的处理器架构中提供了不同级别的缓存一致性(Cache Coherence),其中一部分只提供最低的保证,即允许不同的处理器在任意时刻从一个存储位置上看到不同的值。操作系统、编译器以及运行时(有时甚至还包括应用程序)需要弥合这种在硬件能力与线程安全需求之间的差异。
要想确保每个处理器都能在任意时刻知道其他处理器正在进行的工作,将需要非常大的开销。在大多数时间里,这种信息都是不需要的,因此处理器会适当放宽存储一致性保证,以换取性能的提升。在架构定义的内存模型中将告诉应用程序可以从内存系统中获取怎样的保证,此外还定义了一些特殊的指令(称为内存屏障或屏障),当需要共享数据时,这些指令就能实现额外的存储协调保证。为了使 Java 开发在人员无需关心不同架构上内存模型之间的差异,Java 还提供了自己的内存模型,并且 JVM 通过在适当的位置上插入内存屏障来屏蔽在 JMM 与底层平台内存模型之间的差异。
程序执行一段简单的假设:想象在程序中只存在唯一的操作执行顺序,而不考虑这些操作在何种处理器上执行,并且在每次读取变量时,都能获得在执行时序中(在任何处理器)最近一次写入该变量的值。这种乐观的模型就被称为串行一致性。软件开发人员经常会错误的假设存在串行一致性,但在任何一款现代多处理器架构中都不会提供这种串行一致性,JMM 也是如此。冯诺依曼模型中这种经典串行计算模型,只能近似描述现代多处理器的行为。
在现代支持共享内存的多处理器(和编译器)中,当跨线程共享数据时,会出现一些奇怪的情况,除非通过使用内存屏障来防止这些情况的发生。幸运的是,Java 程序不需要指定内存屏障的位置,而只需要正确的使用同步来找出何时将访问共享状态。
16.1.2 重排序
在第二章中介绍竟态条件和原子性故障时,我们使用了交互图来说明:在没有充分同步的程序中,如果调度器采用不恰当的方式来交替执行不同线程的操作,那么将导致不正确的结果。更糟的是,JMM 还使得不同线程看到的操作执行顺序是不同的,从而导致在缺乏同步的情况下,要推断操作的执行顺序将变得更加复杂。各种使操作延迟或者看似乱序执行的不同原因,都可以归为重排序。
在程序清单 16-1 的 PossibleReording 中说明了,在么有正确同步的情况下,即使要推断最简单的并发程序的行为也是很困难的。很容易想象 PossibleReording 是如何输出(1,0)或(0,1)或(1,1)的:线程 A 可以在线程 B 开始之前就执行完成,线程 B 也可以在线程 A 开始之前执行完成,或者二者的操作交替执行。但奇怪的是,PossibleReording 还可以输出 (0,0)。由于每个线程中的各个操作之间不存在数据流依赖性,因此这些操作可以乱序执行。(即使这些操作按顺序执行,但在将缓存刷新到主内存的不同时序中也可能出现这种情况,从线程 B 的角度看,线程 A 中的复制操作可能以相反的次序执行)。图 16-1 给出了一种可能由重排序导致的交替执行方式,这种情况恰好会输出 (0,0)。
public class PossibleReordering {
static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
Thread one = new Thread(new Runnable() {
public void run() {
a = 1;
x = b;
}
});
Thread other = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
one.start();
other.start();
one.join();
other.join();
System.out.println("( "+ x + "," + y + ")");
}
}
PossibleReording 是一个简单的程序,但要列举出它所有可能的结果却非常困难。内存级重排序会使程序的行为变得不可预测。如果没有使用同步,那么推断出执行顺序将是非常困难的,而要确保在程序中正确的使用同步确实非常容易的。同步将限制编译器、运行时和硬件对内存操作重排序的方式,从而在实施重排序时不会破坏 JMM 提供的可见性保证。
16.1.3 JMM 简介
JMM 是通过各种操作来定义的,包括对变量的读写操作、监视器的加解锁操作、线程的启动和合并操作。JMM 为程序中所有的操作定义了一个偏序关系,称之为 HappensBefore。要想保证执行操作 B 的线程能够看到操作 A 的结果(无论 A 和 B 是否在同一个线程中执行),那么在 A 和 B 之间必须满足 HappensBefore 关系。如果两个操作之间缺乏 HappensBefore 关系,那么 JVM 可以对它们进行任意的重排序。
偏序关系 π 是集合上的一种关系,具有反对称、自反和传递性,但对于任意两个元素 x、y 来说,并不需要一定满足 x π y 或 y π x 的关系。我们每天都在使用偏序关系来表达喜好,如我们可以使用更喜欢寿司而不是三明治,可以更喜欢莫扎特而不是马勒,但我们不必在三明治和莫扎特之间做出明确的喜好选择。
当一个变量被多个线程读取并且至少被一个线程写入时,如果在读取操作和写入操作之间没有依照 HappensBefore 来排序,那么就会产生数据竞争问题。在正确同步的程序中不存在数据竞争,并会表现出串行一致性,这意味着程序中的所有操作都会安装一种固定的和全局的顺序执行。
HappensBefore 规则包括:
- 程序顺序规则。如果程序中操作 A 在操作 B 之前,那么在线程中 A 操作将在 B 操作之间执行。
- 监视器锁规则。在监视器上的解锁操作必须在同一个监视器锁上的加锁操作之前执行。
- volatile 变量规则。对 volatile 变量的写入操作必须在对该变量的读取操作之前执行。
- 程序启动规则。在线程上对 Thread.start 的调用必须在该线程中执行任何操作之前执行。
- 线程结束规则。线程中任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从 Thread.join 中成功返回,或者在调用 Thread.isAlive 时返回 false。
- 中断规则。当一个线程在另一个线程上调用 interrupt 时,必须在被中断线程检测到 interrupt 调用之前执行(或者抛出中断异常,或者调用 isInterrupted 和 interrupted)。
- 终结器规则。对象的构造函数必须在启动该对象的终结器之间执行完成。
- 传递性。如果操作 A 在操作 B 之前执行,并且操作 B 在操作 C 之前执行,那么操作 A 必须在操作 C 之前执行。
虽然这些操作只满足偏序关系,但同步操作,如锁的获取与释放操作,以及 volatile 变量的读写操作,都满足全序关系。因此,在描述 HappensBefore 关系时,就可以使用“后续的加锁操作”和“后续的 volatile 变量读取操作”等表述语句。
图 16-2 给出了当两个线程使用同一个锁进行同步时,在它们之间的 HappensBefore 关系。在线程 A 内部的所有操作都按照他们在源程序中的先后顺序来排序,在线程 B 内部的操作也是如此。由于 A 释放了锁 M,并且 B 随后获得了锁 M,因此 A 中所有在释放锁之前的操作,也就位于 B 请求锁之后的所有操作之前。如果这两个线程在不同的锁上进行同步,那么就不能推断出它们之间的动作关系,因为这两个线程的操作之间并不存在 HappensBefore 关系。
16.1.4 借助同步
由于 HappensBefore 的排序功能很强大,因此有时候可以“借助”现有同步机制的可见性属性。这需要将 HappensBefore 的程序顺序规则与其他某个顺序规则(通常是监视器锁或 volatile 变量规则)结合起来,从而对某个未被锁保护的变量的访问操作进行排序。这项技术由于对语句的顺序非常敏感,因此很容易出错。这是一项高级技术,并且只有当需要最大限度的提升某些类(如 ReentrantLock)的性能时,才应该使用这种技术。
在 FutureTask 的保护方法 AQS 中说明了如何使用这种借助技术。AQS 维护一个表示同步器状态的整数,FutureTask 用这个整数来保存任务的状态:正在运行、已完成、已取消。但 FutureTask 还维护了其他一些变量,如计数结果。当一个线程调用 set 来保存结果并且另一个线程调用 get 来获取该结果时,这两个线程最好按照 HappensBefore 进行排序,这可以通过将执行结果的引用声明为 volatile 类型来实现,但利用现有的同步机制可以更容易地实现相同的功能。
FutureTask 在设计时能够确保,在调用 tryAcquireShared 之前总能成功的调用 tryReleaseShared。tryReleaseShared 会写入一个 volatile 类型的变量,而 tryAcquireShared 将读取该变量。程序清单 16-2 给出了 innerSet 和 innerGet 等方法,在保存和获取 result 时将调用这些方法。由于 innerSet 将在调用 releaseShared (这又将调用 tryReleaseShared)之前写入 result,并且 innerGet 将在调用 acquireShared (这又将调用 tryReleaseShared)之后读取 result,因此将程序顺序规则与 volatile 变量规则结合在一起,就可以确保 innerSet 中的写入操作在 innerGet 中的读取操作之前执行。
// Inner class of FutureTask
private final class Sync extends AbstractQueuedSynchronizer {
private static final int RUNNING = 1, RAN = 2, CANCELLED = 4;
private V result; private Exception exception;
void innerSet(V v) {
while (true) {
int s = getState();
if (ranOrCancelled(s))
return;
if (compareAndSetState(s, RAN))
break;
}
result = v;
releaseShared(0);
done();
}
V innerGet() throws InterruptedException, ExecutionException {
acquireSharedInterruptibly(0);
if (getState() == CANCELLED)
throw new CancellationException();
if (exception != null)
throw new ExecutionException(exception);
return result;
}
}
之所以将这项技术成为“借助”,是因为它使用了一种现有的 HappensBefore 顺序来确保对象 X 的可见性,而不是专门为了发布 X 而创建一种 HappensBefore 顺序。
在 FutureTask 中使用的借助技术很容易出错,因此要谨慎使用。但在某些情况下,这种借助技术是非常合理的。如,当某个类在其规范中规定他的各个方法之间必须遵循一种 HappensBefore 关系,基于 BlockingQueue 实现的安全发布就是一种“借助”。如果一个线程将对象置入队列并且另一个线程随后获取这个对象,那么这就是一种安全发布,因为在 BlockingQueue 的实现中包含有足够的内部同步来确保入列操作在出列操作之前执行。
在类中提供的其他 HappensBefore 排序包括:
- 将一个元素放入一个线程安全容器的操作将在另一个线程从该容器中获得这个元素的操作之前执行。
- 在 CountDownLatch 上的倒数操作将在线程从闭锁上的 await 方法中返回之前执行。
- 在释放 Semaphore 许可的操作将在从该 Semaphore 上获得一个许可之前执行。
- Future 表示的任务的所有操作将在从 Future.get 中返回之前执行。
- 向 Executor 提交一个 Runnable 或 Callable 的操作将在任务开始执行之前执行。
- 一个线程到达 CyclicBarrier 或 Exchanger 的操作将在其他到达该栅栏或交换点的线程被释放之前执行。如果 CyclicBarrier 使用一个栅栏操作,那么到达栅栏的操作将在栅栏操作之前执行,而栅栏操作又会在线程从栅栏中释放之前执行。
16.2 发布
第三章介绍了如何安全的或者不正确的发布一个对象。对于其中介绍的各种安全技术,他们的安全性都来自于 JMM 提供的保证,而造成不正确发布的真正原因,就是在“发布一个共享对象”与“另一个线程访问该对象”之间缺少一种 HappensBefore 排序。
16.2.1 不安全的发布
当缺少 HappensBefore 关系时,就能出现重排序问题,这就解释了为什么在么有充分同步的情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象。在初始化一个新的对象时需要写入多个变量,即新对象中的各个域。同样,在发布一个引用时也需要写入一个变量,即新对象的引用。如果无法确保发布共享对象引用的操作在另一个线程加载该共享引用之前执行,那么对新对象引用的写入操作将于对象中各个域的写入操作重排序(从使用该对象的线程的角度来看)。在这种情况下,另一个线程可能看到对象引用的最新值,但同时也将看到对象的某些或全部状态中包含的是无效值,即一个被部分构造的对象。
错误的延迟初始化将导致不正确的发布,如程序清单 16-3 所示。初看起来,在程序中存在的问题只有在 2.2.2 中介绍的竟态条件问题。在某些特定条件下,例如当 Resource 的所有实例都相同时,你或许会忽略这些问题(以及在多次创建 Resource 实例时存在低效率问题)。然而,即使不考虑这些问题,UnsafeLazyInitialization 仍然是不安全的,因为另一个线程可能看到对部分构造的 Resource 实例的引用。
@NotThreadSafe
public class UnsafeLazyInitialization {
private static Resource resource;
public static Resource getInstance() {
if (resource == null)
resource = new Resource(); // unsafe publication
return resource;
}
}
假设线程 A 是一个调用 getInstance 的线程。它将看到 resource 为 null,并且初始化一个新的 Resource,然后将 resource 设置为执行这个新实例。当线程 B 随后调用 getInstance,它可能看到 resource 的值为非空,因此使用这个已经构造好的 Resource。最初看不出任何问题,但线程 A 写入 resource 的操作与线程 B 读取 resource 的操作之间不存在 HappensBefore 关系。在发布对象时存在数据竞争问题,因此 B 并不一定看到 Resource 的正确状态。
当新分配一个 Resource 时,Resource 的构造函数将把新实例中的各个域由默认值(由 Object 构造函数写入的)修改为他们的初始值。由于在两个线程中都没有使用同步,因此线程 B 看到的线程 A 中的操作顺序,可能与线程 A 执行这些操作时的顺序并不相同。因此,即使线程 A 初始化 Resource 实例之后再将 resource 设置为指向他,线程 B 仍可能看到对 resource 的写入操作将在对 Resource 各个域的写入操作之前发生。因此,线程 B 就可能看到一个被部分构造的 Resource 实例,该实例肯呢个处于无效的状态,并在随后该实例的状态可能出现无法预料的变化。
除了不改变对象以外,使用被另一个线程初始化的对象通常都是不安全的,除非对象的发布操作是在使用该对象的线程开始使用之前执行。
16.2.2 安全的发布
第三章中介绍的安全发布常用模式可以确保被发布对象对于其他对象是可见的,因为他们保证发布对象的操作将在使用对象的线程开始使用该对象的引用之前执行。如果线程 A 将 X 放入 BlockingQueue (并且随后没有线程修改它),线程 B 从队列中获取 X,那么可以确保 B 看到的 X 与 A 放入的 X 相同。这是因为在 BlockingQueue 的实现中有足够的内部同步确保了 put 方法在 take 方法之前执行。同样,通过使用一个由锁保护共享变量或者使用共享的 volatile 类型变量,也可以确保对该变量的读取操作和写入操作按照 HappensBefore 关系来排序。
事实上,HappensBefore 比安全发布提供了更强的可见性与顺序保证。如果将 X 从 A 安全的发布到 B,那么这种安全发布可以保证 X 状态的可见性,但无法保证 A 访问的其他变量的状态可见性。然而,如果 A 将 X 置入队列的操作在线程 B 从队列中获取 X 的操作之间执行,那么 B 不仅能看到 A 留下的 X 的状态,(假设线程 A 或其他线程都没有对 X 再次进行修改),而且还能看到 A 在移交 X 之前所做的任何操作。
既然 JMM 已经提供了这种更强大的 HappensBefore 关系,那么为什么还要介绍 @GuardedBy
和安全发布呢?与内存写入操作的可见性相比,从转移对象的所有权以及对象公布等角度来看,它们更符合大多数程序的设计。HappensBefore 排序是在内存访问级别上的操作,它是一种“并发级汇编语言”,而安全发布的运行级别更接近程序设计。
16.2.3 安全初始化模式
有时候,我们需要推迟一些高开销的对象的初始化操作,并且只有当使用这些对象时才对其进行初始化,但我们也看到了在无用延迟初始化时导致的问题。在程序清单 16-4 中,通过将 getResource 方法声明为 synchronized,可以修复 UnsafeLazyInitialization 中的问题。由于 getInstance 的代码路径很短(只包括一个判断遇见和一个预测分支),因此如果 getInstance 没有被多个线程频繁调用,那么在 SafeLazyInitialization 上不会存在激烈的竞争,从而提供令人满意的性能。
@ThreadSafe
public class SafeLazyInitialization {
private static Resource resource;
public synchronized static Resource getInstance() {
if (resource == null)
resource = new Resource();
return resource;
}
}
在初始化中采用了特殊的方式来处理静态域(或者在静态初始化块中初始化的值),并提供了额外的线程安全性保证。静态初始化器是由 JVM 在类的初始化阶段执行的,即在类被加载后并且被线程使用之前。由于 JVM 将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以确保该类已被加载,因此在静态初始化期间,内存写入操作将自动对所有线程可见。因此无论是在被构造期间还是被引用时,静态初始化的对象都不需要显式的同步。然而,这个规则仅适用于在构造时的状态,如果对象是可变的,那么在读线程和写线程之间仍然需要通过同步机制来确保随后的修改操作是可见的,以及避免数据被破坏。
如程序清单 16-5 所示,通过使用提前初始化,避免了在每次调用 SafeLazyInitialization 中的 getInstance 时所产生的同步开销。通过将这项技术和 JVM 的延迟加载机制结合起来,可以形成一种延迟初始化技术,从而在常见的代码路径中避免使用同步。在程序清单 16-6 的“延迟初始化占位类模式”中使用了一个专门的类来初始化 Resource。JVM 将推迟 ResourceHolder 的初始化操作,直到开始使用这个类时才初始化,并且由于通过一个静态初始化来创建 Resource,因此不需要额外的同步。当任何一个线程第一次调用 getResource 时,都会使 ResourceHolder 被加载和被初始化,此时静态初始化器将执行 Resource 的初始化操作。
@ThreadSafe public class EagerInitialization {
private static Resource resource = new Resource();
public static Resource getResource() { return resource; }
}
@ThreadSafe public class ResourceFactory {
private static class ResourceHolder {
public static Resource resource = new Resource();
}
public static Resource getResource() {
return ResourceHolder.resource ;
}
}
16.2.4 双重检查加锁
在任何一本介绍并发的书中都会讨论声名狼藉的双重检查加锁(DCL),如程序清单 16-7 所示。在早期的 JVM 中,同步(甚至是无竞争的同步)都存在着巨大的同步开销。因此,人们先出了许多“聪明的(或者至少看上去聪明)”技巧来降低同步的影响,有些技巧很好,但有些技巧却不然,甚至是糟糕的,DSL 就属于糟糕的一类。
@NotThreadSafe
public class DoubleCheckedLocking {
private static Resource resource;
public static Resource getInstance() {
if (resource == null) {
synchronized (DoubleCheckedLocking.class) {
if (resource == null)
resource = new Resource();
}
}
return resource;
}
}
由于早期的 JVM 在性能上存在一些有待优化的地方,因此延迟初始化经常被用来避免不必要的高开销操作,或者用来降低程序的启动时间。在编写正确的延迟初始化方法中需要使用同步。但在当时,同步不仅执行速度慢,并且重要的是,开发人员还没有完全理解同步的含义:虽然人们能很好的理解了“独占性”,却没有很好的理解“可见性”的含义。
DCL 声称能实现两全其美——在常见的代码路径上的延迟初始化中不存在同步开销。它的工作原理是,首先检查是否在没有同步的情况下需要初始化,如果 resource 引用不为空,那么就直接使用它。否则,就进行同步并再次检查 Resource 是否已被初始化,从而保证只有一个线程对其共享的 Resource 执行初始化。在常见的代码路径中——获取一个已构造完成的 Resource 引用时并没有使用同步。这就是问题所在:在 16.2.1 中介绍过,线程能够看到一个仅被部分构造的 Resource。
DCL 的真正问题在于:当在没有同步的情况下读取一个共享对象时,可能发生的最糟糕情况就是看到一个失效值(在这个例子中是一个空值),此时 DCL 方法将通过在持有锁的情况下再次尝试来避免这种风险。然而,实际情况远比这种情况要糟糕——线程可能看到引用的当前值,但对象的状态值却是失效的,这意味着线程可以看到对象处于失效或错误的状态。
在 JMM 的后续版本(5.0 及以上)中,如果把 resource 声明为 volatile,那么就能启用 DCL,并且这种技术对性能的影响很小,因为 volatile 变量读取操作的性能通常只是略高于非 volatile 变量读取操作的性能。然而,DCL 的这种用法已经被广泛得废弃了——促使该模式出现的驱动力(无竞争同步的执行速度很慢每、JVM 启动时很慢)已经不复存在,因而他不是一种高效的优化措施。延迟初始化占位类模式能带来同样的优势,并且更易理解。
16.3 初始化过程中的安全性
如果能够确保初始化过程的安全性,那么就可以使的被正确构造的不可变对象在没有同步的情况下也能安全的在多个线程之间共享,而不管他们是如何发布的,甚至通过某种数据竞争来发布。(这意味着,如果 Resource 是不可变的,那么 UnsafeLazyInitialization 实际上是线程安全的)。
如果不能确保初始化的安全性,那么当在发布或线程中没有使用同步时,一些本因为不可变对象(如 String)的值将会发生改变。安全性架构依赖于 String 的不可变性,如果缺少了初始化安全性,那么可能导致一个安全漏洞,从而使恶意代码绕过安全检查。
初始化安全性将确保,对于被正确构造的对象,所有线程都能看到由构造函数为对象各个 final 域设置的正确值,而不管采用何种方式来发布对象。而且,对于可以通过被正确构造对象中某个 fianl 域到达的任意变量(如某个 final 数组中的元素,或者由一个 final 域引用的 HashMap 的内容)将同样对于其他线程是可见的。
对于含有 final 域的对象,初始化安全性可以防止对对象的初始引用被重排序到构造过程之前。当构造函数完成时,沟站函数对 final 域的所有写入操作,以及对通过这些域可以到达的任何变量的写入操作,都将被“冻结”,并且任何获得该对象引用的线程都至少能确保看到被冻结的值。对于通过 fianl 域可到达的初始变量的写入操作,将不会与构造过程后的操作一起被重排序。
初始化安全性意味着,程序清单 16-8 的 SafeStates 可以安全的发布,即便通过不安全的延迟初始化,或者在没有同步的情况下将 SafeStates 的引用放到一个工友的静态域,或者没有使用同步以及依赖于非线程安全的 HashSet。
@ThreadSafe
public class SafeStates {
private final Map<String, String> states;
public SafeStates() {
states = new HashMap<String, String>();
states.put("alaska", "AK");
states.put("alabama", "AL");
...
states.put("wyoming", "WY"); }
public String getAbbreviation(String s) {
return states.get(s);
}
}
然而,许多对 SafeStates 的细微修改可能会破坏它的线程安全性。如果 states 不是 final 类型,或者存在除构造函数以外的其他方法能修改 states,那么初始化安全性将无法确保在缺少同步的情况下安全的访问 SafeStates。如果在 SafeStates 中还有其他的非 final 域,那么其他线程任然可能看到这些域上的不正确的值。这也导致了对象在构造过程中逸出,从而使初始化安全性的保证失效。
初始化安全性只能保证通过 final 域可达的值从构造过程完成时开始的可见性。对于通过非 final 域科大的值,或者在构成过程完成后可能改变的值,必须采用同步来确保可见性。
小结
Java 内存模型说明了某个内存操作在哪些情况下对于其他线程是可见的。其中包括确保这些操作是按照一种 HappensBefore 的偏序关系进行排序,而这种关旭是基于内存操作和同步操作等级别来定义的。如果缺少充足的同步,那么在当线程访问共享数据时,会发生一些非常奇怪的问题。然而,如果使用第二、三章介绍的更高级规则,如 @GuardedBy
和安全发布,那么即使不考虑 HappensBefore 的底层细节,也能确保线程安全性。
2 - Java 并发模式
3 - 深入理解并行
3.1 - CH01-关于本书
- Is Parallel Programming Hard, And If So, What Can You Do About It?
- 中文版:深入理解并行编程
- 作者:Paul E. McKenney
- 译者:谢宝友、鲁阳
- 中文版基于 2015.x,对照英文版本 2017.11.22a。
- 本书的英文版以长期维护的开源形式免费供读者阅读,且每个版本都存在变更,大到整书的组织结构、小到示例代码。
- 因此建议想要基于本书开展实践的同学直接阅读英文版。
本书专注于基于 基于共享内存的并行编程 ,重点放在软件栈底层的软件,比如操作系统内核、并行的数据库管理系统、底层系统库等。
本书包含一些广泛应用且使用频繁的设计技巧,而非一些适用范围有限的最佳算法。
内容简介:
- CH01:关于本书。
- CH02:并行编程概览。
- CH03:介绍共享内存并行硬件。因为,在不了解底层硬件的情况下很难编写出正确的并行代码。
- CH04:为常用的、基于共享内存的并行编程原语提供了一个简要的概览。
- CH05:深入介绍了并行领域中可能最简单的一个问题——计数。
- CH06:介绍了一些设计层的方法,用于解决 CH05 中遇到的问题。
- CH07:锁。
- CH08:数据所有权。
- CH09:延期处理机制——引用计数、危险指针、顺序锁、RCU。
- CH10:将前面介绍的技术应用到哈希表。
- CH11:各种形式的并行代码校验手段。
- CH12:形式验证。
- CH13:通过示例的形式介绍了一系列中等规模的并行编程问题。
- CH14:高级同步方法,如无锁同步、并行实时计算。
- CH15:关于内存序的高级主题。
- CH16:一些实践建议。
- CH17:并行编程的未来方向,包括共享内存并行系统设计、软件和硬件事务内存、函数式并行编程。
- 附录-C:着重介绍了内存屏障的原理与实践。
本文作者 Paul 是 Linux 内核大神,40 年开发经验。 全书干货居多,作者的介绍非常细致,很多高级主题让我这个新手感到震撼,值得反复阅读。 这里为了学习理解、加深记忆,标注、摘抄、整理了中文版、英文版中的内容,仅供个人学习、交流、查阅。 写书、译书不易,感谢作者、译者的辛勤汗水,请支持正版。
3.2 - CH02-简介
导致并行编程困难的历史原因
困难的分类:
- 并行系统在历史上的高价格与稀缺性。
- 研究者和从业人员对并行系统的经验欠缺。
- 缺少公开的并行代码。
- 并行编程缺少被广泛了解的工程准则。
- 相对于处理本身,通信的代价高昂,即使在紧凑的 共享内存系统 中也是如此。
目前这些问题的现状:
- 基于摩尔定律,并行系统的价格降低。
- 研究者和从业人员开始广泛接触并行系统。
- 大量开源的并行软件项目出现。
- 开发者社区形成,这些开发者知道产品级的并行代码需要什么样的准则。
- 通信、处理代价高昂的问题依然存在,而光的有限速度及原子特性会限制该领域的进展,但方法总是有的。
并行编程的目标
性能
大多并行编程的尝试都是为了提升性能。摩尔定律仍然在晶体管密度方面有效,但在单线程性能方面已经不再有效,因此 对性能的关注点从硬件转移到了并行软件。这意味着先编写单线程代码,再通过升级 CPU 来提升性能已不再可行。因此首先要考虑的是性能而不是扩展性。
即使拥有多个 CPU,也不必全部都用起来。并行编程主要是为了性能优化,但这只是众多优化措施中的一种。 如果程序够快则无需优化或并行化,亦或是基于单线程方式的优化。如果需要基于并行的方式进行优化,则需要与最好的串行算法进行比较,已确定并行化的必要性。
生产率
硬件的价格已远低于软件的开发与维护成本。仅仅高效的使用硬件已经不再足够,高效的利用开发者已经变得同等重要。
通用性
要想减少开发并行程序的高昂成本,一种方式是 让程序尽量通用。如果其他影响因素一样,通用的软件能获得更多用户从而摊薄成本。但通用性会带来更大的性能损失和生产率损失。如下是一些典型的并行开发环境。
- C/C++ 锁与线程:包含 POSIX 线程(pthreads)、Windows 线程以及众多系统内核环境。性能优秀、通用性良好,但生产率低。
- Java:生产率比 C/C++ 要高,虽然性能不断进步但仍低于 C/C++。
- MPI:该消息传递接口向大量的科学和技术计算提供能力,提供了无与伦比的性能和扩展性。虽然通用但主要面向科学计算。生产率低于 C/C++。
- OpenMP:该编译指令集用于并行循环,因此用于特定任务从而限制了其性能。比 MPI、C/C++ 要简单。
- SQL:结构化编程语言 SQL 仅用于数据库查询,性能出色、生产率优秀。
同时满足性能、生产率、通用性要求的并行编程环境仍不存在,因此必须在三者之间进行权衡。 越往上层,生成率越重要;越往下层,性能和通用性越重要。大量的开发工作消耗在上层,必须提高通用性以降低成本;下层的性能损失很难在上层得到恢复。越往上层,采用额外的硬件比采用额外的开发者更划算。
本书面向底层开发,因此主要关心 性能和通用性。
并行编程的替代方案
并行编程只是提升性能的方案之一,以下是一些其他流行的方案:
- 运行多个串行应用实例:
- 会增加内存消耗、CPU 指令周期浪费在重复计算中间结果上,也会增加数据复制操作。
- 利用现有的并行软件构建应用:
- 通常会牺牲性能,至少会逊色于精心构造的并行程序,但可以显著降低开发难度。
- 对串行应用进行逻辑优化:
- 来自并行计算的速度提升与 CPU 个数大约成正比,而对软件逻辑进行的优化可能会带来指数级的性能提升。
- 不同程序的性能瓶颈不同。
复杂的原因
并行编程需要双向交互:人告诉计算机要做什么;人还需要通过结果的性能和扩展性来评价程序。
我们所要考虑的并行程序开发者的任务,对于串行程序开发者来说是完全不需要的。我们将这些任务分为 4 类:
分割任务
合理的对任务进行分割以提升并行度,可以极大提升性能和扩展性,但是也会增加复杂性。 比如,分割任务可能会让全局错误处理和事件处理更复杂,并行程序可能需要一些相当复杂的同步措施来安全的处理这些全局事件。总的来说,每个任务分割都需要一些交互,如果某个线程不存在任何交互,那它的执行对任务本身也就不产生任何影响。但是交互也就意味着额外的、可能降低性能的开销。
如果同时执行太多线程,CPU 缓存将会溢出,引起过高的缓存未命中,降低性能。
运行程序并发执行会大量增加程序的状态集,导致程序难以理解和调试,降低生产率。
并行访问控制
单线程的线性程序对程序的所有资源都有访问权,主要是内存数据结构,但也可能是 CPU、内存、高速缓存、IO 设备、计算加速器、文件等。并行访问控制的问题有:
- 访问特定的资源是否受限于资源的位置。 比如本地或远程、显式或隐式、赋值或消息传递等。
- 线程如何协调对资源的访问。 这种协调由不同的并行语言或环境通过大量同步机制实现,如:消息传递、加锁、事务、引用计数、显式计时、共享原子变量、数据所有权等。因此要面对死锁、活锁、事务回滚等问题。
资源分割与复制
最有效的并行算法和系统都善于对资源进行并行化,所以并行编程的编写最好从分割写密集型资源和复制经常访问的读密集型资源开始。 这里所说的访问频繁的数据,可能是计算机系统、海量存储设备、NUMA节点、CPU、页面、Cache Line、同步原语实例、代码临界区等等多个层次。
与硬件交互
开发者需要根据目标硬件的高速缓存分布、系统的拓扑结果或者内部互联协议来对应用进行量体裁衣。
组合使用
最好的实践会将上述 4 种类型的基础性任务组合应用。比如,数据并行方案首先把数据分割以减少组件内的交互需求,然后分割相应的代码,最后对数据分区并与线程映射,以便提升吞吐、减少线程内交互。
3.3 - CH03-硬件特性
本章主要关注 共享内存系统中的同步和通信开销,仅涉及一些共享内存并行硬件设计的初级知识。
概述
人们容易认为 CPU 的性能就像在一条干净的赛道上赛跑,但事实上更像是一个障碍赛训练场。
流水线 CPU
在 20 世纪 80 年代,典型的微处理器在处理一条指令之前,至少需要取值、解码和执行这三个时钟周期来完成当前指令。到 90 年代之后,CPU 可以同时处理多条指令,通过一条很长的流水线来控制 CPU 内部的指令流。
带有长流水线的 CPU 要想达到最佳性能,需要程序给出高度可预测的控制流。如果程序代码执行的是紧凑循环,那么这种程序就能提供 可预测的控制流,此时 CPU 可以正确预测出在大多数情况下代码循环结束后的分支走向。在这种程序中,流水线可以一直保持在满状态,CPU 高速运行。
如果程序中带有很多循环,且循环计数都比较小,或者面向对象的程序中带有很多虚方法,每个虚方法都可以引用不同的对象实例,而这些对象实例都实现了一些频繁被调用的成员函数,此时 CPU 很难或者完全不可能预测某个分支的走向。这样一来,CPU 要么等待控制流进行到足以知道分支走向的方向,要么干脆猜测,但常常出错。这时流水线会被排空,CPU 需要等待流水线被新指令填充,这将大幅降低 CPU 的性能。
- 分支预测的原理?
内存引用
在 20 世纪 80 年代,微处理器从内存中读取一个值的时间一般比执行一条指令的时间短,即指令执行慢于内存 IO。在 2006 年,同样是读取内存中的一个值的时间,微处理器可以执行上百条甚至千条指令。这源于摩尔定律对 CPU 性能的提升,以及内存容量的增长。
虽然现代微型计算机上的大型缓存极大减少了内存访问延迟,但是只有高度可预测的数据访问模式才能发挥缓存的最大效用。因此对内存的引用也就造成了对 CPU 性能的严重影响。
原子操作
原子操作本身的概念在某种意义上与 CPU 流水线上一次执行多条指令的操作产生了冲突。而现代 CPU 使用了很多手段让这些操作看起来是原子的,即使这些指令实际上并非原子。比如标出所有包含原子操作所需数据的流水线,确保 CPU 在执行原子操作时,所有这些流水线都属于正在执行原子操作的 CPU,并且只有在这些流水线仍归该 CPU 所有时才推进原子操作的执行。这样一来,因为所有数据都只属于该 CPU,即使 CPU 流水线可以同时执行多条指令,其他 CPU 也无法干扰此 CPU 的原子操作执行。但这种方式要求流水线必须能够被延迟或冲刷,这样才能执行让原子操作过程正确完成的一系列操作。
非原子操作则与之相反,CPU 可以从流水线中按照数据出现的顺序读取并把结果放入缓冲区,无需等待流水线的归属切换。
虽然 CPU 设计者已经开始优化原子操作的开销,但原子指令仍频繁对 CPU 性能造成影响。
内存屏障
原子操作通常只用于数据的单个元素,由于许多并行算法都需要在更新多个元素时保证正确的执行顺序,因此大多数 CPU 都提供了内存屏障。
spin_lock(&mylock);
a = a +1;
spin_unlock(&mylock);
像这样一个基于锁的临界区中,锁操作必须包含隐式或显式的内存屏障。内存屏障可以防止 CPU 为了提升性能而进行乱序执行,因此内存屏障也一定会影响性能。
高速缓存未命中
现代 CPU 使用大容量的高速缓存来降低由于低速的内存访问带来的性能惩罚。但是,CPU 高速缓存事实上对多 CPU 间频繁访问的变量起到了反面效果。因为当某个 CPU 想去改变变量的值时,极有可能该变量的值刚被其他 CPU 修改过。这时,变量存在于其他 CPU 的高速缓存中,这将导致代价高昂的高速缓存为命中。
IO 操作
缓存未命中可被视为 CPU 之间的 IO 操作,也是代价最小的 IO 操作之一。IO 操作涉及网络、大容量存储,或者人类本身(人机交互 IO)。IO 操作对性能的影响也远远大于前面所有提到的所有影响因素。
这也是共享内存并行计算和分布式系统式的并行编程的其中一个不同点:共享内存式并行编程的程序一般不会处理比缓存未命中更糟的情况,而分布式并行编程的程序则会遭遇网络通信延迟。因此,通信的开销占实际执行任务的比率是一项关键的设计参数。
开销
硬件体系结构
这是一个 8 核计算机概要图:每个芯片上有 2 个核,每个核带有自己的高速缓存,每个芯片内还带有一个互联模块,使芯片内的两个核可以互相通信,图中央的系统互联模块可以让 4 个芯片之间互相通信,并且与主存进行连接。
数据以缓存行(cache line)为单位在系统中传输,缓存行对应内存中一个 2 的乘方大小的字节,大小通常为 32 到 256 之间。当 CPU 从内存中读取一个变量到它的寄存器中时,必须首先将该变量的缓存行读取到 CPU 高速缓存;CPU 寄存器中的一个值存储到内存时,不仅需要将包含了该值的缓存行写入 CPU 高速缓存,还必须确保其他 CPU 没有该缓存行的复制。
比如,如果 CPU0 在对一个变量执行"比较并交换(CAS)“操作,而该变量所在的缓存行存储在 CPU7 的高速缓存中。下面是将要发送的事件序列:
- CPU0 检查本地高速缓存,没有找到缓存行。
- 请求被转发到 CPU0 和 CPU1 的互联模块,检查 CPU1 的高速缓存,没有找到缓存行。
- 请求被转发到系统互联模块,检查其他三个芯片,得知缓存行被 CPU6 和 CPU7 所在的芯片持有。
- 请求被转发到 CPU6 和 CPU7 的互联模块,检查这两个 CPU 的高速缓存,在 CPU7 的高速缓存中找到缓存行。
- CPU7 将缓存行发送到自己所属的互联模块,并且刷新掉自己高速缓存中的缓存行。
- CPU6 和 CPU7 所在芯片的互联模块将缓存行发送给系统互联模块。
- 系统互联模块将缓存行发送给 CPU0 和 CPU1 所在芯片的互联模块。
- CPU0 和 CPU1 所在芯片的互联模块将缓存行发送给 CPU0 的高速缓存。
- CPU0 现在可以对高速缓存中的变量执行 CAS 操作。
操作开销
上图是各种同步机制相与 CPU 周期时间的比率。(4-CPU 1.8 GHz AMD Opteron 844 System)
软件设计的启示
并行算法必须将每个线程设计成尽可能独立运行的线程。越少使用线程间的同步通信手段,比如原子操作、锁或其他消息传递方法,应用程序的性能和扩展性就会越好。想要达到优秀的并行性和扩展性,就需要在并行算法和实现中挣扎,小心的选择数据结构和算法,尽量使用现有的并行软件和环境,或者将并行问题转换为已经拥有并行解决方案的问题。
- 好消息是多核系统变得廉价且可靠。
- 另一个好消息是,现在很多同步操作的开销正变得越来越小。
- 坏消息是 高速缓存未命中的开销仍然很高,特别是在大型系统上。 本书剩余部分则会讨论如何解决该问题。
3.4 - CH04-并行工具
本章主要介绍一些并行编程领域的基本工具,主要是类 Linux 系统上可以供应用程序使用的工具。
脚本语言
Shell 脚本提供了一种简单有效的并行化:
1 compute_it 1 > compute_it.1.out &
2 compute_it 2 > compute_it.2.out &
3 wait
4 cat compute_it.1.out
5 cat compute_it.2.out
第 1、2 行分别启动了两个实例,通过 &
符号使这两个程序在后台运行,并分别将程序的输出重定向到一个文件。第 3 行等待两个实例执行完毕,第 4、5 行显示程序的输出。
另外,例如 make
脚本语言提供了一个 -j
选项来指定编译过程中同时执行多少个并行任务,make -j4
则表示同时执行 4 个并行编译过程。
既然基于脚本的并行编程这么简单,为什么还需要其他工具呢?
POSIX 多进程
POSIX 进程的创建与销毁
进程通过 fork()
原语创建,通过 kill()
原语销毁,也可以通过 exit()
原语实现自我销毁。执行 fork()
原语的进程被称为新创建进程的父进程,父进程可以功能通过 wait()
原语等待子进程执行完毕。
1 pid = fork();
2 if (pid == 0) {
3 / * child * /
4 } else if (pid < 0) {
5 / * parent, upon error * /
6 perror("fork");
7 exit(-1);
8 } else {
9 / * parent, pid == child ID * /
10 }
fork()
的返回值表示了其执行状态,即上面片段中的 pid。
1 void waitall(void)
2 {
3 int pid;
4 int status;
5
6 for (;;) {
7 pid = wait(&status);
8 if (pid == -1) {
9 if (errno == ECHILD)
10 break;
11 perror("wait");
12 exit(-1);
13 }
14 }
15 }
父进程使用 wait()
原语来等待子进程时,wait()
只能等待一个子进程。我们将 wait()
原语封装成一个 waitall()
函数,该函数与 shell 中的 wait
意义一样:for(;;)
将会一直循环,每次循环等待一个子进程,阻塞直到该子进程退出,并返回子进程的进程 ID 号,如果该进程号为 -1,则表示 wait()
无法等待子进程执行完毕。如果检查错误码为 ECHILD
则表示没有其他子进程了,这时退出循环。
wait()
原语的复杂性在于,父进程与子进程之间不共享内存,而最细粒度的并行化需要共享内存,这时则要比不共享内存式的并行化复杂很多。
这种 fork-waitall
的形式被称为 fork-join。
POSIX 线程创建与销毁
在一个已有的进程中创建线程,需要调用 pthread_create()
原语,它的第一个参数指向 pthread_t
类型的指针,第二个 NULL 参数是一个可选的指向 pthread_attr_t
结构的指针,第三个参数是新线程要调用的函数(下面的例子中是 mythread()
),最后一个 NULL 参数是传递给 mythread()
的参数。
1 int x = 0;
2
3 void * mythread(void * arg)
4 {
5 x = 1;
6 printf("Child process set x=1\n");
7 return NULL;
8 }
9
10 int main(int argc, char * argv[])
11 {
12 pthread_t tid;
13 void * vp;
14
15 if (pthread_create(&tid, NULL,
16 mythread, NULL) != 0) {
17 perror("pthread_create");
18 exit(-1);
19 }
20 if (pthread_join(tid, &vp) != 0) {
21 perror("pthread_join");
22 exit(-1);
23 }
24 printf("Parent process sees x=%d\n", x);
25 return 0;
26 }
- 第 7 行中,
mythread
直接选择了返回,也可以使用pthread_exist()
结束。 - 第 20 行的
pthread_join()
原语是对 fork-join 中wait()
的模仿,它一直阻塞到 tid 变量指向的线程返回。线程的返回值要么是传给pthread_exit()
的返回值,要么是线程调用函数的返回值,这取决于线程退出的方式。
上面代码的执行结果为:
Child process set x=1
Parent process sees x=1
上面的代码中小心构造了一次只有一个线程为变量赋值的场景。任何一个线程为某变量赋值而另一线程读取变量值的场景,都会产生数据竞争(data-race)。因此我们需要一些手段来安全的并发读取数据,即下面的加锁原语。
POSIX 锁
POSIX 规范支持开发者使用 POSIX 锁来避免数据竞争。POSIX 锁包括几个原语,其中最基础的是 pthread_mutex_lock()
和 pthread_mutex_unlock()
。这些原语将会操作 pthread_mutex_t
类型的锁。该锁的静态声明和初始化由 PTHREAD_MUTEX_INITIALIZER
完成,或者由 pthread_mutex_init()
来动态分配并初始化。
因为这些加锁、解锁原语是互相排斥的,所以一次只能有一个线程在一个特定的时刻持有一把特定的锁。比如,如果两个线程尝试同时获取一把锁,那么其中一个线程会首先获准持有该锁,另一线程只能等待第一个线程释放该锁。
POSIX 读写锁
POSIX API 提供了一种读写锁,用 pthread_rwlock_t
类型来表示,与 pthread_mutex_t
的初始化方式类似。pthread_rwlock_wrlock()
获取写锁,pthread_rwlock_rdlock()
获取读锁,pthread_rwlock_unlock()
用于释放锁。
读写锁是专门为大多数读的情况设计的。该锁能够提供比互斥锁更多的扩展性,因为互斥锁从定义上已经限制了任意时刻只能有一个线程持有锁,而读写锁运行任意多的线程同时持有读锁。
读写锁的可扩展性不甚理想,尤其是临界区较小时。为什么读锁的获取这么慢呢?应该是由于所有想获取读锁的线程都要更新 pthread_rwlock_t
的数据结构,因此一旦 128 个线程同时尝试获取读写锁的读锁时,那么这些线程必须逐个更新读锁中的 pthread_rwlock_t
结构。最幸运的线程几乎立即就获得了读锁,而最倒霉的线程则必须在前 127 的线程完成对该结构的更新后再能获得读锁。而增加 CPU 则会让性能变得更糟。
但是在临界区较大,比如开发者进行高延迟的文件或网络 IO 操纵时,读写锁仍然值得使用。
原子操作(GCC)
读写锁在临界区最小时开销最大,因此需要其他手段来保护极其短小的临界区。GCC 编译器提供了许多附加的原子操作:
- 返回参数原值
__sync_fetch_and_sub()
__sync_fetch_and_or()
__sync_fetch_and_and()
__sync_fetch_and_xor()
__sync_fetch_and_nand()
- 返回变量新值
__sync_add_and_fetch()
__sync_sub_and_fetch()
__sync_or_ and_fetch()
__sync_and_and_fetch()
__sync_xor_and_fetch()
__sync_nand_ and_fetch()
经典的比较并交换(CAS)是由一对原语 __sync_bool_compare_and_swap()
和 __sync_val_compare_and_swap()
提供的,当变量的原值与指定的参数值相等时,这两个原语会自动将新值写到指定变量。第一个原语在操作成功时返回 1,或在变量原值不等于指定值时返回 0;第二个原语在变量值等于指定的参数值时返回变量的原值,表示操作成功。任何对单一变量进行的原子操作都可以用 CAS 方式实现,上述两个原语是通用的,虽然第一个原语在适用的场景中效率更高。CAS 操作通常作为其他原子操作的基础。
__sync_synchronize()
原语是一个内存屏障,它限制编译器和 CPU 对指令乱序执行的优化。有些时候只限制编译器对指令的优化就够了,CPU 的优化可以保留,这时则需要 barrier()
原语。有时只需要让编译器不优化某个内存访问就够了,这时可以使用 ACCESS_ONCE()
原语。后两个原语并非由 GCC 直接提供,可以按如下方式实现:
#define ACCESS_ONCE(x) (*(volatile typeof(x) *)&(x))
#define barrier() __asm____volatile__("": : :"memory")
POSIX 的操作的替代选择
线程操作、加解锁原语、原子操作的出现早于各种标准委员会,因此这些操作存在多种变体。直接使用汇编实现这些操作也十分常见,不仅因为历史原因,还可以在某些特定场景下获得更好的性能。
如何选择
基于经验法则,应该在能够胜任工作的工具中选择最简单的一个。
- 尽量保持串行。
- Shell 脚本。
- C 中的 fork-join。
- POSIX 线程库原语。
- 第几章将要介绍的原语。
除此之外,不要忘记除了共享内存多线程执行之外,还可以选择进程间通信和消息传递。
3.5 - CH05-计数
计数概念的简单性让我们在探索并发中的基本问题时,无需被繁复的数据结构或复杂的同步原语干扰,因此可以作为并行编程的极佳切入对象。
并发计数并不简单
1 long counter = 0;
2
3 void inc_count(void)
4 {
5 counter++;
6 }
7
8 long read_count(void)
9 {
10 return counter;
11 }
- 1,声明一个计数器
- 5,将计数器加 1
- 10,读取计数器的值
当计数器不停读取计数但又几乎不增加计数时,计算性能非常好。但存在计数丢失。精确计数的最简单方式是使用原子操作:
1 atomic_t counter = ATOMIC_INIT(0);
2
3 void inc_count(void)
4 {
5 atomic_inc(&counter);
6 }
7
8 long read_count(void)
9 {
10 return atomic_read(&counter);
11 }
- 1,声明一个原子计数器
- 5,将计数器原子加 1
- 10,原子读取计数器的值
以上都是原子操作,因此非常精确,单线程时速度是非原子方式的 1/6,两个线程时速度是非原子方式的 1/10,即原子计数的性能随着 CPU 和线程数的增加而下降。
下图以 CPU 视角展示了原子操作带来的性能损耗,为了让每个 CPU 得到机会来增加一个全局变量,包含变量的缓存行需要在所有 CPU 间传播,沿下图中箭头所示的方向。这种传播相当耗时,从而导致了上图中糟糕的性能。
统计计数器
常见的统计计数器场景中,计数器更新频繁但很少被读,或者甚至完全不读。
设计
统计计数器一般以每个线程一个计数器的方式实现(或者在内核运行时以每个 CPU 一个),所以每个线程仅更更新自己的计数。而总的计数值就是所有线程计数值的和。
基于数组的实现
分配一个数组,数组每个元素对应一个线程(假设数组已经按缓存行对其并且被填充,以防止出现假共享)。
该数组可以用一个“每线程”原语来表示:
1 DEFINE_PER_THREAD(long, counter);
2
3 void inc_count(void)
4 {
5 __get_thread_var(counter)++;
6 }
7
8 long read_count(void)
9 {
10 int t;
11 long sum = 0;
12
13 for_each_thread(t)
14 sum += per_thread(counter, t);
15 return sum;
16 }
- 1,定义了一个数组,包含一套类型为 long 的每线程计数器 counter。
- 3~6,增加计数的函数,使用
__get_thread_var()
原语来定位当前运行线程对应 counter 数组的元素。因为该元素仅能由对应的线程修改,因此使用非原子自增即可。 - 8~16,读取总计数的函数,使用
for_each_thread()
原语遍历当前运行的所有线程,使用per_thread()
原语获取指定线程的计数。因为硬件可以原子地存取正确对齐的 long 型数据,并且 GCC 充分利用了这一点,所以使用非原子读取操作即可。
该方法随着 inc_count()
函数的更新者线程增加而线性扩展,原因是每个 CPU 可以快速的增加自己线程的变量值,不再需要代价高昂的、跨越整个计算机系统的通信,如下图所示:
但这种在“更新端”扩展极佳的方式在存在大量线程时会给“读取端”带来极大代价。接下来将介绍另一种方式,能在保留更新端扩展性的同时,减少读取端产生的代价。
最终结果一致的实现
一种保留更新端扩展性的同时又能提升读取端性能的方式是:削弱一致性要求。
前面介绍的计数算法要求保证返回的值在 read_count()
执行前一刻的理想计数值和 read_count()
执行完毕时的理想计数值之间。最终一致性方式提供了弱一些的保证:当不调用 inc_count()
时,调用 read_count()
最终会返回正确的值。
我们维护一个全局计数来利用”最终结果一致性“。但是因为写者只操作自己线程的每线程计数,我们需要一个单独的线程负责将每线程计数的计数值传递给全局计数,而读者仅需访问全局计数值。如果写者正在更新计数,读者读取的全局计数值将不是最新的,不过一旦写者更新完毕,全局计数最终会回归正确的值。
1 DEFINE_PER_THREAD(unsigned long, counter);
2 unsigned long global_count;
3 int stopflag;
4
5 void inc_count(void)
6 {
7 ACCESS_ONCE(__get_thread_var(counter))++;
8 }
9
10 unsigned long read_count(void)
11 {
12 return ACCESS_ONCE(global_count);
13 }
14
15 void * eventual(void * arg)
16 {
17 int t;
18 int sum;
19
20 while (stopflag < 3) {
21 sum = 0;
22 for_each_thread(t)
23 sum += ACCESS_ONCE(per_thread(counter, t));
24 ACCESS_ONCE(global_count) = sum;
25 poll(NULL, 0, 1);
26 if (stopflag) {
27 smp_mb();
28 stopflag++;
29 }
30 }
31 return NULL;
32 }
33
34 void count_init(void)
35 {
36 thread_id_t tid;
37
38 if (pthread_create(&tid, NULL, eventual, NULL)) {
39 perror("count_init:pthread_create");
40 exit(-1);
41 }
42 }
43
44 void count_cleanup(void)
45 {
46 stopflag = 1;
47 while (stopflag < 3)
48 poll(NULL, 0, 1);
49 smp_mb();
50 }
- 1~2,定义了跟踪计数值的没线程变量和全局变量。
- 3,定义了 stopflag,用于控制程序结束。
- 5~8,增加计数函数
- 10~13,读取计数函数
- 34~42,
count_init()
函数创建了位于 15~32 行的eventual()
线程,该线程将遍历所有线程,对每个线程的本地计算 counter 进行累加,将结果放入 global_count。eventual
线程在每次循环之间等待 1ms(随便选择的值)。 - 44~50,
count_cleanup()
函数用来控制程序结束。
本方法在提供极快的读取端计数性能的同时,仍然保持线性的更新端计数性能曲线。但也带来额额外的开销,即 eventual
线程。
基于每线程变量的实现
GCC 提供了一个用于每线程存储的 _thread
存储类。下面使用该类来实现统计计数器,该实现不仅能扩展,而且相对于简单的非原子自增来说几乎没有性能损失。
1 long __thread counter = 0;
2 long * counterp[NR_THREADS] = { NULL };
3 long finalcount = 0;
4 DEFINE_SPINLOCK(final_mutex);
5
6 void inc_count(void)
7 {
8 counter++;
9 }
10
11 long read_count(void)
12 {
13 int t;
14 long sum;
15
16 spin_lock(&final_mutex);
17 sum = finalcount;
18 for_each_thread(t)
19 if (counterp[t] != NULL)
20 sum += * counterp[t];
21 spin_unlock(&final_mutex);
22 return sum;
23 }
24
25 void count_register_thread(void)
26 {
27 int idx = smp_thread_id();
28
29 spin_lock(&final_mutex);
30 counterp[idx] = &counter;
31 spin_unlock(&final_mutex);
32 }
33
34 void count_unregister_thread(int nthreadsexpected)
35 {
36 int idx = smp_thread_id();
37
38 spin_lock(&final_mutex);
39 finalcount += counter;
40 counterp[idx] = NULL;
41 spin_unlock(&final_mutex);
42 }
- 1~4,定义所需变量,
counter
是每线程计数变量,counterp[]
数组允许线程访问彼此的计数,finalcount
在各个线程退出时将计数值累加到综合,final_mutex
协调累加计数总和值的线程和退出的线程。 - 更新者调用
inc_count()
函数,见 6~9 行。 - 写者调用
read_count()
函数,首先在 16 行获取与正在退出线程互斥的锁,21 行释放锁。17 行初始化已退出线程的每线程计数总和,18~20 将还在运行的线程的每线程计数累加进总和,最后,22 行返回总和。 - 25~32,
count_register_thread()
函数,每个线程在访问自己的计数前都要调用它,将本线程对应countp[]
数组中的元素指向线程的每线程变量 counter。 - 34~42,
count_unregister_thread()
函数,每个之前调用过count_register_thread()
函数的线程在退出时都要调用该函数。38 行获取锁,41 行释放锁,因此排除了有线程正在调用read_count()
同时又有线程调用count_unregister_thread()
函数的情况。39 行将本线程的每线程计数添加到全局 finalcount 中,然后将countp[]
数组的对应元素设置为 NULL。随后read_count()
调用可以在全局变量 finalcount 里找到已退出线程的计数值,并且顺序访问countp[]
数组时可以跳过已退出线程,从而获得正确的总计数结果。
该方式让更新者的性能几乎和非原子计数一样,并且也能线性扩展。另外,并发的读者竞争一个全局锁,因此性能不佳,扩展性差。但是这不是统计计数器要面对的问题,因为统计计数器总是在增加计数,很少读取计数。
近似上限计数器
另一种计数的场景是上限检查,比如需要维护一个已分配数据结构数目的计数器,来防止分配超过一个上限。我们假设这些结构的生命周期很短,数目也极少能超出上限。对近似值上限来说,偶尔超出少许是可以接受的。
设计
一种可能的实现是将近似总数值(10000)平均分配给每个线程,然后每个线程一个固定个数的资源池。假如有 100 个线程,每个线程管理一个有 100 个结构的资源池。这种方式简单,在有些情况下有效,但是无法处理一种常见情况:某个结构由一个结构创建,但由另一个线程释放。一方面,如果线程释放一个结构就得一分的话,那么一直在分配结构的线程很快就会分配光资源池,而一直在释放结构的线程积攒了大量分数却无法使用。另一方面,如果每个被释放的结构都能让分配它的 CPU 加一分,CPU 就需要操纵其他 CPU 的计数,这会带来昂贵的原子操作或其他跨线程通信手段。
因此,在很多重要的情况下我们不能讲计数问题完全分割。对于上限计数,我们可以采用一种分割计数方法的变体,部分地分割计数。比如在四个线程中,每个线程拥有一份每线程变量 counter,但同时每个线程也持有一份每线程的最大值 countermax。
如果某个线程需要增加其 counter,可是此时 counter 等于 countermax,这时该如何处理呢?此时可以把此线程 counter 值的一半转移给 globalcount,然后在增加 counter。举个例子,加入某线程的 counter 和 countermax 都为 10,我们可以执行如下操作:
- 获取全局锁
- 给 globalcount 增加 5
- 当前线程的 couter 减少 5,以抵消全局的增加
- 释放全局锁
- 递增当前线程的 counter,编程 6
虽然该操作中需要全局锁,但是该锁只有在每 5 次增加操作后才获取一次,从而降低了竞争程度,如果我们增大了 countermax 的值,竞争程度还会进一步降低。但是增大 countermax 值的副作用是 globalcount 精确度的降低。假设一台 4 CPU 系统,此时 countermax 值为 10,global 和真实计数值的误差最高可达 40,如果把 countermax 增加到 100,那么 globalcount 和真实计数值的误差可达 400。
因此问题成了我们到底有多在意 globalcount 和真实计数值的偏差。真实计数值由 globalcount 和所有每线程 counter 相加得出。误差取决于真实计数值和计数上限的差值有多大,差值越大,countermax 就越不容易超过 globalcountmax 的上限。这就代表着任何一个线程的 countermax 变量可以根据当前的差值计算取值。当离上限还比较远时,可以给每线程变量 countermax 赋值一个较大的数,这样对性能和扩展性都有好处。当靠近上限时,可以给这些 countermax 赋值一个比较小的数,这样可以降低超过统计上限 globalcountmax 的风险,从而减少误差。
这种设计就是一个并行快速路径的例子,这是一种重要的设计模式,适用于下面的情况:在多数情况下没有线程间的通信和交互开销,对偶尔进行的跨线程通信又使用了静心设计的(但是开销仍然很大的)全局算法。
精确上限计数器
一种实现精确计数的方式是允许线程放弃自己的计数,另一种是采用原子操作。当然,原子操作会减慢快速路径。
原子上限计数
如果想要一个线程减少另一个线程上的计数,需要自动的操作两个线程的 counter 和 countermax 变量。通常的做法是将这两个变量合并成一个变量,比如一个 32 位的变量,高 16 位代表 counter,低 16 位代表 countermax。
这种方式运行计数一直增长直到上限,但是也带来了快速路径上原子操作的开销,让快速路径明显变慢了。虽然在某些场合这种变慢是允许的,但是仍然值得我们去探索让读取端性能更好的算法。而使用信号处理函数从其他线程窃取计数就是一种算法。因为信号处理函数可以运行在收到信号线程的上下文,所以就不需要原子操作了。
Signal-Theft 上限计数
虽然每线程状态只由对应线程修改,但是信号处理函数仍然有必要进行同步。
上图中的状态机展示了这种同步机制。Signal-Threft 状态机从”空闲“状态开始,当 add_count()
和 sub_count()
发现线程的本地计数和全局计数之和已经不足以容纳请求的大小时,对应的慢速路径将每线程的 threft 状态设置为”请求”(除非线程没有计数值,这样它就直接转换为“准备完毕”)。只有在慢速路径获得 gblcnt_mutex_lock 之后,才允许从“空闲”状态转换为其他状态。然后慢速路径向每个线程发送一个信号,对应的信号处理函数检查本地线程的 threft 和 counting 状态。如果 threft 状态不为“请求”,则信号处理函数就不能改变其状态,只能直接返回。而 threft 状态为“请求”时,如果设置了 counting 变量,表名当前线程正处于快速路径,信号处理函数将 threft 状态设置为“确认”,而不是“准备完毕”。
如果 threft 状态为“确认”,那么只有快速路径才有权改变 threft 的状态。当快速路径完成时,会将 threft 状态设置为“准备完毕”。
一旦慢速路径发现某个线程的 threft 状态为“准备完毕”,这时慢速路径有权窃取此线程的计数。然后慢速路径将线程的 threft 状态设置为“空闲”。
在一般笔记本电脑上,使用 signal-threft 的实现比原子操作的实现快两倍。由于原子操作的相对缓慢,signal-threft 实现在 Pentium-4 处理器上比原子操作好的多,但是后来,老式的 8086 对称处理器系统在原子操作实现的路径深度更短,原子操作的性能也随之提升。可是,更新端的性能提升是以读取端的高昂开销为代价的,POSIX 信号不是没有开销的。如果考虑最终的性能,则需要在实际部署应用的系统上测试这两种方式。
特殊场合的并行计数
即便如此,如果计数的值总是在 0 附近变动,精确计数就没什么用了,正如统计对 IO 设备的访问计数一样。如果我们并不关心当前有多少计数,这种统计值总是在 0 附近变动的计数开销很大。比如在可移除 IO 设备的访问计数问题,除非有人想移除设备,否则访问次数完全不重要,而移除设备这种情况本身又很少见。
一种简单的解决办法是,为计数增加一个很大的“偏差值”(比如 10 亿),确保计数的值远离 0,让计数可以有效工作。当有人想拔出设备时,计数又减去偏差值。计数最后几次的增长将是非常低效的,但是对之前的所有计数去可以全速运行。
虽然带偏差的计数有用且有效,但这只是可插拔 IO 设备访问计数问题的部分解决办法。当尝试移除设备时,我们不仅需要当前精确的 IO 访问计数,还需要从现在开始阻止未来的访问请求。一种方式是在更新计数时使用读写锁的读锁,在读取计数时使用同一把读写锁的写锁。
并行计数讨论
本章展示了传统计数原语会遇见的问题:可靠性、性能、扩展性。C 语言的 ++ 操作符不能在多线程代码中保证函数的可靠性,对单个变量的原子操作性能不好,可扩展性也差。
并行计数性能
统计计数算法性能:
算法 | 写延迟 | 延迟(1核) | 读延迟(32核) |
---|---|---|---|
数组快速通道 | 11.5ns | 308ns | 409ns |
最终一致 | 11.6ns | 1ns | 1ns |
每线程变量 | 6.3ns | 389ns | 51,200ns |
RCU | 5.7ns | 354ns | 501ns |
上限计数算法性能:
算法 | 是否精确 | 写延迟 | 读延迟(1核) | 读延迟(32核) |
---|---|---|---|---|
每线程变量-1 | 否 | 3.6ns | 375ns | 50,700ns |
每线程变量-2 | 否 | 11.7ns | 369ns | 51,000ns |
原子方式 | 是 | 51.4ns | 427ns | 49,400ns |
信号方式 | 是 | 10.2ns | 370ns | 54,000ns |
并行计算的专门化
上述算法仅在各自的问题领域性能出色,这可以说是并行计算的一个主要问题。毕竟 C 语言的 ++ 操作符在所有单线程程序中性能都不错,不仅仅是个别领域。
我们提到的问题不仅是并行性,更是扩展性。我们提到的问题也不专属于算术问题,假设你还要存储和查询数据库,是不是还会用 ASCII 文件、XML、关系型数据库、链表、紧凑数组、B 树、基树或其他什么数据结构和环境来存取数据,这取决于你需要做什么、做多快、数据集有多大。
同样,如果需要计数,合适的方案取决于统计的数有多大、有多少个 CPU 并发操纵计数、如何使用计数,以及需要的性能和可扩展性程度。
总结
本章的例子显示,分割是提升可扩展性和性能的重要工具。计数有时可以被完全分割,或者被部分分割。
- 分割能够提升性能和可扩展性。
- 部分分割,也就是仅分割主要情况的代码路径,性能也很出色。
- 部分分割可以应用在代码上,但是也可以应用在时间空间上。
- 读取端的代码路径应该保持只读,对共享内存的“伪同步写”严重降低性能和扩展性。
- 经过审慎思考的延迟处理能够提升性能和扩展性。
- 并行性能和扩展性通常是跷跷板的两端,达到某种程度后,对代码的优化反而会降低另一方的表现。
- 对性能和可扩展性的不同需求及其他很多因素,会影响算法、数据结构的设计。
3.6 - CH06-分割同步设计
本章将描述如何设计能够更好利用多核优势的软件。编写并行软件时最重要的考虑是如何进行分割。正确的分割问题能够让解决方案简单、扩展性好且高性能,而不恰当的分割问题则会产生缓慢且复杂的解决方案。“设计”这个词非常重要:对你来说,应该是分割问题第一、编码第二。顺序颠倒会让你产生极大的挫败感,同时导致软件低劣的性能和扩展性。
分割练习
哲学家就餐
该问题指的是,桌子周围坐着 5 位哲学家,而桌子上每两个哲学家之间有一根叉子,因此是 5 个哲学家 5 根叉子。每个哲学家只能用他左手和右手旁的叉子用餐,一旦开始用餐则不吃到心满意足是不会停下的。
我们的目标就是构建一种算法来阻止饥饿。一种饥饿的场景是所有哲学家都去拿左手边的叉子。因为他们在吃饱前不会放下叉子,并且他们还需要第二把叉子才能开始用餐,所以所有哲学家都会挨饿。注意,让至少一位哲学家就餐并不是我们的目标,即使让个别哲学家挨饿也是要避免的。
Dijkstra 的解决方法是使用一个全局信号量。假设通信延迟忽略不计,这种方法十分完美。因此,近来的解决办法是像下图一样为叉子编号。每个哲学家都先拿他盘子周围编号最小的叉子,然后再拿编号最高的叉子。这样坐在图中最上方的哲学家会先拿起他左手边的叉子,然后是右手边的叉子,而其他哲学家则先拿起右手边的叉子。因为有两个哲学家试着会去拿叉子 1,而只有一位会成功,所以只有 4 位哲学家抢 5 把叉子。至少 4 位中的一位肯定能够拿到两把叉子,这样就能开始就餐了。
这种为资源编号并按照编号顺序获取资源的通用技术经常在被用在防止死锁上。 但是很容易就能想象出来一个事件序列来产生这种效果:虽然大家都在挨饿,但是一次只有一个哲学家就餐。
- P2 拿起叉子 1,阻止 P1 拿起叉子 1。
- P3 拿起叉子 2。
- P4 拿起叉子 3。
- P5 拿起叉子 4。
- P5 拿起叉子 5,开始就餐。
- P5 放下叉子 4 和 5。
- P4 拿起叉子 4,开始就餐。
简单来说,该算法会导致每次仅有一个哲学家能够就餐,即使 5 个哲学家都在挨饿,但事实上此时有足够的叉子供两名哲学家同时就餐。
上图是另一种解决方式,里面只有 4 位哲学家,而不是 5 位,这样可以更好的说明分割技术。最上方和最右边的哲学家合用一个叉子,而最下面和最左面的哲学家合用一个叉子。如果所有哲学家同时感觉饿了,至少有两位能够同时就餐。另外如图所示,现在叉子可以捆绑成一对,这样同时拿起或放下,就简化了获取和释放锁的算法。
这是水平化分割的一个例子,或者叫数据并行化,这么叫是因为哲学家之间没有依赖关系。在数处理型的系统中,“数据并行化”是指一种类型的数据只会被多个同类型软件组件中的一个处理。
双端队列
双端队列是一种元素可以从两端插入和删除的数据结构。这里将展示一种分割设计策略,能实现合理且简单的解决方案。
右手锁与左手锁
右手锁与左手锁是一种看起来很直接的办法,为左手端的入列操作添加一个左手锁,为右手端的出列操作添加一个右手锁。但是这种办法的问题是当队列中的元素不足 4 个时,两个锁的返回会发生重叠。这种重叠是由于移动任何一个元素不仅只影响元素本身,还要影响它左边和右边相邻的元素。这种范围在图中被涂上了淹死,蓝色表示左手锁的范围,红色表示右手锁的范围,紫色表示重叠的范围。虽然创建这样一种算法是可能的,但是至少要小心着五种特殊情况,尤其是在队列另一端的并发活动会让队列随时可能从一种特殊情况转变为另外一种特殊情况的场景。所以最好考虑其他解决方案。
复合双端队列
上图是一种强制确保锁的范围不会发生冲突的方法。两个单独的双端队列串联在一起,每个队列用自己的锁保护。这意味着数据偶尔会从双端队列的一列跑到另一列。此时必须同时持有两把锁。为避免死锁,可以使用一种简单的锁层级关系,比如,在获取右手锁前先获取左手锁。这比在同一列上同时使用两把锁要简单的多,因为我们可以无条件的让左边的入列元素进入左手队列,右边的入列元素进入右手队列。主要的复杂度来源于从空队列中出列,这种情况下必须做到如下几点:
- 如果持有右手锁,释放并获取左手锁,重新检查队列释放仍然为空。
- 获取右手锁。
- 重新平衡跨越两个队列的元素。
- 移除指定的元素。
- 释放两把锁。
代码实现也并不复杂,再平衡操作可能会将某个元素在两个队列之间来回移动,这不仅浪费时间,而且想要获得最佳性能,还需针对工作负荷不断微调。
哈希双端队列
哈希永远是分割一个数据结构的最简单有效的方法。可以根据元素在队列中的位置为每个元素分配一个序号,然后以此对双端队列进行哈希,这样第一个从左边进入空队列的元素编号为 0,第一个从右边进入空队列的元素编号为 1。其他从左边进入只有一个元素的队列的元素编号依次递减(-1,-2,-3…),而其他从右边进入只有一个元素的队列的元素编号依次递增(2,3,4…)。关键是,实际上并不用真正为元素编号,元素的序号暗含它们在队列中的位置中。
然后,我们用一个锁保护左手下标,用另外一个锁保护右手下标,再各用一个锁保护对应的哈希链表。上图展示了 4 个哈希链表的数据结构。注意锁的范围没有重叠,为了避免死锁,只在获取链表锁之前获取下标锁,每种类型的锁(下标或链表),一次获取从不超过一个。
每个哈希链表都是一个双端队列,在这个例子中,每个链表拥有四分之一的队列元素。上图最上面部分是 R1 元素从右边入队后的状态,右手的下标增加,用来引用哈希链表 2。上图第二部分是又有 3 个元素从右手入队。正如你所见,下标回到了它们初始的状态,但是每个哈希队列现在都是非空的。上图第三部分是另外三个元素从左边入队,而另外一个元素从右边入队后的状态。
从上图第三部分的状态可以看出,左出队操作将会返回元素 L-2,并让左手下边指向哈希链 2,此时该链表只剩下 R2。这种状态下,并发的左入队和右入队操作可能会导致锁竞争,但这种锁竞争发生的可能性可以通过使用更大的哈希表来降低。
上图展示了 12 个元素如何组成一个有 4 个并行哈希桶的双端队列。每个持有单锁的双端队列拥有整个并行双端队列的四分之一。
双端队列讨论
复合式实现在某种程度上要比哈希式实现复杂,但是仍然属于比较简单的。当然,更加智能的再平衡机制可以非常复杂,但是和软件实现相比,这里使用的软件再平衡机制已经很不错了,这个方法甚至不比使用硬件辅助算法的实现差多少。不过,从这种机制中我们最好也只能获得 2 倍的扩展能力,因为最多只有两个线程并发的持有出列的锁。这个局限同样适用于使用非足额同步方法的算法,比如 Michael 的使用 CAS 的出队算法。
事实上,正如 Dice 等人所说,非同步的单线程双端队列实现性能非常好,比任何他们研究过的并行实现都搞很多。因此,不管哪种实现,由于队列的严格先入先出特性,关键点都在于共享队列中出队或出队的巨大开销。
更近一步,对于严格先入先出的队列,只有在线性化点不对调用者可见时,队列才是严格先入先出。事实上,在事前的例子中“线性化点”都隐藏在带锁的临界区内。而这些队列在单独的指令开始时,并不保证先入先出。这表明对于并发程序来说,严格先入先出的特性并没有那么有价值。实际上 Kirsch 等人已经证明不提供先入先出保证的队列在性能和扩展性上更好。这些例子说明,如果你打算让并发数据出入一个单队列时,真的该重新考虑一下整体设计。
分割讨论
哲学家就餐问题的最后解法是该问题的最优解法,是“水平并行化”或“数据并行化”的极佳例子。在这个例子中,同步的开销接近于 0 或等于 0。相反,双端队列的实现是“垂直并行化”或者“管道”极佳示例,因为数据从一个线程转移到另一个线程。“管道”需要密切合作,因此为获得某种程度上的效率,需要做的工作更多。
设计准则
想要获取最佳的性能和扩展性,简单的办法就是不断尝试,直到你的程序和最优实现水平相当。但是如果你的代码不是短短数行,如何能在浩如烟海的代码中找到最优实现呢?另外,什么才是最优实现呢?前面给出了三个并行编程的目标:性能、生产率和通用性,最优的性能常常要付出生产率和通用性的代价。如果不在设计时就将这些选择考虑进去,就很难在限定的时间内开发出性能良好的并行程序。
但是除此之外,还需要更详细的设计准则来指导实际的设计,这就是本节的主题。在真实的世界中,这些准则将在某种程度上冲突,这需要设计者小心权衡得失。这些准则可以被认为是设计中的阻力,对这些阻力进行恰当权衡,就被称为“设计模式”。
基于三个并行编程目标的设计准则是加速、竞争、开销、读写比率和复杂性。
加速倍速:之所以花费如此多时间和精力进行并行化,加速性能是主要原因。加速倍速的定义是运行程序的顺序执行版本所需要的时间,除以执行并行版本所需时间的比例。
竞争:如果对一个并行程序来说,增加更多的 CPU 并不能让程序忙起来,那么多出来的 CPU 是因为竞争的关系而无法工作。可能是锁竞争、内存竞争或者其他什么性能杀手的原因。
工作-同步比率:单处理器、单线程、不可抢占、不可中断版本的并行程序完全不需要任何同步原语。因此,任何消耗在这些原语上(通信中的高速缓存为命中、消息延迟、加解锁原语、原子指令和内存屏障)的时间都是对程序意图完成的工作没有直接帮助的开销。同步开销与临界区中代码的开销之间的关系是重要的衡量准则,更大的临界区能容忍更大的同步开销。工作-同步开销比率与同步效率的概念有关。
读-写比率:对于极少更新的数据结构,更多是采用“复制”而不是“分割”,并且用非对称的同步原语来保护,以提高写者同步开销的代价来降低读者的同步开销。对频繁更新的数据结构的优化也是可以的。
复杂性:并行程序比相同的顺序执行的程序复杂,这是因为并行程序要比顺序执行程序维护更多的状态,虽然这些状态在某些情况下理解起来很容易。并行程序员必须要考虑同步原语、消息传递、锁的设计、临界区识别以及死锁等诸多问题。
更大的复杂性通常转换为了更高的开发代价和维护代价。因此,对现有程序修改的范围和类型非常受代码预算的限制,因为对原有程序的新年能加速需要消耗相当的时间和精力。在更糟糕的情况,增加复杂性甚至会降低性能和扩展性。
进一步说,在某种范围内,还可以对顺序执行程序进行一定程度的优化,这笔并行化更廉价、高效。并行化只是众多优化手段中的其中一种,并且只是 一种主要解决 CPU 为性能瓶颈的优化。
这些准则结合在一起,会让程序达到最大程度的加速倍数。前三个准则相互交织在一起,所以本节将着重分写这三个准则的交互关系。
请注意,这些准则也是需求说明的一部分。比如,加速倍速既是愿望、又是工作符合的绝对需求,或者说是运行环境。
理解这些设计准则之间的关系,对于权衡并行程序的各个设计目标十分有用。
- 程序在临界区上所花费的时间越少,潜在的加速倍速就越大。这是 Amdahl 定律的结果,这也是因为在一个时刻只能有一个 CPU 进入临界区的原因。更确切的说,程序在某个互斥的临界区上所耗费的时间必须大大小于 CPU 数的倒数,因为这样增加 CPU 数量才能达到事实上的加速。比如在 10 核系统上运行的程序只能在关键的临界区上花费少于 1/10 的时间,这样才能有效的扩展。
- 因为竞争所浪费的大量 CPU 或者时间,这些时间本来可以用于提高加速倍速,应该少于可用 CPU 的数目。CPU 数量和实际的加速倍速之间的差距越大,CPU 的使用率越低。同样,需要的效率越高,可以继续提升的加速倍速就越小。
- 如果使用的同步原语相较它们保护的临界区来说开销太大,那么加速程序运行的最佳办法是减少调用这些原语的次数(比如分批进入临界区、数据所有权、非对称同步、代码锁)。
- 如果临界区相较保护这块临界区的原语来说开销太大,那么加速程序运行的最佳办法是增加程序的并行化程度,比如使用读写锁、数据锁、非对称同步或数据所有权。
- 如果临界区相较保护这块临界区的原语来说开销太大,并且对受保护的数据结构读多于写,那么加速程序运行的最佳办法是增加程序的并行化程度,比如读写锁或非对称同步。
- 各种增加 SMP 性能的改动,比如减少锁竞争程度,能改善响应时间。
同步粒度
上图是对同步粒度不同层次的图形表示。每一种同步粒度都用一节内容来描述。
串行程序
如果程序在单处理器上运行足够快,并且不与其他进程、线程或者中断处理程序发生交互,那么你可以将代码中所有的同步原语删除,远离他们带来的开销和复杂性。好多年前曾有人争论摩尔定律最终会让所有程序变得如此,但是随着 2003 年以来 Intel CPU 的 CPU MIPS 和时钟频率增长速度的停止,此后要增加性能,就必须提高程序的并行化程度。是否这种趋势会导致一块芯片上继承几千个 CPU,这方面的争论不会很快停息,但是考虑本文作者 Paul 是在一台双核笔记本上敲下这句话的,SMP 的寿命极有可能比你我都长。另一个需要注意的地方是以太网的带宽持续增长。这种增长会进一步促进对多线程服务器的优化,这样才能有效处理通信载荷。
请注意,这并不意味着你应该在每个程序中都使用多线程方式编程。我再次说明,如果一个程序在单处理器上运行的很好,那么你就从 SMP 同步原语的开销和复杂性中解脱出来吧。
代码锁
代码锁是最简单的设计,仅使用全局锁。在已有的程序中使用代码锁,可以很容易让程序在多个处理器上运行。如果程序只有一个共享资源,那么代码锁的性能是最优的。但是,许多较大且复杂的程序会在临界区上执行多次,这就让代码锁的扩展性大大受限。
因此,最好在这样的程序中使用代码锁:只有一小段执行时间在临界区程序,或者对扩展性要求不高。在这种情况下,代码锁可以让程序相对简单,和单线程版本类似。
并且,代码锁尤其容易引起“锁竞争”,一种多个 CPU 并发访问同一把锁的情况。
数据锁
许多数据结构都可以分割,数据结构的每个部分带有一把自己的锁。这样虽然每个部分一次只能执行一个临界区,但是数据结构的各个部分形成的临界区就可以并行执行了。如果此时同步带来的开销不是主要瓶颈,那么可以使用数据来降低锁竞争程度。数据锁通过将一块过大的临界区分散到各个小的临界区来减少锁竞争,比如,维护哈希表中的 per-hash-bucket 临界区。不过这种扩展性的增强带来的是复杂性的少量提升,增加了额外的数据结构 struct bucket。
但是数据锁带来了和谐,在并行程序中,这总是意味着性能和扩展性的提升。因为这个原因,Sequent 在它的 DYNIX 和 DYNIX/ptx 操作系统中使用了数据锁。
不过,那些照顾过小孩的人可以证明,再细心的照料也不能保证一切风平浪静(多个小孩争抢一个玩具)。同样的情况也适用于 SMP 程序。比如,Linux 内核维护了一种文件和目录的缓存(dcache)。该缓存中的每个条目都有一把自己的锁。但是相较于其他条目,对应根目录的条目和它的直接后代更容易被遍历到。这将导致许多 CPU 竞争这些热门条目的锁。这就像虽然玩具有多个,但所有的孩子都要去挣同一个玩具。
在动态分配结构中,在许多情况下,可以设计算法来减少数据冲突的次数,某些情况下甚至可以完全消灭冲突(如 dcache)。数据锁通常用于分割像哈希表一样的数据结构,也适用于每个条目用某个数据结构的实例表示这种情况。
数据锁的关键挑战是对动态分配数据结构加锁,如何保证在获取锁时结构本身还存在。通过将锁放入静态分配且永不释放的哈希桶可以解决该挑战。但是这种手法不适用于哈希表大小可变的情况,所以锁也需要动态分配。在这种情况,还需要一些手段来阻止哈希桶在锁被获取之后的这段时间内释放。
数据所有权
数据所有权方法按照线程或者 CPU 的个数分割数据结构,在不需要任何同步开销的情况下,每个线程或者 CPU 都可以访问属于它的子集。但是如果线程 A 希望访问另一个线程 B 的数据,那么线程 A 是无法直接做到这一点。取而代之的是,线程 A 需要先与线程 B 通信,这样线程 B 以线程 A 的名义执行操作,或者另一种方法,将数据迁移到线程 A 上来。
数据所有权看起来很神秘,但是却应用得十分频繁:
- 任何只能被一个 CPU 或者一个线程访问的变量都属于这个 CPU 或者这个线程。
- 用户接口的实例拥有对应的用户上下文。这在与并行数据库引擎交互的应用程序中十分常见,让并行引擎看起来就像顺序执行的程序一样。这样应用程序拥有用户接口和当前操作。显式的并行化只在数据库引擎内部可见。
- 参数模拟,通常授予每个线程一段特定的参数区间,以此达到某种程度的并行化。有一些计算平台专门用来解决这类问题。
如果共享比较多,线程或者 CPU 间的通信会带来较大的复杂性和通信开销。不仅如此,如果最热的数据正好被一个 CPU 拥有,那么这个 CPU 就成了热点。不过,在不需要共享的情况下,数据所有权可以达到理想性能,代码也可以像顺序程序一样简单。最坏情况通常被称为尴尬的并行化。
另一个数据所有权的重要用法是当数据是只读时,这种情况下,所有线程可以通过复制来拥有数据。
并行快速路径
细粒度(通常能够带来更高的性能)的设计要比粗粒度的设计复杂。在许多情况下,一小部分代码带来了绝大部分开销。所以为什么不把精力放在这一小块代码上呢?
这就是并行快速路径设计模式背后的思想,尽可能并行化常见情况下的代码路径,同时不产生并行化整个算法所带来的复杂性。必须要理解这一点,不只是算法需要并行化,算法所属的工作负载也要并行化。构建这种并行快速路径,需要极大的创造性和设计上的努力。
并行快速路径结合了两种以上的设计模式,因此成为了一种模板设计模式。下列是并行快速路径结合其他设计模式的例子:
- 读写锁。
- Read-Copy-Update,大多作为读写锁的替代使用。
- 层次锁。
- 资源分配器缓存。
读写锁
如果同步开销可以忽略不计(比如程序使用了粗粒度的并行化),并且只有一小段临界区修改数据,那么让多个读者并行处理可以显著提升扩展性。写者与读者互斥,写者与另一写者也互斥。
读写锁是非对称锁的一种简单实例。Snaman 描述了一种在许多集群系统上使用的非对称锁,该锁有 6 种模式,其设计令人叹为观止。
层次锁
层次锁背后的思想是,在持有一把粗粒度锁时,同时再持有一把细粒度锁。这样一来,我们付出了获取第二把锁的开销,但是我们只持有它一小段时间。在这种情况下,简单的数据锁方法则更简单,而且性能更好。
资源分配器缓存
本节展示一种简明扼要的并行内存分配器,用于分配固定大小的内存。
并行资源分配问题
并行内存分配器锁面临的基本问题,是在大多数情况下快速地分配和释放内存,和在特殊情况下高效地分配和释放内存之间的矛盾。
假设有一个使用了数据所有权的程序——该程序简单地将内存按照 CPU 个数划分,这样每个 CPU 都有属于自己的一份内存。例如,该系统有 2 个 CPU 和 2G 内存。我们可以为每个 CPU 分配 1G 内存,这样每个 CPU 都可以访问属于自己的那一份内存,无需加锁,也不必关心由锁带来的复杂性和开销。可是这种简单的模型存在问题,如果有一种算法,需要让 CPU0 分配所有内存,让 CPU1 释放内存,就像生产者——消费者算法中的行为一样,这样该模型就失效了。
另一个极端,代码锁,则受到大量竞争和通信开销的影响。
资源分配的并行快速路径
常见的解决方案让每个 CPU 拥有一块规模适中的内存块缓存,以此作为快速路径,同时提供一块较大的共享内存池分配额外的内存块,该内存池使用代码锁加以保护。为了防止任何 CPU 独占内存块,我们给每个 CPU 的缓存可以容纳的内存块大小加以限制。在双核系统中,内存块的数据流如下图所示,当某个 CPU 的缓存池满时,该 CPU 释放的内存块被传送到全局缓存池中,类似的,当 CPU 缓存池为空时,该 CPU 所要分配的内存块也是从全局缓存池中取出来。
真实世界设计
虽然并行的玩具资源分配器非常简单,但是真实世界中的设计在几个方面上继续扩展了这个方案。
首先,真实的资源分配器需要处理各种不同的资源大小,在示例中只能分配固定的大小。一种比较流行的做法是提供一些列固定大小的资源,恰当地放置以平衡内碎片和外碎片,比如 20 世纪 80 年代后期的 BSD 内存分配器。这样做就意味着每种资源大小都要有一个“globalmem”变量,同样对应的锁也要每种一个,因此真实的实现将采用数据锁,而非玩具程序中的代码锁。
其次,产品级的系统必须可以改变内存的用途,这意味着这些系统必须能将内存块组合成更大的数据结构,比如页(page)。这种组合也需要锁的保护,这种锁必须是专属于每种资源大小的。
第三,组合后的内存必须回到内存管理系统,内存页也必须是从内存管理系统分配的。这一层面所需要的锁将依赖于内存管理系统,但也可以是代码锁。在这一层面中使用代码锁通常是可以容忍的,因为在设计良好的系统中很少触及这一级别。
尽管真实世界中的设计需要复杂许多,但背后的思想也是一样的——对并行快速路径这一原则的反复利用。以下是真实世界中的并行分配器类型:
等级 | 锁类型 | 目的 |
---|---|---|
每线程资源池 | 数据所有权 | 高速分配 |
全局内存资源池 | 数据锁 | 将内存块放在各个线程中 |
组合 | 数据锁 | 将内存块放在页中 |
系统内存 | 代码锁 | 获取、释放系统内存 |
分割之外
本章讨论了如何运用数据分割这一思想,来设计既简单又能线性扩展的并行程序。运用分割和复制的主要目标是达到线性的加速倍数,换句话说,确保需要做的工作不会随着 CPU 或线程的增长而显著增长。通过分割或复制可以解决尴尬的并行问题,使其可以线性加速,但是我们还能做得更好吗?
为了回答这个问题,让我们来看一看迷宫问题。前年依赖,迷宫问题一直是一个令人着迷的研究对象,所以请读者不要感到意外,计算机可以生产并且解决迷宫问题,其中包括生物计算机、甚至是一些可插拔硬件。大学有时会将迷宫的并行解法布置成课程作业,作为展示并行计算框架优点的工具。
常见的解法是使用一个并行工作队列的算法(PWQ)。本节比较 PWQ 方法、串行解法(SEQ)、和使用了另一种并行算法的解法,这些方法都能解决任何随机生成的矩形迷宫问题。
略。
3.7 - CH07-锁
近来对并行编程的研究中,锁总是扮演着坏人的角色。在许多论文和演讲中,锁背负着诸多质控,包括引起死锁、锁争抢、饥饿、不公平的锁、并发数据访问以及其他许多并发带来的罪恶。有趣的是,真正在产品级共享内存并行软件中承担重担的角色是——你猜对了——锁。那锁到底是英雄还是坏蛋呢?
这种认识源于以下几个原因:
- 很多因锁产生的问题都在设计层面就可以解决,而且在大多数场合工作良好,比如:
- 使用锁层级以避免死锁。
- 使用死锁检测工具,比如 Linux 内核 lockdep 模块。
- 使用对锁友好的数据结构,比如数组、哈希表、基树。
- 有些锁的问题只在竞争程度很高时才会出现,一般只有不良的设计才会出现竞争如此激烈的锁。
- 有些锁的问题可以通过其他同步机制配合锁来避免。包括统计计数、引用计数、危险指针、顺序锁、RCU,以及简单的非阻塞数据结构。
- 直到不久之前,几乎所有的共享内存并行程序都是闭源的,所以多数研究者很难了解业界的实践解决方案。
- 锁在某些软件上运行的很好,在某些软件上运行的很差。那些在锁运行良好的软件上做开发的程序员,对锁的态度往往比另一些没那么幸运的程序员更加正面。
- 所有美好的故事都需要一个坏人,锁在研究文献中扮演坏小子的角色已经有着悠久而光荣的历史了。
努力活着
死锁
当一组线程中的每个线程都持有至少一把锁,此时又等待该组线程中的某个成员释放它持有的一把锁时,死锁就会发生。
如果缺乏外界干预,死锁会一直持续。除非持有锁的线程释放,没有线程可以获取到该锁,但是持有锁的线程在等待获取该锁的线程释放其他锁之前,又无法释放该锁。
我们可以用有向图来表示死锁,节点代表锁和线程。
如上图。从锁指向线程的箭头表示该线程持有了该锁。比如线程 B 持有锁 2 和 4。从线程到锁的箭头表示线程在等待这把锁,比如线程 B 等待锁 3 释放。死锁场景至少包含至少一个以上的死锁循环。在上图中,死锁循环是线程 B、锁 3 、线程 C、锁 4,然后又回到线程 B。
虽然有一些软件环境,比如数据库系统,可以修复已有的死锁,但是这种方式要么杀掉其中一个线程,要么强制从某个线程中偷走一把锁。杀掉线程和强制偷锁对于事务交易是可以的,但是对内核和应用程序这种层次的锁来说问题多多,处理部分更新的数据库极端复杂,非常危险,而且很容易出错。
因此,内核和应用程序要么避免死锁,而非从死锁中恢复。避免死锁的策略有很多,包括锁的层次、锁的本地层次、锁的分级层次、包含指向锁的指针的 API 的使用策略、条件锁、先获取必须的锁、一次只用一把锁的设计,以及信号/中断处理函数的使用策略。虽然没有任何一个避免死锁策略可以适用于所有情况,但是市面上有很多避免死锁的工具可供选择。
锁的层次
锁的层次是指为锁逐级编号,禁止不按顺序获取锁。在上图中我们可以用数字为锁编号,这样如果线程已经获得了编号相同的锁或者更高编号的锁,就不允许获得编号相同或者编号更低的锁。线程 B 违反这个层次,因此它在持有锁 4 时又视图获取锁 3,因此导致死锁发生。
再次强调,按层次使用锁时要为锁编号,严禁不按顺序获取锁。在大型程序中,最好用工具来检查锁的层次。
锁的本地层次
但是所的层次本质要求全局性,因此很难应用在库函数上。如果调用了某个库函数的应用程序开没开始实现,那么倒霉的库函数程序员又怎么才能遵循这个还不存在的应用程序中的锁层次呢?
一种特殊的情况是,幸运的也是普遍的情况,是库函数并不涉及任何调用者代码。这时,如果库函数持有任何库函数的锁,它绝对不会再去获取调用者的锁,这样就避免出现库函数和调用者之间互相持有锁的死循环。
但假设某个库函数确实调用了某个调用者的代码。比如,qsort()
函数调用了调用者提供的比较函数。并发版本的 qsort()
通常会使用锁,虽然可能性不大,但是如果比较函数复杂且使用了锁,那么久有可能发生死锁。这时库函数该如何避免死锁?
出现这种情况时的黄金定律是:在调用未知代码前释放所有的锁。为了遵循该定律,qsort()
函数必须在调用比较函数前释放它所持有的全部锁。
为了理解本地层次锁的好处,让我们比较一下下面的两个图:
不带本地层次锁的 qsort:
基于所的本地层次实现的 qsort():
在两幅图中,应用程序 foo()
和 bar()
在分别持有锁 A 和锁 B 时调用了 qsort()
。因为这是并行版本,所以 qsort()
内还要获取锁 C。函数 foo()
将函数 cmp()
传给 qsort()
,而 cmp()
中要获取锁 B。函数 bar()
将一个简单的整数比较函数传给 qsort()
,而这个简单的函数不支持任何锁。
现在假设 qsort()
在持有锁 C 时调用 cmp()
,这违背了之前提过的黄金定律“释放所有锁”,那么死锁会发生。为了让读者理解,假设一个线程调用 foo()
,另一个线程调用 bar()
。第一个线程会获取锁 A,第二个线程会获取锁 B。如果第一个线程调用 qsort()
时获取锁 C,那么这时它在调用 cmp()
时将无法获得锁 B。但第一个线程获得了锁 C,所以第二个线程调用 qsort()
时无法获取锁 C,因此也无法释放锁 B,导致死锁。
相反,如果 qsort()
在调用比较函数之前释放锁 C,就可以避免死锁。
如果每个模块在调用未知代码前释放全部锁,那么每个模块自身都避免了死锁,这样整个系统也就避免发生死锁了。这个定律极大的简化了死锁分析,增强了代码的模块化。
锁的分级层次
不幸的是,有时 qsort()
无法在调用比较函数前释放全部锁。这时,我们无法通过以调用未知代码之前释放全部锁的方式来构建锁的本地层次。可是我们可以构建一种分级层次,如下图:
在这张图上,cmp()
函数在获取了锁 A、B、C 后再获取新的锁 D,这就避免了死锁。这样我们把全局层次锁分成了三级,第一级是锁 A 和锁 B,第二级是锁 C,第三级是锁 D。
请注意,让 cmp()
使用分级的层次锁 D 并不容易。恰恰相反,这种改动需要在设计层面进行大量更改。然而,这种变动往往是避免死锁时需要付出的点小小代价。
锁的层次和指向锁的指针
虽然有些例外情况,一般来说设计一个包含着指向锁的指针的 API 意味着这个设计本身就存在问题。将内部的锁传递给其他软件组件违反了信息隐藏原则,而信息隐藏恰恰是一个关键的设计准则。
比如两个函数要返回某个对象,而在对象成功返回之前必须持有调用者提供的锁。再比如 POSIX 的 pthread_cond_wait()
函数,要传递一个指向 pthread_mutex_t
的指针来放置错过唤醒而导致的挂起。
长话短说,如果你发现 API 需要将一个指向锁的指针作为参数或者返回值,请慎重考虑一下是否需要修改这个设计。有可能这是正确的做法,但是经验告诉我们这种可能性很低。
条件锁
假如某个场景设计不出合理的层次锁。这在现实生活中是可能发生的,比如,在分层网络协议栈里,报文流是双向的。当报文从一个层传向另一个层时,有可能需要在两层中同时获取锁。因为报文可以从协议栈上层往下层传,也可能相反,这简直是死锁的天然温床。
在这个例子中,当报文在协议栈中从上往下发送时,必须逆序获取下一层锁。反之则需要顺序获得锁。解决办法是强加一套锁的层次,但在必要时又可以有条件地乱序获取锁。
先获取必要的锁
条件锁有一个重要的特例,在执行真正的处理工作之前,已经拿到了所有必须的锁。在这种情况下,处理不需要是幂等的:如果这时不能在不释放锁的情况下拿到某把锁,那么释放所有持有的锁,重新获取。只有在持有所有必要的锁以后才开始处理工作。但是这样又可能导致活锁,后续将会讨论这一点。
两阶段加锁在事务数据库系统中已经存在很长时间了,它就应用了这个策略。两阶段加锁事务的第一个阶段,只获取锁但不释放锁。一旦所有必须的锁全部获得,事务进入第二阶段,只释放锁但不获取锁。这种加锁方法使得数据库可以对执行的事务提供串行化保护,换句话说,保证事务看到和产生的数据在全局范围内顺序一致。很多数据库系统都依靠这种能力来终止事务,不过两阶段加锁也可以简化这种方法,在持有所有必要的锁之前,避免修改共享数据。虽然使用两阶段锁仍然会出现活锁或死锁,但是在现有的大量数据库类教科书中已经有很多实用的解决办法。
一次只用一把锁
在某些情况下,可以避免嵌套加锁,从而避免死锁。比如,如果有一个可以完美分割的问题,每个分片拥有一把锁。然后处理任何特定分片的线程只需获取对应该分片的锁。因为没有任何线程在同一时刻持有一把以上的锁,死锁就不可能发生。但是必须有一些机制来保证在没有持锁的情况下所需数据结构依然存在。
信号/中断处理函数
涉及信号处理函数的死锁通常可以很快解决:在信号处理函数中调用 pthread_mutex_lock()
是非法的。可是,精心构造一种可以在信号处理函数中使用的锁是有可能的。除此之外,基本所有的操作系统内核都允许在中断处理函数里获取锁,中断处理函数可以说是内核对信号处理函数的模拟。
其中的诀窍是在任何可能中断的处理函数里获取锁的时候阻塞信号(或者屏蔽中断)。不仅如此,如果已经获取了锁,那么在不阻塞信号的情况下,尝试去获取任何可能中断处理函数之外被持有的锁,都是非法操作。
假如处理函数获取锁是为了处理多个信号,那么无论是否获得了锁,甚至无论锁是否是在信号处理函数之内获取的,每个信号也都必须被阻塞。
不幸的是,在一些操作系统里阻塞和解除阻塞信号都属于代价昂贵的操作,这里包括 Linux,所以出于性能上的考虑,能在信号处理函数内持有的锁仅能在信号处理函数内获取,应用程序和信号处理函数之间的通信通常使用无锁同步机制。
或者除非处理致命异常,否则完全禁用信号处理函数。
本节讨论
对于基于内存共享的并行程序员来说,有大量避免死锁的策略可用,但是如果遇到这些策略都不适用的场景,总还是可以用串行代码来实现的。这也是为什么专家级程序员的工具箱里总是有好几样工具的原因之一,但是别忘了总有些活适合用其他工具处理。不过,本节描述的这些策略在很多场合都被证明非常有用。
活锁与饥饿
虽然条件锁是一种有效避免死锁的机制,但是有可能被滥用。考虑下面的例子:
1 void thread1(void)
2 {
3 retry:
4 spin_lock(&lock1);
5 do_one_thing();
6 if (!spin_trylock(&lock2)) {
7 spin_unlock(&lock1);
8 goto retry;
9 }
10 do_another_thing();
11 spin_unlock(&lock2);
12 spin_unlock(&lock1);
13 }
14
15 void thread2(void)
16 {
17 retry:
18 spin_lock(&lock2);
19 do_a_third_thing();
20 if (!spin_trylock(&lock1)) {
21 spin_unlock(&lock2);
22 goto retry;
23 }
24 do_a_fourth_thing();
25 spin_unlock(&lock1);
26 spin_unlock(&lock2);
27 }
考虑以下事件顺序:
- 4:线程 1 获取 lock1,然后调用 do_one_thing()
- 18:线程 2 获取 lock2,然后调用 do_a_third_thing()
- 6:线程 1 试图获取 lock2,由于线程 2 已经持有而失败
- 20:线程 2 试图后去 lock1,由于线程 1 已经持有而失败
- 7:线程 1 释放 lock1,然后跳转到第 3 行的 retry
- 21:线程 2 释放 lock2,然后跳转到 17 行的 retry
- 以上过程不断重复,活锁将华丽登场
活锁可以被看做是饥饿的一种极端形式,此时不再是一个线程,而是所有线程都饥饿了。活锁和饥饿都属于事务内存软件实现中的严重问题,所以现在引入了竞争管理器这样的概念来封装这些问题。以锁为例,通常简单的指数级退避就能解决活锁和饥饿。指数级退避是指在每次重试之前增加按指数级增长的延迟。不过,为了获取更好的性能,退避应该有个上限,如果使用排队锁甚至可以在高竞争时获取更好的性能。当然,更好的办法还是通过良好的并行设计使锁的竞争程度变低。
不公平的锁
不公平的锁被看成是饥饿的一种不太严重的表现形式,当某些线程争抢同一把锁时,其中一部分线程在绝大多数时间都可以获取到锁,另一部分线程则遭遇不公平对待。这在带有道速共享缓存或者 NUMA 内存的机器上可能出现。
如上图。如果 CPU0 释放了一把其他 CPU 都想获得的锁,因为 CPU0 与 CPU1 共享内部链接,所以 CPU1 相较于 CPU2~7 则更易抢到锁。反之亦然,如果一段时间后 CPU0 又开始争抢该锁,那么 CPU1 释放时 CPU0 则更易获得锁,导致锁绕过 CPU2~7,只在 CPU0 和 CPU1 之间换手。
低效率的锁
锁是由原子操作和内存屏障实现的,并且常常带有高速缓存未命中。正如我们第三章所见,这些指令代价都是十分昂贵的,粗略地说开销比简单指令要高出两个数量级。这可能是锁的一个严重问题,如果用锁来保护一条指令,你很可能在以百倍的速度带来开销。对于相同的代码,即使假设扩展性非常完美,也需要 100 个 CPU 才能跟上一个执行不加锁版本的 CPU。
这种情况强调了“同步粒度”一节中的权衡,粒度太粗会限制扩展性,粒度太小会导致巨大的同步开销。
不过一旦持有了锁,持有者可以不受干扰的访问被锁保护的代码。获取锁可能代价高昂,但是一旦持有,特别是对较大的临界区来说,CPU 高速缓存反而是高效的性能加速器。
锁的类型
互斥锁
互斥锁正如其名,一次只能被一个线程持有。持锁者对受锁保护的代码享有排他性的访问权。当然,这是在假设该锁保护了所有应当受保护的数据的前提下。虽然有些工具可以帮你检查,但最终的责任还是落在开发者身上,一定要保证所有需要的路径都受互斥锁的保护。
读写锁
读写锁一方面允许任意数量的读者同时持有锁,另一方面允许最多一个写者持有锁。理论上,读写锁对读侧重的数据来说拥有极佳的扩展性。在实践中的扩展性则取决于具体的实现方式。
经典的读写锁实现使用一组只能以原子操作方式修改的计数和标志。这种实现和互斥锁一样,对于很小的临界区来说开销太大,获取和释放锁的开销比一条简单指令的开销要高出两个数量级。当然,如果临界区足够长,获取和释放锁的开销与之相比就可以忽略不计了。可是因为一次只有一个线程能操作锁,随着 CPU 数目的增加,临界区的代价也需要增加才能平衡掉开销。
另一个设计读写锁的方式是使用每线程互斥锁,这种读写锁对读者非常有利。线程在读的时候只需要获取本线程的锁即可,而在写的时候需要获取所有线程的锁。在没有写者的情况下,每个读锁的开销相当于一条原子操作和一个内存屏障的开销之和,而且不会有高速缓存未命中,这点对于锁来说非常不错。不过,写锁的开销包括高速缓存未命中,再加上原子操作和内存屏障的开销之和——再乘以线程的个数。
简单的说,读写锁在有些场景非常有用,但各种实现方式都有各自的缺点。读写锁的正统用法是用于非常长的只读临界区,临界区耗时几百微秒或者毫秒甚至更多则最好。
读写锁之外
读写锁和互斥锁允许的规则大不相同:互斥锁只允许一个持有者,读写锁允许任意多个持有者持有读锁(但只能有一个持有写锁)。锁可能的允许规则有很多,VAX/VMS 分布式锁管理器就是其中一个例子。下图是各种状态之间的兼容性:
规则类型 | 空(未持锁) | 并发读 | 并发写 | 受保护读 | 受保护写 | 互斥访问 |
---|---|---|---|---|---|---|
空(未持锁) | ||||||
并发读 | N | |||||
并发写 | N | N | N | |||
受保护读 | N | N | N | |||
受保护写 | N | N | N | N | ||
互斥访问 | N | N | N | N | N |
N 表示不兼容,空值表示兼容。
VAX/VMS 分布式锁管理器有 6 个状态。为了更好的比较,互斥锁有 2 个状态(持锁和未持锁),而读写锁有 3 个状态(未持锁、持读锁、持写锁)。
这里第一个状态是空状态,也就是未持锁。这个状态与其他任何状态兼容,这也是我们期待的,如果没有线程持有锁,那么也不会阻止其他获取了锁的线程执行。
第二个状态是并发读,该状态与除了排他状态之外的所有状态兼容。并发读状态可用于对数据结构进行粗略的累加统计,同时允许并发写的操作。
第三个状态是并发写,与空状态、并发读、并发写兼容。并发写状态可以用于近似统计计数的更新,同时允许并发的读操作和写操作。
第四个状态是受保护读,与空状态、并发读、受保护兼容。受保护状态可用于读取数据结构的准确结果,同时允许并发的读操作,但是不允许并发的写操作。
第五个状态是受保护写,与空状态、并发读兼容。受保护写状态可用于在可能会受到受保护读干扰的情况下写数据结构,允许并发的读操作。
第六个状态是互斥访问,仅与空状态兼容。互斥访问状态可用于需要排他访问的场合。
有趣的是,互斥锁和读写锁可以用 VAX/VMS 分布式锁管理器来模拟。互斥锁仅使用空状态和互斥访问状态,读写锁仅使用空状态、受保护的读/写状态。
虽然 VAX/VMS 分布式锁管理器广泛用于分布式数据库领域,但是在共享内存的应用程序中却很少见。其中一个可能的原因是分布式数据库中的通信开销在一定程度上可以抵消 VAX/VMS 分布式锁管理器带来的复杂性。
然而,VAX/VMS 分布式锁管理器只是一个例子,用来说明锁背后的概念和灵活性。同时这个例子也是对现代数据库管理系统所使用的锁机制的简单介绍,相对于 VAX/VMS 分布式锁管理器的 6 个状态,有些数据库中使用的锁甚至可以有 30 多个状态。
范围锁
到目前为止我们讨论的加锁原语都需要明确的获取和释放函数,比如 spin_lock()
和 spin_unlock()
。另一种方式是使用面向对象的“资源分配即初始化”(RAII)模式。该设计模式常见于支持自动变量的语言,如 C++,当进入对象的范围时调用构造函数,当退出对象的范围时调用析构函数。同理,加锁可以让构造函数去获取锁、析构函数来释放锁。
这种方法十分有用,事实上 1991 年本书作者曾认为这是唯一有用的加锁方法。RAII 式加锁有一个非常好的特性,你不需要精心思考在每个会退出对象范围的代码路径上释放锁,该特性避免了一系列 BUG 的出现。
但是,RAII 式加锁也有其黑暗面。RAII 使得对获取和释放锁的封装极其困难,比如在迭代器内。在很多迭代器的实现中,你需要在迭代器的开始函数内获取锁,在结束函数内释放锁。相反 RAII 式加锁要求获取和释放锁都发生在相同的对象范围,这使得对它们的封装变得困难,甚至无法实现。
因为范围只能嵌套,所以 RAII 式加锁不允许重叠的临界区。这让锁的很多有用的用法变得不可能,比如,对于协调对并发访问某事件的树状锁。对于任意规模的并发访问,只允许其中一个成功,其余请求最好是让他们越早失败越好。否则在大型系统上(几百个 CPU)对锁的竞争会称为大的问题。
上图是一个示例数据结构(来自 Linux 内核的 RCU 实现)。在这里,每个 CPU 都分配一个 rcu_node 的叶子节点,每个 rcu_node 节点都拥有一个指向父节点的指针 ->parent
,直到根节点的 rcu_node 节点,它的 ->parent
指针为 NULL。每个父节点可以拥有的子节点数目可以不同,但是一般是 32 或 64。每个 rcu_node 节点都有一把名为 ->fqslock
的锁。
这里使用的是一种通用策略——锦标赛,任意指定 CPU 有条件地获取它对应的 rcu_node 叶子节点的锁 ->fqslock
,如果成功,尝试获取其父节点的锁,如成功再释放子节点的锁。除此之外,CPU 在每一层检查全局变量 gp_flags
,如果每个变量表明其他 CPU 已经访问过这个事件,该 CPU 被淘汰出锦标赛。这种先获取——再释放顺序一直持续到要么 gp_flags
变量表明已经有人赢得锦标赛,某一层获取 ->fqslock
锁失败,要么拿到了根节点 rcu_node 结构的锁 ->fqslock
。
锁在实现中的问题
系统总是给开发者提供最好的加、解锁原语,例如 POSIX pthread 互斥锁。然而,学习范例实现总是有点用的,因为这样读者可以考虑极端工作负载和环境带来的挑战。
基于原子交换的互斥锁实现示例
1 typedefintxchglock_t;
2 #define DEFINE_XCHG_LOCK(n) xchglock_t n = 0
3
4 void xchg_lock(xchglock_t *xp)
5 {
6 while (xchg(xp, 1) ==1) {
7 while(*xp == 1)
8 continue;
9 }
10 }
11
12 void xchg_unlock(xchglock_t *xp)
13 {
14 (void)xchg(xp, 0);
15 }
这个锁的结构只是一个 int,如第 1 行所示,这里可以是任何整数类型。这个锁的初始值为 0,代表锁已释放,即第二行代码。
通过 4~10 行上的 xchg_lock()
函数执行锁的获取。此函数使用嵌套循环,外部循环重复地将锁的值与 1 做原子交换(即加锁)。如果旧值已经是 1(即该锁已经被别人持有),那么内部循环(7~8)持续自旋直到锁可用,那么到时候外部循环再一次尝试获取锁。
锁的释放由 12~15 行的 xchg_unlock()
函数执行。第 14 行将值 0(即解锁)原子地交换到锁中,从而标记锁已经释放。
虽然这是一个测试并设置(test-and-set)的例子,但是在生产环境中广泛采用一种非常类似的机制来实现纯自旋锁。
互斥锁的其他实现
基于原子指令的锁有很多可能的实现,Mellor-Crummey 和 Scott 综述了其中很多种。这些实现代表着设计权衡多个维度中的不同顶点。例如,上一节提到的基于原子交换的测试并设置锁,在低度锁竞争时性能良好,并且具有内存占用小的有点。它避免了给不能使用它的线程提供锁,但作为结果可能会导致不公平或甚至在高度锁竞争时出现饥饿。
相比之下,在 Linux 内核中使用的门票锁(ticket-lock)避免了在高度锁竞争时的不公平,但后果是其先入先出的准则可以将锁授予给当前无法使用它的线程,例如,线程由于被抢占、中断或其他方式而失去 CPU。然而,避免太过担心抢占和中断的可能性同样重要,因为抢占和中断也有可能在线程刚获取锁后发生。
只要是等待者在某个内存地址自旋以等待锁的各种实现,包括测试并设置锁和门票锁,都在高度锁竞争时存在性能问题。原因是释放锁的线程必须更新对应的内存地址。在低度竞争时,这不是问题:相对的缓存行很可能仍然属于本地 CPU 并且仍然可以由持有锁的线程来更改。相反,在高度竞争时,每个尝试获取锁的线程将拥有高速缓存行的只读副本,因此锁的持有者将需要使所有此类副本无效,然后才能更新内存地址来释放锁。通常,CPU 和线程越多,在高度竞争条件下释放锁时所产生的开销就约大。
这种负可扩展性已经引发了许多种不同的排队锁(queued-lock)实现。排队锁通过为每个线程分配一个队列元素,避免了高昂的缓存无效化开销。这些队列元素链接在一起构成了一个队列,控制着等待线程获取锁的顺序。这里的关键点在于每个线程只在自己的队列元素上自旋,使得锁持有者只需要使下一个线程的 CPU 缓存中的第一个元素无效即可。这种安排大大减少了在高度锁竞争时交换锁的开销。
最近的排队锁实现也将系统的架构纳入到考虑之中,优先在本地予锁,同时采取措施避免饥饿。这些实现可以看成是传统上用在调度磁盘 IO 时使用的电梯算法的模拟。
不幸的是,相同的调度逻辑虽然提高了排队锁在高度竞争时的时效率,也增加了其在低度竞争时的开销。因此,Beng-hong Lim 和 AnantAgarwal 将简单的测试并设置锁与排队锁结合,在低度竞争时使用测试并设置锁,在高度竞争时切换到排队锁,因此得以在低度竞争时获得低开销,并在高度竞争时获得公平的高吞吐量。Browning 等人采取了类似的方法,但避免了单独标志的使用,这样测试并设置锁的快速路径可以使用简单测试和设置锁实现所用的代码。这种方法已经用于生产环境。
在高度锁竞争中出现的另一个问题是当锁的持有者受到延迟,特别是当延迟的原因是抢占时,这可能导致优先级翻转,其中低优先级的线程持有锁,但是被中等优先级且绑定在某 CPU 上的线程抢占,这导致高优先级线程在尝试获取锁时阻塞。结果是绑定在某 CPU 的中优先级进程阻止高优先级进程运行。一种解决方案是优先级继承,这已被广泛用于实时计算,尽管这种做法仍有一些持续的争议。
避免优先级翻转的另一种做法是在持有锁时防止抢占。由于在持有锁的同时防止抢占也提高了吞吐量,因此大多数私有的 UNIX 内核都提供某种形式的调度器同步机制,当然这主要是由于某家大型数据库供应商的努力。这些机制通常采取提示的形式,即此时不应当抢占。这些提示通过在特定寄存器中设置某个比特位的形式实现,这使得提示机制拥有极低的锁获取开销。作为对比,Linux 没有使用提示机制,而是用一种称为 futexes 的机制来获得类似的效果。
有趣的是,在锁的实现中原子指令并不是不可或缺的部分。在 Herlihy 和 Shavit 的教科书中可以找到一种锁的漂亮实现,只使用简单的加载和存储,提到这点的目的是,虽然这个实现没有什么实际应用,但是详细的研究这个实现将非常具有娱乐性和启发性。不过,除了下面描述的一个例外,这样的研究将留下作为读者的练习。
Gamsa 等人描述了一种基于令牌的机制,其中令牌在 CPU 之间循环,当令牌到达给定的 CPU 时,它可以排他性的访问由该令牌保护的任何内容。很多方案可以实现这种基于令牌的机制,例如:
- 维护一个每 CPU 标志,对于除一个 CPU 之外的所有 CPU,其标志始终为 0。当某个 CPU 的标志非 0 时,它持有令牌。当它不需要令牌时,将令牌置 0,并将下一个 CPU 的标志设置为 1 或其他任何非 0 标志值。
- 维护每 CPU 计数器,其初始值设置为对应 CPU 的编号,我们假定其范围值为 0 到 N-1,其中 N 是 CPU 的数目。当某个 CPU 的计数大于下一个 CPU 的计数时(要考虑计数的溢出),这个 CPU 持有令牌。当它不需要令牌时,它将下一个 CPU 的计数器设置为一个比自己的计数更大的值。
这种锁不太常见,因为即使没有其他 CPU 正在持有令牌,给定的 CPU 也不一定能立即获得令牌。相反,CPU 必须等待直到令牌到来。当 CPU 需要定期访问临界区的情况下这种方法很有用,但是必须要容忍不确定的令牌传递速率。Gamas 等人使用它来实现一种 RCU 的变体,但是这种方法也可以用于保护周期性的每 CPU 操作,例如冲刷内存分配器使用的每 CPU 缓存,或者垃圾收集的每 CPU 数据结构,又或者是将每 CPU 数据写入共享内存(或大容量存储)。
随着越来越多的人熟悉并行硬件及并且越来越多的并行化代码,我们可以期望出现更多的专用加解锁原语。不过,你应该仔细考虑这个重要的安全提示,只要可能,尽量使用标准同步原语。标准同步原语与自己开发的原语相比,最大的优点就是标准原语通常更不容易出现 BUG。
基于所的存在保证
并行编程一个关键挑战是提供存在保证,使得在整个访问尝试过程中,可以在保证该对象存在的前提下访问给定对象。在某些情况下,存在保证是隐式的。
- 基本模块中的全局变量和静态局部变量在应用程序正在运行时存在。
- 加载模块中的全局和静态局部变量在该模块保持加载时存在。
- 只要存在至少一个函数还在被使用,包括将保持加载状态。
- 给定的函数实例的堆栈变量,在该实例返回前一直存在。
- 如果你正在某个函数中执行,或者正在被这个函数调用(直接或间接),那么这个函数一定有一个获得实例。
虽然这些隐式存在保证非常直白,但是设计隐式存在保证的故障真的发生过。
但更有趣也更麻烦的涉及堆内存的存在保证,动态分配的数据结构将存在到它被释放为止。这里要解决的问题是如何将结构的释放和对其的并发访问同步起来。一种方法是使用显式保证,例如加锁。如果给定结构只能在持有一个给定的锁时被释放,那么持有锁就保证那个结构的存在。
但这种保证取决于锁本身的存在。一种保证锁存在的简单方式是把锁放在一个全局变量内,但全局锁具有可扩展性受限的特点。有种可以让可扩展性随着数据结构的大小增加而改进的方法,是在每个元素中放置锁的结构。不幸的是,把锁放在一个数据元素中以保护这个数据元素本身的做法会导致微秒的竟态条件。
解决该问题的方式是使用一个全局锁的哈希集合,使得每个哈希桶都有自己的锁。该方法允许在获取指向数据元素的指针之前获取合适的锁。虽然这种方式对于只存放单个数据结构中的元素非常有效,比如哈希表,但是如果有某个数据元素可以是多个哈希表成员,或者更复杂的数据结构,比如树或图时,就会有问题了。不过这些问题还是可以解决的,事实上,这些解决办法形成了基于锁的软件事务性内存实现。后续将介绍如何简单快速地提供存在保证。
锁:英雄还是恶棍
如现实生活中的情况一样,锁可以是英雄也可以是恶棍,既取决于如何使用它,也取决于要解决的问题。以作者的经验,那些写应用程序的家伙很喜欢锁,那些写并行库的同行不那么开心,那些需啊哟并行化现有顺序库的人则非常不爽。
应用程序中的锁:英雄
当编写整个应用程序时(或整个内核)时,开发人员可以完全控制设计,包括同步设计,假设设计中能够良好地使用分割,锁可以是非常有效的同步机制,锁在生产环境级别的高质量并行软件中大量使用已经说明了一切。
然而,尽管通常其大部分同步设计是基于锁,这些软件也几乎总还是利用了其他一些同步机制,包括特殊计数算法、数据所有权、引用计数、顺序锁和 RCU。此外,业界也使用死锁检测工具。获取/释放锁平衡工具、高速缓存未命中分析和基于计数器的性能剖析等等。
通过仔细设计、使用良好的同步机制和良好的工具,锁在应用程序和内核领域工作的相当出色。
并行库中的锁:只是一个工具
与应用程序和内核不同,库的设计者不知道与库函数交互的代码中锁是如何设计的。事实上,那段代码可能在几年后才会出现。因此,库函数设计者对锁的控制力较弱,必须在思考同步设计时更加小心。
死锁当然是需要特别关注的,这里需要运用前面介绍死锁时提到的技术。一个流行的死锁避免策略是确保库函数中的锁是整个程序的锁层次中的独立子树。然而,这个策略实现起来可能比他看起来更难。
前面死锁一节中讨论了一种复杂情况,即库函数调用应用程序代码,qsort()
的比较函数的参数是切入点。另一个复杂情况是与信号处理程序的交互。如果库函数接收的信号调用了应用程序的信号处理函数,几乎可以肯定这会导致死锁,就像库函数直接调用了应用程序的信号处理程序一样。最后一种复杂情况发生在那些可以在 fork()
和 exec()
之间使用的库函数,例如,由于使用了 system()
函数。在这种情况下,如果你的库函数在 fork()
的时候持有锁,那么子进程就在持有该锁的情况下出生。因为会释放锁的线程在父进程运行,而不是子进程,如果子进程调用你的库函数,死锁会随之而来。
在这些情况下,可以使用一下策略来避免死锁问题:
- 不要使用回调或信号。
- 不要从回调或信号处理函数中获取锁。
- 让调用者控制同步。
- 将库 API 参数化,以便让调用者处理锁。
- 显式地避免回调死锁。
- 显式地避免信号处理程序死锁。
既不使用回调,也不使用信号
如果库函数避免使用回调,并且应用程序作为一个整体也避免使用信号,那么由该库函数获得的任何锁将是锁层次中的叶子节点。这种安排避免了死锁。虽然这个策略在其适用时工作的非常好,但是有一些应用程序必须使用信号处理程序,并且有一些库函数必须使用回调。这时可以使用下一个策略。
避免在回调和信号处理函数中用锁
如果回调和处理函数都不获取锁,他们就不会出现在死锁循环中,这使得库函数只能成为锁层次树上的叶子节点。这个策略对于 qsort
的大多数使用情况非常有效,它的回调通常只是比较两个传递给回调的值。这个策略也奇妙地适合许多信号处理函数,通常来说在信号处理函数内获取锁是不明智的行为,但如果应用程序需要处理来自信号处理函数的复杂数据结构,这种策略可能会行不通。
这里有一些方法,即便必须操作复杂的数据结构也可以避免在信号处理函数中获取锁。
- 使用基于非阻塞同步的简单数据结构。
- 如果数据结构太复杂,无法合理使用非阻塞同步,那么创建一个允许非阻塞入队操作的队列。在信号处理函数中,而不是在复杂的数据结构中,添加一个元素到队列,描述所需的更改。然后一个单独的线程在队列中将元素删除,并执行需要使用锁的更改。关于并发队列已经有很多现成的实现。
这种策略应当在偶尔的人工或(最好是)自动的检查回调和信号处理函数时强制使用。当进行这些检查时要小心警惕,防止那些聪明的开发者(不明智地)自制一些使用原子操作的加锁原语。
调用者控制的同步
让调用者控制同步。当调用者可控数据结构的不同实例调用库函数时,这招非常管用,这时每个实例都可以单独同步。例如,如果库函数要操作一个搜索树,并且如果应用程序需要大量的独立搜索树,那么应用程序可以将锁与每个树关联。然后应用程序获取并根据需要来释放锁,使得库函数完全不需要知道并行性。
但是,如果库函数实现的数据结构需要内部并发执行,则此策略将失败。例如,哈希表或并行排序。在这种情况下,库绝对必须控制自己的同步。
参数化的库函数同步
这里的想法是向库的 API 添加参数以指定要获取的锁、如何获取和释放锁。该策略允许应用程序通过指定要获取的锁(通过传入指向所的指针等)以及如何获取它们(通过传递指针来加锁和解锁),来全局避免死锁。而且还允许线程给定的库函数通过决定加锁和解锁的位置,来控制自己的并发性。
特别的,该策略允许加锁和解锁函数根据需要来阻塞信号,而不需要库函数代码关心那些信号需要被哪些锁阻塞。这种策略使用的分离关注点的方式十分有效,不过在某些情况下,后续介绍的策略将会表现的更好。
也就是说,如果需要明确的将指向所的指针传递给外部 API,必须非常小心考虑。虽然这种做法有时在所难免,但你总应该试着寻找一种替代设计。
明确地避免回调死锁
前面已经讨论了此策略的基本规则:在调用未知代码之前释放所有锁。这通常是最好的办法,因为它允许应用程序忽略库函数的锁层次结构,库函数仍然是应用程序锁层次结构中的一个叶子节点或孤立子树。
若在调用未知代码之前不能释放所有的锁,死锁一节介绍的分层锁层级就适合这种情况。例如,如果未知代码是一个信号处理函数,这意味着库函数要在所有持有锁的情况屏蔽信号,这种做法复杂且缓慢。因此,在信号处理函数(可能不明智地)获取锁的情况,可以使用下一种策略。
明确地避免信号处理函数死锁
信号处理函数的死锁可以按如下方式明确避免:
- 如果应用程序从信号处理函数中调用库函数,那么每次在除信号处理函数以外的地方调用库函数时,必须阻塞该信号。
- 如果应用程序在持有从某个信号处理函数中获取的锁时调用库函数,那么每次在除信号处理函数之外的地方调用库函数时,必须阻塞该信号。
这些规则可以通过使用类似 Linux 内核的 lockdep 锁依赖关系检测工具来检查。lockdep 的一大优点就是它从不受人类直觉的影响。
在 fork() 和 exec() 之间使用的库函数
如前所述,如果执行库函数的线程在其他线程调用 fork 时持有锁,父进程的内存会被复制到子进程,这个事实意味着子进程从被创建的那一刻就持有该锁。负责释放锁的线程运行在父进程的上下文,而不是在子进程,这意味着子进程中这个锁的副本永远不会被释放。因此,任何在子进程中调用相同库函数的尝试都将会导致死锁。
这个问题的解决方法是让库函数检查是否锁的持有者仍在运行,若不是,则通过重新初始化来“撬开”锁并再次获取它。然而,这种方法有几个漏洞:
- 受该锁保护的数据结构可能在某些中间状态,所以简单的“撬开”锁可能会导致任意内存被更改。
- 如果子进程创建了额外的线程,则两个线程可能会同时“撬开”锁,结果是两个线程都相信自己拥有锁。从而再次导致任意内存被更改。
atfork() 函数就是专门用来帮助处理这些情况的。这里的想法是注册一个三元组函数,一个由父进程在 fork 之前调用,一个由父进程在 fork 之后调用,一个由子进程在 fork 之后调用。然后可以在这三个点进行适当的清理工作。
但是要注意,atfork 处理函数的代码通常十分微秒。atfork 最适合的情况是锁保护的数据结构可以简单的由子进程重新初始化。
讨论
无论使用何种策略,对库 API 的描述都必须包含该策略和调用者如何使用该策略的清晰描述。简而言之,设计并行库时使用锁是完全可能的,但没有像设计并行应用程序那样简单。
并行化串行库时的锁:恶棍
随着到处可见的低成本多核系统的出现,常见的任务往往是并行化已有的库,这些库的设计仅考虑了单线程使用的情况。从并行编程的角度看,这种对于并行性的全面忽视可能导致库函数 API 的严重缺陷。比如:
- 隐式的禁止分割。
- 需要锁的回调函数。
- 面向对象的意大利面条式代码。
禁止分割
假设你正在编写一个单线程哈希表实现。可以很容易并且快速地得到哈希表中元素总数的精确计数,同时也可以很容易并且快速地在每次添加和删除操作后返回此计数。所以为什么在实际中不这么做呢?
一个原因是精确计数器在多核系统上要么执行错误,要么扩展性不佳。因此,并行化这个哈希表的实现将会出现错误或者扩展性不佳的情况。
那么我们能做什么呢?一种方式是返回近似计数,另一种方式是完全不用元素计数。无论哪种方式,都有必要检查哈希表的使用,看看为什么添加和删除操作需要元素的精确计数。这里有几种可能性:
- 确定何时调整哈希表的大小。这时,近似计数应该工作得很好。调整大小的操作也可以由哈希桶中最长链的长度触发,如果合理分割每个哈希桶的话,那么很容易得出每个链的长度。
- 得到遍历整个哈希表所需的大概时间。这时,使用近似计数也不错。
- 处于诊断的目的。例如,检查传入哈希表和从哈希表传出时丢失的元素。然而,鉴于这种用法是诊断性目的,分别维护每个哈希链的长度也可以满足要求,然后偶尔再锁住添加删除操作时将各个长度求和输出。
现在有一些理论基础研究,阐述了并行库 API 在性能和扩展性上受到的约束。任何设计并行库的人都需要密切注意这些约束。
虽然对于一个对并发不友好的 API 来说,人们很容易去职责锁是罪魁祸首,但这并没有用。另一方面,人们除了同情当年写下这段代码的倒霉程序员之外,也没有什么更好的办法。如果程序员能在 1985 年就能遇见未来对并行性的需求,那简直是稀罕和高瞻远瞩,如果那时就能设计出一个对并行友好的 API,那真是运气和荣耀的罕见巧合了。
随着时间的变化,代码必须随之改变。也就是说,如果某个受欢迎的库拥有大量用户,在这种情况下对 API 进行不兼容的更改将是相当愚蠢的。添加一个对并行友好的 API 来补充现有的串行 API,可能是这种情况下的最佳行动方案。
然而,出于人类的本性,不行的开发者们更可能抱怨的是锁带来的问题,而不是他们自身对糟糕(虽然可以理解) API 的设计选择。
容易死锁的回调
前面已经描述了对回调的无规律使用将提高加锁的难度,同时还描述了如何设计库函数来避免这些问题,但是期望一个 20 世纪 90 年代的程序员在没有并行编程经验时就能遵循这些设计,是不是有点不切实际?因此,尝试并行化拥有大量回调的已有单线程程序库的程序员,很可能会相当憎恨锁。
如果有一个库使用了大量回调,可能明智的举动是向库函数中添加一个并行友好的 API,以允许现有用户逐步进行代码的切换。或者,一些人主张在这种情况下使用事务内存。有一点需要注意,硬件事务内存无助于解决上述情景,除非硬件事务内存实现提供了前进保证(forward-progress guarantee),不过很少有事务内存做到这一点。
面向对象的意大利面条式代码
从 20 世纪 80 年代末或 90 年代初的某个时候,面向对象编程变得流行起来,因此在生产环境中出现了大量面向对象式的代码,大部分是单线程的。虽然 OO 是一种很有价值的软件技术,但是毫无节制的使用对象可以很容易写出面向对象式的意大利面条代码。在面向对象式的意大利面条代码中,执行流基本上是以随机的方式从一个对象走到另一个对象,使得代码难以理解,甚至无法加入锁层次结构。
虽然很多人可能会认为,不管在任何情况下这样的代码都应该清理,说着容易做着难。如果你的任务是并行化这样的野兽,通过对前面描述的技巧的运用,以及后续将继续讨论的技术,你对人生(还有锁)感到绝望的机会会大大减少。这种场景似乎是事务性内存出现的原因,所以事务内存也值得一试。也就是说,应该根据前面讨论的硬件习惯来选择同步机制,如果同步机制的开销大于那些被保护的操作一个数量级,结果必然不会漂亮。
这些情况下有一个问题值得提出,代码是否应该继续保持串行执行?例如,或许在进程级别而不是线程级别引入并行性。一般来说,如果任务证明是非常困难,确实值得花一些时间思考并通过其他方法来完成任务,或者通过其他任务来解决手头的问题。
总结
锁也许是最广泛也最常用的同步工具。然而,最好是在一开始设计应用程序或库时就把锁考虑进去。考虑可能要花一整天的时间,才能让很多已有的单线程代码并行运行,因此锁不应该是并行编程工具箱里的唯一工具。
3.8 - CH08-数据所有权
避免锁带来的同步开销的最简单方式之一,就是在线程之间(或者对于内核来说,CPU 之间)包装数据,以便让数据仅被一个线程访问或修改。这种方式非常重要,事实上,它是一种应用模式,甚至新手凭借本能也会如此使用。
多进程
在前面基于 Shell 的并行编程示例中,两个进程之间不共享内存。这种方法几乎完全消除了同步开销。这种极度简单和最佳性能的组合显然是相当有吸引力的。
部分数据所有权和 pthread 线程库
在第五章“计数”中大量使用了数据所有权技术,但是做了一些改变。不允许线程修改其他线程拥有的数据,但是允许线程读取这些数据。总之,使用共享内存允许更细粒度的所有权和访问权限概念。
纯数据所有权也是常见且有用的。比如前面讨论的每线程内存分配器缓存,在该算法中,每个线程的缓存完全归该线程所有。
函数输送
上面讨论的是一种弱形式的数据所有权,线程需要更改其他线程的数据。这可以被认为是将数据带给他需要的函数。另一种方式是将函数发送给数据。
指派线程
前面的小节描述了允许每个线程保留自己的数据副本或部分数据副本的方式。相比之下,本节将描述一种分解功能的方式,其中特定的指定线程拥有完成其他工作所需的数据的权限。之前讨论的最终一致性计数器实现就提供了一个例子。eventual() 函数中运行了一个指定线程,该线程周期性地将每线程计数拉入全局计数器,最终将全局计数器收敛于实际值。
私有化
对于共享内存的并行程序,一种提升性能的和可扩展性的方式是将共享数据转换成由特定线程拥有的私有数据。
比如使用私有化方式来解决哲学家就餐问题,这种方式具有比标准教科书解法更好的性能和扩展性。原来的问题是 5 个哲学家坐在桌子旁边,每个相邻的哲学家之间有一把叉子,最多允许两个哲学家同时就餐。我们可以通过提供 5 把额外的叉子来简单地私有化这个问题,所有每个哲学家都有自己的私人叉子。这允许所有 5 个哲学家同时就餐,也大大减少了一些传播疾病的机会。
在其他情况下,私有化会带来开销。总之,在并行程序员的工具箱中,私有化是一个强大的工具,但必须小心使用。就像其他同步原语一样,他可能会带来复杂性,同时降低性能和扩展性。
数据所有权的其他用途
当数据可以被分割时,数据所有权最为有效,此时很少或没有需要跨线程访问或更新的地方。幸运的是,这种情况很常见,并且在各种并行编程环境中广泛存在。
- 所有消息传递环境,例如 MPI。
- MapReduce。
- 客户端——服务器系统,包括 RPC、Web 服务好几乎任何带有后端数据库服务的系统。
- 无共享式数据库系统。
- 具有单独的每进程地址空间的 fork-join 系统。
- 基于进程的并行性,比如 Erlang 语言。
- 私有变量,例如 C 语言在线程环境中的堆栈自动变量。
数据所有权可能是最不起眼的同步机制。当使用得当时,它能提供无与伦比的简单性、性能、扩展性。也许他的简单性使他没有得到应有的尊重。
3.9 - CH09-延后处理
延后工作的策略可能在人类有记录历史出现之前就存在了,它偶尔被嘲笑为拖延甚至纯粹的懒惰。但直到最近几十年,人们才认识到该策略在简化并行化算法的价值。通用的并行编程延后处理方式包括引用计数、顺序锁、RCU。
引用计数
引用计数指的是跟踪一个对象被引用的次数,防止对象被过早释放。虽然这是一种概念上很简单的技术,但是细节中隐藏着很多魔鬼。毕竟,如果对象不会太提前释放,那么就不需要引用计数了。但是如果容易被提前释放,那么如何阻止对象在获取引用计数过程中被提前释放呢?
该问题有以下几种可能的答案:
- 在操作引用计数时必须持有一把处于对象之外的锁。
- 使用不为 0 的引用计数创建对象,只有在当前引用计数不为 0 时才能获取新的引用计数。如果线程没有对某指定对象的引用,则它可以在已经具有引用的另一线程的帮助下获得引用。
- 为对象提供存在担保,这样在任何有实体尝试获取引用的时刻都无法释放对象。存在担保通常是由自动垃圾收集器来提供,并且在后续章节介绍的 RCU 中也会能提供存在担保。
- 为对象提供类型安全的存在担保,当获取到引用时将会执行附加的类型检查。类型安全的存在担保可以由专用内存分配器提供,也可以由 Linux 内核中的 SLAB_DESTORY_BY_RCU 特性提供。
当然,任何提供存在担保的机制,根据其定义也能提供类型安全的保证。所以本节将后两种答案合并成放在 RCU 一类,这样我们就有 3 种保护引用获取的类型,即锁、引用计数和 RCU。
考虑到引用计数问题的关键是对引用获取和释放对象之间的同步,我们共有 9 种可能的机制组合。
获取同步 | 释放同步—锁 | 释放同步—引用计数 | 释放同步—RCU |
---|---|---|---|
锁 | — | CAM | CA |
引用计数 | A | AM | A |
RCU | CA | MCA | CA |
下图将引用计数机制归为以下几个大类:
- (—):简单计数,不适用原子操作、内存屏障、对齐限制。
- (A):不使用内存屏障的原子计数。
- (AM):原子计数,仅在释放时使用内存屏障。
- (CAM):原子计数,在获取时使用原子操作检查,在释放时使用内存屏障。
- (CA):原子计数,在获取时使用原子操作检查。
- (MCA):原子计数,在获取时使用原子操作检查,同时还使用内存屏障。
但是,由于 Linux 内核中所有“返回值的原子”都包含内存屏障,所有释放操作也包含内存屏障。因此类型 CA 和 MCA 与 CAM 相等,这样就剩下四种类型:—、A、AM、CAM。后续章节将会列出支持引用计数的 Linux 原语。稍后的章节也将给出一种优化,可以改进引用获取和释放十分频繁、而很少需要检查引用是否为 0 这一情况下的性能。
各种引用计数的实现
简单计数
简单计数,既不使用原子操作、也不使用内存屏障,可以用于在获取和释放引用计数时都用同一把锁保护的情况。在这种情况下,引用计数可以以非原子操作方式读写,因为锁提供了必要的互斥保护、内存屏障、原子指令和禁用编译器优化。这种方式适用于锁在保护引用计数之外还保护其他操作的情况,这样也使得引用一个对象必须得等到锁(被其他地方)释放后再持有。
原子计数
原子计数适用于这种情况:任何 CPU 必须先持有一个引用才能获取引用。这是用在当单个 CPU 创建一个对象以供自己使用时,同时也允许其他 CPU、任务、定时器处理函数或者 CPU 后来产生的 IO 完成回调处理函数来访问该对象。CPU 在将对象传递给其他实体之前,必须先以该实体的名义获取一个新的引用。在 Linux 内核中,kref 原语就是用于这种引用计数的。
因为锁无法保护所有引用计数操作,所以需要原子计数,这意味着可能会有两个不同的 CPU 并发地操纵引用计数。如果使用普通的增减函数,一对 CPU 可以同时获取引用计数,假设他们都获取到了计数值 3。如果他们各自都增加各自的值,就得到计数值 4,然后将值写回引用计数中。但是引用计数的新值本该是 5,这样就丢失了其中一次增加。因此,计数的增减操作必须使用原子操作。
如果释放引用计数由锁或 RCU 保护,那么就不需要再使用内存屏障了(以及禁用编译器优化),并且锁也可以防止一对释放操作同时执行。如果是 RCU,清理必须延后直到所有当前 RCU 读端的临界区执行完毕,RCU 框架会提供所有需要的内存屏障和进制编译器优化。因此,如果 2 个 CPU 同时释放了最后 2 个引用,实际的清理工作将延后到所有 CPU 退出它们读端的连接区才会开始。
带释放内存屏障的原子计数
Linux 内核的网络层采用了这种风格的引用,在报文路由中用于跟踪目的地缓存。实际的实现要更复杂一点,本节将关注 struct_dst_entry
引用计数是如何满足这种实例的。
如果调用者已经持有一个 dst_entry 的引用,那么可以使用 dist_clone()
原语,该原语会获取另一个引用,然后传递给内核中的其他实体。因为调用者已经持有了一个引用,dis_clone()
不需要再执行任何内存屏障。将 dst_entry 传递给其他实体的行为是否需要内存屏障,要视情况而定,不过如果需要内存屏障,那么内存屏障已经嵌入在传递给 dst_entry 的过程中了。
dist_release()
原语可以在任何情况下调用,调用者可能在调用 dst_release()
的上一条语句获取 dst_entry 结构的元素的引用。因此在第 14 行上,dst_release()
原语包含了一个内存屏障,阻止编译器和 CPU 的乱序执行。
请注意,开发者在调用 dst_clone()
和 dst_release()
时不需要关心内存屏障,只需要了解使用这两个原语的规则就够了。
带检查和释放内存屏障的原子计数
引用计数的获取和释放可以并发执行这一事实增加了引用计数的复杂性。假设某次引用计数的释放操作发现引用计数的新值为 0,这表明他现在可以安全清除被引用的对象。此时我们肯定不希望在清理工作进行时又发生一次引用计数的获取操作,所以获取操作必须包含一个检查当前引用值是否为 0 的检查。该检查必须是原子自增的一部分。
Linux 黑盒的 fget()
和 fput()
原语都属于这种风格的引用计数,下面是简化后的实现:
第 4 行的 fget 取出一个指向当前进程的文件描述符表的指针,该表可能在多个进程间共享。第 6 行调用 rcu_read_lock
,进入 RCU 读端临界区。后续任何 call_rcu
原语调用的回调函数将延后到对应的 rcu_read_unlock
完成后执行。第 7 行根据参数 fd 指定的文件描述符,查找对应的 struct file 结构,文件描述符的内容稍后再讲。如果指定的文件描述符存在一个对应的已打开文件,那么第 9 行尝试原子地获取一个引用计数。如果第 9 行的操作失败,那么第 10、11 行退出 RCU 读写端临界区,返回失败。如果第 9 行的操作成功,那么第 14、15 行退出读写端临界区,返回一个指向 struct file 的指针。
fcheck_files
原语是 fget
的辅助函数。该函数使用 rcu_dereference
原语来安全地获取受 RCU 保护的指针,用于之后的解引用(这会在如 DEC Alpha 之类的 CPU 上产生一个内存屏障,在这种机器上数据依赖并不保证内存顺序执行)。第 22 行使用 rcu_dereference
来获取指向任务当前的文件描述符表的指针,第 25 行获取该 struct file 的指针,然后调用 rcu_dereference
原语。第 26 行返回 struct file 的指针,如果第 24 行检查失败,那么这里返回 NULL。
fput
原语释放一个 struct file 的引用。第 31 行原子地减少引用计数,如果自减后值为 0,那么第 32 行调用 call_rcu
原语来释放 struct file(通过 call_rcu()
的第二个参数指定的 file_free_rcu
函数),不过这只在当前所有执行 RCU 读端临界区的代码执行完毕后才会发生。等待当前所有执行 RCU 读端临界区的时间被称为“宽限期”。请注意,atomic_dec_and_test
原语中包含一个内存屏障。在本例中该屏障并非必要,因为 struct file 只有在所有 RCU 读端临界区完成后才能销毁,但是在 Linux 中,根据定义所有会返回值的原子操作都需要包含内存屏障。
一旦宽限期完毕,第 39 行 file_free_rcu
函数获取 struct file 的指针,第 40 行释放该指针。
本方法也用于 Linux 虚拟内存系统中,请见针对 page 结构的 get_page_unless_zero
和 put_page_test_zero
函数,以及针对内存映射的 try_to_unuse
和 mmput
函数。
危险指针
前面小节讨论的所有引用计数机制都需要一些其他预防机制,以防止在正在获取引用计数的引用时删除数据元素。该机制可以是一个预先存在的对数据元素的引用、锁、RCU 或原子操作,但所有这些操作都会降低性能和扩展性,或者限制应用场景。
有一种避免这些问题的方法是反过来实现引用计数,也就是说,不是增加存储在数据元素内的某个整数,而是在每 CPU(或每线程)链表中存储指向该数据元素的指针。这个链表里的元素被称为危险指针。每个元素都有一个“虚引用计数”,其值可以通过计算有多少个危险指针指向该元素而得到。因此,如果该元素已经被标记为不可访问,并且不再有任何引用它的危险指针,该元素就可以安全地释放。
当然,这意味着危险指针的获取必须要谨慎,以避免并发删除导致的破坏性后果。
因为使用危险指针的算法可能在他们的任何步骤中重新启动对数据结构的遍历,这些算法通常在获得所有危险指针之前,必须注意避免对数据结构进行任何更改。
以这些限制为交换,危险指针可以为读端提供优秀的性能和扩展性。在第十章将会比较危险指针及其他引用计数机制的性能。
支持引用计数的 Linux 原语
atomic_t
,可提供原子操作的 32 位类型定义。void atomic_dec(atomic_t *var)
,不需要内存屏障或阻止编译器优化的原子自减引用计数操作。int atomic_dec_and_test(atomic_t *var)
,原子减少引用计数,如果结果为 0 则返回 true。需要内存屏障并且阻止编译器优化,否则可能让引用计数在原语外改变。void atomic_inc(atomic_t *var)
,原子增加引用计数,不需要内存屏障或禁用编译器优化。int atomic_inc_not_zero(atomic_t *var)
,原子增加引用计数,如果结果不为 0,那么在增加后返回 true。会产生内存屏障并禁止编译器优化,否则引用会在原语外改变。int atomic_read(atomic_t *var)
,返回引用计数的整数值。非原子操作、不需要内存屏障、不需要禁止编译器优化。void atomic_set(atomic_t *var, int val)
,将引用计数的值设置为 val。非原子操作、不需要内存屏障、不需要禁止编译器优化。void call_rcu(struct rcu_head *head, void (*func)(struct rcu_head *head))
,在当前所有执行 RCU 读端临界区完成后调用 func,不过call_rcu
原语是立即返回的。请注意,head 通常是受 RCU 保护的数据结构的一个字段,func 通常是释放该数据结构的函数。从调用call_rcu
到调用 func 之间的时间间隔被称为宽限期。任何包含一个宽限期的时间间隔本身就是一个宽限期。type *container_of(p, type, f)
,给出指针 p,指向类型为 type 的数据结构中的字段 f,返回指向数据结构的指针。void rcu_read_lock(void)
,标记一个 RCU 读端临界区的开始。void rcu_read_unlock(void)
,标记一个 RCU 读端临界区的结束。RCU 读临界区可以嵌套。void smp_mb__before_atomic_dec(vod)
,只有在该平台的atomic_dec
原语没有产生内存屏障,禁止编译器的乱序优化时才有用,执行上面的操作。struct rcu_head
用于 RCU 基础框架的数据结构,用来跟踪等待宽限期的对象。通常作为受 RCU 保护的数据结构中的一个字段。
计数优化
在经常更改计数但很少检查计数是否为 0 的场合里,像第 5 章讨论的那样,维护一个每 CPU 或者每任务计数很有用。关于此计数在 RCU 上的实例,参见关于可睡眠 RCU 的论文。该方法可避免在增减计数函数中使用原子操作或内存屏障,但还是要禁用编译器的乱序优化。另外,像 synchronize_srcu
这样的原语,检查总的引用计数是否为 0 的速度十分缓慢。这使得该方法不适合用于频繁获取和释放引用计数的场合,不过对于极少检查引用计数是否为 0 的场合还是合适的。
顺序锁
Linux 内核中使用的顺序锁主要用于保护以读取为主的数据,多个读者观察到的状态必须一致。不像读写锁,顺序锁的读者不能阻塞写者。它反而更像是危险指针,如果检测到有并发的写者,顺序锁会强迫读者重试。在代码中使用顺序锁的时候,设计很重要,尽量不要让读者有重试的机会。
顺序锁的关键组成部分是序列号,没有写着的情况下其序列号为偶数值,如果有一个更新正在进行中,其序列号为奇数值。读者在每次访问之前和之后可以对值进行快照。如果快照是奇数值,又或者如果两个快照的值不同,则存在并发更新,此时读者必须丢弃访问的结果,然后重试。读者使用 read_seqbegin
和 read_seqretry
函数访问由顺序锁保护的数据。写者必须在每次更新前后增加该值,并且在任意时间内只允许一个写者。写者使用 write_seqlock
和 write_sequnlock
函数更新由顺序锁保护的数据。
顺序锁保护的数据可以拥有任意数量的并发读者,但一次只有有一个写者。在 Linux 内核中顺序锁用于保护计时的校准值。它也用在遍历路径名时检测并发的重命名操作。
可以将顺序锁的读端和写端临界区视为事务,因此顺序锁定可以被认为是一种有限形式的事务内存,后续将会讨论。顺序锁的限制是:顺序锁限制更新和;顺序锁不允许遍历指向可能被写者释放的指针。事务内存当然不存在这些限制,但是通过配合使用其他同步原语,顺序锁也可以克服这些限制。
顺序锁允许写者延迟读者,但反之不可。在存在大量写操作的环境中,这可能引起对读者的不公平甚至饥饿。另一方面,在没有写者时,顺序锁的运行相当快且可以线性扩展。人们总是要鱼和熊掌兼得:快速的读者和不需要重试的读者,并且不会发生饥饿。此外,如果能够不受顺序锁对指针的限制就更好了。下面将介绍同时拥有这些特性的同步机制。
读-复制-修改(RCU)
RCU 介绍
假设你正在编写一个需要访问随时变化的数据的并行实时程序,数据可能是随着温度、湿度的变化而逐渐变化的大气压。该程序的实时响应要求是如此严格,不允许存在任何自旋或阻塞,因此锁就被排除了。同样也不允许使用重试循环,这就排除了顺序锁。幸运的是,温度和压力的范围通常是可控的,这样使用默认的编码数据集也可行。
但是,温度、湿度和压力偶尔会偏离默认值太远,在这种情况下,有必要提供替换默认值的数据。因为温度、湿度和压力是逐渐变化的,尽管数值必须在几分钟内更新,但提供更新值并不是非常紧急的事情。该程序使用一个全局指针,即 gptr,通常为 NULL,表示要使用默认值。否则,gptr 指向假设命名为 a/b/c 的变量,它们的值用于实时计算。
我们如何在不妨碍实时性的情况下安全地为读者提供更新后的数据呢?
上图是一种经典的方式。第一排显示默认状态,其中 gptr 等于 NULL。在第二排中,我们已经分配了一个默认的结构,如问号所示。在第三排,我们已经初始化了该结构。接下来,我们让 gptr 来引用这个新元素。在现代计算机系统中,并发的读者要么看到一个 NULL 指针、要么看到指向新结构 p 的指针,不会看到中间结果,从这种意义上说,这种赋值是原子的。因此,每个读者都可以读到默认值 NULL,或者获取新赋值的非默认值。但无论哪种方式,每个读者都会看到一致的结果。更好的是,读者不需要使用任何昂贵的同步原语,因此这种方式非常适合用于实时场景。
但是我们迟早需要从并发的读者手中删除指向指针的数据。让我们转到一个更加复杂的例子,我们正在删除一个来自链表的元素,如下图:
此链表最初包含元素 A/B/C,首先我们需要删除元素 B,我们使用 list_del()
执行删除操作,此时所有新加入的读者都将看到元素 B 已经从链表删除了。然而,可能仍然有老读者在引用这个元素。一旦所有这些旧的读者读取完成,我们可以安全地释放元素 B,如图中最后一部分所示。
但是我们怎么知道读者何时完成读取呢?
引用计数的方案很有诱惑力,但是这也可能导致长延迟,正如锁和顺序锁,我们已经拒绝这种选择。
让我们考虑极端情况下的逻辑,读者完全不将他们的存在告诉任何人。这种方式显然让读者的性能更佳(毕竟免费是一个非常好的价格),但留给写者的问题是如何才能确定所有的老读者都已经完成。如果要给这个问题提供一个合理的答案,我们显然需要一些额外的约束条件。
有一种约束适合某种类型的实时操作系统(以及某些操作系统内核),让线程不会被抢占。在这种不可抢占的环境中,每个线程将一直运行,直到它明确地并自愿地阻塞自己。这意味着一个不能阻塞的无限循环将使该 CPU 在循环开始后无法用于任何其他目的。不可抢占性还要求线程在持有自旋锁时禁止阻塞。如果没有这个禁止,当持有自旋锁的线程被阻塞后,所有 CPU 都可能陷入某个要求获取自旋锁的线程中无法自拔。要求获取自旋锁的线程在获得锁之前不会放弃他们的 CPU,但是持有锁的线程因为拿不到 CPU,又不能释放自旋锁。这是一种经典的死锁。
然后我们对遍历链表的读线程施加相同的约束:这样的线程在完成遍历之前不允许阻塞。返回到上图的第二排,其中写者刚刚执行完 list_del()
,想象 CPU0 这时做了一个上下文切换。因为读者不允许在遍历链表时阻塞,所以我们可以保证所有先前运行在 CPU0 上的读者已经完成。将这个推理扩展到其他 CPU,一旦每个 CPU 被观察到执行了上下文切换,我们就能保证所有之前的读者都已经完成,该 CPU 不会再有任何引用元素 B 的读线程。此时写者可以安全地释放元素 B 了,也就是上图最后一排所示的状态。
这种方法的示意图如下所示,图中的时间从顶部推移到底部:
虽然这种方法在生产环境上的实现可能相当复杂,但是玩具实现却非常简单:
for_each_online_cpu(cpu);
run_on(cpu);
for_each_online_cpu()
原语遍历所有 CUP,run_on()
函数导致当前线程在指定的 CPU 上运行,这会强制目标 CPU 切换上下文。因此,一旦 for_each_online_cpu
完成,每个 CPU 都执行了一次上下文切换,这又保证了所有之前存在的读线程已经完成。
请注意,该方法不能用于生产环境。正确处理各种边界条件和对性能优化的强烈要求意味着用于生产环境的代码实现将十分复杂。此外,可抢占环境的 RCU 实现需要读者实际去做点什么事情。不过,这种简单的不可抢占方法在概念上是完整的,并为下一节理解 RCU 的基本原理形成了良好的初步基础。
RCU 基础
RCU 是一种同步机制,2002 年 10 月引入 Linux 内核。RCU 允许读操作可以与更新操作并发执行,这一点提升了程序的可扩展性。常规的互斥锁让并发线程互斥执行,并不关心该线程是读者还是写者,而读写锁在没有写者时允许并发的读者,相比于这些常规操作,RCU 在维护对象的每个版本时确保读线程保持一致,同时保证只在所有当前读端临界区都执行完毕后才释放对象。RCU 定义并使用了高效且易于扩展的机制,用来发布和读取对象的新版本,还用于延后旧版本对象的垃圾收集工作。这些机制恰当地在读端和更新端分布工作,让读端非常快速。在某些场合下(比如非抢占式内核里),RCU 读端的函数完全是零开销。
看到这里,读者通常会疑惑“究竟 RCU 是什么”,或者“RCU 怎么工作”。本节将致力于从一种基本的视角回答上述问题,稍后的章节将从用户使用和 API 的视角从新看待这些问题。最后一节会给出一个图表。
RCU 由三种基础机制构成,第一个机制用于插入,第二个机制用于删除,第三个用于让读者可以不受并发插入和删除的干扰。
订阅机制
RCU 的一个关键特性是可以安全的扫描数据,即使数据此时正被修改。RCU 通过一种发布——订阅机制达到了并发的数据插入。举个例子,假设初始值为 NULL 的全局指针 gp 现在被赋值指向一个刚分配并初始化的数据结构。如下代码所示:
1 struct foo {
2 int a;
3 int b;
4 int c;
5 };
6 struct foo *gp = NULL;
7
8 /* . . . */
9
10 p = kmalloc(sizeof(*p), GFP_KERNEL);
11 p->a = 1;
12 p->b = 2;
13 p->c = 3;
14 gp = p;
不幸的是,这块代码无法保证编译器和 CPU 会按照顺序执行最后 4 条赋值语句。如果对 gp 的复制发生在初始化 p 的各种字段之前,那么并发的读者会读到未初始化的值。这里需要内存屏障来保证事情按顺序发生,可是内存屏障又向来以难用著称。所以我们这里用一句 rcu_assign_pointer()
原语将内存屏障封装起来,让其拥有发布的语义。最后 4 行代码如下:
1 p->a = 1;
2 p->b = 2;
3 p->c = 3;
4 rcu_assign_pointer(gp, p);
rcu_assign_pointer “发布”一个新结构,强制让编译器和 CPU 在为 p 的个字段复制之后再去为 gp 赋值。
不过,只保证更新者的执行顺序并不够,因为读者也需要保证读取顺序。请看下面的代码:
1 p = gp;
2 if (p != NULL) {
3 do_something_with(p->a, p->b, p->c);
4 }
这块代码看起来好像不会受乱序执行的影响,可惜事与愿违,在 DEC Alpha CPU 机器上,还有启用编译器值推测优化时,会让 p->a, p->b, p->c 的值在 p 赋值之前被读取,此时编译器会先猜测 p->a, p->b, p->c 的值,然后再去读取 p 的实际值来检查编译器的猜测是否正确。这种类型的优化十分激进,甚至有点疯狂,但是这确实发生在档案驱动(profile-driven)优化的上下文中。
显然,我们必须在编译器和 CPU 层面阻止这种危险的优化。rcu_dereferenc
e 原语用了各种内存屏障和编译器指令来达到这一目的。
1 rcu_read_lock();
2 p = rcu_dereference(gp);
3 if (p != NULL) {
4 do_something_with(p->a, p->b, p->c);
5 }
6 rcu_read_unlock();
rcu_dereference()
原语用一种订阅的方式来获取指定指针的值。然后后续的解引用操作可以看见在对应的发布操作(rcu_read_pointer)前进行的初始化。rcu_read_lock
和 rcu_read_unlock
是肯定需要的:这对原语定义了 RCU 读端的临界区。后续将会介绍它们的意图,不过请注意,这对原语既不会自旋或阻塞,也不会组织 list_add_rcu
的并发执行。事实上,在没有配置 CONFIG_PREEMPT 的内核里,这对原语就是空函数。
虽然理论上 rcu_assign_pointer
的 rcu_dereference
可以用于构造任何能想象到的受 RCU 保护的数据结构,但是实践中常常只用于上层的构造。因此这两个原语是嵌入在特殊的 RCU 变体——即 Linux 操纵链表的 API 中。Linux 有两种双链表的变体,循环链表和哈希表 struct hlist_head/struct hlist_node。前一种如第一个图所示,深色代表链表元头,浅色代表链表元素。而第二张图给出了一种简化方法:
第 15 行必须采用某种同步机制(最常见的是各种锁)来保护,放置多核 list_add 实例并发执行。不过,同步并不能阻止 list_add 的实例与 RCU 的读者并发执行。
订阅一个受 RCU 保护的链表则非常直接:
1 struct foo {
2 struct list_head *list;
3 int a;
4 int b;
5 int c;
6 };
7 LIST_HEAD(head);
8
9 /* . . . */
10
11 p = kmalloc(sizeof(*p), GFP_KERNEL);
12 p->a = 1;
13 p->b = 2;
14 p->c = 3;
15 list_add_rcu(&p->list, &head);
list_add_rcu 原语向指定的链表发布了一条项目,保证对应的 list_for_each_entry_rcu 可以订阅到同一条目。
Linux 的其他双链表、哈希链表都是线性链表,这意味着它的头部节点只需要一个指针,而不是向循环链表那样需要两个,如上图所示。因此哈希表的使用可以减少哈希表的 hash bucket 数组一半的内存消耗。和前面一样,这种表示法太麻烦了,哈希表也可以用和链表一样的简化表达方式。
向受 RCU 保护的哈希表发布新元素和向循环链表的操作十分类似,如下面的示例:
1 struct foo {
2 struct hlist_node *list;
3 int a;
4 int b;
5 int c;
6 };
7 HLIST_HEAD(head);
8
9 /* . . . */
10
11 p = kmalloc(sizeof(*p), GFP_KERNEL);
12 p->a = 1;
13 p->b = 2;
14 p->c = 3;
15 hlist_add_head_rcu(&p->list, &head);
和之前一样,第 15 行必须要使用某种同步机制来保护,比如锁。
订阅受 RCU 保护的哈希表和订阅循环链表没什么区别:
1 rcu_read_lock();
2 hlist_for_each_entry_rcu(p, head, list) {
3 do_something_with(p->a, p->b, p->c);
4 }
5 rcu_read_unlock();
下表是 RCU 的订阅和发布原语,及一个取消发布原语:
类别 | 发布 | 取消发布 | 订阅 |
---|---|---|---|
指针 | rcu_asign_pointer | rcu_assign_pointer(…, NULL) | rcu_dereference |
链表 | list_add_rcu list_add_tail_rcu list_replace_rcu | list_del_rcu | list_for_each_entry_rcu |
哈希链表 | hlist_add_after_rcu hlist_add_before_rcu hlist_add_head_rcu hlist_replace_rcu | hlist_del_rcu | hlist_for_each_entry_rcu |
请注意,list_replace_rcu/list_del_rcu/hlist_replace_rcu/hlist_del_rcu 这些 API 引入了一点复杂性。何时才能安全地释放刚被替换或删除的数据元素?我们怎么知道何时所有读者释放了他们对数据元素的引用?
这些问题将在随后的小节中得到回到。
等待已有的 RCU 读者执行完毕
从最基本的角度来说,RCU 就是一种等待事物结束的方式。当然,有很多其他的方式可以用来等待事物结束,比如引用计数、读写锁、事件等等。RCU 最伟大之处在于它可以等待 20000 种不同的事物,而无需显式的跟追他们中的每一个,也无需去单行对性能的影响、对扩展性的限制、复杂的死锁场景、还有内存泄露带来的危害等等那些使用显式跟踪手段会出现的问题。
在 RCU 的例子中,被等待的事物称为 RCU 读端临界区。RCU 读端临界区从 rcu_read_lock 原语开始,到对应的 rcu_read_unlock 原语结束。RCU 读端临界区可以嵌套,也可以包含一大段代码,只要这其中的代码不会阻塞会睡眠。如果遵守这些约定,就可以使用 RCU 去等待任何代码的完成。
RCU 通过间接地确定这些事物合适完成,才实现了这样的壮举。
如上图所示,RCU 是一种等待已有的 RCU 读端临界区执行完毕的方法,这里的执行完毕也包括在临界区内执行的内存操作。不过请注意,某个宽限期开始后才启动的 RCU 读端临界区会扩展到该宽限期的结尾处。
下列伪代码展示了使用 RCU 等待读者的基本算法:
- 做出改变,比如替换链表中的一个元素。
- 等待所有已有的 RCU 读端临界区执行完毕,这里需要注意的是后续的 RCU 读端临界区无法获取刚刚删除元素的引用。
- 清理,比如释放刚才被替换的元素。
如下面的代码片段所示,其中演示了这个过程,其中字段 a 是搜索关键字:
1 struct foo {
2 struct list_head *list;
3 int a;
4 int b;
5 int c;
6 };
7 LIST_HEAD(head);
8
9 /* . . . */
10
11 p = search(head, key);
12 if (p == NULL) {
13 /* Take appropriate action, unlock, & return. */
14 }
15 q = kmalloc(sizeof(*p), GFP_KERNEL);
16 *q = *p;
17 q->b = 2;
18 q->c = 3;
19 list_replace_rcu(&p->list, &q->list);
20 synchronize_rcu();
21 kfree(p);
第 9、20、21 行实现了刚才提到的 3 个步骤。16~19 行正如 RCU 其名(读-复制-更新),在允许并发度的同时,第 16 行复制,17~19 更新。
正如前面讨论的 synchronize_rcu
原语可以相当简单。然而,想要达到生产质量,代码实现必须处理一些困难的边界情况,并且需要大量优化,这两者都将导致显著的复杂性。虽然知道 synchronize_rcu
有一个简单的实现很好,但是其他问题仍然存在。例如,当 RCU 读者遍历正在更新的链表时会看到什么?该问题将在下节讨论。
维护最近被更新对象的多个版本
本节将展示 RCU 如何维护链表的多个版本,供并发的读者访问。本节通过两个例子来说明在读者还处于 RCU 读端临界区时,被读者引用的数据元素如何保持完整性。第一个例子展示了链表元素的删除,第二个例子展示了链表元素的替换。
例子1:在删除过程中维护多个版本
在开始这个例子前,我们先将上面的代码中 11~20 行改为如下形式:
1 p = search(head, key);
2 if (p != NULL) {
3 list_del_rcu(&p->list);
4 synchronize_rcu();
5 kfree(p);
6 }
这段代码用下图展示的方式跟新链表。每个元素中的三个数字分别代表子弹 a/b/c 的值。红色的元素表示 RCU 读者此时正持有该元素的引用。请注意,我们为了让图更清楚,忽略了后向指针和从尾指向头的指针。
等第 3 行的 list_del_rcu 执行完毕后,5、6、7 元素从链表中被删除,如第 2 行代码所示。如果读者不直接与更新者同步,所以读者可能还在并发地扫描链表。这些并发的读者都有可能看见,也有可能看不见刚刚被删除的元素,这取决于扫描的时机。不过,刚好在取出指向被删除元素指针后被延迟的读者(比如由于终端、ECC 内存错误、配置了 CONFIG_PREEMPT_RT 内核中的抢占),有就可能在删除后还看见链表元素的值。因此,我们此时有两个版本的链表,一个拥有元素 5、6、7,而另一个没有。元素 5、6、7 用黄色标注,表名老读者可能还在引用它,但是新读者已经无法得到它的引用。
请注意,读者不允许在退出 RCU 读临界区后还维护元素 5、6、7的引用。因此,一旦第 4 行的 synchronize_rcu 执行完毕,所有已有的读者都要保证执行完成,不能再有读者引用该元素,图图中第三排的绿色部分。这样我们就又回到了唯一版本的链表。
此时,元素 5、6、7 可以被安全释放了。如图中最后一排所示。这样我们就完成了元素的删除。本节后面部分将描述元素的替换。
例子2:在替换过程中维护多个版本
在开始替换之前,我们先看看前面例子中最后几行代码:
1 q = kmalloc(sizeof(*p), GFP_KERNEL);
2 *q = *p;
3 q->b = 2;
4 q->c = 3;
5 list_replace_rcu(&p->list, &q->list);
6 synchronize_rcu();
7 kfree(p);
链表的初始状态包括指针 p 都和删除例子中一样,如下图中的第一排所示:
和前面一样,每个元素的三个数字分别代表字段 a/b/c。红色的元素表示读者可能正在引用,并且因为读者不直接与更新者同步,所以读者有可能与整个替换过程并发执行。请注意我们为了图表的清晰,再一次忽略了后向指针和指向头的指针。
下面描述了元素 2、5、3 如何替换元素 5、6、7 的过程,任何特定的读者都有可能看见这两个值的其中一个。
第 1 行用 kmalloc 分配了要替换的元素,如图第二排所示。此时没有读者持有刚分配的元素的引用(绿色),并且该元素是未初始化的(问号)。
第 2 行将旧元素复制给新元素,如图第三排所示。新元素此时还不能被读者访问,但是已经初始化了。
第 3 行将 q->b 的值更新为 2,这样新元素终于对读者可见了,因此颜色也变成了红色,如图第五排所示。此时,链表就有两个版本了。已经存在的老读者也可能看到元素 5、6、7(黄色),而新读者可能会看到 5、2、3。不过这里可以保证任何读者都能看到一个完好的链表。
随着第 6 行 synchronize_rcu 的返回,宽限期结束,所有在 list_replace_rcu 之前开始的读者都已经完成。特别是任何可能持有元素 5、6、7 引用的读者保证已经退出了它们的 RCU 读端临界区,不能继续持有引用。因此,不再有任何读者持有旧数据的引用,如图第六排绿色部分所示。这样我们又回到了单一版本的链表,只是用新元素替换了旧元素。
等第 7 行的 kfree 完成后,链表就成了图中最后一排的样子。
不过尽管 RCU 是因替换的例子而得名的,但是 RCU 在内核中的用途还是和简单的删除例子一样。
讨论上述这些例子假设整个更新操作都持有一把互斥锁,这意味着任意时刻最多会有两个版本的链表。
这个事件序列显示了 RCU 更新如何使用多个版本,在有读者并发的情况下安全地执行改变。当然,有些算法无法优雅地处理多个版本。有些技术在 RCU 中采用了这些算法,但是超过了本节的范围。
RCU 基础总结
本节描述了 RCU 算法的三个基本组件。
- 添加新数据的发布——订阅机制。
- 等待已有 RCU 读者结束的方法。
- 维护多个版本数据的准则,允许在不影响或延迟其他并发 RCU 读者的前提下改变数据。
这三个 RCU 组件使得数据可以在有并发读者时被改写,通过不同方式的组合,这三种组件可以实现各种基于 RCU 算法的变体,后续将会讨论。
RCU 的用法
本节将从使用 RCU 的视角,以及使用哪种 RCU 的角度来回答“什么是 RCU”。因为 RCU 最常用的目的是替换已有的机制,所以我们首先观察 RCU 与这些机制之间的关系。
RCU 是读写锁的替代者
也许在 Linux 内核中 RCU 最常见的用途就是在读占大多数时间的情况下替换读写锁了。可是在一开始我并没有想到 RCU 的这个用途,事实上在 20 世纪 90 年代初期,我在实现通用 RCU 实现之前选择实现了一种轻量的读写锁。我为这个轻量级读写锁原型想象的每个用途最后都是用 RCU 来实现了。事实上,在请练级读写锁第一次实际使用时 RCU 已经出现了不止三年了。兄弟们,我是不是看起来很傻!
RCU 和读写锁最关键的相似之处在于两者都有可以并行执行的读端临界区。事实上,在某些情况下,完全可以从机制上用对应的读写锁 API 来替换 RCU 的 API。不过,这样做有什么必要呢?
RCU 的有点在于性能、没有死锁,并能提供实时的延迟。当然 RCU 也有一些缺点,比如读者与写者并发执行,比如低优先级 RCU 读者可以阻塞正等待宽限期完毕的高优先级线程,还比如宽限期的延迟可以有好几毫秒。这些优点和缺点在后续会继续介绍。
如下图,“性能 RCU”相较于读写锁在读端的性能优势。
请注意,在单个 CPU 上读写锁比 RCU 慢一个数量级,在 16 个 CPU 要慢两个数量级,RCU 的扩展性要好很多。在上面两个例子中,错误曲线几乎是水平的。
更温和的视角来自 CONFIG_PREEMPT 内核,虽然 RCU 仍然超过了读写锁 1 到 3 个数量级,如下图。请注意,读写锁在 CPU 数目很多时的陡峭曲线。在任一方向上误差都超过了一个标准差。
当然,如下图中所示,由于不现实的零临界区长度,读写锁的低性能被夸大了。随着临界区的增长,RCU 的性能优势也不再显著,在上图的 16 个 CPU 系统中,Y 轴代表读端原语的总开销,X 轴代表临界区长度。
但是考虑到很多系统调用(以及他们所包含的 RCU 读端临界区)都能在几毫秒内完成,所以这个结果对 RCU 是有利的。另外,下面将会讨论,RCU 读端原语基本上是不会死锁的。
免于死锁虽然 RCU 在多数为读的工作符合下提供了显著的性能优势,但是使用 RCU 的主要目标却不是它可以免于死锁的特性。这种免于死锁的能力来源于 RCU 的读端原语不阻塞、不自旋,甚至不会向后跳转,所以 RCU 读端原语的执行实现是确定的。这使得 RCU 读端原语不可能组成死锁循环。
RCU 读端免于死锁的能力带来了一个有趣的结果,RCU 读者可以无条件地升级为 RCU 更新者。在读写锁中尝试这种升级则会造成死锁。进行 RCU 读者到更新者提升的代码如下所示:
1 rcu_read_lock();
2 list_for_each_entry_rcu(p, &head, list_field) {
3 do_something_with(p);
4 if (need_update(p)) {
5 spin_lock(my_lock);
6 do_update(p);
7 spin_unlock(&my_lock);
8 }
9 }
10 rcu_read_unlock();
请注意,do_update 是在所的保护下执行的,也是在 RCU 读端的保护下执行。
RCU 免于死锁的特性带来的另一个有趣后果是 RCU 不会受很多优先级反转问题的影响。比如,低优先级的 RCU 读者无法阻止高优先级的 RCU 更新者获取更新端锁。类似的,低优先级的更新者也无法阻止高优先级的 RCU 读者进入 RCU 读端临界区。
实时延迟因为 RCU 读端原语既不自旋也不阻塞,所以这些原语有着极佳的实时延迟。另外,如之前所说,这也就意味着这些原语不会受与 RCU 读端原语和锁有关的优先级反转影响。
但是,RCU 还是会受到更隐晦的优先级反转问题影响,比如,在等待 RCU 宽限期结束时阻塞的高优先级进程,会被 -rt 内核的低优先级 RCU 读者阻塞。这也可以用 RCU 优先级提升来解决。
RCU 读者与更新着并发执行因为 RCU 读者既不自旋也不阻塞,还因为 RCU 更新者没有任何类似回滚或中止的语义,所以 RCU 读者和更新者必然可以并发执行。这意味着 RCU 读者有可能访问旧数据,还有可能发现数据不一致,无论这两个问题中的哪一个都有可能让读写锁卷土重来。
不过,令人吃惊的是在大量场景中,数据不一致的旧数据都不是问题。网络路由表是一个经典例子。因为路由的更新可能要花相当长的一段时间才能到达指定系统,所以系统可能会在更新到来后的一段时间内仍然将报文发到错误的地址去。通常在几毫秒内将报文发送到错误的地址并不算什么问题。并且,因为 RCU 的更新者可以在无需等待 RCU 读者执行完毕的情况下发生,所以 RCU 读者可能会比读写锁的读者更早看到更新后的路由表。如下图所示:
一旦收到更新,rwlock 的写者在最后一个读者完成之前不能继续执行,后续的读者在写者更新完成之前不能去读。不过,这一点也保证了后续的读者可以看见最新的值,如果图中绿色部分。相反,RCU 读者和更新者互相不会阻塞,这就允许 RCU 读者可以更快看见更新后的值。当然,因为读者和更新者的执行重叠了一部分,所以所有 RCU 读者都“可能”看见更新后的值,包括图中三个在更新者之前就已开始的 RCU 读者。然而,再一次强调,只有绿色的 RCU 读者才能保证看到更新后的值。
简而言之,读写锁和 RCU 提供了不同的保证。在读写锁中,任何在写者开始之后开始的读者都保证能看到新值,而在写者正在自旋时候开始的读者有可能看到新值,也有可能看到旧值,这取决于读写锁实现中的读写这哪一个优先级更高。与之相反,在 RCU 中,在更新者完成后才开始的读者都保证能看见新值,在更新者开始后才完成的读者有可能看见新值或旧值,这取决于具体的时机。
这里面的关键点是,虽然限定在计算机系统这一范围内读写锁保证了一致性,但是这种一致性是以增加外部世界的不一致性作为代价的。换句话说,读写锁以外部世界的旧数据作为代价,获取了内部的一致性。
然而,总有一种场合让系统无法容忍数据不一致和旧数据。幸运的是,有很多种办法可以避免这种问题,有一些办法是基于前面提到的引用计数。
低优先级 RCU 读者可以阻塞高优先级的回收者。在实时 RCU 中,被抢占的读者将阻止正在进行中的宽限期完成,即使高优先级的任务因为等待宽限期完成而阻塞也是如此。实时 RCU 可以通过用 call_rcu 替换 synchronize_rcu 来避免该问题,或者采用 RCU 优先级提升来避免,不过该方法在 2008 年初还处于实验状态。虽然有必要讨论 SRCU 和 QRCU 的优先级提升,但是现在实时领域还没有实际的需求。
延续好几毫秒的 RCU 宽限期。除了 QRCU 和前面提到的几个玩具 RCU 实现,RCU 宽限期会延续好几个毫秒。虽然有些手段可以消除这样长的延迟带来的损害,比如使用在可能时使用异步接口,但是根据拇指定律,这也是 RCU 使用在读数据占多数的场景的主要原因。
读写锁与 RCU 代码对比。在最好的情况下,将读写锁转换成 RCU 非常简单,如下图所示,这些都来自 Wikipedia。
详细阐述如何使用 RCU 替换读写锁已经超出了本书的范围。
RCU 是一种受限的引用计数机制
因为宽限期不能在 RCU 读端临界区进行时完毕,所以 RCU 读端原语可以像受限的引用计数机制一样使用。比如下面的代码片段:
1 rcu_read_lock(); /* acquire reference. */
2 p = rcu_dereference(head);
3 /* do something with p. */
4 rcu_read_unlock(); /* release reference. */
rcu_read_lock 原语可以看做是获取对 p 的引用,因为在 rcu_dereference 为 p 赋值之后才开始宽限期无法在配对的 rcu_read_unlock 之前完成。这种引用计数机制是受限的,因为我们不允许在 RCU 读端临界区中阻塞,也不允许将一个任务的 RCU 读端临界区传递给另一个任务。
不管上述的限制,下列代码可以安全的删除 p:
1 spin_lock(&mylock);
2 p = head;
3 rcu_assign_pointer(head, NULL);
4 spin_unlock(&mylock);
5 /* Wait for all references to be released. */
6 synchronize_rcu();
7 kfree(p);
将 p 赋值给 head 阻止了任何获取将来对 p 的引用的操作,synchronize_rcu 等待所有值钱获取的引用释放。
当然,RCU 也可以与传统的引用计数结合,LKML 中对此有过讨论,前面也做过了总结。
但是何必这么麻烦呢?我再回到一次,部分原因是性能,如果下图所示,图中再次显示了在 16 个 3GHZ CPU 的 Intel x86 系统中采集的数据。
并且,和读写锁一样,RCU 的性能优势主要来源于较短的临界区,如下图中所示。另外,和读写锁一样,许多系统调用(以及他们包含的任何 RCU 读端临界区)都在几毫秒内完成。
但是,伴随着 RCU 的限制有可能相当麻烦,比如,在许多情况下,在 RCU 读端临界区中禁止睡眠可能与我们的整个目标不符。下节将从解决该问题的方式触发,同时涉及在某些情况下如何降低传统引用计数的复杂性。
RCU 是一种可大规模使用的引用计数机制
前面曾经说过,传统的引用计数通常与某种或者一组数据结构有联系。然而,维护大量不同种类的数据结构的单一全局引用计数,通常会导致包含引用计数的缓存来回“乒乓”。这种缓存行“乒乓”会严重影响系统性能。
相反,RCU 的较轻量级读端原语允许读端极其频繁的调用,却只带来微不足道的性能影响,这使得 RCU 可以作为一种几乎没有任何惩罚的“批量引用计数机制”。当某个任务需要在一系列代码中持有引用时,可以送可休眠 RCU(SRCU)。但是这里没有包含特殊情景,一个任务将引用传递给另一个引用,在开始一次 IO 时获取引用,然后当对应的 IO 完成时在中断处理函数里释放该引用。(原则上 SRCU 的实现可以处理这一点,但是在实践中还不清楚这是否是一个好的权衡)
当然,SRCU 带来了它自己的限制条件,即要传递给对应 srcu_read_lock 和 srcu_read_unlock 返回值,以及硬件终端处理函数或者 NMI/SMI 处理函数不能调用 SRCU 原语。SRCU 的限制会带来多少问题,如何更好的处理这些问题,这一切还尚未有定论。
RCU 是穷人版的垃圾回收器
当人们开始学习 RCU 时,有种比较少见的感叹是“RCU 有点像垃圾回收器”。这种感叹有一部分是对的,不过还是会给学习造成误导。
也许思考 RCU 与垃圾回收器(GC)之间关系的最好办法是,RCU 类似自动的决定垃圾回收的时机。但是 RCU 与 GC 有两个不同点:
- 程序员必须手动指示何时可以回收数据结构。
- 程序员必须手动标出可以合法持有引用的 RCU 读端临界区。
尽管存在这些差异,两者的相似度仍然很高,就我所知至少有一篇理论分析 RCU 的文献曾经分析过两者的相似度。不仅如此,我所知道的第一种类 RCU 的机制就是运用垃圾回收器来处理宽限期。下节提供了一种更好思考 RCU 的方法。
RCU 是一种提供存在担保的方法
Gamsa 等人讨论了存在担保,并且描述了如何用一种类似 RCU 的机制提供这种担保。前面讨论过如何通过锁来提供存在担保及其弊端。如果任何受 RCU 保护的数据元素在 RCU 读端临界区中被访问,那么数据元素在 RCU 读端临界区持续期间保证存在。
1 int delete(int key)
2 {
3 struct element *p;
4 int b;
5
6 b = hashfunction(key);
7 rcu_read_lock();
8 p = rcu_dereference(hashtable[b]);
9 if (p == NULL || p->key != key) {
10 rcu_read_unlock();
11 return 0;
12 }
13 spin_lock(&p->lock);
14 if (hashtable[b] == p && p->key == key) {
15 rcu_read_unlock();
16 rcu_assign_pointer(hashtable[b], NULL);
17 spin_unlock(&p->lock);
18 synchronize_rcu();
19 kfree(p);
20 return 1;
21 }
22 spin_unlock(&p->lock);
23 rcu_read_unlock();
24 return 0;
25 }
上面的代码展示了基于 RCU 的存在担保如何通过从哈希表删除元素的函数来实现每数据元素锁。第 6 行计算哈希函数,第 7 行进入 RCU 读端临界区。如果第 9 行发现哈希表对应的哈希项(bucket)为空,或者数据元素不是我们想要删除的那个,那么第 10 行退出 RCU 读端临界区,第 11 行返回错误。
如果第 9 行判断为 false,第 13 行获取更新端的自旋锁,然后第 14 行检查元素是否还是我们想要的。如果是,第 15 行退出 RCU 读端临界区,第 16 行从哈希表中删除找到的元素,第 17 行释放锁,第 18 行等待所有值钱已经存在的 RCU 读端临界区退出,第 19 行释放刚被删除的元素,最后 20 行返回成功。如果 14 行的判断发现元素不再是我们想要的,那么 22 行释放锁,第 23 行退出 RCU 读端临界区,第 24 行返回错误以删除该关键字。
细心的读者可能会发现,该例子中只不过是前面“RCU 是一种等待事物结束的方式”中那个例子的变体。细心的读者还会发现免于死锁要比 前面讨论的基于锁的存在担保更好。
RCU 是一种提供类型安全内存的方法
很多无锁算法并不需要数据元素在被 RCU 读端临界区引用时保持完全一致,只要数据元素的类型不变就可以了。换句话说,只要结构类型不变,无锁算法可以允许某个数据元素在被其他对象引用时可以释放并重新分配,但是决不允许类型上的改变。这种“保证”,在学术文献中被称为“类型安全的内存”,比前一节提到的存在担保要弱一些,因此处理起来也要困难一些。类型安全的内存算法在 Linux 内核中的应用是 slab 缓存,被 SLAB_DESTROY_BY_RCU 专门标记出来的缓存通过 RCU 将释放的 slab 返回给系统内存。在任何已有的 RCU 读端临界区持续读期间,使用 RCU 可以保证所有带有 SLAB_DESTROY_BY_RCU 标记且正在使用的 slab 元素仍然在 slab 中,且类型保持一致。
这些算法一般使用了一个验证步骤,用于确定刚刚被引用的数据结构确实是被请求的数据。这种验证要求数据结构的一部分不能被释放——重新分配过程触碰。通常这种有效性检查很难保证不存在隐晦且难以解决的故障。
因此,虽然基于类型安全的无锁算法在一种很难达到的情景下非常有效,但是你最好还是尽量使用存在担保。毕竟简单总是好的。
RCU 是一种等待事物结束的方式
在前面我们提到 RCU 的一个重要组件是等待 RCU 读者结束的方法。RCU 的强大之处,其中之一就是允许你在等待上千个不同事物结束的同时,又不用显式去跟踪其中每一个,因此也就无需担心性能下降、扩展限制、复杂的死锁场景、内存泄露等显式跟踪机制自身的问题。
在本节中,我们将展示 synchronize_sched 的读端版本(包括禁止抢占、禁止中断原语)如何让你实现与不可屏蔽中断(NMI)处理函数的交互,如果用锁来实现,这将极其困难。这种方法被称为“纯 RCU”,Linux 的多处使用了该方法。
“纯 RCU” 设计的基本形式如下:
- 做出改变,比如,OS 对一个 NMI 做出反应。
- 等待所有已有读端临界区完全退出(比如使用 synchronize_sched 原语)。这里的关键是后续的 RCU 读端临界区保证可以看见变化发生后的样子。
- 扫尾工作,比如,返回表明改变成功完成的状态。
本节剩下的部分将使用 Linux 内核中的例子做展示。在下面的例子中 timer_stop 函数使用 synchronize_sched 确保在释放相关资源之前,所有正在处理的 NMI 处理函数都已完成。下面是对该例简化后的代码实现:
1 struct profile_buffer {
2 long size;
3 atomic_t entry[0];
4 };
5 static struct profile_buffer *buf = NULL;
6
7 void nmi_profile(unsigned long pcvalue)
8 {
9 struct profile_buffer *p = rcu_dereference(buf);
10
11 if (p == NULL)
12 return;
13 if (pcvalue >= p->size)
14 return;
15 atomic_inc(&p->entry[pcvalue]);
16 }
17
18 void nmi_stop(void)
19 {
20 struct profile_buffer *p = buf;
21
22 if (p == NULL)
23 return;
24 rcu_assign_pointer(buf, NULL);
25 synchronize_sched();
26 kfree(p);
27 }
第 1~4 行定义了 profile_buffer 结构,包含一个大小和一个变长数据的入口。第 5 行定义了指向 profile_buffer 的指针,这里假设别处对该指针进行了初始化,指向内存的动态分配区。
第 7~16 行定义了 nmi_profile 函数,供 NMI 中断处理函数使用。该函数不会被抢占,也不会被普通的中断处理函数中断,但是,该函数还是会受高速缓存未命中、ECC 错误以及被一个核的其他硬件线程抢占时钟周期等因素影响。第 9 行使用 rcu_dereference 原语来获取指向 profile_buffer 的本地指针,这样做是为了确保在 DEC Alpha 上的内存顺序执行,如果当前没有分配 profile_buffer,第 11 和 12 行退出,如果参数 pcvalue 超出范围,第 13 和 14 行退出。否则,第 15 行增加以参数 pcvalue 为下标的 profile_buffer 项的值。请注意,profile_buffer 结构中的 size 保证了 pcvalue 不会超出缓冲区的范围,即使突然将较大的缓冲区替换成了较小的缓冲区也是如此。
第 18~27 行定义了 nmi_stop 函数,由调用者负责互斥访问(比如持有正确的锁)。第 20 行获取 profile_buffer 的指针,如果缓冲区为空,第 22 和 23 行退出。否则,第 24 行将 profile_buffer 的指针置 NULL(使用 rcu_assign_pointer 原语在弱顺序的机器中保证内存顺序访问)。第 25 行等待 RCU Sched 的宽限期结束,尤其是等待所有不可抢占的代码——包括 NMI 中断处理函数一一结束。一旦执行到第 26 行,我们就可以保证所有获取到指向旧缓冲区指针的 nmi_profile 实例都已经返回了。现在可以安全释放缓冲区,这时使用 kfree 原语。
简而言之,RCU 让 profile_buffer 动态切换变得简单(你可以试试原子操作,或者还可以用锁来折磨下自己)。但是,RCU 通常还是运用在较高层次的抽象上,正如前面几个小节所述。
RCU 用法总结
RCU 的核心只是提供一下功能的 API。
- 用于添加新数据的发布——订阅机制。
- 等待已有 RCU 读者结束的方法。
- 维护多版本的准则,使得在有 RCU 读者并发时不会影响或延迟数据更新。
也就是说,在 RCU 之上建造更高抽象级别的架构是可能的,比如前面几节列出的读写锁、引用计数和存在担保。更进一步,我对 Linux 社区会继续为 RCU 寻找新用法丝毫不感到怀疑,当然其他的同步原语肯定也是这样。
上图展示了 RCU 的适用范围,这是关于 RCU 最有用的经验法则。
如图中顶部的蓝色框所示,如果你的读侧重数据允许获取旧值和不一致的结果,RCU 是最好的(但有关旧值和不一致数据的更多信息见下面的部分)。Linux 内核在这种情况的例子是路由表。因为可能需要很多秒甚至几分钟,才能更新路由表并通过互联网传播出去,这时系统已经以错误的方式发送相当一段时间的数据包了。再以小概率发送几毫秒错误的数据就简直算不上什么事了。
如果你有一个以读为主工作的负载,需要一致的数据,RCU 可以工作的不错,如绿色“读侧重,需要一致数据”框所示。Linux 内核在这种情况下的例子是从系统 V 的用户态信号 ID 映射到相应的内核数据结构。读信号量往往大大超过它们被创建和销毁的速度,所以这个映射是读侧重的。然而,在已被删除的信号量上执行信号量操作是错误的。这种对一致性的要求是通过内核信号量数据结构中的锁、以及在删除信号量时设置“已删除”标志达到的。如果用户 ID 映射到了一个具有“已删除”标志的内核数据结构,这个内核数据结构将被忽略,同时用户 ID 被设为无效。
虽然这要求读者获得内核信号量的锁,但这允许内核不用对要映射的数据结构加锁。因此,读者可以无锁地遍历从 ID 映射来的树状数据结构,这反过来大大提升了性能、可扩展性和实时响应性。
如黄色“读写”框所示,当数据需要一致性时,RCU 也可用于读写平衡的工作负载,虽然通常要与其他同步原语结合使用。例如,在最近的 Linux 内核中,目录项缓存使用了 RCU、顺序锁、每 CPU 锁和每数据结构锁,这才用于在常见情况下无锁地遍历路径名。虽然 RCU 在这种读写平衡的情况下可以非常有益,但是这种用法通常要比读侧重情况复杂的多。
最后,如底部的红色框所示,当以更新为主并且需要数据一致性的工作负载时,很少有适用 RCU 的地方,虽然也存在一些例外。此外,如前所述,在 Linux 内核里,SLAB_DESTROY_BY_RCU slab 分配器为 RCU 读者提供类型安全的内存,这可以大大简化非阻塞同步和其他无锁算法的实现。
简而言之,RCU 是一个包括用于添加新数据的发布——订阅机制的 API,等待已存在的 RCU 读者完成的一种方式,以及一门维护多个版本以使更新不会上海或延迟并发的 RCU 读者的学科。这个 RCU API 最适合以读者为主的情况,特别是如果应用程序可以容仍陈旧不一致的数据。
Linux 内核中的 RCU API
等待完成的 API 族
发布-订阅、版本维护 API
这些 API 的用处
RCU 究竟是什么
RCU 的核心不过是一种支持对插入操作的发布和订阅、等待所有 RCU 读者完成、维护多个版本的 API。也就是说,完全可以在 RCU 之上构建抽象级别更高的模型,比如读写锁、引用计数、存在担保等在前面列出的模型。更进一步,我相信 Linux 社区也会继续不断的寻找新的用法,当然其他的同步原语肯定也是一样。
当然,对 RCU 更复杂的看法还包括所有拿这些 API 能做的事情。
但是,对很多人来说,想要完整观察 RCU,就需要一个 RCU 的例子实现。
RCU 的玩具实现
基于锁的 RCU
基于每线程锁的 RCU
基于计数的简单 RCU
避免更新者饥饿的引用计数 RCU
可扩展的基于计数的 RCU
基于自由增长计数的 RCU
基于自由增长计数的可嵌套 RCU
基于静止状态的 RCU
总结
之前的章节列出了各种 RCU 原语的理想特性。这里我们整理一个列表,供有意实现自己的 RCU 的读者作为参考:
- 必须有读者端原语和宽限期原语,如:rcu_read_lock/rcu_read_unlock, synchronize_rcu。任何在宽限期开始前就存在的 RCU 读端临界区必须在宽限期结束前完毕。
- RCU 读端原语应该有最小的开销。特别是应该避免如高速缓存未命中、原子操作、内存平展和分支之类的操作。
- RCU 读端原语应该有 O(1) 的时间复杂度,可以用于实时用途。(这意味着读者可以与更新着并发执行)
- RCU 读端原语应该在所有上下文中都可以使用(在 Linux 内核中,只有空循环时不能使用 RCU 读端原语)。一个重要的特例是 RCU 读端原语必须可以在 RCU 读端临界区中使用,换句话说,必须允许 RCU 读端临界区嵌套。
- RCU 读端临界区不应该有条件判断,不会返回失败。该特性十分重要,因为错误检查会增加复杂度,让测试和验证变得更加复杂。
- 除了静止状态意外的任何操作都能在 RCU 读端原语中执行。比如像 IO 这样不幂等的操作也应用允许。
- 应该允许在 RCU 读端临界区中执行的同时更新一个受 RCU 保护的数据结构。
- RCU 读端和更新端的原语应该在内存分配器的设计和实现上独立,换句话说,同样的 RCU 实现应该能在不管数据原语是分配还是释放的同时,保护数据元素。
- RCU 宽限期不应该被在 RCU 读端临界区之外阻塞的线程而阻塞(但是请注意,大多数基于静止状态的实现破坏了这一愿望)。
RCU 练习
如何选择
下图提供了一些粗略的经验法则,可以帮助你选择延迟处理技术。
存在保证 | 写读者并行 | 读取端开销 | 批量引用 | 低内存占用 | 无条件获取 | 非阻塞更新 | |
---|---|---|---|---|---|---|---|
引用计数 | Y | Y | ++->atomic(*) | Y | ? | ||
危险指针 | Y | Y | MB(**) | Y | Y | ||
顺序锁 | 2MB(***) | N/A | N/A | ||||
RCU | Y | Y | 0->2MB | Y | Y | ? |
*
:在每次重试中遍历的每个元素上产生**
:在每次重试时产生***
:原子操作MB
:内存屏障
如“存在保证”一列中所示,如果你需要链接的数据元素的存在保证,那么必须使用引用计数、危险指针或 RCU。顺序锁不提供存在保证,而是提供更新检测,遭遇更新时重试读取端临界区。
当然,如“写读者并行”一列中所示,更新检测意味着顺序锁定不允许更新者和读者同步进行。毕竟,防止这种同步前进是使用顺序锁的全部意义所在。这时可以让顺序锁与引用计数、危险指针或 RCU 结合,以便同时提供存在保证和更新检测。事实上,Linux 内核就是以结合 RCU 和顺序锁的方式进行路径名查找的。
“读取端开销”一列给出了这些技术在读取端的大致开销。引用技术的开销变化范围很大。在低端,简单的非原子自增就够了,至少在有锁的保护下获取引用时如此。在高端,则需要完全有序的原子操作。引用计数会在遍历每个数据元素时产生此开销。危险指针在遍历每个元素时都产生一个内存屏障的开销,顺序锁在每次尝试执行临界区会产生两个内存屏障的开销。RCU 实现的开销从零到每次执行读取端临界区时的两个内存屏障开销不等,后者为 RCU 带来最佳性能,特别是对于读取端临界区需要遍历很多数据元素时。
“批量引用”一列表示只有 RCU 能够以恒定开销获取多个引用。属性怒锁的条件“N/A”,这是因为顺序锁采用更新检测额不是获取引用。
“低内存占用”一列表示那些技术的内存占用较低。此列和“批量引用”一列互补:因为获取大量元素的引用的能力意味着所有这些数据元素必须持续存在,这反过来意味着交道的内存占用。例如,一个线程可能会删除大量的数据元素,而此时另一个线程则并发执行长时间的 RCU 读端临界区。因为读端临界区可能潜在保留对任何新近删除元素的引用,所以在整个临界区持续时间内都必须保留所有这些元素。相反,引用计数和危险指针保留只有那些实际上并发读者引用的特定数据元素。
然而,这种低内存占用的优势是有代价的,如表中“无条件获取”一列。想要看到这一点,请想象哟一个大型的链式数据结构,引用技术或危险指针的读者(线程 A)持有该结构中某个鼓励数据元素的引用。考虑如下事件顺序:
- 线程 B 删除 A 引用的数据元素。由于这个引用,数据元素还不能被释放。
- B 删除 与 A 引用的所有数据元素相邻的所有数据元素。因为没有指向这些数据元素的引用,所有他们都被立即释放。因为 A 的数据元素已被删除,它指向的外部指针不更新。
- 所有 A 的数据元素的外部指针现在指向的是被释放的地址,因此已经不能安全的遍历。
- 因此,引用计数或危险指针的实现无法让 A 通过任何指向数据元素外部的指针来获取引用。
简而言之。任何提供精确引用追踪的延后处理计数都要做好无法获取引用的准备。因此,RCU 高内存占用的缺点反而意味着易于使用的优势,即 RCU 读者不需要处理获取失败的情况。
Linux 内核有时通过结合使用 RCU 和引用技术,来解决内存占用、精确跟踪和获取失败之间的这种竞争关系。RCU 用于短期引用,这意味着 RCU 读端临界区可以很短。这就意味着响应的 RCU 宽限期也很短,从而限制了内存占用。对于一些需要长期引用的数据元素,可以使用引用计数。这意味着只有少数数据元素需要处理应用获取失败的复杂性,因为 RCU,大部分引用的获取都是无条件的。
最后,“非阻塞更新”一列表示危险指针可以提供这种特性。引用计数则要取决于实现。然而,因为在更新端的锁,顺序锁定不能提供非阻塞更新。RCU 的写者必须等待读者,这也排除了完全非阻塞更新。不过有时唯一的阻塞操作是等待释放内存,这在很多情况下都可视为是非阻塞的。
更新端的问题
对于读侧重的情况,本章中提到的延迟处理技术一般都非常适用,但这提出了一个问题:“更新端怎么办?”。毕竟,增加读者的性能和扩展性是很好的,但是自然而然我们也希望为写者提供出色的性能和扩展性。
对于写者,我们已经看到了一种具有高性能和扩展性的情况,即前面提到的计数算法。这些计数算法通过部分分割数据结构,使得可以在本地进行更新,而较昂贵的读取则必须在整个数据结构上求和。Silas BoydWickhizer 把这种概念推广到 OpLog 上,Linux 内核路径名查找、VM 反向映射和 stat 系统调用都使用了这个工具。
另一种方法,称为 Disruptor,是为处理大量流数据输入的引用程序设计的。该方法是依靠单个生产者和单个消费者的 FIFO 队列,最小化对同步的需要。对于 Java 应用程序,Disruptor 还具有减少对垃圾处理器的使用这个优点。
当然,只要是可行的情况,完全分割或“分片”系统总是能提供优秀的性能和扩展性。
3.10 - CH10-数据结构
访问数据的效率如此重要,因此对算法的讨论也包括相关数据结构的时间复杂度。然而,对于并行程序,时间复杂度的度量还必须包括并发效应。如前所述,这些效应可能是压倒性的因素,这意味着对并发数据结构的设计,必须要像关注串行数据结构中时间复杂度一样关注并发复杂度。
例子
我们将使用薛定谔的动物园这个应用来评估性能。薛定谔拥有一个动物园,里面有大量的动物,他想使用内存数据库来记录他们。动物园中的每个动物在数据库中都有一个条目,每个动物都有一个唯一的名称作为主键,同时还有与每个动物有关的各种数据。
出生、捕获、购买将导致数据插入,而死亡、释放和销售将导致数据删除。因为薛定谔的动物园包括了大量短命动物,包括老鼠可昆虫,所以数据库必须能够支持高频更新请求。
对薛定谔的动物感兴趣的人可以查阅它们,但是,薛定谔已经注意到他的猫拥有非常高的查询率,多到以至于他怀疑是他的老鼠们在使用数据库来查询它们的天敌。这意味着薛定谔的应用必须支持对单个数据条目的高频查询请求。
记住这个应用程序,这里面包含了大量的数据结构。
可分割的数据结构
如今的计算机世界使用了各种各样的数据结构,市面上讲数据结构的教科书多如牛毛。本节专注于单个数据结构,即哈希表。这种方法允许我们更深入地研究如何与并发数据结构交互,同时也能让我们更加熟悉这个在实践中大量应用的数据结构。
哈希表的设计
第六章中强调了通过分割来获得可观性能和扩展性的必要,因此可分割性必须是选择数据结构的首要标准。并行性的主力军——哈希表——很好的满足了这个标准。哈希表在概念上非常简单,包含一个哈希桶的数组。哈希函数将指定数据的键映射到哈希桶元素,也就是存储数据的地方。因此,每个哈希桶有一个数据元素的链表,称为哈希链。如果配置得当,这些哈希链会相当短,允许哈希表可以非常有效地访问任意指定的元素。
另外,每个桶可以有自己的锁,所以哈希表中不同的桶可以完全独立的插入、删除和查找。因此,包含大量元素的哈希表提供了极好的可扩展性。
哈希表的实现
1 struct ht_elem {
2 struct cds_list_head hte_next;
3 unsigned long hte_hash;
4 };
5
6 struct ht_bucket {
7 struct cds_list_head htb_head;
8 spinlock_t htb_lock;
9 };
10
11 struct hashtab {
12 unsigned long ht_nbuckets;
13 struct ht_bucket ht_bkt[0];
14 };
上面的片段展示了简单的固定大小的哈希表使用的一组数据结构,使用链表和每哈希桶的锁。下面的片段展示了如何将它们组合在一起。hashtab 结构包含了 4 个 ht_bucket 结构,->bt_nbuckets
字段代表桶的数量。每个桶都包含链表头 ->htb_head
和锁 ->htb_lock
。链表元素 htb_elem 结构通过它们的 ->hte_next
字段找到下一个元素,每个 ht_elem 结构在 ->hte_hash
字段中缓存相应元素的哈希值。ht_elem 结构嵌入在哈希表中的另一个较大结构里,并且这个较大结构可能包含了复杂的键。
下面的片段展示了映射和加解锁函数。第 1~2 行定义了宏 HASH2BKT,它将哈希值映射到相应的 ht_bucket 结构体。这个宏使用了一个简单的模数,如果需要更好的哈希函数,调用者需要自行实现从数据键到哈希值的映射函数。剩下的两个函数分别获取和释放与指定的哈希桶对应的 ->htb_lock 锁。
1 #define HASH2BKT(htp, h) \
2 (&(htp)->ht_bkt[h % (htp)->ht_nbuckets])
3
4 static void hashtab_lock(struct hashtab *htp,
5 unsigned long hash)
6 {
7 spin_lock(&HASH2BKT(htp, hash)->htb_lock);
8 }
9
10 static void hashtab_unlock(struct hashtab *htp,
11 unsigned long hash)
12 {
13 spin_unlock(&HASH2BKT(htp, hash)->htb_lock);
14 }
下面的片段展示了 hashtab_lookup,如果指定的键或哈希值存在,它返回一个指向元素的指针,否则返回 NULL。此函数同时接受哈希值和指向键的指针,因为这允许此函数的调用者使用任意的键和哈希函数,cmp 函数指针用于传递比较键的函数,类似于 qsort 的方式。第 11 行将哈希值映射成指向相应哈希桶的指针。第 12~19 行的循环每次执行检查哈希桶中链表的一个元素。第 15 行检查是否与哈希值匹配,如果否,则第 16 行前进到下一个元素。第 17 行检查是否与实际的键匹配,如果是,则第 18 行返回指向匹配元素的指针。如果没有与之匹配的元素,则第 20 行返回 NULL。
1 struct ht_elem *
2 hashtab_lookup(struct hashtab *htp,
3 unsigned long hash,
4 void *key,
5 int (*cmp)(struct ht_elem *htep,
6 void *key))
7 {
8 struct ht_bucket *htb;
9 struct ht_elem *htep;
10
11 htb = HASH2BKT(htp, hash);
12 cds_list_for_each_entry(htep,
13 &htb->htb_head, 14 hte_next) {
15 if (htep->hte_hash != hash)
16 continue;
17 if (cmp(htep, key))
18 return htep;
19 }
20 return NULL;
21 }
下面的片段是 hashtab_add 和 hashtab_del 函数,分别从哈希表中添加和删除元素。
1 void
2 hashtab_add(struct hashtab *htp,
3 unsigned long hash,
4 struct ht_elem *htep)
5 {
6 htep->hte_hash = hash;
7 cds_list_add(&htep->hte_next,
8 &HASH2BKT(htp, hash)->htb_head);
9 }
10
11 void hashtab_del(struct ht_elem *htep)
12 {
13 cds_list_del_init(&htep->hte_next);
14 }
hashtab_add 函数只是简单的在第 6 行设置元素的哈希值,然后将其添加到第 7~8 行的相应桶中。hashtab_del 函数简单地从哈希桶的链表中移除指定的元素,因为是双向链表所以这很容易。在调用这个函数中任何一个之前,调用者需要确保此时没有其他线程正在访问或修改相同的哈希桶,例如,可以通过事先调用 hashtab_lock 来加以保护。
下面的片段展示了 hashtab_alloc 和 hashtab_free 函数,分别负责表的分配和释放。分配从第 7~9 行开始,分配使用的是系统内存。如果第 10 行检测到内存已耗尽,则第 11 行返回 NULL 给调用者。否则,第 12 行将初始化桶的数量,第 13~16 行的循环初始化桶本身,第 14 行初始化链表头,第 15 行初始化锁。最后,第 17 行返回一个指向新分配哈希表的指针。第 20~23 行的 hashtab_free 函数则直接了当地释放内存。
1 void
2 hashtab_add(struct hashtab *htp,
3 unsigned long hash,
4 struct ht_elem *htep)
5 {
6 htep->hte_hash = hash;
7 cds_list_add(&htep->hte_next,
8 &HASH2BKT(htp, hash)->htb_head);
9 }
10
11 void hashtab_del(struct ht_elem *htep)
12 {
13 cds_list_del_init(&htep->hte_next);
14 }
哈希表的性能
上图展示的是在 8 核 2GHZ Intel Xeon 系统上的性能测试结果,使用的哈希表具有 1024 个桶,每个桶带有一个锁。性能的扩展性几乎接近线性,但是即使只有 8 个 CPU,性能已经不到理想性能水平的一半。产生这个缺口的一部分原因是由于虽然在单 CPU 上获取和释放锁不会产生高速缓存未命中,但是在两个或更多 CPU 上则不然。
随着 CPU 数目的增加,情况只有变得更糟,如下图所示。
我们甚至不需要额外的线来表示理想性能, 9 个或 9 个以上的 CPU 时性能非常糟糕。这显然警示了我们按照中等数量的 CPU 外推性能的危险。
当然,性能大幅下降的一个原因可能是哈系桶数目的不足。毕竟,我们没有将哈系桶填充到占据一条完整的缓存行,因此每条缓存行有多个哈系桶。这可能是在 9 个 CPU 上导致高速缓存颠簸的原因。当然这一点很容易通过增加哈希桶的数量来验证。
如上图所示,虽然增加了哈系统的数量后性能确实有点提高,可扩展性仍然惨不忍睹。特别是,我们还是看到 9 个 CPU 之后性能会急剧下降。此外,从 8192 个桶增加到 16384 个桶,性能几乎没有提升。显然还有别的东西再捣鬼。
其实这是多 CPU 插槽系统惹的祸,CPU 0~7 和 32~39 会映射到第一个槽位,如下图所示。因此测试程序只用前 8 个 CPU 时性能相当好,但是测试涉及到插槽 0 的 CPU 0~7 和插槽 1 的 CPU 8 时,会产生跨插槽边界数据传递的开销。如果前所述,这可能会验证降低性能。总之,对于多插槽系统来说,除了数据结构完全分割之外,还需要良好的局部性访问能力。
读侧重的数据结构
虽然通过分割数据结构可以帮助我们带来出色的可扩展性,但是 NUMA 效应也会导致性能和扩展性的严重恶化。另外,要求读者互斥写者也可能会导致在读侧重的场景下的性能。然而,我们可以通过使用前面介绍的 RCU 来实现性能和可扩展性的双丰收。使用危险指针也可以达到类似的效果。
受 RCU 保护的哈希表实现
对于受 RCU 保护的每桶一锁的哈希表,写者按照上节描述的方式使用锁,但读者使用 RCU。数据结构、HASH2BKT、hashtab_lock、hashtab_unlock 函数与上一节保持一致。但是读者使用拥有更轻量级并发控制的 hashtab_lock_lookup。如下片段所示:
下面的片段则展示了 hashtab_lookup 函数的实现。这与前面的实现类似,除了将 cds_list_for_each_entry 替换为 cds_list_for_each_entry_rcu。这两个原语都按照顺序遍历 htb->htb_head 指向的哈希链表,但是 cds_list_for_each_entry_rcu 还要强制执行内存屏障以应对并发插入的情况。这是两种哈希表实现的重大区别,与纯粹的每桶一锁实现不同,受 RCU 保护的实现允许查找、插入、删除操作同时运行,支持 RCU 的 cds_list_for_each_entry_rcu 可以正确处理这种增加的并发性。还要主要 hashtab_lookup 的调用者必须在 RCU 读端临界区内,例如,调用者必须在调用 hashtab_lookup 之前调用 hashtab_lock_lookup(当前在之后还要调用 hashtab_unlock_lookup)。
下面的片段展示了 hashtab_add 和 hashtab_del,两者都是非常类似于其在非 RCU 哈希表实现中的对应函数。hashtab_add 函数使用 cds_list_add_rcu 而不是 cds_list_add,以便在有人正在查询哈希表时,将元素安装正确的排序添加到哈希表。hashtab_del 函数使用 cds_list_del_rcu 而不是 cds_list_del_init,它允许在查找到某数据元素后该元素马上被删除的情况。和 cds_list_del_init 不同,cds_list_del_rcu 仍然保留该元素的前向指针,这样 hashtab_lookup 可以遍历新删除元素的后继元素。
当让,在调用 hashtab_del 之后,调用者必须等待一个 RCU 宽限期(例如在释放或以其他方式重用新删除元素的内存之前调用 syncronize_rcu)。
受 RCU 保护的哈希表的性能
上图展示了受 RCU 保护的和受危险指针保护的只读哈希表的性能,同时与上一节的每桶一锁实现做比较。如你所见,尽管 CPU 数和 NUMA 效应更大,RCU 和危险指针实现都能接近理想的性能和可扩展性。使用全局锁的实现的性能也在图中给出了标示,正如预期一样,其结果针织比每桶一锁的实现更加糟糕。RCU 做得比危险指针稍微好一些,但在这个以对数刻度表示的图中很难看出差异来。
上图显示了线性刻度上的相同数据。这使得全局锁实现基本和 X 轴平行,但也更容易辨别 RCU 和危险指针的相对性能。两者都显示在 32 CPU 处的斜率变化,这是由于硬件多线程的缘故。当使用 32 个或更少的 CPU 时,每个线程都有自己的 CPU 时,每个线程都有自己的 CPU 核。在这种情况下,RCU 比危险指针做的更好,因为危险指针的读取端内存屏障会导致 CPU 时间的浪费。总之,RCU 比危险指针能更好地利用每个硬件线程上的 CPU 核。
如前所述,薛定谔对他的猫的受欢迎程度感到惊讶,但是随后他认识到需要在他的设计中考虑这种受欢迎程度。下图显示了程序在 60 个 CPU 上运行的结果,应用程序除了查询猫咪以外什么也不做。对这个挑战,RCU 和危险指针实现的表现很好,但是每桶一锁实现的负载为负,最终性能比全局锁实现还差。我们不应该对此感到吃惊,因为如果所有的 CPU 都在查询猫咪,对应于猫的那个桶的锁实际上就是全局锁。
这个只有猫的基准测试显示了数据分片方法的一个潜在问题。只有与猫的分区关联的 CPU 才能访问有关猫的数据,这限制了只查询猫时系统的吞吞量。当然,有很多应用程序可以将数据均匀地分散,对于这些应用,数据分片非常适用。然而,数据分片不能很好地处理“热点”,由薛定谔的猫触发的热点只是其中一个例子。
当然,如果我们只是去读数据,那么一开始我们并不需要任何并发控制。因此,下图显示修正后的结果。在该图的最左侧,所有 60 个 CPU 都在进行查找,在图的最右侧,所有 60 个 CPU 都在做更新。对于哈希表的 4 种实现来说,每毫秒查找数随着做更新的 CPU 数量的增加而减少,当所有 60 个 CPU 都在更新时,每毫秒查找数达到 0。相对于危险指针 RCU 做的更好一些,因为危险指针的读取端内存屏障在有更新存在时产生了更大的开销。这似乎也说明,现代硬件大大优化了内存屏障的执行,从而大幅减少了在只读情况下的内存屏障开销。
上图展示了更新频率增加对查找的影响,而下图则显示了更新频率的增加对更新本身的影响。危险指针和 RCU 从一开始就占据领先,因为,与每桶一锁不同,危险指针和 RCU 的读者并不排斥写者。然而,随着做更新操作的 CPU 数量的增加,开始显示出更新端开销的存在,首先是 RCU,然后是危险指针。当然,所有这三种实现都要比全局锁实现更好。
当然,很可能查找性能的差异也受到更新速率差异的影响。为了检查这一点,一种方法是人为地限制每桶一锁和危险指针实现的更新速率,以匹配 RCU 的更新速率。这样做显然不会显著提高每桶一锁实现的查找性能,也不会拉近危险指针与 RCU 之间的差距。但是,去掉危险指针的读取端内存屏障(从而导致危险指针的实现不安全)确实弥合了危险指针和 RCU 之间的差距。虽然这种不安全的危险指针实现通常足够可靠,足以用于基准测试用途,但是绝对不推荐用于生产用途。
对受 RCU 保护的哈希表的讨论
RCU 实现和危险指针会导致一种后果,一对并发读者可能会不同意猫此时的状态。比如,其中在某只猫被删除之前,一个读者可能已经提取了指向猫的数据结构的指针,而另一个读者可能在之后获得了相同的指针。第一个读者会相信那只猫还活着,而第二个读者会相信猫已经死了。
当然,薛定谔的猫不就是这么一回事吗,但事实证明这对于正常的非量子猫也是相当合理的。
合理的原因是,我们我发准确得知动物出生或死亡的时间。
为了搞明白这一点,让我们假设我们可以通过心跳检查来测得猫的死亡。这又带来一个问题,我们应该在最后一次心跳之后等待多久才宣布死亡。只等待 1ms?这无疑是荒谬的,因为这样一只健康活猫也会被宣布死亡——然后复活,而且每秒钟还发生不止一次。等待一整个月也是可笑的,因为到那时我们通过嗅觉手段也能非常清楚地知道,这只可怜的猫已经死亡。
因为动物的心脏可以停止几秒钟,然后再次跳动,因此及时发现死亡和假警概率之间存在一种权衡。在最后一次心跳和死亡宣言之间要等待多久,两个兽医很有可能不同意彼此的意见。例如,一个兽医可能声明死亡发生在最后一次心跳后 30s,而另一个可能要坚持等待完整的一分钟。在猫咪最后一次心跳后第二个 30s 周期内,两个兽医会对猫的状态有不同的看法。
当然,海森堡教导我们生活充满了这种不确定性,这也是一件好事,因为计算机硬件和软件的行为在某种程度上类似。例如,你怎么知道计算机有个硬件出问题了呢?通常是因为它没有及时回应。就像猫的心跳,折让硬件是否有故障出现了一个不确定性窗口。
此外,大多数的计算机系统旨在与外部世界交互。因此,对外的一致性是至关重要的。然而,正如我们在前面看到的,增加内部的一致性常常以外部一致性作为代价。像 RCU 和危险指针这样的技术放弃了某种程度上的内部一致性,以获得改善后的外部一致性。
总之,内部一致性不一定是所有问题域关心的部分,而且经常在性能、可扩展性、外部一致性或者所有上述方面产生巨大的开销。
不可分割的数据结构
固定大小的哈希表可以完美分割,但是当可扩展的哈希表在增长或收缩时,就不那么容易分割了。不过事实证明,对于受 RCU 保护的哈希表,完全可以写出高性能并且可扩展的实现。
可扩展哈希表的设计
与 21 世纪初的情况形成鲜明对比的是,现在有不少于三个不同类型的可扩展的受 RCU 保护的哈希表实现。第一个也是最简单的一个是 Herbert Xu 为 Linux 内核开发的实现,我们首先来介绍它。
这个哈希表实现背后的关键之处,是每个数据元素可以有两组链表指针,RCU 读者(以及非 RCU 的写者)使用其中一组,而另一组则用于构造新的可扩展的哈希表。此方法允许在哈希表调整大小时,可以并发地执行查找、插入和删除操作。
调整大小操作的过程下图 1~4 所示,图 1 展示了两个哈希桶的初始状态,时间从图 2 推进到 图 3。初始状态使用 0 号链表来将元素与哈希桶链接起来。然后分配一个包含 4 个桶的数组,并且用 1 号链表将进入 4 个新的哈希桶的元素链接起来。者产生了如图 2 所示的状态(b),此时 RCU 读者仍让使用原来的两桶数组。
随着新的四桶数组暴露给读者,紧接着是等待所有老读者完成读取的宽限期操作,产生如图 3 所示的状态(c)。这时,所有 RCU 读者都开始使用新的四桶数组,这意味着现在可以释放旧的两桶数组,产生图 4 所示的状态(d)。
可扩展哈希表的实现
调整大小操作是通过插入一个中间层次的经典方法完成的,这个中间层次如下面的片段中 12~25 行的结构体 ht 所示。第 27~30 行所示的是结构体 hashtab 仅包含指向当前 ht 结构的指针以及用于控制并发地请求调整哈希大小的自旋锁。如果我们使用传统的基于锁或原子操作的实现,这个 hashtab 结构可能会称为性能和可扩展性的严重瓶颈。但是,因为调整大小操作应该相对少见,所以这里 RCU 应该能帮我们大忙。
结构体 ht 表示哈希表的尺寸信息,其大小第 13 行的 ->ht_nbuckets 字段指定。为了避免出现不匹配的情况,哈希表的大小和哈系统的数组(第 24 行 ->ht_btk[]
)存储于相同的结构中。第 14 行上的 ->ht_resize_cur 字段通常等于 -1,当正在进行调整大小操作时,该字段的值表示其对应数据已经添入新哈希表的哈希桶索引,如果当前没有进行调整大小的操作,则 ->ht_new 为 NULL。因此,进行调整大小操作本质上就是通过分配新的 ht 结构体并让 ->ht_new 指针指向它,然后没遍历一个旧表的桶就前进一次 ->ht_resize_cur。当所有元素都移入新表时,hashtab 结构的 ->ht_cur 字段开始指向新表。一旦所有的旧 RCU 读者完成读取,就可以释放旧哈希表的 ht 结构了。
第 16 行的 ->ht_idx 字段指示哈希表实例此时应该使用哪一组链表指针,ht_elem 结构体里的 ->hte_next[] 数组用该字段作为此时的数组下标,见第 3 行。
第 17~23 行定义了 ->ht_hash_private、->ht_cmp、->ht_gethash、->ht_getkey 等字段,分别代表着每个元素的键和哈希函数。->ht_hash_private 用于扰乱哈希函数,其目的是防止通过对哈希函数所使用的参数进行统计分析来发起拒绝服务攻击。->ht_cmp 函数用于比较两个键,->ht_gethash 计算指定键的哈希值,->ht_getkey 从数据中提取键。
ht_bucket 结构与之前相同,而 ht_elem 结构与先前实现的不同仅在于用两组链表指针代替了代替了先前的单个链表指针。
在固定大小的哈希表中,对桶的选择非常简单,将哈希值转换成相应的桶索引。相比之下,当哈希表调整大小时,还有必要确定此时应该从旧表还是新表的哈希桶中进行选择。如果旧表中要选择的桶已经被移入新表中,那么应该从新表中选择存储桶。相反,如果旧表中要选择的桶还没有被移入新表,则应从旧表中选择。
桶的选择如相面的代码所示,包括第 1~8 行的 ht_get_bucket_single 和第 10~24 行的 ht_get_bucket。ht_get_bucket_single 函数返回一个指向包含指定键的桶,调用时不允许发生调整大小的操作。第 5~6 行,他还将与键相对应的哈希值存储到参数 b 指向的内存。第 7 行返回对应的桶。
ht_get_bucket 函数处理哈希表的选择,在第 16 行调用 ht_get_bucket_single 选择当前哈希表的哈希值对应的桶,用参数 b 存储哈希值。如果第 17 行确定哈希表正在调整大小,并且第 16 行的桶已经被移入新表,则第 18 行选择新的哈希表,并且第 19 行选择新表中的哈希值对应的桶,并再次用参数 b 存储哈希值。
如果第 21 行确定参数 i 为空,则第 22 行记录当前使用的是哪一组链表指针。最后,第 23 行返回指向所选哈希桶的指针。
这个实现的 ht_get_bucket_single 和 ht_get_bucket 允许查找、修改和调整大小操作同时执行。
读取端的并发控制由 RCU 提供。但是更新端的并发控制函数 hashtab_lock_mod 和 hashtab_unlock_mod 现在需要处理并发调整带下操作的可能性,如下片段所示:
第 1~19 行是 hashtab_lock_mod,第 9 行进入 RCU 读端临界区,以放置数据结构在遍历期间被释放,第 10 行获取对当前哈希表的引用,然后第 11 行获得哈希桶所对应的键的指针。第 12 行获得了哈希桶的锁,这将防止任何并发的调整大小操作移动这个桶,当然如果调整大小操作已经移动了该桶,则这一步没有任何效果。然后第 13 行检查并发的调整大小操作是否已经将这个桶移入新表。然后第 13 行检查并发的调整大小操作是否已经将这个桶移入新表,如果没有,则第 14 行在持有所选桶的锁时返回(此时仍处于 RCU 读端临界区内)。
否则,并发的调整大小操作将此桶移入新表,因此第 15 行获取新的哈希表,第 16 行选择键对应的桶。最后,第 17 行获取桶的锁,第 18 行释放旧表的桶的锁。最后,hashtab_lock_mod 退出 RCU 读端临界区。
hashtab_unlock_mod 函数负责释放由 hashtab_lock_mod 获取的锁。第 28 行能拿到当前哈希表,然后第 29 行调用 ht_get_bucket,以获得键锁对应的桶的指针,当让该桶可能已经位于新表。第 30 行释放桶的锁,以及最后第 31 行退出 RCU 读端临界区。
现在已经有了桶选择和并发控制逻辑,我们已经准备好开始搜索和更新哈希表了。如下所示的 hashtab_lookup、hashtab_add、hashtab_del 函数:
上面第 1~21 行的 hashtab_lookup 函数执行哈希查找。第 11 行获取当前哈希表,第 12 行获取指定键对应的桶的指针。当调整大小操作已经越过该桶在旧表中的位置时,则该桶位于新表中。注意,第 12 行业传入了表明使用哪一组链表指针的索引。第 13~19 行的循环搜索指定的桶,如果第 16 行检测到匹配的桶,第 18 行则返回指向包含数据元素的指针。否则如果没有找到匹配的桶,第 20 行返回 NULL 表示失败。
第 23~37 行的 hashtab_add 函数向哈希表添加了新的数据元素。第 32~34 行获取指定键对应的桶的指针(同时提供链表指针组下标)。第 35 行如前所述,为哈希表添加新元素。在这里条用者需要处理并发性,例如在调用 hashtab_add 前后分别调用 hashtab_lock_mod 和 hashtab_unlock_mod。这两个并发控制函数能正确地与并发调整大小操作互相同步:如果调整大小操作已经超越了该数据元素被添加到的桶,那么元素将添加到新表中。
第 39~52 行的 hashtab_del 函数从哈希表中删除一个已经存在的元素。与之前一样,第 48~50 行获取桶并传入索引,第 51 行删除指定元素。与 hashtab_add 一样,调用者负责并发控制,并且需要确保并发控制可以处理并发调整大小的操作。
实际调整大小由 hashtab_resize 执行,如上所示。第 17 行有条件地获取最顶层的 ht_lock,如果获取失败,则第 18 行返回 EBUSY 以表明正在进行调整大小操作。否则,第 19 行获取当前哈希表的指针,第 21~24 行分配所需大小的新哈希表。如果指定了新的哈希函数,将其应用于表,否则继续使用旧哈希表的哈希函数。如果第 25 行检测到内存分配失败,则第 26 行释放 htlock 锁并且在第 27 行返回出错的原因。
第 29 行开始桶的移动过程,将新表的指针放入酒标的 ht_new 字段,第 30 行确保所有不知道新表存在的读者在调整大小操作继续之前完成释放。第 31 行获取当前表的链表指针索引,并将其存储到新表中,以防止两个哈希表重写彼此的链表。
第 33~44 行的循环每次将旧表的一个桶移动到新的哈希表中。第 34 行将获取旧表的当前桶的指针,第 35 行获取该桶的自旋锁,并且第 36 行更新 ht_resize_cur 以表明此桶正在移动中。
第 37~42 行的循环每次从旧表的桶中移动一个元素到对应新表的桶中,在整个操作期间持有新表桶中的锁。最后,第 43 行释放旧表中的桶锁。
一旦执行到 45 行,所有旧表的桶都已经被移动到新表。第 45 行将新创建的表视为当前表,第 46 行等待所有的旧读者(可能还在引用旧表)完成。然后第 47 行释放调整大小操作的锁,第 48 行释放旧的哈希表,最后第 49 行返回成功。
可扩展哈希表的讨论
上图比较了可扩展哈希表和固定哈希表在拥有不同元素数量下的性能。图中为每种元素个数绘制了三条曲线。
最上面的三条线是有 2048 个元素的哈希表,这三条线分别是:2048 个桶的固定哈希表、1024 个桶的固定哈希表、可扩展哈希表。这种情况下,因为哈希链很短因此正常的查找开销很低,因此调整大小的开销反而占据主导地位。不过,因为桶的个数越多,固定大小哈希表性能优势越大,至少在给予足够操作暂停时间的情况下,调整大小操作还是有用的,一次 1ms 的暂停时间显然太短。
中间三条线是 16348 个元素的哈希表。这三条线分别是:2048 个桶的固定哈希表、可扩展哈希表、1024 个桶的固定哈希表。这种情况下,较长的哈希链将导致较高的查找开销,因此查找开销大大超过了调整哈希表大小的操作开销。但是,所有这三种方法的性能在 131072 个元素时比在 2048 个元素时差一个数量级以上,这表明每次将哈希表大小增加 64 倍才是最佳策略。
该图的一个关键点是,对受 RCU 保护的可扩展哈希表来说,无论是执行效率还是可扩展性都是与其固定大小的对应者相当。当然在实际调整大小过程中的性能还是受到一定程度的影响,这是由于更新每个元素的指针时产生了高速缓存未命中,当桶的链表很短时这种效果最显著。这表明每次调整哈希表的大小时应该一步到位,并且应该防止由于频繁的调整操作而导致的性能下降,在内存宽裕的环境中,哈希表大小的增长幅度应该比缩小时的幅度更大。
该图的另一个关键点是,虽然 hashtab 结构体是不可分割的,但是它特使读侧重的数据结构,这说明可以使用 RCU。鉴于无论是在性能还是在扩展性上,可扩展哈希表都非常接近于受 RCU 保护的固定大小哈希表,我们必须承认这种方法是相当成功的。
最后,请注意插入、删除和查找操作可以与调整大小操作同时进行。当调整元素个数极多的哈希表大小时,需要重视这种并发性,特别是对于那些必须有严格响应是时间限制的应用程序来说。
当然,ht_elem 结构的两个指针集合确实会带来一定的内存开销,我们将在下一节展开讨论。
其他可扩展哈希表
上一节讨论的可扩展哈希表,其缺点之一就是消耗的内存大。每个数据元素拥有两对链表指针。是否可以设计一种受 RCU 保护的可扩展哈希表,但链表只有一对?
答案是“是”。Josh Triplett 等人创造了一种相对(relativistic)哈希表,可以递增地分割和组合相应的哈希链,以便读者在调整大小操作期间始终可以看到有效的哈希链。这种增量分割和组合取决于一点事实,读者可以看到在其他哈希链中的数据元素。这一点是无害的,当发生这种情况时,读者可以简单地忽略这些由于键不匹配而看见的无关数据元素。
上图展示了如何将相对哈希表缩小两倍的过程。此时,两个桶的哈希表收缩成一个桶的哈希表,又称为线性链表。这个过程将较大的旧表中的桶合并为较小的新标中的桶。为了让这个过程正常进行,我们显然需要限制两个表的哈希函数。一种约束是在底层两个表中使用相同的哈希函数,但是当从大到小收缩时去除哈希值的最低位。例如,旧的两桶哈希表使用哈希值的高两位,而新的单桶哈希表使用哈希值的最高位。这样,在较大的旧表中相邻的偶数和奇数桶可以合并成较小的新表中的单个桶里,同时哈希值仍然可以覆盖单桶中的所有元素。
初始状态显示在图的顶部,从初始状态(a)开始,时间从顶部到底部前进,收缩过程从分配新的较小数组开始,并且使新数组的每个桶都指向旧表中相应桶的第一个元素,到达状态(b)。
然后,两个哈希链连接在一起,到达状态(c)。在这种状态下,读了偶数编号元素的读者看不出有什么变化,查找元素 1 和 3 的读者同样也看不到变化。然而,查找其他奇数编号元素的读者会遍历元素 0 和 2。这样做是无害的,因为任何奇数键都不等于这两个元素。这里会有一些性能损失,但是另一方面,这与新表完全就位以后将经历的性能损失完全一样。
接下来,读者可以开始访问新表,产生状态(d)。请注意,较旧的读者可能仍然在遍历大哈希表,所以在这种状态下两个哈希表都在使用。
下一步是等待所有旧的读者完成,产生状态(e)。
在这种状态下,所有读者都使用新表,以便旧表中桶可以被释放,最终到达状态(f)。
扩展相对哈希表的过程与收缩相反,但需要更多的宽限期步骤,如下图。
在该图的顶部是初始状态(a),时间从顶部前进到底部。
一开始我们分配较大的哈希表,带有两个桶,到达状态(b)。请注意,这些新桶指向旧桶对应部分的第一个元素。当这些新桶被发布给读者后,到达状态(c)。过了宽限期后,所有读者都将使用新的大哈希表,到达状态(d)。在这个状态下,只有那些遍历偶数值哈希桶的读者才会遍历到元素 0,因此现在的元素 0 是白色的。
此时,旧表的哈希桶可以被释放,但是在很多实现中仍然使用这些旧桶来跟踪将链表元素“解压缩”到对应新桶中的进度。在对这些元素的第一遍执行中,最后一个偶数编号的元素其“next”指针将指向后面的偶数编号元素。在随后的宽限期操作之后,到达状态(e)。垂直箭头表示要解压缩的下一个元素,元素 1 的颜色现在为黑色,表示只有那些遍历奇数哈希桶的读者才可以接触到它。
接下来,在对这些元素的第一遍执行中,最后一个奇数编号的元素其“next”指针将指向后面的奇数编号元素。在随后的宽限期操作之后,到达状态(f)。最后的解压缩操作(包括宽限期操作)到达最终状态(g)。
简而言之,相对哈希表减少了每个元素的链表指针的数量,但是在调整大小期间产生额外的宽限期。一般来说这些额外的宽限期不是问题,因为插入、删除和查找可能与调整大小同时进行。
结果证明,完全可以将每元素的内存开销从一对指针降到一个指针,同时保留 O(1) 复杂度的删除操作。这是通过使用受 RCU 保护的增广拆序链表(split-order list)做到的。哈希表中的数据元素被排列成排序后的单向链表,每个哈希桶指向该桶中的第一个元素。通过设置元素的 next 指针的低阶位来标记为删除,并且在随后的遍历中再次访问这些元素时将它们从链表中移除。
受 RCU 保护的拆序链表非常复杂,但是为所有插入、删除、查找操作提供无锁的进度保证。在实时应用中这种保证及其重要。最新版本的用户态 RCU 库提供了一个实现。
其他数据结构
前面的小节主要关注因为可分割性而带来的高并发性数据结构,高效的处理读侧重的访问模式,或者应用读侧重性来避免不可分割性。本节会简要介绍其他数据结构。
哈希表在并行应用上的最大优点之一就是它是可以完全分割的,至少是在不调整大小时。一种保持可分割性的尺寸独立性的方法是使用基树(radix tree)。也称 trie。Trie 将需要搜索的键进行分割,通过各个连续的键分区来遍历下一级 trie。因此,trie 可以被认为是一组嵌套的哈希表,从而提供所需的可分割性。Trie 的一个缺点是稀疏的键空间导致无法充分利用内存。有许多压缩技术可以解决这个缺点,包括在遍历前将键映射到较小的键空间中。基树在实践中被大量使用,包括在 Linux 内核中。
哈希表和 trie 的一种重要特例,同时也可能是最古老的数据结构,是数组及其多维对应物——矩阵。因为矩阵的可完全分割性质,在并发数值计算算法中大量应用了矩阵。
自平衡树在串行代码中被大量使用,AVL 树和红黑树可能是最著名的例子。早期尝试并行化 AVL 树的实现很复杂,效率也很可疑。但是最近关于红黑树的研究是使用 RCU 读者来保护读,哈希后的锁数组来保护写,这种实现提供了更好的性能和可扩展性。事实证明,红黑树的积极再平衡虽然适用于串行程序,但不一定适用于并行场景。因此,最近有文章创造出受 RCU 保护的较少再平衡的“盆景树”,通过付出最佳树深度的代价以获得跟有效的并发更新。
并发跳跃链表(skip list)非常适合 RCU 读者,事实上这代表着早期在学术上对类似 RCU 技术的使用。
前面讨论过的并行双端队列,虽然同是在性能和扩展性上不太令人印象深刻,并行堆栈和并行队列也有着悠久的历史。但是它们往往是并行库具有的共同特征。研究人员最近提出放松堆栈和队列对排序的约束,有一些工作表名放松排序的队列实际上比严格 FIFO 的队列具有更好的排序属性。
乐观来说,未来对并行数据结构的持续研究似乎会产生具有惊人性能的新颖算法。
微优化
以上展示的数据结构都比较直白,没有利用底层系统的缓存层次结构。此外,对于键到哈希值的转换和其他一些频繁的操作,很多实现使用了指向函数的指针。虽然这种方式提供了简单性和可移植性,但在很多情况下会损失一些性能。
以下部分涉及实例化(specializtion)、节省内存、基于硬件角度的考虑。请不要错误的将这些小节看成是本书讨论的主题。市面上已经有大部头讲述如何在特定 CPU 上做优化,更不用说如今常用的 CPU 了。
实例化
前面提到的可扩展哈希表使用不透明类型的键。这给我们带来了极大地灵活性,允许使用任何类型的键,但是由于使用了函数指针,也导致了显著的开销。现在,现代化的硬件使用复杂的分支预测技术来最小化这种开销,但在另一方面,真实世界的软件往往比今天的大型硬件分支越策表可容纳的范围更大。对于调用指针来说尤其如此,在这种情况下,分支预测硬件必须是在分支信息之外另外记录指针信息。
这种开销可以通过实例化哈希表实现来确定键的类型和哈希函数。这样做出列图 10.24 和图 10.25 的 ht 结构体中的 ht_cmp、ht_gethash、ht_getkey 函数指针,也消除了这些指针的相应调用。这使得编译器可以内联生成固定函数,这消除的不仅是调用指令的开销,而且消除了参数打包的开销。
此外,可扩展哈希表的设计考虑了将桶选择与并发控制分离的 API。虽然这样可以用单个极限测试来执行本章中的所有哈希表实现,但是这也意味着许多操作必须将计算哈希值和与可能的大小调整操作交互这些事情来回做两次。在要求性能的环境中,hashtab_lock_mod 函数也可以返回对所选桶的指针,从而避免后续调用 ht_get_bucket。
除此之外,和我在 20 世纪 70 年代带一次开始学习编程相比,现代硬件的一大好处是不太需要实例化。这可比回到 4K 地址空间的时代效率高多了。
比特与字节
本章讨论的哈希表几乎没有尝试节省内存。例如在 10.24 中,ht 结构体的 ht_idx 字段的取值只能是 0 或 1,但是却占用完整的 32 位内存。完全可以删除它,例如,从 ht_resize_key 字段窃取一个比特。因为 ht_resize_key 字段足够大寻址任何内存地址,而且 ht_bucket 结构体总是要比一个字节长,所以 ht_resize_key 字段肯定有多个空闲比特。
这种比特打包技巧经常用在高度复制的数据结构中,就像 Linux 内核中的 page 结构体一样。但是,可扩展哈希表的 ht 结构复制程度并不太高。相反我们应该关注 ht_bucket 结构体。有两个地方可以减小 ht_bucket 结构:将 htb_lock 字段放在 htb_head 指针的低位比特中;减少所需的指针数量。
第一点可以利用 Linux 内核中的位自旋锁,由 include/linux/bit_spinlock.h 头文件提供。他们用在 Linux 内核的内存敏感数据结构中,但也不是没有缺点:
- 比传统的自旋锁语义慢。
- 不能参与 Linux 内核中的 lockdep 死锁检测工具。
- 不记录锁的所有权,想要进一步调试会变得复杂。
- 不参与 -rt 内核中的优先级提升,这意味着保持位自旋锁时必须禁用抢占,这可能会降级实时延迟。
尽管有这些缺点,位自旋锁在内存十分珍贵时非常有用。
10.4.4 节讨论了第二点的一个方面,可扩展哈希表只需要一组桶链表指针来代替 10.4 节实现中所需的两组指针。另一个办法是使用单链表来替代在此使用的双向链表。这种方式的一个缺点是,删除需要额外的开销:要么标记传出指针以便以后删除、要么通过搜索要删除的元素的桶链表。
简而言之,人们需要在最小内存开销和性能、简单性之间权衡。幸运的是,在现代系统上连接内存允许我们优先考虑性能和简单性,而不是内存开销。然而,即使拥有今天的大内存系统,有时仍然需要采取极端措施以减少内存开销。
硬件层面的考虑
现代计算机通常在 CPU 和主存储器之间移动固定大小的数据块,从 32 字节到 256 字节不等。这些块名为缓存行(cacheline),如 3.2 节所述,这对于高性能和可扩展性是非常重要的。将不兼容的变量放入同一缓存行会严重降低性能和扩展性。例如,假设一个可扩展哈希表的数据元素具有一个 ht_elem 结构,它与某个频繁增加的计数器处于相同的高速缓存行中。频繁增加的计数器将导致高速缓存行出现在执行增量的 CPU 中。如果其他 CPU 尝试遍历包含数据元素的哈希桶链表,则会导致昂贵的告诉缓存未命中,降低性能和扩展性。
如上图所示,这是一种在 64 位字节高速缓存行的系统上解决问题的方法。这里的 gcc aligned 属性用于强制将 counter 字段和 ht_elem 结构体分成独立的缓存行。这将允许 CPU 全速遍历哈希桶链表,尽管此时计数器也在频繁增加。
当然,这引出了一个问题,“我们怎么知道缓存行是 64 位大小?”。在 Linux 系统中,此信息可以从 /sys/devices/system/cpu/cpu */cache/
目录得到,甚至有时可以在安装过程重新编译应用程序以适应系统的硬件结构。然而,如果你想要体验困难,也可以让应用程序在非 Linux 系统上运行。此外,即使你满足于运行只有在 Linux 上,这种自修改安装又带来了验证的挑战。
幸运的是,有一些经验法则在实践中工作的相当好,这是作者从一份 1995 年的文件中收到的。第一组规则设计重新排列结构以适应告诉缓存的拓扑结构。
- 将经常更新的数据与以读为主的数据分开。例如,将读侧重高的数据放在结构的开头,而将频繁更新的数据放于末尾。如果可能,将很少访问的数据放在中间。
- 如果结构有几组字段,并且每组字段都会在独立的代码路径中被更新,将这些组彼此分开。再次,尽量在不同的组之间放置很少访问的数据。在某些情况下,将每个这样的组放置在被原始结构单独引用的数据结构中也是可行的。
- 在可能的情况下,将经常更新的数据与 CPU、线程或任务相关联。
- 在有可能的情况下,应该尽量将数据分割在每 CPU、每线程、每任务。
最近已经有一些朝向基于痕迹的自动重排的结构域的研究。这项工作可能会让优化的工作变得不那么痛苦,从而从多线程软件中获得出色性能和可扩展性。
以下是一组处理锁的额外的经验法则:
- 当使用高竞争度的锁来保护被频繁更新的数据时,采取以下方式之一:
- 将锁与其保护的数据处于不同的缓存行中。
- 使用适用于高度竞争的锁,例如排队锁。
- 重新设计以减少竞争。
- 将低度竞争的锁置于与它们保护的数据相同的高速缓存行中。这种方法意味着因锁导致的当前 CPU 的高速缓存未命中同时也带来了它的数据。
- 使用 RCU 保护读侧重的数据,或者,如果 RCU 不能使用并且临界区非常长时,使用读写锁。
当然这些只是经验法则,而非绝对规则。最好是先做一些实验以找到最适合你的特殊情况的方法。
总结
本章主要关注哈希表,包括不可完全分割的可扩展哈希表。本章关于哈希表的阐述是围绕高性能可扩展数据访问的许多问题的绝佳展示,包括:
- 可完全分割的数据结构在小型系统上工作良好,比如单 CPU 插槽系统。
- 对于较大型的系统,需要将局部数据访问性和完全分割同等看待。
- 读侧重技术,如危险指针和 RCU,在以读为主的工作负载时提供了良好的局部访问性,因此即使在大型系统中也能提供出色的新能和可扩展性。
- 读侧重技术在某些不可分割的数据结构上也工作得不错,例如可扩展的哈希表。
- 在特定工作负载时实例化数据可以获得额外的性能和可扩展性,例如,将通用的键替换成 32 位整数。
- 尽管可移植性和极端性能的要求通常是互相干扰的,但是还是有一些数据结构布局技术可以在这两套要求之间达到良好的平衡。
但是如果没有可靠性,性能和可扩展性也算不上什么。因此下一章将介绍“验证”。
3.11 - CH11-验证
我也写过一些并行软件,他们一来就能够运行。但这仅仅是因为在过去的 20 年中我写了大量的并行软件。而更多的并行程序是在捉弄我,但却让我认为它们第一次就能正确工作。
因此,我强烈需要对我的并行程序进行验证。与其他软件验证相比,并行软件验证的基本点是:意识到计算机知道什么是错误的。因此,你的任务就是逼迫计算机告诉你哪里是错误的。所以说,本章可以认为是一个审问计算机的简单教程。
更多的教程可以在最近的验证书籍中找到,至少有一本比较老但相当有价值的书籍。验证是及其重要的主题,它涵盖了所有形式的软件,因此也值得深入研究。但是,本书主要关注并行方面,因此本章只会粗略对这一重要主题进行阐述。
简介
BUG 来自何处
BUG 来自于开发者。基本问题是:人类大脑并没有伴随着计算机一起进化。相反,人类大脑是伴随着其他人类以及动物大脑而进化的。由于这个历史原因,下面三个计算机的特征往往会让人类觉得惊奇。
- 计算机缺乏常识性的东西。几十年来,人工智能总是无功而返。
- 计算机通常无法理解人类的意图,或者更正式的说,计算机缺少心理理论。
- 计算机通常不能做局部性的计划,与之相反,它需要你将所有细节和每一个可能的场景都一一列出。
前两点是毋庸置疑的,这已经被大量失败的产品证明。这些产品中,最著名的可能要算 Clippy 和 Microsoft Bob 了。通过视图与人类用户相关联,这两款产品所表现出的常识和心理理论预期不尽如人意。也许,最近在智能手机上出现的软件助手将有良好的表现。也就是说,开发者仍然在走老路,软件助手可能对终端用户有益,但对开发者来说并没有什么助益。
对于人类喜欢的局部性计划来说,需要跟过的解释。特别是它是一个典型的双刃剑。很显然,人类对局部性计划的偏爱是由于我们假设这些计划将拥有常识和对计划意图的良好理解。后一个假设通常类似于这样一种常见情况,执行计划的人和制定计划的人是同一个人。在这种情况下,当阻碍计划执行的情况出现时,计划总是会在随后被修正。因此,局部性计划对于人类来说表现的很不错。举个特别的例子,在无法订立计划时,与其等待死亡还不如采取一些随机动作,这有更高的可能性找到食物。不过,以往在日常生活中行之有效的局部性计划,在计算机中并不见得凑效。
而且,对遵循局部性计划的需求,对于人类心灵有重要的影响。这来自于贯穿人类历史的事实,生命通常是艰难而危险的。这一点通常好不令人奇怪,当遭遇到锋利的牙齿好爪子时,执行一个局部性的计划需要一种几乎癫狂的乐观精神——这种精神实际上存在于绝大多数人类身上。这也延伸到对编程能力的自我评估上来了。这已经被包括实验性编程这样的面试技术的效果锁证实。实际上,比疯狂更低一级的乐观水平,在临床上被称为“临床型郁闷”。在他们的日常生活中,这类人通常面临严重的困扰。这里强调一下,近乎疯狂的乐观对于一个正常、健康的生命反直觉的重要性。如果你没有近乎癫狂的乐观精神,就不太可能会启动一个困难但有价值的项目。
一个重要的特殊情况是,虽然项目有价值,但是其价值尚不值得花费它所需要的时间。这种特殊情况是十分常见的,早期遇到的情况是,投资者没有足够的意愿投入项目实际需要的投资。对于开发者来说,自然的反应就是产生不切实际的乐观估计,认为项目已经被允许启动。如果组织足够强大,幸运的结果是进度受到影响或者预算超支,而项目终归还有见到天日的哪一天。但是,如果组织还不够强大,并且决策者在项目变得明朗之前预估它不值得投资,因而快速、错误的终止项目,这样的项目将可能毁掉组织。这可能导致其他组织重拾该项目,并且要么完美它、要么终止它,或者被它所毁掉。这样的项目可能会在毁掉多个组织后取得成功。人们只能期望,组织最终能够成功的管理一系列杀手项目,使其保持一个适当的水平,使得自身不会被下一个项目毁掉。
虽然疯狂乐观可能是重要的,但是它是 BUG 的重要来源(也许还包括组织失败)。因此问题是,如何保持一个大型项目所需要的乐观情绪,同时保持足够清醒的认识,使 BUG 保持在足够低的水平?
心态
当你进行任何验证工作时,应当记住以下规则:
- 没有 BUG 的程序,仅仅是那种微不足道的程序。
- 一个可靠的程序,不存在已知的 BUG。
从这些规则来看,可以得出结论,任何可靠的、有用的程序至少都包含一个未知的 BUG。因此,对一个有用的程序进行验证工作,如果没有找到任何 BUG,这本身就是一件失败的事情。因此,一个好的验证工作,就是一项破坏性的实践工作。这意味着,如果你是那种乐于破坏事务的人,验证工作就是一项好差事。
要求脚本检查错误的输入,如果找到 time 输出错误,还要给出相应的诊断结果。你应当向这个程序提供什么样的测试输入?这些输入与单线程程序生成的 time 输出一致。
但是,也许你是超级程序员,你的代码每次都在初次完成时就完美无缺。如果真是这样,那么祝贺你!可以放心跳过本章了。但是请原谅,我对此表示怀疑。我遇到那些声称能在第一次就能写出完美程序的人,比真正能够实现这一壮举的人还要多的多。根据前面堆乐观和过于自信的讨论,这并不令人奇怪。并且,即使你真是一个超级程序员,也将会发现,你的调试工作也仅仅是比一般人少一些而已。
对我们其他人来说,另一种情况是,在正常的乐观状态和严重的悲观情绪之间摇摆。如果你乐于毁坏事物,这将是有帮助的。如果你不喜欢毁坏事物,或者仅仅乐于毁坏其他人的事物,那就找那些喜欢毁坏代码并且让他们帮助你测试这些代码吧。
另一种有用的心态是,当其他人找到代码中的 BUG 时,你就仇恨代码吧。这种仇恨有助于你越过理智的界限,折磨你的代码,以便增加自己发现代码中 BUG 的可能性,而不是由其他人来发现。
最后一种心态是,考虑其他人的生命依赖于你的代码的正确性的几率。这将激励你去折磨代码,以找到 BUG 的下落。
不同种类的心态,导致了这样一种可能性,不同的人带着不同的心态参与到项目中。如果组织得当,就能很好的工作。
有些人可能会提醒自己,他们只不过是在折磨一个没有生命的物品。而且,他们也会做这样的假设,谁不折磨自己的代码,代码将会反过来折磨自己。
不过,这也留下一个问题,在项目生命周期中,何时开始验证工作。
何时开始验证
验证工作应该与项目的启动同时进行。
需要明白这一点,需要考虑到,与小型软件相比,在大型软件中找到一个 BUG 困难的多。因此,要将查找 BUG 的时间和精力减少到最小,应当对较小的代码单元进行测试。即使这种方式不会找到所有 BUG,至少能找到相当大一部分 BUG,并且更易于找到并修复这些 BUG。这种层次的测试也可以提醒在设计中的不足之处,将设计不足造成的浪费在代码编写上的时间减少的最小。
但是为什么在验证设计之前,要等待代码就绪呢?希望你阅读一下第 3、4 章,这两章展示了避免一些常见设计缺陷的信息。与同事讨论你的设计,甚至将其简单写出来,这将有助于消除额外的缺陷。
有一种很常见的情形,当你拥有一份设计,并等待开始验证时,其等待时间过长。在你完整理解需求之前,过于乐观的心态难道不会导致你开始设计?对此问题的回到总是会是“是的”。避免缺陷需求的一个好办法是,了解你的用户。要真正为用户服务好,你不得不与他们一起共度一段时间。
某类项目的首个项目,需要不同的方法进行验证,例如,快速原型。第一个原型的主要目的,是学习应当如何实现项目,而不是在第一次尝试时就创建一个正确的实现。但是,请注意,你不应该忽略验证工作,这是很重要的。不过,对于一个原型的验证工作可以采取不同的、快速的方法。
现在,我们已经为你树立了这样的观念,你应当在开始项目时就启动验证工作。后面的章节包含了一定数量的验证技术和方法,这些技术和方法已经证明了其价值。
开元之路
开源编程技术已经证明其有效,它包含严格的代码审查和测试。
我本人可以证明开源社区代码审查的有效性。我早期为 Linux 内核所提供的某个补丁,涉及一个分布式文件系统。在这个分布式文件系统中,某个节点上的用户向一个特定文件写入数据,而另一个节点的用户已经将该文件映射到内存中。在这种情况下,有必要使收到影响的页面映射失效,以允许在写入操作期间,文件系统所维护数据的一致性。我在补丁中进行了初次尝试,并且恪守开源格言“尽早发布、经常发布”,我提交了补丁。然后考虑如何测试它。
但是就在我确定整体测试策略之前,我收到一个回复,指出了补丁中的一些 BUG。我修复了这些 BUG,重新提交补丁,然后回过头来考虑测试策略。但是,在我有机会编写测试代码之前,我收到了针对重新提交补丁的回复,指出了更多的 BUG。这样的过程重复了很多次,以至于我不确定自己是否有机会测试补丁了。
这个经历将开源界所说的真理在我的脑海中打上了深深的烙印:只要有足够多的眼球,所有 BUG 都是浅显的。
当你提交代码或补丁时,想想以下问题:
- 到底有多少这样的眼球真正看了你的代码?
- 到底有多少这样的眼球,他们经验丰富、足够聪明,能够真正找到你的 BUG?
- 他们究竟什么时候看你的代码?
我是幸运的,有一些人,他们期望我的补丁中提供的功能,他们在分布式文件系统方面有着长期的经验,并且几乎立即就查看了我的补丁。如果没有人查看我的补丁,就不会有代码走查,因此也就不会找到 BUG。如果查看我补丁的人缺少分布式文件系统方面的经验,那么就不大可能找到所有的 BUG。如果它们等几个月或几年之后才查看我的补丁,我可能会忘记补丁是如何工作的,修复它们将更困难。
我们也千万不能忘记开源开发的第二个原则,即密集测试。例如,大量的人测试 Linux 内核。它们某些人会提交一些测试补丁,甚至你也提交过这样的补丁。另外一些人测试 next 树,这是有益的。但是,很有可能在你编写补丁,到补丁出现在 next 树之间,存在几周甚至几个月的延迟。这样的延迟可能使你对补丁没了新鲜感。对于其他测试维护树来说,仍然有类似的延迟。
相当一部分人直接将补丁提交到主线,或者提交到主源码树时,才测试它们的代码。如果你的维护者只有在你已经提交测试之后才会接受代码,这将形成死锁情形,你的代码需要测试后才能被接受,而只有被接受后才能开始测试。但是,测试主线代码的人们还是很积极的,因为很多人及组织要等到代码被拉入 Linux 分发版才测试其代码。
即使有人测试了你的补丁,也不能保证他们在适当的硬件和软件配置,以及适当的工作负载下测试了这些补丁,而这些配置和负载是找到 BUG 所必须的。
因此,即使你是为开源项目写代码,也有必要为开发和运行自己的测试套件而做好准备。测试开发是一项被低估,但是非常有价值的技能,因此请务必获取可用套件的全部优势。鉴于测试开发的重要性,我们将对这个主题进行更多的讨论。因此,随后的章节中,将讨论当你已经有一个好的测试套件时,怎么找到代码中的 BUG。
跟踪
如果你正在基于用户态 C 语言程序进行工作,当所有其他手段失效时,添加 printk 或 printf。
原理很简单,如果你不清楚如何运行到代码中某一点,在代码中多加点打印语句,以展示出到底发生了什么。你可以通过使用类似 gdb 或 kgdb 这样的调试器,来达到类似的效果,并且这些调试器拥有更多的方便性和灵活性。还有其他更多先进的工具,一些最新发行的工具提供在错误点回放的能力。
这些强大的测试工具都是有价值的。尤其是目前的典型系统都拥有超过 64K 内存,并且 CPU 都允许在超过 4MHZ 的频率。关于这些工具已经存在不少文章了,本章将再补充一点。
但是,当手上的工作是为了在高性能并行算法的快速路径上指出错误所在,那么这些工具都有严重的缺陷,即这些工具本身机会带来过高的负载。为了这个目的,存在一些特定的跟踪技术,典型的是使用数据所有权技术,以便将运行时数据收集负载最小化。在 Linux 内核中的一个例子是“teace event”。另一个处理用户态程序的例子是 LTTng。这些技术都无一例外使用了每 CPU 缓冲区,这允许以极低的负载来收集数据。即使如此,使用跟踪有时也会改变时序,并足以隐藏 BUG,导致海森堡 BUG。
即使你避免了海森堡 BUG,也还有其他陷阱。例如,即使机器知道所有的东西,它几乎知道所有的东西,以至于超过了大脑的处理能力,该怎么办呢?为此,高质量的测试套件通常配有精巧的脚本来分析大量的输出数据。但是请注意:脚本并不必然揭示那些奇怪的事件。有的 RCU 压力脚本就是一个很好的例子,在 RCU 周期被无限延迟的情况下,这个脚本的早期版本运行的很好。这当然会导致脚本被修改,以检查 RCU 优雅周期延迟的情况,但是这并不能改变如下事实,该脚本仅仅检查那些为认为能够检查的问题。这个脚本是有用的,但是有时候,它仍然不能代替对 RCU 压力输出结果的手动扫描。
对应产品来说,使用追踪,特别是使用 printk 调用进行追踪,存在另外一个问题,他们的负载太高了。在这样的情况下,断言是有用的。
断言
通常假设以下面的方式实现断言:
1 if (something_bad_is_happening())
2 complain();
这种模式通常被封装成 C-预处理宏或者语言内联函数,例如,在 Linux 内核中,它可能被表示为 WARN_ON(something_bad_is_happening())。当然,如果 something_bad_is_happening 被调用得过于频繁,其输出结果将掩盖其错误报告,在这种情况下 WARN_ON_ONCE(something_bad_is_happening) 可能更合适。
在并行代码中,可能发生的一个特别糟糕的情况是,某个函数期望在一个特定的锁保护下运行,但是实际上并没有获得锁。有时候,这样的函数会有这样的注释,调用者在调用本函数时,必须持有 foo_lock。但是,这样的注释并没有真正的作用,除非有人真的读了它。像 lock_is_held(&foo_lock) 这样的语句则会更有效。
Linux 内核的 lockdep 机制更进一步,它即报告潜在的死锁,也允许函数验证适当的锁持有者。当然,这些额外的函数引入了大量负载。因此,lockdep 并不一定适用于生产环境。
那么,当检查是必须的,但是运行时负载不能被容忍时,能够做些什么呢?一种方式是静态分析。
静态分析
静态分析是一种验证技术,其中一个程序将第二个程序作为输入,它报告第二个程序的错误和漏洞。非常有趣的是,几乎所有的程序都通过它们的编译器和解释器来执行静态分析。这些工具远远算不上完美,但在过去的几十年中,它们定位错误的能力得到了几大的改善。部分原因是它们现在拥有超过 64K 内存进行它们的分析工作。
早期的 UNIX lint 工具是非常有用的,虽然它的很多功能都被并入 C 编译器了。目前仍然有类似 lint 的工具在开发和使用中。
Sparse 静态分析查找 Linux 内核中的高级错误,包括:
- 对指向用户态数据结构的指针进行误用。
- 对过长的常量接收赋值。
- 空的 switch 语句。
- 不匹配的锁申请、释放原语。
- 对每 CPU 原语的误用。
- 在非 RCU 指针上使用 RCU 原语,反之亦然。
虽然编译器极有可能会继续提升其静态分析能力,但是 sparse 静态分析展示了编译器外静态分析的优势,尤其是查找应用特定 BUG 的优势。
代码走查
各种代码走查活动是特殊的静态分析活动,只不过是由人来完成分析而已。
审查
传统意义上来说,正式的代码审查采取面对面会谈的形式,会谈者有正式定义的角色:主持人、开发者以及一个或两个其他参与者。开发者通读整个代码,解释做什么,以及它为什么这样运行。一个或者两个参与者提出疑问并抛出问题。而主持人的任务,则是解决冲突并做记录。这个过程对于定于 BUG 是非常有效的,尤其是当所有参与者都熟悉手头代码时,更加有效。
但是,对于全球 Linux 内核开发社区来说,这种面对面的过程并不一定运行得很好,虽然通过 IRC 会话它也许能够很好运行。与之相反,全球 Linux 内核社区有个人进行单独的代码审查,并通过邮件或者 IRC 提供意见。则记录由邮件文档或者 IRC 日志提供。主持人志愿提供相应的服务。偶尔来一点口水战,这样的过程也允许的相当不错,尤其是参与者对手头的代码都很熟悉的时候。
是时候进行 Linux 内核社区的代码审查过程改进了,这是很有可能的。
- 有时,人们缺少进行有效的代码审查所需要的时间和专业知识。
- 即使所有的审查讨论都被存档,人们也经常没有记录对问题的见解,人们通常无法找到这些讨论过程。这会导致相同的错误被再次引入。
- 当参与者吵得不可开交时,有时难于解决口水纷争。尤其是交战双方的目的、经验及词汇都没有共同之处时。
因此,在审查时,查阅相关的提交记录、错误报告及 LWN 文档等相关文档是有价值的。
走查
传统的代码走查类似于正式的代码审查,只不过小组成员以特定的测试用例集来驱动,对着代码摆弄电脑。典型的走查小组包含一个主持人,一个秘书,一个测试专家,以及一个或者两个其他的人。这也是非常有效的,但是也非常耗时。
自从我参加到正式的走查依赖,已经有好几十年了。而且我也怀疑如今的走查将使用单步调试。我想到的一个特别恐怖的过程是这样的:
- 测试者提供测试用例。
- 主持人使用特定的用例作为输入,在调试器中启动代码。
- 在每一行语句执行前,开发者需要预先指出语句的输出,并解释为什么这样的输出是正确的。
- 如果输出与开发者预先指出的不一致,这将被作为一个潜在 BUG 的迹象。
- 在并发代码走查中,一个并发老手将提出问题,什么样的代码会与当前代码并发运行,为什么这样的并行没有问题?
恐怖吧,当然。但是有效吗?也许。如果参与者对需求、软件工具、数据结构及算法都有良好的理解,相应的走查可能非常有效。如果不是如此,走查通常是在浪费时间。
自查
虽然开发者审查自己的代码并不总是有效,但是有一些情形下,无法找到合适的替代方案。例如,开发者可能是被授权查看代码的唯一人员,其他合格的开发人员可能太忙,或者有问题的代码太离奇,以至于只有在开发者展示一个原型后,他才能说服他人认证对待它。在这行情况下,下面的过程是十分有用的,特别是对于复杂的并行代码而言。
- 写出包含需求的设计文档、数据结构图表,以及设计选择的原因。
- 咨询专家,如果有必要就修正设计文档。
- 用笔在纸上写下代码,一边写代码一边修正错误。抵制住对已经存在的、几乎相同的代码序列的引用,相反,你应该复制他们。
- 如果有错误,用笔在干净的纸上面复制代码,以便做这些事情一边修正错误。一直重复,直到最后两份副本完全相同。
- 为那些不是那么显而易见的代码,给出正确性证明。
- 在可能的情况下,自底向上的测试代码片段。
- 当所有代码都集成后,进行全功能测试和压力测试。
- 一旦代码通过了所有测试,写下代码级的文档,也许还会对前面讨论的设计文档进行扩充。
当我在新的 RCU 代码中,忠实的遵循这个流程时,最终只有少量 BUG 存在。在面对一些著名的异常时,我通常能够在其他人之前定位 BUG。也就是说,随着时间的推移,以及 Linux 内核用户数量和种类的增加,这变得更难以解决。
对于新代码来说,上面的过程运转的很好,但是如果你需要对已经编写完成的代码进行审查时,又会怎样呢?如果你编写哪种将废弃的代码,在这种情况下,当然可以实施上面的过程,但是下面的方法也是有帮助的,这是不是会令你感到不适那么绝望。
- 使用你喜欢的文档工具,描述问题中所述代码的高层设计。使用大量的图来表示数据结构,以及这些是如何被修改的。
- 复制一份代码,删掉所有的注释。
- 用文档逐行记录代码是在干什么。
- 修复你所找到的 BUG。
这种方法能够工作,是因为对代码进行详细描述,是一种极为有效的发现 BUG 的方法。虽然后面的过程也是一种真正理解别人代码的好方法,但是在很多情况下,只需要第一步就够了。
虽然由别人来进行复查及审查可能更有效,但是由于某种原因无法让别人参与进来时,上述过程就十分有用了。
在这一点上,你可能想知道如何在不做上述那些无聊的纸面工作的情况下,编写并行代码。下面是一些能够达到目的的且经过时间检验的方法。
- 通过扩展使用已有并行库函数,写出一个顺序程序。
- 为并行框架写出顺序执行的插件。如果地图渲染、BIONC 或者 WEB 应用服务器。
- 做如下优秀的并行设计,问题被完整的分割,然后仅仅实现顺序程序,这些顺序程序并发运行而不必相互通信。
- 坚守在某个领域(如线性代数),在这些领域中,工具可以自定对问题进行分解及并行化。
- 对并行原语进行及其严格的使用,这样最终代码容易被看出其正确性。但是请注意,它总是诱使你打破“小步前进”这个规则,以获得更好的性能可扩展性。破坏规则常常导致意外。也就是说,除非你小心进行本节描述的纸面工作,否则就会有意外发生。
一个不幸的事情是,即使你做了纸面工作,或者使用前述某个方法,以安全地避免纸面工作,仍然会有 BUG。如果不出意外,更多用户或者更多类型的用户将更快暴露出更多的 BUG。特别是,这些用户做了最初那些开发者所没有考虑到的事情时,更容易暴露 BUG。下一步将描述如何处理概率性 BUG,这些 BUG 在验证并行软件时都非常常见。
几率和海森堡 BUG
某些时候你的并行程序失败了。
但是你使用前面章节的技术定位问题,现在,有了适当的修复办法!
现在的问题是,需要多少测试以确定你真的修复了 BUG,而不仅仅是降低了故障发生的几率,或者说仅仅修复了几个相关 BUG 中的某几个,或者是干了一些无效的、不相关的修改。简而言之,是通过了还是侥幸?
不幸的是,摸着良心来回答这个问题,其答案是:要获得绝对的确定性,其所需要的测试量是无限的。
假如我们愿意放弃绝对的确定性,取而代之的是获得某种高几率的东西。那么我们可以用强大的统计工具来应对这个问题。但是,本节专注于简单的统计工具。这些工具是及其有用的,但是请注意,阅读本节并不能代替你采用那些优秀的统计工具。
从简单的统计工具开始,我们需要确定,我们是在做离散测试,还是在做连续测试。离散测试以良好定义的、独立的测试用例为特征。例如,Linux 内核补丁的启动测试就是一个离散测试的例子。启动内核,它要么启动、要么不能启动。虽然你可能花费一小时来进行内核启动测试,试图启动内核的次数、启动成功的次数,通常比花在测试上面的时间更人关注。功能测试往往是离散的。
另一方面,如果我的补丁与 RCU 相关,我很可能会运行 rcutortue,这是一个十分奇妙的内核模块,用于测试 RCU。它不同于启动测试。在启动测试中,一旦出现相应的登录提示符,就表名离散测试已经成功结束。Rcutortue 则会一直持续运行,知道内存崩溃或要求它停止为止。因此,rcutortue 测试的持续时间,将比启动、停止它的次数更令人关注。所以说,rcutortue 是一个持续测试的例子,这类测试包含很多压力测试。
离散测试和持续测试的统计方式有所不同。离散测试的统计更简单,并且,离散测试的统计通常可以被计入持续测试中。因此,我们先从离散测试开始。
离散测试统计
假设在一个特定的测试中,BUG 有 10% 的机会发生,并且我们做了 5 次测试。我们怎么算一次运行失败的几率?方法如下:
- 计算一次测试过程成功的几率,应该是 90%。
- 计算所有 5 次测试成功的几率,应该是 0.9 的 5 次方,大约是 59%。
- 存在两种可能性,要么 5 次全都成功,要么至少又一次失败。因此,至少又一次失败的可能是 100% 减去 59%,即 41%。
假设一个特定测试有 10% 的几率失败。那需要允许多少次测试用例,才能导致失败的几率超过 99%?毕竟,如果我们将测试用例运行的次数足够多,使得至少有一次失败的几率达到 99%,如果此时并没有失败,那么斤斤有 1% 的几率表名这是由于好运气所导致。
公式太多….省略…
持续测试统计
定位海森堡 BUG
这个思路也有助于说明海森堡 BUG,增加追踪和断言可以轻易减少 BUG 出现的几率。这也是为什么轻量级追踪和断言机制是如此重要的原因。
“海森堡 BUG” 这个名字来源于量子力学的海森堡不确定性原理,该原理指出,在任何特定时间点,不可能同时精确计量某个粒子的位置和速度。任何视图更精确计量某个粒子位置的手段,都会增加速度的不确定性。类似的效果出现在海森堡 BUG 上,视图对海森堡 BUG 进行追踪,将会根本上改变其症状,甚至导致 BUG 不再出现。
既然物理领域启发出这个问题的名字,那么我们着眼于物理领域的解决方案是合乎逻辑的。幸运的是,粒子物理学能够用于这个任务,为什么不构造“反——海森堡 BUG”的东西来消灭海森堡 BUG 呢?
本节描述一些手段来实现这一点。
- 为竞争区增加延迟。
- 增加负载强度。
- 独立的测试可疑子系统。
- 模拟不常见的事件。
- 对有惊无险的事件进行计数。
针对海森堡 BUG 来构造“反——海森堡 BUG”,这更像是一种艺术,而不是科学。
增加延迟
增加负载强度
隔离可疑的子系统
模拟不常见的事件
对有惊无险的事件进行计数
性能评估
3.12 - CH12-形式验证
本章通过形式证明的方式来弥补测试的不足。略。
3.13 - CH13-综合应用
本章会给出一些处理某些并发编程难题的提示。
计数难题
对更新进行计数
假设薛定谔想要对每一只动物的更新数量进行计数,并且这些更新使用一个没数据元素锁进行同步。这样的计数怎样才能做得最好?
当然,可以考虑第 5 章中任何一种计数算法,但是在这种情况下,最优的方法简单的多。仅仅需要在每一个数据元素中放置一个计数器,并且在元素锁的保护下递增元素就行了。
对查找进行计数
如果薛定谔还想对每只动物的查找进行计数,而这些查找由 RCU 保护。怎样的计数才能做到最好?
一种方法是像 13.1.1 节所述,由一个每元素锁来对查找计数进行保护。不幸的是,这将要求所有查找过程都获得这个锁,在大型系统中,这将形成一个严重的瓶颈。
另一种方法是对计数说“不”,就像 noatime 挂载选项的例子。如果这种方法可行,那显然是最好的办法。毕竟,什么都没有比什么都不做还快。如果查找计数不能被省略,就继续读下去。
第 5 章中的任何计数都可以做成服务,5.2 节中描述的统计计数可能是最常见的选择。但是,这导致大量的内存访问,所需要的计数器数量是数据元素的数量乘以线程数量。
如果内存开销太大,另一个方法是保持每 socket 计数,而不是每 CPU 计数,请注意图 10.8 所示的哈希表性能结果。这需要计数递增作为原子操作,尤其对于用户态来说更是这样。在用户态中,一个特定的线程可能随时迁移到另一个 CPU 上运行。
如果某些元素被频繁的查找,那么存在一些其他方法。这些方法通过维护一个每线程日志来进行批量更新,其对特定元素的多次日志操作可以被合并。当对一个特定日志操作达到一定的递增次数,或者一定的时间过去以后,日志记录将被反映到相应的数据元素中去。Silas Boyd-Wickizer 已经做了一些工作。
使用 RCU 拯救并行软件性能
本节展示如何对本书较早讨论的某些例子应用 RCU 技术。某些情况下,RCU 提供更加简单的代码,另外一些情况下则能提供更好的性能和可扩展性,还有一些情况下,同时提供两者的优势。
RCU 和基于每 CPU 变量的统计计数
5.2.4 节描述了一个统计计数的实现,该实现提供了良好的性能,大致的说是简单的递增,并且能够线性扩展——但仅仅通过 inc_count 递增。不幸的是,需要通过 read_count 读取其值的线程需要获得一个全局锁,因此招致高的高效,并且扩展性不佳。
设计
设计的目的是使用 RCU 而不是 final_mutex 来保护线程在 read_count 中的遍历,已获得良好的性能和扩展性,而不仅仅是保护 inc_count。但是,我们并不希望放弃求和计算的精确性。特别是,当一个特定线程退出时,我们绝对不能丢失退出线程的计数,也不能重复对它进行计数。这样的错误将导致将不精确的结果作为精确结果,换句话说,这样的错误使得结果完全没有意义。并且事实上,final_mutex 的一个目的是,确保线程不会在 read_count 运行过程中,进入并退出。
因此,如果我们不用 final_mutex,就必须拿出其他确保一致性的方法。其中一种方法是将所有已退出线程的计数和,以及指向每线程计数的指针放到一个单一的数据结构。这样的数据结构,一旦没 read_count 使用就保持不变,以确保 read_count 看到一致的数据。
实现
片段 13.5 第 1~4 行展示了 countarray 结构,它包含一个 total 字段,用于对之前已经退出线程的计数、counterp[] 数组,指向当前正在运行的每线程 counter。这个及饿哦股允许特定的 read_count 执行过程看到一致的计数总和,以及运行线程的集合。
第 6~8 行包含每线程 counter 变量的定义,全局指针 countarray 引用单签 countarray 结构,以及 final_mutex 自旋锁。
第 10~13 行展示 inc_count,与之前没有变化。
第 15~29 行展示 read_count,它被大量修改了。第 21~27 行以 rcu_read_lock 和 rcu_read_unlock 代替获得、释放 final_mutex 锁。第 22 行使用 rcu_dereference 将当前 countarray 数据结构的快照获取到临时变量 cap 中。正确的使用 RCU 将确保:在第 27 行的 RCU 读端临界区结束前,该 countarray 数据结构不会被释放掉。第 23 行初始化 sum 作为 cap->total,它表示之前已经退出的线程计数值之和。第 23~26 行将正在运行的线程对应的每线程计数值添加到 sum 中。最后第 28 行返回 sum。
countarray 的初始值由第 31~39 行的 count_init 提供。这个函数在第一个线程创建之前运行,其任务是分配初始数据结构,并将其置为 0,然后将它赋值给 countarray。
第 41~48 行展示了 count_register_thread 函数,他被每一个新创建线程所调用。第 43 行获取当前线程的索引,第 45 行获取 final_mutex,的 46 行将指针指向线程的 counter,第 47 行释放 final_mutex 锁。
第 50~70 行展示了 count_unregister_thread 函数,没一个线程在退出前,条用此函数。第 56~60 行分配一个新的 countarray 数据结构,第 61 行获得 final_mutex 锁,第 67 行释放锁。第 62 行将当前 countarray 的值复制到新分配的副本,第 63 行将现存线程的 counterp 添加到新结构的总和值中,第 64 行将真正退出线程的 counterp[] 数组元素置空,第 66 行保留当前值(很快就会变成旧的)countarray 结构的指针引用,第 66 行使用 rcu_assign_pointer 设置 countarray 结构的新版本。第 68 行等待一个优雅周期的流逝。这样,任何可能并发执行 read_count,并且可能拥有对旧的 countarray 结构引用的线程,都能退出它们的 RCU 读端临界区,并放弃对这些结构的引用。因此,第 69 行能够安全释放旧的 countarray 结构。
讨论
对 RCU 的使用,使得正在退出的线程进行等待,直到其他线程保证,其已经结束对退出线程的 thread 变量的使用。这允许 read_count 函数免于使用锁,因而对 inc_count 和 read_count 函数来说,都为其提供了优良的性能和可扩展性。但是这些性能和扩展性来自于代码复杂性的增加。希望编译器和库函数的编写者能够提供用户层的 RCU,以实现跨越线程安全访问 thread 变量,大大减少 thread 变量使用者所能见到的复杂性。
RCU 及可插拔 IO 设备的计数器
5.5 节展示了一对奇怪的代码段,以处理对可插拔设备的 IO 访问计数。由于需要获取读写锁,因此这些代码段会在快速路径上(开始一个 IO)招致过高的负载。
执行 IO 的代码与原来的代码非常类似,它使用 RCU 读端临界区代替原代码中的读写锁的读端临界区。
1 rcu_read_lock();
2 if (removing) {
3 rcu_read_unlock();
4 cancel_io();
5 } else {
6 add_count(1);
7 rcu_read_unlock();
8 do_io();
9 sub_count(1);
10 }
RCU 读端原语拥有极小的负载,因此提升了快速路径的速度。
移除设备的新代码片段如下:
1 spin_lock(&mylock);
2 removing = 1;
3 sub_count(mybias);
4 spin_unlock(&mylock);
5 synchronize_rcu();
6 while (read_count() != 0) {
7 poll(NULL, 0, 1);
8 }
9 remove_device();
在此,我们将读写锁替换为排他自旋锁,并增加 synchronize_rcu 以等待所有 RCU 读端临界区完成。由于 synchronize_rcu 的缘故,一旦我们允许到第 6 行,就能够知道,所有剩余 IO 已经被识别到了。
当然 synchronize_rcu 的开销可能比较大。不过,既然移除设备这种情况比较少见,那么这种方法通常是一个不错的权衡。
数组及长度
如果我们有一个受 RCU 保护的可变长度数组,如下面的代码片段:
1 struct foo {
2 int length;
3 char *a;
4 };
数组 ->a[] 的长度可能会动态变化。在任意时刻,其长度由字段 ->length 表示。当然,这带来了如下竞争条件。
- 数组被初始化为 16 个字节,因此 length 等于 16。
- CPU0 紧挨着 length 的值,得到 16。
- CPU1 压缩数组长度到 8,并将 ->a[] 赋值为指向新 8 字节长的内存块的指针。
- CPU0 从 ->a[] 获取到新的指针,并且将新值存储到元素 12 中。由于数组仅仅有 8 个字符,这导致 SEGV 或内存破坏。
我们可以使用内存屏障来放置这种情况。该方法确实可行,但是带来了读端的开销,更糟的是需要显式使用内存屏障。
一个更好的办法是将值及数组放进同一个数据结构,如下所示:
1 struct foo_a {
2 int length;
3 char a[0];
4 };
5
6 struct foo {
7 struct foo_a *fa;
8 };
分配一个新的数组(foo_a 数据结构),然后为新的数组长度提供一个新的存储空间。这意味着,如果某个 CPU 获得 fa 引用,也就能能确保 length 能够与 a 的长度相匹配。
- 数组最初为 16 字节,因此 length 等于 16.
- CPU0 加载 fa 的值,获得指向数据结构的指针,该数据结构包含值 16,以及 16 字节的数组。
- CPU0 加载 fa->length 的值,获得其值 16.
- CPU 压缩数组,使其长度为 8,并且将指针赋值为新分配的 foo_a 数据结构,该结构包含一个 8 字节的内存块 a。
- CPU 0 从 a 获得新指针,并且将新值存储到第 12 个元素。由于 CPU0 仍然引用旧的 foo_a 数据结构,该结构包含 16 字节的数组,一切都正常。
当然,在所有情况下,CPU1 必须在释放旧数组前等待下一个优雅周期。
相关联的字段
假设每一只薛定谔动物由下面所示的数据元素表示:
1 struct animal {
2 char name[40];
3 double age;
4 double meas_1;
5 double meas_2;
6 double meas_3;
7 char photo[0]; /* large bitmap. */
8 };
meas_1、meas_2、meas_3 字段是一组相关联的计量字段,它们被频繁更新。读端从单词完整更新的角度看到这三个值,这是特别重要的,如果读端看到 meas_1 的旧值,而看到 meas_2 和 meas_3 的新值,读端将会变得非常迷惑。我们怎样才能确保读端看到协调一致的三个值呢?
一种方法是分配一个新的 animal 数据结构,将旧结构复制到新结构中,更新新结构的三个字段,然后,通过更新指针的方式,将旧的结构替换为新的结构。这确保所有读 端看到测量值的一致集合。但是由于 photo 字段的原因,这需要复制一个大的数据结构。这样的复制操作可能带来不能接受的大开销。
另一种方式是如下所示中的那样插入一个中间层:
1 struct measurement {
2 double meas_1;
3 double meas_2;
4 double meas_3;
5 };
6
7 struct animal {
8 char name[40];
9 double age;
10 struct measurement *mp;
11 char photo[0]; /* large bitmap. */
12 };
当进行一次新的测量时,一个新的 measurement 数据结构被分配,将测量值填充到该结构,并且 animal 的及饿哦股 mp 字段被更新为指向先 measurement 结构,这是使用 rcu_asign_pointer 完成的更新。当一个优雅周期流逝以后,旧的 measurement 数据可以被释放。
这种方式运行读端以最小的开销,看到所选字段的关联值。
散列问题
本节着眼于在处理哈希表时,可能会碰上的一些问题。请注意,这些问题也适用于许多其他与搜索相关的数据结构。
相关联的数据元素
这种情形类似于 13.2.4 节中的问题:存在一个哈希表,我们需要两个或更多元素的关联视图。这些数据元素被同时更新,并且我们不希望看到不同元素之间的不同版本。
一种方式是使用顺序锁,这样更新将在 write_seqlock 的保护下进行。而要求一致性的读请求将在 read_seqbegin/read_seqretry 循环体中进行。请注意,顺序锁并不是 RCU 保护机制的替代品:顺序锁是保护并发修改操作,而 RCU 仍然是需要的,它保护并发的删除。
当相关数据元素少,读这些元素的时间很短,更新速度也低的时候,这种方式可以运行的很好。否则,更新可能会频繁发生,以至于读者总是不能完成。要逃避读者饥饿问题,一种方式是在读端重试太多次之后让其使用写端原语,但是这会同时降低性能和扩展性。
另外,如果写端原语使用得太频繁,那么,由于锁竞争的原因,将带来性能和扩展性的问题。要避免这个问题,其中一种方法是维护一个每数据元素的顺序锁,并且,在更新时应该持有所有涉及元素的锁。但是复杂性在于:在单词扫描数据库期间,需要获得所有数据的文档视图。
如果元素分组被良好定义且有持久性,那么一种方式是将指针添加到数据元素中,将特定组的元素链接在一起。读者就能遍历所有这些指针,以访问同一组内的所有元素。
对更新友好的哈希表遍历
如果需要对哈希表中的所有元素进行统计扫描。例如,薛定谔可能希望计算所有动物的平均长度——重量比率。更进一步假设,薛定谔愿意忽略在统计扫描进行时,那些正在从哈希表中添加或移除的动物引起的轻微错误。那么如何来控制并发性?
一种方法是:将统计扫描置于 RCU 读端临界区之内。这允许更新并发的进行,而不影响扫描进程。特别是,扫描过程并不阻塞更新操作,反之亦然。这允许对包含大量数据元素的哈希表进行扫描,这样的扫描将被优雅的支持,即使面对高频率的更新时也是如此。
3.14 - CH14-高级同步
本章将介绍高级同步的两个分类:无锁同步、实时同步。
当面临极端要求时,无锁同步会非常有帮助,但不幸的是无锁同步并非灵丹妙药。如在第五章的末尾所述,你应该在考虑采用无锁同步之前首先考虑分区、并行,以及在第八、九章所述的充分测试的脆弱 API。
避免锁
尽管锁在并行生产环境中吃苦耐劳,但在很多场景下可以通过无锁技术来大幅提高性能、扩展性和实时响应性。这种无锁技术的一个实际例子是第 5.2 节中所述的统计计数,它不但避免了锁,同时还避免了原子操作、内存屏障,甚至是计数器自增时的缓存未命中。我们已经介绍过的与无锁技术相关的例子有:
- 第 5 章中一些计数算法的快速路径。
- 第 6.4.3 中资源分配器缓存的快速路径。
- 第 6.5 中的迷宫求解器。
- 第 8 章中的数据所有权技术。
- 第 9 章中介绍的引用计数与 RCU 技术。
- 第 10 章中查找逻辑的代码路径。
- 第 13 章中介绍的大多数技术。
总的来说,无锁技术十分有用且已被大量应用。
然后,无锁技术最好是能够因此在设计良好的 API 之后,比如 inc_count、memblock_allock、rcu_read_lock 等等。因为对无锁的技术的混乱使用可能会引入一些难以解决的 BUG。
很多无锁技术的关键组件是内存屏障,下面的章节将会详细介绍。
无阻塞同步
术语“非阻塞同步(NBS)”描述 6 类线性化算法,这些算法具有前向执行保证。这些前向执行保证与构成实时程序的基础相混淆。
- 实时前向执行保证通常有些与之相关的确定时间。例如,“调度延迟必须小于 100ms”。相反,NBS 仅仅要求执行过程限定在有限时间内,没有确定的边界。
- 有时,实时前向执行具有概率性。比如,在软实时保证中“至少在 99.9% 的时间内,调度延迟必须小于 100ms”。相反,NBS 的前向执行保证传统上是无条件的。
- 实时前向执行保证通常以环境约束为条件。例如,仅仅当每个 CPU 至少有一定比例处于空闲时间,或者 IO 速度低于某些特定的最大值时,对最高优先级任务才能得到保证。相反,NBS 的前向执行保证通常是无条件的。
- 实时前向执行保证通常适用于没有软件 BUG 的情况下。相反,绝大多数 NBS 保证即使在面对错误终止 BUG 时也适用。
- NBS 前向执行保证隐含线性化的意思。相反,实时前向执行保证通常独立于像线性化这样的约束。
不考虑这样的差异,很多 NBS 算法对实时程序极其有用。
在 NBS 层级中,目前有 6 种级别,大致如下:
- 无等待同步:每个线程在有限时间内运行。
- 无锁同步:至少某一个线程将在有限时间内运行。
- 无障碍同步:在没有争用的情况下,每个线程将在有限时间内运行。
- 无冲突同步:在没有争用的情况下,至少某一线程将在有限时间内运行。
- 无饥饿同步:在没有错误的情况下,每个线程将在有限时间内运行。
- 无死锁同步:在么有错误的情况下,至少某一个线程将在有限时间内运行。
第 1、2 类 NBS 于 1990 年代初期制定。第 3 类首次在 2000 年代初期制定。第 4 类首次在 2013 年制定。最后两类已经非正式使用了数十年,但是在 2013 年重新制定。
从原理上讲,任何并行算法都能够被转换为无等待形式,但是存在一个相对小的常用 NBS 算法子集,将在后续章节列出。
简单 NBS
最简单的 NBS 算法可能是使用获取——增加(atomic_add_return)原语对下整型计数器进行原子更新。
另一个简单的 NBS 算法用数组实现整数集合。在此,数组索引标识一个值,该值可能是集合的成员,并且数组元素标识该值是否真的是集合成员。NBS 算法的线性化准则要求对数组的读写,要么使用原子指令、要么与内存屏障一起使用,但是在某些不太罕见的情况下,线性化并不重要,简单使用易失性加载和存储就足够了。例如,使用 ACCESS_ONCE。
NBS 集合也可以使用位图来实现,其中每一个值可能是集合中的某一位。通常,读写操作可以通过原子位维护指令来实现。虽然 CAS 指令也可以使用。
5.2 一节中讨论的统计计数算法可被认为是无等待算法,但仅仅是用了一个狡猾的定义技巧,在该定义中,综合被考虑为近似值而不是精确值。由于足够大的误差区间是计算计数器综合的 read_count 函数的时间长度函数,因此不可能证明发生了任何非线性化行为。这绝对将统计计算算法划分为无等待算法。该算法可能是 Linux 内核中最常见的 NBS 算法。
另一个常见的 NBS 算法是原子队列,其中元素入队操作通过一个原子交换指令实现,随后是对新元素前驱元素的 next 指针的存储,如图 14.19 所示。该图展示了用户态 RCU 库的实现。当返回前向元素的引用时,第 9 行更新引用新元素的尾指针,该指针存储在局部变量 old_tail 中。然后第 10 行更新前向 next 指针,以引用最新添加的元素。最后第 11 行返回队列最初是否为空的标志。
虽然将单个元素出队需要互斥(因此出队是阻塞的),但是将所有队列元素非阻塞的移除是可能的。不可能的是以非阻塞的方式将特定元素出队。入队可能是在第 9 行和第10 行之间失败,因此问题中的元素仅仅部分入队。这将导致半 NBS 算法,其中入队是 NBS 但是出队是阻塞式的。因此在实践中使用此算法的部分原因是,大多数产品软件不需要容忍随意的故障终止错误。
NBS 讨论
创建完全的非阻塞队列是可能的。但是,这样的队列要比上面列出的半 NBS 算法负责的多。这里的经验是,认真考虑你真的需要什么?放宽不相关的需求通常可以极大增加简单性和性能。
最近的研究指出另一种放宽需求的重要方式。结果是,不管是从理论上还是从实践上来说,提供公平调度的系统可以得到大部分无等待同步的优势,即使当算法仅仅提供 NBS 时也是这样。事实上,由于大量产品中使用的调度器都提供公平性,因此,与简单也更快的 NBS 相比,提供无等待同步的更复杂算法通常并没有实际的优势。
有趣的是,公平调度仅仅是一个有益的约束,在实践中常常得到满足。其他的约束集合可以允许阻塞算法实现确定性的实时响应。例如,如果以特定有限级的 FIFO 顺序来授予请求的公平锁,那么避免优先级翻转(如优先级继承或优先级上限)、有限数量的线程、有限长度的临界区、有限的加载,以及避免故障终止 BUG,可以让基于锁的应用获得确定性的响应时间。这个方法当然模糊了锁及无等待同步之间的区别,一切无疑都是好的。期望理论框架持续进步,进一步提高其描述如何在实践中构建软件的能力。
并行实时计算
什么是实时计算
将实时计算进行分类的一种传统方式,是将其分为硬实时和软实时。其中充满阳刚之气的硬实时应用绝不会错过其最后期限,而仅有阴柔之美的软实时应用,则可能被频繁(并且经常)错误其最后期限。
软实时
很容易发现软实时定义的问题。一方面,通过这个定义,任何软件都可以被说成是软实时应用:我的应用在 0.5ps 内计算 100 万点傅里叶变换;没门,系统时钟周期超过 300ps!如果术语软实时被滥用,那就明显需要某些限定条件。
因此,我们应当这么说:一个特定软实时应用必须至少在一定比例的时间范围内,满足实时响应的要求。例如,我们可能这么说,它必须在 99.9% 的时间范围内,在 20ms 内执行完毕。
这当然带来的问题,当应用程序不能满足响应时间要求,应当做什么?答案根据应用程序而不同,不过有一个可能是,被控制的系统有足够的灵活性和惯性,对于偶尔出现的延迟控制行文,也不会出现问题。另一种可能的做法是,应用有两种方式计算结果,一种方式是快速且具有确定性,但是不太精确的方法,还有一种方式是非常精确,但是具有不确定的计算时间。合理的方法是并行启动这两种方法,如果精确的方法不能按时完成,就中止并使用快速但不精确方法所产生的结果。对于快速但不精确的方法,一种实现是在当前时间周期内不采取任何控制行为,另一种实现是采取上一个时间周期同样的控制行为。
简而言之,不对软实时进行精确的度量,谈论软实时就没有任何意义。
硬实时
相对的,硬实时的定义相当明确。毕竟,一个特定的系统,它要么是满足其执行期限,要么不满足。不幸的是,这种严格的定义意味着不可能存在任何硬实时的系统。事实上,你能够构建更强大的系统,也许还有额外的冗余性。但是另一个事实是,我们可以找到一把更大的锤子。
不过话说回来,由于这明显不仅仅是一个硬件问题,而实在是一个大的硬件问题,因此指责软件是不公平的。这表明我们定义硬实时软件为哪种总是能够满足其最后期限的软件,其前提是没有硬件故障。不幸的是,故障不仅仅是一个可选项。硬实时响应是整个系统的属性,而不仅仅是软件属性。
但是我们不能求全责备,也许我们可以像前面所述的软实时方法那样,通过发出通知消息的方法来解决问题。
如果一个系统在不能满足符合法律条文的最后期限是,总是立即发出告警通知。但是这样的系统是无用的。很明显,很明显,必须要求系统在一定比例的时间内,满足其最后期限,或者,必须禁止其连续突破其最后期限这样的操作达到一定次数。
显然,我们没办法来对硬实时或软实时给出一种明确无误的说法。
现实世界的实时
虽然像“硬实时系统总是满足最后期限的要求”这样的句子读起来很上口,无疑也易于记忆,但是,其他一些东西也是现实世界的实时系统所需要的。虽然最终规格难于记忆,但是可以对环境、负载及实时应用本省施加一些约束,以简化构建实时系统。
环境约束
环境约束处理“硬实时”所隐含的响应时间上的无限制承诺。这些约束指定允许的操作温度、空气质量、电磁辐射的水平及类型。
当然,某些约束比其他一些约束更容易满足。人们都知道市面上的计算机组件通常不能在低于冰点的温度下运行,这表明了对气候控制的要求。
一位大学老朋友曾经遇到过这样的挑战,在具有相当活跃的氯化合物条件下的太空中,操作实时系统。他明智的将这个跳转转交给硬件设计同事了。实际上,同事在计算机上环绕施加大气成分的约束,这样的约束是由硬件设计者通过物理密封来实现的。
另一个大学朋友在计算机控制系统上工作,该系统在真空中使用工业强度的电弧来喷镀钛锭。有时,有时,将不会基于钛锭的路径来确定电弧的路径,而是选择更短更优的路径。正如我们在物理课程中学习到的一样,电流的突然变化会形成电磁波,电流越大、变化越大,形成超高功率的电磁波。这种情况下,形成的电磁脉冲足以导致 400 米外的 rubber ducky 天线引线产生 1/4 伏的变化。这意味着附近的导体能看到更大的电压。这包含那些组成控制喷镀过程的计算机导体。尤其是,包括计算机复位线的电压,也足以将计算机复位。这使得每一位涉及的人感到惊奇。这种情况下,面临的挑战是使用适当的硬件,包括屏蔽电缆、低速光钎网络。也就是说,不太引人注目的电子环境通常可能通过使用错误检测及纠正这样的软件代码来处理。也就是说,重要的是需要记住,虽然错误检测及纠正代码可以减少错误几率,但是通常不能将错误几率降低到零,这可能形成另一种实时响应的障碍。
也存在一些其他情形,需要最低水平的能源。例如,通过系统电源线和通过设备的能源。系统与这些设备通信,这些设备是被监控或者控制的外部系统的一部分。
一些欲在高强度的震动、冲击环境下运行的系统,例如发动机控制系统。当我们从连续震动转向间歇性冲击,将会发现更多令人头疼的需求。例如,在我大学本科学习期间,遇到一台老旧的雅典娜弹道计算机,它被设计用于即使手榴弹在其附近引爆也能持续正常工作。最后一个例子,是飞机上的黑匣子,它必须在飞机发生意外之前、之中、之后都持续运行。
当然,在面对环境冲击和碰撞时,使硬件更健壮是有可能的。巧妙的机械减震装置可以减少震动和冲击的影响,多层屏蔽可以减少低能量的电磁辐射的影响,错误纠正代码可以减少高能量辐射的影响,不同的灌封、密封技术可以减少空气质量的影响,加热、制冷系统可以应付温度的影响。极端情况下,三模冗余可以减少系统部分失效导致的整体不正确几率。但是,所有这些方法都有一个共同点:虽然它们能减少系统失败的几率,但是不能将其减低为零。
尽管这些重要的环境约束通常是通过使用更健壮的硬件来处理,但是在接下来的两节中的工作负载及应用约束通常由软件来处理。
负载约束
和人一样的道理,通过使其过载,通常可以阻止实时系统满足其最后期限的要求。例如,如果系统被过于频繁的中断,他就没有足够的 CPU 带宽来处理它的实时应用。对于这种问题,一种使用硬件的解决方案是限制中断提交给系统的速率。可能的软件解决方案包括:当中断被频繁提交给系统时,在一段时间内禁止中断,将频繁产生中断的设备进行复位,甚至完全禁止中断,转而采用轮询。
由于排队的影响,过载也可能降低响应时间,因此对于实时系统来说,过度供应 CPU 贷款并非不正常,一个运行的系统应该有 80% 的空闲时间。这种方法也适用于存储和网络设备。某些情况下,应该讲独立的存储和网络硬件保留给高优先级实时应用所使用。当然,这些硬件大部分时间都处于空闲状态,这并非不正常。因为对于实时系统来说,响应时间比吞吐量更重要。
单谈,要想保持足够低的利用率,在整个设计和实现过程汇总都需要强大的专业知识。没有什么事情与之相似,一个小小的功能就不经意间将最后期限破坏掉。
应用约束
对于某些操作来说,比其他操作更易于提供其最后响应时间。例如,对于中断和唤醒操作来说,得到其响应时间规格是很常见的,而对于文件系统卸载操作来说,则很难得到其响应时间规格。其中一个原因是,非常难于阶段文件系统卸载操作锁需要完成的工作量,因为卸载操作需要将所有内存中的数据刷新到存储设备中。
这意味着,实时应用程序必须限定其操作,这些操作必须合理提供受限的延迟。不能提供合理延迟的操作,要么将其放到非实时部分中去,要么将其完全放弃。
也可能对应用的非实时部分进行约束。例如,非实时应用是否可以合法使用实时应用的 CPU?在应用个实时部分预期非常繁忙期间,是否允许非实时部分全速运行?最后,应用实时部分允许将非实时应用的吞吐量降低到多少?
现实世界的实时规格
正如前面章节所见,现实世界的实时规格需要包装环境约束,负载及应用本身的约束。此外,应用的实时部分允许使用的操作,必然受限于硬件及软件实现方面的约束。
对于每一个这样的操作,这些约束包括最大响应时间(也可能包含一个最小响应时间),以及满足响应时间的几率。100% 的几率表示相应的操作必须提供硬实时服务。
某些情况下,响应时间以及满足响应时间的几率,都十分依赖于操作参数。例如,在本地局域网中的网络操作很有可能在 100ms 内完成,这好于穿越大陆的广域网上的网络操作。更进一步来说,在铜制电缆和光纤网络上的网络操作,更有可能不需要耗时的重传操作就能完成,而相同的操作,在有损 WIFI 网络之上,则更有可能错误严格的最后期限。类似的可以预期,从固态硬盘 SSD 读取数据,将比从老式 USB 连接的旋转硬盘读取更快完成。
某些实时应用贯穿操作的不同阶段。例如,一个控制胶合板的实时系统,它从旋转的原木上剥离木材薄片。这样的系统必须:将原木装载到车床;将原木固定在车床上,以便将原木中最大的柱面暴露给刀片;开始旋转原木;持续的改变刀具位置,以将原木切割为木板;将残留下来的、太小而不能切割的原木移除;同时,等待下一根原木。5 个阶段的每一步,都有自身的最后期限和环境约束,例如,第 4 步的最后期限远比第 6 步严格,其最后期限是毫秒级而不是秒级。因此,希望低优先级任务在第 6 阶段运行,而不要在第 4 阶段运行。也就是说,应当小心选择硬件、驱动和软件装置,这些选择将被要求支持第 4 步更严格的要求。
每种阶段区别对待的方法,其关键优势是,延迟额度可以被细分,这样应用的不同部分可以被独立的开发,每一部分都有其自己的延迟额度。当然,与其他种类的额度相比,偶尔会存在一些冲突,即哪些组件应当获得多大比例的额度。并且,从另一个角度来说,与其他种类的额度相比,严格的验证工作是需要的,以确保正确聚焦与延迟,并且对于延迟方面的问题给出早期预警。成功的验证工作几乎总是包含一个好的测试集,这样的测试集对于学究来说并不总是感到满意,但是好在有助于完成相应的任务。事实上,截止 2015 年初,大多数现实世界的实时系统使用验收测试,而不是形式化证明。
也就是说,广泛使用测试条件来验证实时系统有一个确实存在的缺点,即实时软件仅仅在特定硬件上,使用特定的硬件和软件配置来进行验证。额外的硬件及配置需要额外的开销,也需要耗时的测试。也许形式验证领域将大大改进,足以改变这种状况,但是直到 2015 年初,形式验证还需要继续进行大的改进。
除了应用程序实时部分的延迟需求,也存在应用程序非实时部分的性能及扩展性需求。这些额外的需求反映出一个事实,最终的实时延迟通常都是通过降低扩展性和平均性能来实现的。
软件工程需求也是很重要的,尤其是对于大型应用程序来说,更是如此。这些大型应用程序必须被大型项目组锁开发和维护。这些工程需求往往偏重于增加模块化和故障的隔离性。
以上所述,仅仅是产品化实时系统中,最后期限及环境约束所需工作的一个大概说明。我们期望,它们能够清晰展示那些实时计算方面教科书式方法的不足。
谁需要实时计算
如果说,所有计算实际上都是实时计算,这可能会引起争议。举一个极端的例子,当在线购买生日礼物的时候,你可能在接受者生日之前礼物能够到达。甚至是前年之交的 Web 服务,也存在亚秒级的响应约束,这样的需求并没有随着时间的推移而缓解。虽然如此,专注于那些实时应用更好一点,这些实时应用的实时需求并不能由非实时系统及其应用所实现。当然,由于硬件成本的降低,以及带宽和内存的增加,实时和非实时之间的界限在持续变化,不过这样的变化并不是坏事。
实时计算用于工业控制应用,范围涵盖制造业到航空电子;科学应用,也许最引人注目的是用于大型天文望远镜上的自适应光学;军事应用,包含前面提到的航空电子;金融服务应用,其第一台挖掘出机会的计算机最有可能获得大多数最终利润。这 4 个领域以“产品探索”、“声明探索”、“死亡探索”、“金钱探索”为特征。
金融服务应用于其他三种应用之间的微秒差异在于其他的非物质特征,这意味着非计算机方面的延迟非常小。与之相对的是,其他三类应用的固有延迟使得实时响应的优势很小,甚至没有什么优势。所以金融服务应用,相对于其他实时信息处理应用来说,更面临着装备竞争,有最低延迟的应用通常能够获胜。虽然最终的延迟需求仍然可以由第 15.1.3.4 节中描述的内容来指定,但是这些需求的特殊性质,已经将金融和信息处理应用的需求变为“低延迟”,而不是实时。
不管我们到底如何称呼它,实时计算总是有实实在在的需求。
谁需要并行实时计算
还不太清楚谁真正需要并行实时计算,但是低成本多核系统的出现已经将并行实时计算推向了前沿。不幸的是,传统实时计算的数学基础均假设运行在单 CPU 系统中,很少有例外。例如,有一些现代平方计算硬件,其方式适合于实时计算周期,一些 Linux 内核黑客已经鼓励学术界进行转型,以利用其优势。
一种方法是,意识到如下事实,许多实时系统表现为生物神经系统,其相应范围包含实时反映和非实时策略与计划,如图 15.4 所示。硬实时反应运行在单 CPU 上,它从传感器读数据并控制动作。而应用的非实时策略与计划部分,则运行在余下的 CPU 上面。策略与计划活动可能包括静态分析、定期校准、用户接口、支撑链活动及其他准备活动。高计算负载准备活动的例子,请回想 15.1.3.4 节讨论的应用。当某个 CPU 正在进行剥离原木的高速实时计算时,其他 CPU 可以分析下一原木的长度及形状,以确定如何放置原木,以最大可能的获得更多数量的高品质模板。事实证明,很多应用都包含非实时及实时组件。因此这种方法通常能用于将传统实时分析与现代多核硬件相结合。
另一个不太有用的方法,是将所有其他硬件线程关闭,只保留其中一个硬件线程,这就回到了单处理器实时数学计算。不过,这种方法失去了潜在的成本和能源优势。也就是说,获得这些优势需要克服第 3 章所述的并行计算困难。而且,不但要处理一般情况,更要处理最坏的情况。
因此,实现并行实时系统可能是一个巨大的挑战。处理这些挑战的方法将在随后的章节给出。
实现并行实时系统
我们将着眼于两种类型的实时系统:事件驱动及轮询。事件驱动的实时系统有更多事件处理空闲状态,对实时事件的响应,是通过操作系统向上传递给应用的。可选的系统可以在后台运行非实时的工作负载,而不是使其处于空闲状态。轮询实时系统有一个特点,存在一个绑在 CPU 上运行的实时线程,该线程运行在一个紧凑循环中,在每一轮轮询中,线程轮询输入事件并更新输出。该循环通常完全运行在用户态,它读取并写入硬件寄存器,这些寄存器被映射到用户态应用程序的地址空间。可选的,某些应用将轮询循环放到内核中,例如,通过使用可加载内核磨矿将其放到内核中。
不管选择何种类型,用来实现实时系统的方法都依赖于最后期限。如图 15.5 所示。从图的顶部开始,如果你可以接受超过 1s 的响应时间,就可以使用脚本语言来实现实时应用。实际上,脚本语言通常是奇怪的用户,并不是我推荐一定要用这种方法。如果要求延迟大于几十毫秒,旧的 Linux 内核也可以使用,同样的,这也不是我推荐一定要用这种方法。特定的实时 Java 实现可以通过几毫秒的实时响应延迟,即使在垃圾回收器被使用时也是这样。如果仔细配置、调整并运行在实时友好的硬件中,Linux 2.6 及 3.x 内核能够提供几百微秒的实时延迟。如果小心避免垃圾回收,特定的 Java 实现可以提供低于 100ms 的实时延迟。(但是请注意,避免垃圾回收就意味着避免使用 Java 大型标准库,也就失去了 Java 的生成效率优势)。打上了 -rt 实时补丁的 Linux 内核可以提供低于 20ms 的延迟。没有内存转换的特定实时系统(RTOSes)可以提供低于 10ms 的延迟。典型的,要实现低于微秒的延迟,需要手写汇编代码,甚至需要特殊硬件。
当然,小心的配置及调节工作,需要针对所有调用路径。特别是需要考虑硬件或固件不能提供实时延迟的情况,这种情况下,想要弥补其消耗的时间,软件是无能为力的。并且,那些高性能的硬件有时会牺牲最坏情况下的表现,以获得吞吐量。实际上,在禁止中断的情况允许紧致循环,可以提供高质量随机数生成器的基础。而且,某些固件窃取时钟周期,以进行各种内置任务,在某些情况下,它们还会视图通过重新对受影响 CPU 的硬件时钟进行编程,来掩盖其踪迹。当然,在虚拟化环境中,窃取时钟周期是其期望的行为,不过人们仍然努力在虚拟化环境中实现实时响应。因此,对你的硬件和固件的实时能力进行评估,是至关重要的。存在一些组织,它们进行这种评估,包括开源自动开发实验室(OSADL)。
假设有合适的实时硬件和挂进,栈中更上一层就是操作系统,这将在下一节讨论。
实现并行实时操作系统
存在一些可用于实现实时系统的策略。其中一种方法是,将长剑非实时系统置于特定目的的实时操作系统之上,如图 15.6 所示。其中绿色的 “Linux 进程” 框表示非实时任务,这些进程运行在 Linux 内核中,而黄色的“RTOS 进程”框表示运行在 RTOS 之中的实时任务。
在 Linux 内核拥有实时能力之前,这是一种非常常见的方法,并且至今仍然在用。但是,这种方法要求应用被分割为不同的部分,其中一部分运行在 RTOS 之中,而另外的部分运行在 Linux 之中。虽然有可能使两种运行环境看起来类似,例如,通过将 RTOS 侧的 POSIX 系统调用转发给 Linux 侧的线程。这种方法还是存在一些粗糙的边界。
另外,RTOS 必须同时与硬件和内核进行交互,因此,当硬件和内核更改时,需要大量的维护工作。而且,每一个这样的 RTOS 通常都有其独有的系统调用接口和系统库集合,其生态系统和开发者都相互对立。事实上,正是这些问题,驱使将 Linux 和 RTOS 进行结合,因为这种方法允许访问 RTOS 的全实时能力,同时允许应用的非实时代码完全访问 Linux 丰富而充满活力的开源生态系统。
虽然,在 Linux 仅仅拥有最小实时能力的时候,将 Linux 内核与 RTOS 绑在一起,不失为明智而且有用的临时应对措施,这也将激励将实时能力添加到 Linux 内核中。实现这一目标的进展情况如图 15.7 所示。上面的进展展示了抢占禁止的 Linux 内核图。由于抢占被禁止的原因,它基本没有实时能力。中间的展示的一组图其中包含了抢占 Linux 主线内核,其实时能力的增加过程。最后,最下面的行展示了打上 -rt 补丁包的 Linux 内核,它拥有最大化的实时能力。来自于 -rt 补丁包的功能,已经被添加到主线分支,因此随着时间的推移,主线 Linux 内核的能力在不断增加。但是,最苛刻的实时应用仍然使用 -rt 补丁包。
如图 15.7 顶部所示的不可抢占内核以 CONFIG_PREEMPT=n 的配置进行构建,因此在 Linux 内核中的执行是不能被抢占的。这就意味着,内核的实时响应延迟由 Linux 内核中最长的代码路径所决定,这是在是有点长。不过,用户态的执行是可抢占的,因此在右上角所示的实时 Linux 进程,可以在任意时刻抢占左上角的,运行在用户态的非实时进程。
图 15.7 中部所示的可抢占内核,以 CONFIG_PREEMPT=n 的配置进行构建,这样大多数运行在 Linux 内核中的、进程级的代码可以被抢占。这当然极大改善了实时响应延迟,但是在 RCU 读端临界区、自旋锁临界区、中断处理、中断关闭代码段,以及抢占禁止代码段中,抢占仍然是禁止的。禁止抢占的部分,由图中间行中,最左边的红色框所示。可抢占 RCU 的出现,允许 RCU 读端临界区被抢占,如图中间部分所示。线程化中断处理函数的实现,允许设备中断处理被抢占,如图最右边所示。当然,在此期间,大量其他实时功能被添加,不过,在这种图中不容易将其表示出来。这将在 15.4.1.1 节讨论。
最后一个方法是简单将所有与实时任务无关的东西,都从实时任务中移除,将所有其他事务都从实时任务所需的 CPU 上面清除。在 3.10 Linux 内核中,这是通过 CONFIG_NO_HZ_FULL 配置参数来实现的。请注意,这种方法需要至少一个守护 CPU 执行后台处理,例如运行内核守护任务,这是非常重要的。当然,当在特定的、非守护 CPU 上面,如果仅仅只有一个可运行任务,那么该 CPU 上面的调度时钟中断被关闭,这移除了一个重要的干扰源和 OS 颠簸。除了少数例外情况,内核不会强制将其他非守护 CPU 下线,当在特定 CPU 上只有一个可运行任务时,这会简单的提供更好的性能。如果配置适当,可以郑重向你保证,CONFIG_NO_HZ_FULL 将提供近乎裸机系统的实时线程级性能。
当然,这有一些争议,这些方法到底是不是实时系统最好的方式。而且这些争议已经持续了相当长一段时间。一般来说,正如后面章节所讨论的那样,答案要视情况而定。15.4.1.1 节考虑事件驱动的实时系统,15.4.1.2 节考虑使用 CPU 绑定的轮序循环的实时系统。
事件驱动的实时支持
操作系统为事件确定的实时应用所提供的支持是相当广泛的。不过,本节只关注一部分内容,即时钟、线程化中断、优先级继承、可抢占 CPU、可抢占自旋锁。
很明显,定时器对于实时操作系统来说是极其重要的。毕竟,如果你不能指定某些事件在特定事件完成,又怎能在某个事件点得到其响应?即使在非实时系统中,也会产生大量定时器,因此必须高效处理它们。作为示例的用法,包括 TCP 连接重传定时器(它们几乎总是会在触发之前被中止),定时延迟(在 sleep(1) 中,它几乎不被中断),超时 poll 系统调用(它通常会在触发之前就被中止)。对于这些定时器,一个好的数据结构是优先级队列,对于这样的队列,其添加删除原语非常快速,并且与已经入队的定时器数量相比,其时间复杂度是 O(1)。
用于此目的的经典数据结构是日历队列,在 Linux 内核中被称为时钟轮。这个古老的数据结构也被大量用于离散时间模拟。其思想是时间时能度量的,例如,在 Linux 内核中,时间度量周期是调度时钟中断的周期。一个特定时间可以被表示为整型数,任何视图在一个非整型数时刻提交一个定时器,都将被取整到一个最接近的整数时间值。
一种简单的实现是分配一个一维数组,以时间的地界位进行索引。从原理上来说,这可以运转,但是在那些创建大量长周期超时定时器的实际系统中(例如为 TCP 会话而创建的 45 分钟保活超时定时器),这些定时器几乎总是被中止。长周期的超过定时器对于小数组来说会导致问题,这是因为有太多时间浪费在跳过那些还没有到期的定时器上。从另一个方面来说,一个大道足以优雅的容纳大量长周期定时器的数组,会浪费很多内存,尤其是处于性能和可扩展性考虑,每个 CPU 都需要这样的数组。
解决该冲突的一个常规办法是,以多级分层的方式提供多个数组。在最底层,每一个数组元素表示一个单位时间。在第二层,每个数组元素表示 N 个单位时间,这里的 N 是每个数组的元素个数。在第三层,每一个数组元素表示 N2 个单位时间,一次类推。这种方法允许不同的数组以不同的位进行索引,如图 15.9 所示,它表示一个不太实际的、小的 8 位时钟。在此图中,每一个数组有 16 个元素,因此时钟低 4 位(0xf)对低阶(最右边)数组进行索引,接下来 4 位(0x1)对上一级进行索引。这样,我们有两个数组,每一个数组有 16 个元素,共计 32 个元素。远小于单一数组所需要的 256 的元素。
这个方法对于基于流量的系统来说运行的非常好。每一个特定时间操作的时间复杂度是小于常数的 O(1),每个元素最多访问 m+1 次,其中 m 是层数。
不过,时钟轮对于实时系统来说并不好,有两个原因。第一个原因是:需要在定时器精度和定时器开销之间进行权衡:定时器处理仅仅每毫秒才发生一次,这在很多(但非全部)工作环境是可以接受的。但是这也意味着不能保证定时器低于 1ms 的精度;从另一个角度来说,每 10us 进行一次定时器处理,对于绝大部分(但非全部)环境来说,这提供了可以接受的定时精度,但是这种情况下,处理定时器是如此频繁,以至于不能有时间去做任何其他事情。
第二个原因是需要将定时器从上级级联移动到下级。再次参照 15.9,我们将看到,在上层数组(最左边)的元素 1x 必须向下移动到更低(最右边)数组中,这样才能在它们到期后被调用。不幸的是,可能有大量的超时定时器等待移动,尤其是有较多层数时。这种移动操作的效率,对于面向吞吐量的系统来说是没有问题的,但是在实时系统中,可能导致有问题的延迟。
当然,实时系统可以简单宣策一个不同的数据结构,例如某种形式的堆或者树,对于插入或删除这样的数据维护操作来说,这样会失去 O(1) 时间复杂度,而变成 O(log n)。对于特定目的的 RTOS 来说,这可能是一个不错的选择。对于像 Linux 这样的通用操作系统来说,其效率不高。Linux 这样的通用操作系统通常支持非常大量的定时器。
Linux 内核的 -rt 补丁所做的选择,是将两种定时器进行区分处理,一种是调度延后活动的定时器,一种是对类似于 TCP 报文丢失这样的低可能性粗无偶进行调度的定时器。其中一个关键点是:错误处理通常并不是对时间特别敏感,因此时钟轮的毫秒级精度就足够了。另一个关键点是:错误处理超定时器通常会在早期就被中止,这通常是发生在它们被级联移动之前。最后一点是:与执行事件的定时器相比,系统通常拥有更多的执行错误处理的超时定时器。对于定时事件来说,O(log n) 的数据结构能够提供可接受的性能。
简而言之,Linux 内核的 -rt 补丁将时钟轮用于超时错误处理,将树这样的数据结构用于定时器事件,为所需要的服务类型提供不同的定时器类型。
线程化中断用于处理那些显著降低实时延迟的事件源,即长时间运行的中断处理程序,如图 15.12 所示。这些延迟对那些在单次中断后,发送大量事件的设备来说尤其验证,这意味着中断处理程序将运行一个超长的时间周期以处理这些事情。更糟糕的是,在中断处理程序正在运行时,设备可能产生新的事件,这样的总段处理程序可能会无限期运行,因为无限期的降低实时响应。
处理这个问题的方法是,使用如图 15.13 所示的线程化中断。中断处理程序运行在可抢占 IRQ 线程上下文,它运行在可配置的优先级。设备中断处理程序仅仅运行一小段时间,其运行时间仅仅可以使 IRQ 线程知道新事件的产生。如图所示,线程化中断可以极大的提升实时延迟,部分原因是运行在 IRQ 线程上下文的中断处理程序可以被高优先级实时线程抢占。
但是天下没有免费的午餐,线程化中断有一些缺点。其中一个缺点是增加了中断延迟。中断处理程序并不会立即运行,其执行被延后到 IRQ 线程中。当然,除非设备在实时应用关键路径执行时产生中断,否则也不会存在问题。
另一个缺点是写得不好的高优先级实时代码可能会中断处理程序饿死,例如,会阻止网络代码运行,导致调试问题非常困难。因此,开发者在编写高优先级实时代码时必须非常小心。这被称为蜘蛛侠原则:能力越大、责任越大。
优先级继承用于处理优先级反转。优先级反转可能是这样产生的,在处理其他事情时,锁被可抢占中断处理程序获得。假定一个低优先级线程获得某个锁,但是他被一组中优先级线程所抢占,每个 CPU 都至少有一个这样的中优先级线程。如果一个中断产生,那么一个高优先级 IRQ 线程将抢占一个中优先级线程,但是直到它决定火红的被低优先级所获得的锁,才会发生优先级反转。不幸的是,低优先级线程直到它开始运行才能释放锁,而中优先级线程会阻止它这样做。这样一来,直到某个中优先级线程释放它的 CPU 之后,高优先级 IRQ 线程才能获得锁。简而言之,中优先级线程间接阻塞了高优先级 IRQ 线程,这是一种典型的优先级反转。
注意,这样的优先级饭庄在非线程化中断中不会发生,这是因为低优先级线程必须在持有锁的时候,禁止中断,这也阻止了中优先级线程抢占它。
在优先级继承方案中,视图获得锁的高优先级线程将其优先级传递给获得锁的低优先级线程,直到锁被释放。这组织了长时间的优先级反转。
当然,优先级继承有其限制。比如,如果能够设计你的应用,已完全避免优先级反转,将很有肯能获得稍微好一些的延迟。这不足为怪,因为优先级继承在最坏的情况下,增加了两次上下文切换。也就是说,优先级继承可以将无限期的延迟转换为有限增加的延迟,并且在许多应用中,优先级继承的软件工程优势可能超过其他延迟成本。
另一个限制是,它仅仅处理特定操作系统环境中,基于所的优先级反转。它所能处理的一种优先级反转情况是,一个高优先级线程等待网络 Socket 消息,而该消息被低优先级进程所写入,但是低优先级线程被一组绑定在 CPU 上的中优先级进程所抢占。
最后一个限制包含读写锁。假设我们有非常多的低优先级线程吗,也许有数千个,每个读线程持有一个特定的读写锁。如果所有这些线程都被中优先级线程抢占,而每 CPU 都有至少一个这样的中优先级线程。最终,假设一个高优先级线层被唤醒并试图获得相同读写锁的锁。我们要如何去大量提升持有这些读写锁的线程优先级,这本身更没有什么问题,但是在高优先级线程获得写锁之前,他可能要等待很长一段时间。
有不少针对这种读写锁优先级反转难题的解决方案。
- 在同一时刻,仅仅允许一个读写锁有一个读请求(这是被 Linux 内核的 -rt 补丁所采用的传统方法)。
- 在同一时刻,对于某个特定读写锁,仅仅允许 N 个读请求。其中 N 是 CPU 个数。
- 在同一时刻,对于某个特定读写锁,仅仅允许 N 个读请求。其中 N 是由开发者指定的某个数值。Linux 内核的 -rt 补丁将在某个事件采取这种方法的几率还是比较大的。
- 当读写锁被正在运行的低优先级线程获得读锁时,防止高优先级线层获得其写锁(这是优先级上限协议的一个变种)。
某些情况下,可以通过将读写锁转换为 RCU,来避免读写锁优先级反转。
有时,可抢占 RCU 可被用作读写锁的替代品。在它可以被使用的地方,它允许读者和写者并发运行,这防止了低优先级的读者对高优先级的写者加以任何类型的优先级反转。但是,要使其有用,能够抢占长时间允许的 RCU 读端临界区是有必要的。否则,长时间运行的 RCU 读端临界区将导致过长的实时延迟。
因此,可抢占 RCU 实现被添加到 Linux 内核中。通过在当前读端临界区中,跟踪所有可抢占任务的链表这种方式,该实现就不必分别跟踪每一个任务的状态。以下情况下,允许终止一个优雅周期:所有 CPU 都已经完成所有读端临界区,这些临界区在当前优雅周期之前已经有效;在这些已经存在的临界区运行期间,被抢占的所有任务都已经从链表移除。这种实现的简单版本如 15.15 所示。__rcu_read_lock
函数位于第 1~5 行,而 __rcu_read_unlock
函数位于第 7~22 行。
__rcu_read_lock
函数第 3 行递增一个每任务计数,该计数是嵌套调用 __rcu_read_lock
的计数,第 4 行放置编译器将 RCU 读端临界区后面的代码与 __rcu_read_lock
之前的代码之间进行乱序。
__rcu_read_unlock
函数第 11 行检查嵌套计数是否为 1,换句话说,检查当前是否为 __rcu_read_unlock
嵌套调用的最外一层。如果不是,第 12 行递减该计数,并将控制流程返回到调用者。否则,这是 __rcu_read_unlock
的最外层,这需要通过第 14~20 行对终止临界区进行处理。
第 14 行防止编译器将临界区中的代码与构成 rcu_read_unlock 函数的代码进行乱序。第 15 行设置嵌套计数为一个大的负数,以防止与包含在中断处理程序中的读端临界区产生破坏性竞争。第 16 行防止编译器将这一行的赋值与第 17 行对特殊处理的检查进行乱序。如果第 17 行确定需要进行特殊处理,就在第 18 行调用 rcu_read_unlock_special 进行特殊处理。
有几种情况需要进行特殊处理,但是我们将关注其中一种情况,即当 RCU 读端临界区被抢占时的处理。这种情况下,任务必须将自己从链表中移除,当它第一次在 RCU 读端临界区中被抢占时,它被添加到这个链表中。不过,请注意这些链表被锁保护很重要。这意味着 rcu_read_unlock 不再是有锁的。不过,最高优先级的线程不会被抢占,因此,对那些最高优先级线程来说,rcu_read_unlock 将不会视图去获取任何锁。另外,如果小心实现,锁可以被用来同步实时软件。
无论是否需要特殊处理,第 19 行防止编译器将第 17 行的检查与第 20 行进行乱序,第 20 行将嵌套计数置 0。
在大量读的数据结构中,对于大量读者的情况下,这个可抢占 RCU 实现能到达实时响应,而不会有优先级提升方法所固有的延迟。
由于在 Linux 内核中,持续周期长的基于自旋锁的临界区的原因,可抢占自旋锁是 -rt 补丁集的重要组成部分。这个功能仍然没有合入主线,虽然从概念上来说,用睡眠锁代替自旋锁是一个简单的方案,但是已经证实这是有争议的。不过,对于那些想要实现低于 10us 延迟的实时任务来说,它是非常有必要的。
当然了,有其他不少数量的 Linux 内核组件,他们对于实现显示世界的延迟非常重要,例如最近的最终期限调度策略。不过,本节中的列表,已经可以让你对 -rt 补丁集所增加的 Linux 内核功能,找到好的感觉。
轮询实时支持
乍看之下,使用轮询可能会避免所有可能的操作系统的干扰问题。毕竟,如果一个特定的 CPU 从不进入内核,内核就完全不在我们的视线之内。要将内核排除在外的传统方法,最简单的是不使用内核,许多实时应用确实是运行在裸机之上,特别是那些运行在 8 位微控制器上的应用程序。
人们可能希望,在现代操作系统上,简单通过在特定 CPU 上运行一个 CPU 绑定的用户态线程,避免所有干扰,以获得裸机应用的性能。虽然事实上更复杂一些,但是这已经能够实现了,这是通过 NO_HZ_FULL 实现的。该实现由 Frederic Weisbcher 引入,并已经被接收进 Linux 内核 3.10 版本。不过,需要小心对这种环境进行适当的设置,因为对一些 OS 抖动来源进行控制是必要的。随后的讨论包含对不同 OS 抖动源的控制,包括设备中断、内核线程和守护线程序、调度器实时限制、定时器、非实时设备驱动、内核中的全局同步、调度时钟中断、页面异常,最后,还包括非实时硬件及固件。
中断是大量 OS 抖动源中很突出的一种。不幸的是,大多数情况下,中断是绝对需要的,以实现系统与外部世界的通信。解决 OS 抖动与外部世界通信之间的冲突,其中一个方法是保留少量守护 CPU,并强制将所有中断移动到这些 CPU 中。Linux 源码树中的文件 Documentation/IRQ-affinity.txt 描述了如何将设备中断绑定到特定 CPU、直到 2015 年初,解决该问题的方法如下所示:
echo 0f > /proc/irq/44/smp_affinity
该命令将第 44 号中断限制到 CPU 0~3。请注意,需要对调度时钟中断进行特殊处理,这将在随后章节进行讨论。
第二个 OS 抖动源是来自于内核线程和守护线程。个别的内核线程,例如 RCU 优雅周期内核线程,可以通过使用 taskset 命令、sched_setaffinity 系统调用或者 cgroups,来将其强制绑定到任意目标 CPU。
每 CPU 线程通常更具有挑战性,有时它限制了硬件配置及负载均衡布局。要防止来自于这些内核线程的 OS 干扰,要么不将特定类型的硬件应用到实时系统中,其所有中断和 IO 初始化均运行在守护 CPU 中,这种情况下,特定内核 Kconfig 或者启动参数被选择,从而将其事务从工作 CPU 中移除;要么工作 CPU 干脆不受内核管理。针对内核线程的建议可以在 Linux 内核源码 Documentation 目录 kernek-per-CPU-kthreads.txt 中找到。
在 Linux 内核中,运行在实时优先级的 CPU 绑定线程受到的第三个 OS 抖动是调度器本身。这是一个故意为之的调试空能,设计用于确保重要的非实时任务每秒至少分配到 30ms 的 CPU 时间,甚至是在你的实时应用存在死循环 BUG 时也是如此。不过,当你正在运行一个轮询实时应用时,需要禁止这个调度功能。可以用如下命令完成此项工作。
echo -1 > /proc/sys/kernel/sched_rt_runtime_us
当然,你必须以 Root 身份运行以执行以上命令,并且需要小心考虑蜘蛛侠原理。一种将风险最小化的方法,是将中断和内核线程、守护线程从所有运行 CPU 绑定线程的 CPU 中卸载,正如前面几段中所述那样。另外,应当认真阅读 Documentation/scheduler 目录中的材料。sched-rt-group.txt 中的材料尤为重要,当你正在使用 cgroups 实时功能时更是如此,这个功能通过 CONFIG_RT_GROUP_SCHED Kconfig 参数打开,这种情况下,你也应当阅读 Documentation/cgroups 目录下的材料。
第四个 OS 抖动来自于定时器。绝大多数情况下,将某个 CPU 配置于内核之外,将防止定时器被调度到该 CPU 上。一个重要的例外是再生定时器,即一个特定定时器处理函数触发同样的定时器在随后某个事件内再次发生。如果由于某种原因,这样的定时器在某个 CPU 上已经启动,该定时器被在该 CPU 上持续周期性运行,反复造成 OS 抖动。一个粗暴但是有效的移除再生定时器的方法,是使用 CPU 热插拔将所有运行 CPU 绑定实时应用线程的 CPU 卸载,并重新将这些 CPU 上线,然后启动你的实时应用。
第五个 OS 抖动来自于驱动设备,这些驱动不是用于实时用途。举一个老的典型例子,在 2005 年,VGA 驱动会在禁止中断的情况下,通过将帧缓冲置 0,以清除屏幕,这将导致数十毫秒的 OS 抖动。一种避免设备驱动引入 OS 抖动的方法,是小心选择那些已经在实时系统中大量使用的设备,由于已被大量使用,其实时故障已经被修复。另一个方法是将设备中断和使用该设备的代码限制到特定守护 CPU 中。第三个方法是测试设备支持实时负载的能力,并修复其实时 BUG。
第六个 OS 抖动源来自于一些内核全系统同步算法,也许最引人注目的是全局 TLB 刷新算法。这可以通过避免内存 unmap 操作来避免,特别是要避免在内核中的 unmap 操作。直到 2015 年年初,避免内核 unmap 操作的方法是避免卸载内核模块。
第七个 OS 抖动源来自于调度时钟中断及 RCU 回调。这些可以通过打开 NO_HZ_FULL Kconfig 参数来构建内核,然后 nohz_full= 参数启动内核来加以避免,该参数指定运行实时线程的工作 CPU 列表。例如,nohz_full=2-7 将保留 CPU 2~7 作为工作 CPU,余下 CPU 0~1 作为守护 CPU。只要在每一个工作 CPU 上,没有超过一个可运行任务,那么工作 CPU 将不会产生调度时钟中断。并且每个工作 CPU 的 RCU 回调将在守护 CPU 上被调用。由于其上仅仅只有一个可运行任务,因此那些抑制了调度时钟的 CPU 被称为处于自适应节拍模式。
作为 nohz_full= 启动参数的另一种可选方法,你可以用 NO_HZ_FULL_ALL 来构建内核,它将保留 CPU0 作为守护 CPU,其他所有 CPU 作为工作 CPU。无论哪种方式,重要的是确保保留足够多的守护 CPU,以处理其他所负担的系统其他部分的守护负载,这需要小心的进行评估和调整。
当然,天下没有免费的午餐,NO_HZ_FULL 也不例外。正如前面所提示的那样,NO_HZ_FULL 使得内核/用户之间的切换消耗更大,这是由于需要增加进程统计,也需要将切换事件通知给内核子系统(如 RCU)。开启 POSIX CPU 定时器的进程,其上的 CPU 也被阻止进入“自适应节拍模式”。额外的限制、权衡、配置建议可以在 Documentation/timers/NO_HZ.txt 中找到。
第八个 OS 抖动源是页面异常。由于绝大部分 Linux 实现使用 MMU 进行内存保护,运行在这些系统中的实时应用需要遵从页面异常的影响。使用 mlock 和 mlockall 系统调用来将应用页面锁进内存,以避免主要的页面异常。当然,蜘蛛侠原理仍然适用,因为锁住太多内存可能会阻止其他工作顺利完成。
很不幸,第九个 OS 抖动源是硬件和固件。因此使用那些涉及用于实时用途的系统是重要的。OSADL 运行产期的系统测试,参考其网站(http://osadl.org)定会有所收获。
1 cd /sys/kernel/debug/tracing
2 echo 1 > max_graph_depth
3 echo function_graph > current_tracer
4 # run workload
5 cat per_cpu/cpuN/trace
不幸的是,OS 抖动源列表觉不完整,因为它会随着每个新版本的内核而变化。这使得能够跟踪额外的 OS 抖动源是有必要的。加入 CPU N 运行一个 CPU 绑定的用户态线程。上面的命令片段将给出所有该 CPU 进入内核的时间列表。当然,第 5 行的 N 必须被替换为所要求的 CPU 的编号,第 2 行中 1 可以增加,以显式内核中函数调用的级别。跟踪结果有助于跟踪 OS 抖动源。
正如你所见到那样,在像 Linux 这样的通用 OS 上,运行 CPU 绑定实时线程来获得裸机性能,需要对细节进行耐心细致的关注。自动化将是有用的,某些自动化也得到了应用,但是鉴于其用户相对较少,预期其出现将相对缓慢。不过,在通用操作系统上获得几乎裸机系统的能力,将有望简化某些类型的实时系统建设。
实现并行实时应用
开发实时应用是一个宽泛的话题,本节仅仅涉及某些方面。
实时组件
在所有工程领域,健壮的组件集对于生产率和可靠性来说是比不可少的。本节不是完整的实时软件组件分类——这样的分类需要一整本书,这是一个可用组件类型的简要概述。
查看实时软件组件的一个很自然的地方,是实现无等待同步的算法,实际上,无锁算法对于实时计算也非常重要。不过,无等待同步仅仅保证在有限时间内推进处理过程,并且实时计算需要算法更严格的保证在有限时间内将处理过程向前推进。毕竟,一个世纪也是有限的时间,但是在你的最终期限是以毫秒计算时,它将无意义。
不过,有一些重要的无等待算法,以提供限期响应时间。包含原子测试和设置、原子交换、原子读加、基于唤醒数组的单生成者/单消费者 FIFO 队列,以及不少每线程分区算法。另外,最近的研究已经证实,在随机公平调度及不考虑错误终止故障的情况下,无锁算法确保提供相同的延迟。这意味着,无锁栈及队列将适用于实时用途。
在实践中,锁通常用于实时应用,尽管理论上讲并不完全如此。不过,在严格的约束中,基于所的算法也存在有限延迟。这些约束包括以下几点:
- 公平调度器。在固定优先级调度器的通常情况下,有限延迟通常提供给最高有限级的线程。
- 充足的带宽以支持负载。支持这个约束的一个实现原则也许是“正常运行,在所有 CPU 上至少存在 50% 的空闲时间”,或者更正式的说“提供的负载足够低,以允许工作负载在所有时刻都能够被调度”。
- 没有错误终止故障。
- 获得、切换、释放延迟均有限期的 FIFO 锁原语。同样,通常情况下的锁原语是带有优先级的 FIFO,有限延迟仅仅是提供给最高优先级的线程。
- 某些防止无限优先级反转的方法。本章前面部分提到的优先级上限及优先级继承就足够了。
- 有限的嵌套锁获取。我们可以有无限数量的锁,但是在同一个时刻,只要一个特定线程绝不获得超过一定数量的锁就行了。
- 有限数量的线程。与前面的约束相结合,这个约束意味着等待特定锁的线程数量是有限的。
- 消耗在任何特定临界区上的有限时间。对于有限的等待特定锁的线程数量,以及有限的临界区长度,其等待时间也是有限度的。
这个结果打开用于实时软件的算法及数据结构的宝藏,它也验证尝试的实时实践。
当然,仔细的、简单的应用设计也是十分重要的。世上最好的实时组件,也不能弥补那些缺乏深思熟虑的设计。对于并行实时应用来说,同步开销明显是设计的关键组件。
轮询应用
许多实时应用由绑定 CPU 的单个循环构成,该循环读取传感器数据,计算控制规则,并输出控制。如果提供传感器数据及控制输出的硬件寄存器被映射到应用地址空间,那么该循环就完全不可以使用系统调用。但是请当心蜘蛛侠原则,更多的权利伴随着更多的责任,在这种情况下,其责任是指避免通过对硬件寄存器的不恰当引用而破坏硬件。
这种方式通常运行在裸机上面,这没有操作系统带来的优势(或者说也没有其带来的干扰)。不过,需要增加硬件能力及增加自动化水平来提升软件功能,如用户界面、日志及报告,所有这些都可以受益于操作系统。
在裸机上运行,同时仍然想要获得通用擦做系统的所有特征和功能,其中一个方法是使用 Linux 内核的 NO_HZ_FULL 功能,该功能在 15.4.1.2 节中描述。该支持首先在 Linux 内核 3.10 版本中可用。
流应用程序
一种流行的大数据实时应用获得多种输入源的输入,内部处理它,并输出警告和摘要。这些流应用通常是高度并发的,并发的处理不同信息源。
实现流应用程序的一种方法是使用循环数组缓冲 FIFO,来联结不同的处理步骤。每一个这样的 FIFO,仅仅有一个线程向其放入数据,并有一个(大概是不同的线程)线程从其中取出数据。扇入扇出点使用线程而不是数据结构,因此,如果需要合并几个 FIFO 的输出,一个独立的线程将从一个 FIFO 中输入数据,并将它输出到另外一个 FIFO,该线程是唯一的处理者。类似的,如果一个特定 FIFO 的输出需要被分拆,一个单独的线程将从这个 FIFO 进行获取输入,并且将其输出到多个 FIFO 中。
该规则看起来严格,但是它允许在线程间的通信有最小的同步开销,当试图满足严格的延迟约束时,最下的同步开销是重要的。当每一步中的处理量小,因而同步开销与数据处理相比,同步负载所占比例更大时,这显得尤其重要。
不同的线程可能是 CPU 绑定的,这时,15.4.2.2 节中的建议是适用的。另一方面,如果不同线程阻塞等待其输入 FIFO 的数据,那么 15.4.2.4 节中的建议是适用的。
事件驱动应用
对于事件驱动应用,我们将展示一个奇特的例子,将燃料注入中型工业发动机。在正常的操作条件下,该发动机要求一个特定的时间点,以一度的间隔将燃料注入到顶端正中。我们假设 1500-RPM 的旋转速度,这样就是每秒 25 转,或者大约每秒 9000 个旋转刻度,转换为没刻度即为 111ms。因此,我们需要在大约 100ms 内调度燃料注入。
假设事件等待被用于初始化燃料注入,但是如果你正在构造一个发动机,我希望你提供一个旋转传感器。我们需要测试时间等待功能,可能使用图 15.17 所示的测试程序。不幸的是,如果运行这个程序,我们将遇到不可接受的时钟抖动,即使在 -rt 内核中也是如此。
一个问题是,POSIX CLOCK_REALTIME 并不是为了实时应用,很奇怪吧。相反的,它所表示的“实时”是与进程或者线程所消耗的 CPU 总时间相对。对于实时用途,应当使用 CLOCK_MONOTOMIC。但是,即使做了这样的改变,结果仍然是不可接受的。
另一个问题是,线程必须通过使用 sched_setscheduler 系统调用来提供实时优先级。但是即使有了这个改变还是不够的,因为我们仍然能遇到缺页异常。我们也必须使用 mlockall 系统调用来锁住应用的内存,以防止缺页异常。应用的所有这些改变,结果可能最终是可接受的。
在其他情况下,可能需要进一步调整。可能需要将时间关键的线程绑定到他们自己的 CPU 中,并且可能需要将中断从这些 CPU 中移除。也需要谨慎选择硬件和驱动,并且可能需要谨慎选择内核配置。
从这个例子可以看出,实时计算真不是省油的灯。
RCU 的角色
假设你正在编写一个并行实时应用,该应用需要访问可能会随着温度、湿度和气压的变化而变化的数据。对该应用的实时响应约束相当严格,因此不允许自旋或阻塞,这样就排除掉了锁;也不允许使用重试循环,这样一来排除掉了顺序锁和危险指针。幸运的是,温度和压力通常是受控制的,因此默认的编码数据集通常是足够的。
但是,温度、湿度和压力偶尔会偏离默认值较远,这时有必要提供替换默认值的数据。由于温度、湿度和压力是逐渐变化的,虽然需要在几分钟内完成,但提供更新的值并非紧急事项。该应用将使用一个名为 cur_cal 的全局指针,该指针通常引用 default_cal,这是一个静态分配并初始化的结构,并包含命名为 a/b/c 的默认校准值。否则,cur_dal 将指向提供当前校准值的动态分配结构。
上面的代码清单展示了如何使用 RCU 来解决这个问题。查找逻辑是确定的,如第 9~15 行中的 calc_control,并能符合实时要求。更新逻辑则比较复杂,如第 17~35 行的 update_cal 所示。
该示例展示了 RCU 如何为实时应用提供确定的读端数据结构访问。
实时 vs. 快速
在实时与快速计算之间进行选择可能是一件困难的事情。因为实时系统通常造成非实时系统的吞吐量损失,在不需要使用的时候,使用实时计算会带来问题。另一方面,在需要实时计算时,使用错误也会带来问题。
基于经验法则,使用以下 4 个问题来助你选择:
- 平均的长期吞吐量是唯一目标吗?
- 是否允许重负载降低响应时间?
- 是否有高内存压力,排除使用 mlockall 系统调用?
- 应用的基本工作项是否需要超过 100ms 才能完成?
如果每一个问题的答案都是“是”,则应当选择“快速”而不是“实时”,否则,则可以选择“实时”。请明智选择,并且如果你选择了“实时”,请确保硬件、固件、操作系统都恩能够胜任。
3.15 - CH15-高级同步-内存序
因果关系与顺序是非常直观的,黑客往往能比一般人更好的掌握这些概念。在编写、分析和调试使用标准互斥机制(尤其是锁)的顺序、并行代码时,这些直觉可以成为非常强大的工具。不幸的是,当面对那些并未使用标准机制的代码时这种直觉将彻底失败。一个重要的例子当然是一些代码使用了这些标准机制,但是另外一些使用了更弱的同步机制。事实上,一些人认为更弱也算是一个有点。本章将帮助你了解有效实现同步原语和性能敏感代码的内存排序。
15.1 排序:Why & How?
内存排序的一个动机可以在代码清单 15.1 中的石蕊测试中看到,咋一看似乎 exists 子句可能永远不会触发。毕竟,如果 exists 子句中的 0:r2=0
,我们可以期望“线程 P0 中由 x1 加载到 r2”发生在“线程 P1 对 x1 的保存”,进一步期望“P1 中由 x0 加载到 r2”必须发生在“P0 对 x0 的保存”,因此 1:r2=2
,从而不会触发 exists 子句。这个例子是对称的,因此类似的原因会让我们认为 1:r2=0
保证 0:r2=2
。不幸的是,内存屏障的缺失使得这些期望破灭。即使是在相对更强排序的系统(x86)上,CPU 也有权对 P0 和 P1 中的语句进行重排序。
QQ 15.1:编译器同样能够对代码清单 15.1 中 P0 和 P1 的内存访问进行重排序,对吗?
通常来说,编译器优化比 CPU 可以执行更加广泛和深刻的重排序。但是,在这种情况下,READ_ONCE 和 WRITE_ONCE 中的易失性访问会阻止编译器重排序。而且还要做许多其他事情,因此本节中的示例将大量使用 READ_ONCE 和 WRITE_ONCE。有关 READ_ONCE 和 WRITE_ONCE 的更多细节可以参考 15.3 节。
这种重排序趋向可以使用诸如 Litmus7 之类的工具进行验证,该工具发现在我的笔记本上进行的 1 亿次实验中有 314 次反直觉排序。奇怪的是,两个加载返回值为 2 的完全合法的结果发生的概率反而较低,只有 167 次。这里的教训很清楚:增加反干涉性并不一定意味着能够降低概率!
15.1.1 为什么硬件乱序?
但是为什么第一处会发生内存乱序呢?是 CPU 自身不能追踪顺序吗?
人们期望计算机能够对事物保持追踪,而且很多人还坚持要求它们能够快速的追踪事物。然而如第 3 章中看到的那样,主存没有办法跟上 CPU 的速度,因为在从内存中读取单个变量的时间内,CPU 可以执行数百个指令。因此 CPU 引入了越来越大的缓存,如图 3.9 所示,这意味着虽然一个 CPU 对指定变量的第一次加载会导致昂贵的缓存未命中,如第 3.1.5 一节所述,随后的重复加载会因为初始化未命中会将该变量记载到该 CPU 的缓存中,因此 CPU 后续会非常快速的执行与该变量相关的操作。
然而,还需要适应从多个 CPU 到一组共享变量的频繁并发存储。在缓存一致性系统中,如果缓存包含指定变量的多个副本,则该变量的所有副本必须拥有相同的值。这对于并发加载非常有效,但对于并发存储不太友好:每个存储必须对旧值的所有副本(这是另一个缓存未命中)执行一些操作,考虑到光的有限速度和物质的原子性质,这要比性急的软件黑客预期的速度慢。
因此,CPU 配备了存储缓冲区,如图 15.1 所示。当一个 CPU 需要保存的变量 不在缓存时,新值将会被放置到 CPU 的存储缓冲区。然后 CPU 可以立即继续后续的执行,而不必等待存储对滞留在其他 CPU 缓存中的所有旧值执行操作。
尽管存储缓冲区能够大大提升性能,但可能导致指令和内存引用无序执行,这反过来会导致严重的混淆。需要特别之处的是,这些存储缓冲区会导致内存错误排序,如清单 15.1 中 store-buffering 石蕊测试所示。
表 15.1 展示了内存乱序是如何发生的。第一行是初始状态,这时 CPU0 的缓存中持有 x1、CPU1 的缓存中持有 x0,两个变量的值均为 0。第二行展示了基于 CPU 存储的状态变化(即代码清单 15.1 中的第 9~18 行)。因为两个 CPU 在缓存中都没有要保存的变量,因此两个 CPU 都将他们的存储记录在自己的存储缓冲区中。
第 3 行展示了两个加载(即代码清单 15.1 中的第 1~19 行)。因为变量已被 CPU 的各自缓存加载,因此加载操作会立即返回已缓存的值,这里都是 0。
但是 CPU 还没有完成:稍后会清空自己的存储缓冲区。因为缓存在名为缓存行的较大的块中移动数据,并且因为每个缓存行可以保存多个变量,因此每个 CPU 必须将缓存行放入自己的缓存中,以便它可以更新与其存储器中的变量对应的缓存行部分,但不会影响缓存行的任何其他部分。每个 CPU 还必须确保缓存行不存在于任何其他 CPU 的缓存中,因此使用了“读无效”操作。如第 4 行所示,在两个“读无效”操作完成之后,两个 CPU 都交换了缓存行,因此 CPU0 的缓存现在包含 x0,而 CPU1 的缓存现在包含 x1。一旦两个变量出现在他们的新家中,每个 CPU 都可以将其存储缓冲区放入对应的缓存行中,使每个变量的最终值如第 5 行所示。
QQ 15.3: 但是这些值不需要由缓存被刷新到主存吗?
令人惊讶的是并不必要!在某些系统上,如果两个变量被大量使用,它们可能会在 CPU 之间来回反弹,而不会落到主存中。
总之,需要存储缓冲区来支持 CPU 高效的处理存储指令,但是可能会导致反直觉的存储器乱序。
但是如果你的算法真的需要有序的内存引用,又该怎么办呢?比如,你正在使用一对标志位来与驱动程序通信,其中一个标志表示驱动程序是否正在运行,另一个标志表示是否存在针对该驱动程序的待处理请求。请求者需要设置请求挂起的标志,然后检查驱动程序运行状态的标志,如果为 false 则唤醒驱动程序。一旦驱动程序开始为所有待处理的请求提供服务,就需要清除待处理请求的标志,然后检查请求挂起标志以查看是否需要重新启动。除非有某种办法可以确保硬件按顺序处理存储和加载,否则这种看起来非常合理的方式将无法工作。这也就是下一节的主题。
15.1.2 如何强制排序
事实上,存在编译器指令和标准同步原语(比如锁和 RCU),他们负责通过使用内存屏障(比如 Linux 内核中的 smp_mb)来维护排序错觉。这些内存屏障可以是显式指令,比如在 ARM、POWER、Itanium、Alpha 系统上;或者由其他指令暗含,通常在 X86 系统中既是如此。因为这些标准同步原语维护了排序错觉,从而让你能够以最小的阻力使用这些原语,这样你就可以不用阅读本章的内容了。
然而,如果你需要实现自己的同步原语,或者仅仅是想了解内存排序是如何工作的,那么请继续阅读!第一站是代码清单 15.2,这里将 Linux 内核的完整内存屏障 smp_mb 分别放置到 P0 和 P1 的存储和加载操作之间,其他部分则与代码清单 15.1 保持相同。这些屏障阻止了我的 X86 电脑上 1 亿次实现的反直觉结果的出现。有趣的是,由于这些屏障增加的开销导致结果合法,其中两个负载的返回值都超过 80 万次,而代码清单 15.1 中的无屏障代码仅有 167 次。
这些屏障对排序产生了深远的影响,如表 15.2 所示。虽然前两行与表 15.1 相同,同时第 3 行的 smp_mb 也不会改变状态,但是它们能够确保存储在加载之前完成,这会避免代码清单 15.1 中出现的反直觉结果。请注意,变量 x0 和 x1 在第二行仍然具有多个值,但是正如前面承诺的那样,smp_mb 最终会将一切理顺。
虽然像 smp_mb 这样的完整内存屏障拥有非常强的排序保证,其强度也伴随着极高的代价。很多情况下都可以使用更弱的排序保证来处理,这些保证使用更廉价的内存排序指令,或者在某些情况下根本不适用内存排序指令。表 15.3 提供了 Linux 内核的排序原语及对应保证的清单。每行对应一个原语或原语的类别,以及是否能够提供排序保证,同时又分为 “Prior Ordered Operation” 与 “Subsequent Ordered Operation” 两列。“Y” 表示无条件提供排序保证,否则表示排序保证是部分或有条件提供。“空”则表示不提供任何排序。
需要注意的是这只是一个速查表,因此并不能替代对内存排序的良好理解。
15.1.3 经验法则
QQ:但是我怎么知道一个给定的项目可以在这些经验法则的范围内进行设计和编码呢?
本节的内容将回答该问题。
- 一个给定线程将按顺序看到自身的访问。
- 排序具有条件性的 if-then 语义。
- 排序操作必须是成对的。
- 排序操作几乎不会提供加速效果。
- 排序操作并非魔法。
15.2 技巧与陷阱
现在你只知道硬件可以对内存访问进行重排序,而你也可以阻止这一切的发生,下一步则是让你承认自己的自觉有问题。
首先,让我们快速了解一个变量在一个时间点可能有多少值。
15.2.1 变量带有多个值
将一个变量视为在一个定义良好的全局顺序中采用一个定义良好的值序列是很自然的。不幸的是,接下来将对这个令人欣慰的事情说再见。
考虑代码清单 15.3 中的片段。该代码又多个 CPU 并行执行。第 1 行将共享变量设置为当前 CPU 的 ID,第 2 行从 gettb 函数初始化多个变量,该函数提供在所有 CPU 之间同步的细粒度硬件“基于时间的”计数器的值。第 3~8 行的循环记录了变量保留该 CPU 分配给他的值的时间长度。当然,其中一个 CPU 将赢,因此如果不是第 6~7 行的检查,将永远不会退出循环。
退出循环后,firsttb 将保留分配后不久的时间戳,lasttb 将保持在指定值的共享变量的最后一次采样之前所采用的时间戳,或者如果共享变量已更改则保持等于在进入循环之前的 firsttb 的值。这样我们可以子啊 532ns 的时间段内绘制每个 CPU 对 state.variable 值的视图,如图 15.4 所示。
每个横轴表示指定 CPU 随时间的观察结果,左侧的灰色区域表示对应 CPU 的第一次测量之前的时间。在前 5ns 期间,只有 CPU3 对变量的值有判断。在接下来的 10ns 期间,CPU2 和 CPU3 对变量的值不一致,但此后一致认为该值为 2,这实际是最终商定的值。但是 CPU1 认为该值为 1 的持续时间长达 300ns,而 CPU4 认为该值为 4 的持续时间长达 500ns。
15.2.2 内存引用重排序
上一节表明,即使是相对强有序的系统(x86)也可能在后续的加载中重排序之前的存储,至少在存储和加载不同的变量是会发生这样的状况。本节以此结果为基础,查看加载和存储的其他组合。
15.2.2.1 加载后跟加载
代码清单 15.4 展示了经典的消息传递石蕊测试,x0 表示消息,x1 作为一个标志来表示消息是否可用。在测试中,smp_mb 强制 P0 的存储排序,但是未对加载指定排序。相对强有序的体系结构(如 x86)会强制执行排序。但是弱有序的体系结构通常不会(如 AMP+11)。因此。列表第 25 行的 exists 子句可以被触发。
从不同位置排序加载的一个基本原理是,这样做允许在较早的加载错过缓存时继续执行,但是后续加载的值已经存在。
因此,依赖于有序加载的可移植代码必须添加显式排序,比如在清单 15.5 的第 20 行添加的 smp_mb,这会组织触发 exists 子句。
15.2.2.2 加载后跟存储
代码清单 15.6 展示了经典的“加载缓冲(load-buffering)”石蕊测试。尽管相对强排序的系统(如 x86 或 IBM Mainframe)不会使用后续的存储来重排序先前的加载,但是较弱排序的系统则允许这种排序(如 AMP+11)。因此第 22 行的 exist 子句确实可能被触发。
虽然实际硬件很少展示这种重排序,但可能需要这么做的一种原因是当加载错误缓存时,存储缓冲区几乎已满,且后续存储的缓存行已准备就绪。因此,可移植代码必须强制执行任何必要的排序。比如清单 15.7 所示。smp_store_release 和 smp_load_acquire 保证第 22 行的子句永远不会触发。
15.2.2.3 存储后跟存储
代码清单 15.8 再次展示了经典的消息传递石蕊测试,其中使用 smp_mb 为 P1 的加载提供排序,但是并未给 P0 的存储提供任何排序。再次,相对强的系统架构会执行强制排序,而相对弱的系统架构则并不一定会这么做,这意味着 exists 子句可能会触发。这种重排序可能有益的一种场景是:当存储缓冲区已满,另一个存储区已准备好执行,但最旧的缓存行尚不可用。在这种情况下,允许存储无需完成将允许继续执行。因此,可移植代码必须显式的对存储进行排序,如代码清单 15.5 所示,从而防止 exists 子句的触发。
15.2.3 地址依赖
当加载指令的返回值被用于计算稍后的内存引用所要使用的地址时,产生地址依赖。
代码清单 15.9 展示了消息传递模式的一种链式变型。头指针是 x1,它最初引用 int 变量 y(第 5 行),后者又被初始化为值 1(第 4 行)。P0 将头指针 x1 更新为引用 x0(第 12 行),但仅将其初始化为 2(第 10 行)并强制排序(第 11 行)。P1 获取头指针 x1(第 21 行),然后加载引用的值(第 22 行)。因此,从 21 行的加载到 22 行上的加载存在地址依赖性。在这种情况下,第 21 行返回的值恰好是第 22 行使用的地址,但是可能存在很多变化。
人们可能会期望在第 22 行的取消引用之前排序来自头指针的第 21 行的加载。但是,DEC Alpha 并不是这种做法,它可以使用相关加载的推测值,如第 15.4.1 节中更详细的描述。因此,代码清单 15.9 中的 exists 子句可以被触发。
代码清单 15.10 展示了如何使其正确的工作,即便是在 DEC Alpha 上,将第 21 行的 READ_ONCE 替换为 lockless_dereference,其作用类似于 DEC Alpha 意外所有平台上的 READ_ONCE 后跟 smp_mb,从而在所有平台上执行所需的排序,进而防止 exists 子句触发。
但是,如果依赖操作是存储而非加载又会发生什么呢,比如,在代码清单 15.11 中显示的石蕊测试。因为没有产生质量的平台推测存储,第 10 行的 WRITE_ONCE 不可能覆盖第 21 行的 WEITE_ONCE,这意味着第 25 行的 exists 子句及时在 DEC Alpha 上也无法触发,即使没有在依赖负载的情况下需要的 lockless_dereference。
然而,特别需要注意的是,地址依赖很脆弱,很容易就能被编译器优化打破。
15.2.4 数据依赖
当加载指令的返回值被用于计算稍后的存储指令要存储的数据时,则会发生数据依赖。请注意上面的“数据”:如果加载返回的值用于计算稍后的存储指令要使用的地址时,那么将是地址依赖性。
代码清单 15.12 与清单 15.7 类似,不同之处在于第 18~19 行之间的 P1 排序不是由获取加载强制执行,而是由数据依赖性完成:第 18 行加载的值是第 19 行要存储的值。这种数据依赖性提供的排序足以放置 exists 子句的触发。
和地址依赖性一样,数据依赖性也很脆弱,可以被编译器优化轻松破解。如第 15.3.2 节所述。实际上,数据依赖性可能比地址依赖性更加脆弱。原因是地址依赖性通常涉及指针值。相反,如清单 15.12 所示,通过整数值来传递数据依赖性是很诱人的,编译器可以更自由的优化不存在性。仅举一个例子,如果加载的整数乘以常数 0,编译器将知道结果为 0,因此可以用常数 0 替换加载的值,从而打破依赖性。
简而言之,你可以依赖于数据依赖性,但前提是你要注意防止编译器对他们的破坏。
15.2.5 控制依赖
当需要测试加载指令的返回值以确定是否执行稍后的存储指令时,发生控制依赖性,请注意“稍后的存储指令”:很多平台并不遵循加载到加载控制的依赖关系。
代码清单 15.13 展示了另一个加载——启动示例,这里使用的是控制依赖关系(第 19 行)来命令第 18 行的加载和第 20 行的存储。这种排序足以避免 exists 子句的触发。
但是,控制依赖性甚至比数据依赖性更易被优化掉。第 15.3.3 节描述了为了防止编译器破坏控制依赖性必须要遵循的一些规则。
值得重申的是,控制依赖性仅提供从加载到存储的排序。因此,代码清单 15.14 的第 17~19 行展示的加载到加载过程并不提供排序,因此不会阻止 exists 子句的触发。
总之,控制依赖关系可能很有用,但它们是高维护项。因此,只有在性能考虑不允许其他解决方案时才应使用。
15.2.6 缓存连贯性
在缓存一致性平台上,所有 CPU 都遵循加载到存储指定变量的顺序。幸运的是,当使用 READ_ONCE 和 WRITE_ONCE 时,几乎所有平台都是缓存一致的,如表 15.3 中所示的 SV 一列。
代码清单 15.15 展示了测试缓存连贯性的石蕊测试,其中 IRIW 代表独立写入的独立读取。因为该石蕊测试仅使用一个变量,所以 P2 和 P3 必须在 P0 和 P1 的存储顺序上达成一致。换句话说,如果 P2 认为 P0 的存储首先进行,则 P3 最好不要相信 P1 的存储首先出现。事实上,如果出现这样的情况,第 35 行的 exists 语句将被触发。
很容易推测,对于单个内存区域(比如使用 C 语言的 union 关键字设置),不同大小的重叠加载和存储将提供类似的排序保证。然而 Flur 等人发现了一些令人惊讶的简单石蕊测试,这名这些保证可以在真实硬件上被打破。因此,至少在考虑可移植性的情况下,有必要将代码限制为对指定变量的非重叠相同大小的对齐访问。
添加更多的变量和线程会增加重排序和违反其他反直觉行文的范围,如下一节所述。
15.2.7 多重原子性
Threads running on a multicopy atomic (SF95) platform are guaranteed to agree on the order of stores, even to different variables. A useful mental model of such a system is the single-bus architecture shown in Figure 15.7. If each store resulted in a message on the bus, and if the bus could accommodate only one store at a time, then any pair of CPUs would agree on the order of all stores that they observed. Unfortunately, building a computer system as shown in the figure, without store buffers or even caches, would result in glacial computation. CPU vendors interested in providing multicopy atomicity have therefore instead provided the slightly weaker other-multicopy atomicity (ARM17, Section B2.3), which excludes the CPU doing a given store from the requirement that all CPUs agree on the order of all stores. This means that if only a subset of CPUs are doing stores, the other CPUs will agree on the order of stores, hence the “other” in “other-multicopy atomicity”. Unlike multicopy-atomic platforms, within other-multicopy-atomic platforms, the CPU doing the store is permitted to observe its store early, which allows its later loads to obtain the newly stored value directly from the store buffer. This in turn avoids abysmal performance.
Perhaps there will come a day when all platforms provide some flavor of multicopy atomicity, but in the meantime, non-multicopy-atomic platforms do exist, and so software must deal with them.
Listing 15.16 (C-WRC+o+o-data-o+o-rmb-o.litmus) demonstrates multicopy atomicity, that is, on a multicopy-atomic platform, the exists clause on line 29 cannot trigger. In contrast, on a non-multicopy-atomic platform this exists clause can trigger, despite P1()’s accesses being ordered by a data dependency and P2()’s accesses being ordered by an smp_rmb(). Recall that the definition of multicopy atomicity requires that all threads agree on the order of stores, which can be thought of as all stores reaching all threads at the same time. Therefore, a non-multicopy-atomic platform can have a store reach different threads at different times. In particular, P0()’s store might reach P1() long before it reaches P2(), which raises the possibility that P1()’s store might reach P2() before P0()’s store does.
This leads to the question of why a real system constrained by the usual laws of physics would ever trigger the exists clause of Listing 15.16. The cartoonish diagram of a such a real system is shown in Figure 15.8. CPU 0 and CPU 1 share a store buffer, as do CPUs 2 and 3. This means that CPU 1 can load a value out of the store buffer, thus potentially immediately seeing a value stored by CPU 0. In contrast, CPUs 2 and 3 will have to wait for the corresponding cache line to carry this new value to them.
Table 15.4 shows one sequence of events that can result in the exists clause in Listing 15.16 triggering. This sequence of events will depend critically on P0() and P1() sharing both cache and a store buffer in the manner shown in Figure 15.8.
Row 1 shows the initial state, with the initial value of y in P0()’s and P1()’s shared cache, and the initial value of x in P2()’s cache.
Row 2 shows the immediate effect of P0() executing its store on line 8. Because the cacheline containing x is not in P0()’s and P1()’s shared cache, the new value (1) is stored in the shared store buffer.
Row 3 shows two transitions. First, P0() issues a read-invalidate operation to fetch the cacheline containing x so that it can flush the new value for x out of the shared store buffer. Second, P1() loads from x (line 15), an operation that completes immediately because the new value of x is immediately available from the shared store buffer.
Row 4 also shows two transitions. First, it shows the immediate effect of P1() executing its store to y (line 16), placing the new value into the shared store buffer. Second, it shows the start of P2()’s load from y (line 24).
Row 5 continues the tradition of showing two transitions. First, it shows P1() complete its store to y, flushing from the shared store buffer to the cache. Second, it shows P2() request the cacheline containing y.
Row 6 shows P2() receive the cacheline containing y, allowing it to finish its load into r2, which takes on the value 1.
Row 7 shows P2() execute its smp_rmb() (line 25), thus keeping its two loads ordered.
Row 8 shows P2() execute its load from x, which immediately returns with the value zero from P2()’s cache.
Row 9 shows P2() finally responding to P0()’s request for the cacheline containing x, which was made way back up on row 3.
Finally, row 10 shows P0() finish its store, flushing its value of x from the shared store buffer to the shared cache.
Note well that the exists clause on line 29 has triggered. The values of r1 and r2 are both the value one, and the final value of r3 the value zero. This strange result occurred because P0()’s new value of x was communicated to P1() long before it was communicated to P2().
This counter-intuitive result happens because although dependencies do provide ordering, they provide it only within the confines of their own thread. This threethread example requires stronger ordering, which is the subject of Sections 15.2.7.1 through 15.2.7.4.
15.2.7.1 Cumulativity
The three-thread example shown in Listing 15.16 requires cumulative ordering, or cumulativity. A cumulative memory-ordering operation orders not just any given access preceding it, but also earlier accesses by any thread to that same variable.
Dependencies do not provide cumulativity, which is why the “C” column is blank for both the READ_ONCE() and the smp_read_barrier_depends() rows of Table 15.3. However, as indicated by the “C” in their “C” column, release operations do provide cumulativity. Therefore, Listing 15.17 (C-WRC+o+o-r+a-o.litmus) substitutes a release operation for Listing 15.16’s data dependency. Because the release operation is cumulative, its ordering applies not only to Listing 15.17’s load from x by P1() on line 15, but also to the store to x by P0() on line 8—but only if that load returns the value stored, which matches the 1:r1=1 in the exists clause on line 28. This means that P2()’s load-acquire suffices to force the load from x on line 25 to happen after the store on line 8, so the value returned is one, which does not match 2:r3=0, which in turn prevents the exists clause from triggering.
These ordering constraints are depicted graphically in Figure 15.9. Note also that cumulativity is not limited to a single step back in time. If there was another load from x or store to x from any thread that came before the store on line 13, that prior load or store would also be ordered before the store on line 32, though only if both r1 and r2 both end up containing the address of x.
In short, use of cumulative ordering operations can suppress non-multicopy-atomic behaviors in some situations. Cumulativity nevertheless has limits, which are examined in the next section.
15.2.7.2 Propagation
Listing 15.18 (C-W+RWC+o-r+a-o+o-mb-o.litmus) shows the limitations of cumulativity and store-release, even with a full memory barrier. The problem is that although the smp_store_release() on line 12 has cumulativity, and although that cumulativity does order P2()’s load on line 30, the smp_store_release()’s ordering cannot propagate through the combination of P1()’s load (line 21) and P2()’s store (line 28). This means that the exists clause on line 33 really can trigger.
This situation might seem completely counter-intuitive, but keep in mind that the speed of light is finite and computers are of non-zero size. It therefore takes time for the effect of the P2()’s store to z to propagate to P1(), which in turn means that it is possible that P1()’s read from z happens much later in time, but nevertheless still sees the old value of zero. This situation is depicted in Figure 15.10: Just because a load sees the old value does not mean that this load executed at an earlier time than did the store of the new value.
Note that Listing 15.18 also shows the limitations of memory-barrier pairing, given that there are not two but three processes. These more complex litmus tests can instead be said to have cycles, where memory-barrier pairing is the special case of a twothread cycle. The cycle in Listing 15.18 goes through P0() (lines 11 and 12), P1() (lines 20 and 21), P2() (lines 28, 29, and 30), and back to P0() (line 11). The exists clause delineates this cycle: the 1:r1=1 indicates that the smp_load_acquire() on line 20 returned the value stored by the smp_store_release() on line 12, the 1:r2=0 indicates that the WRITE_ONCE() on line 28 came too late to affect the value returned by the READ_ONCE() on line 21, and finally the 2:r3=0 indicates that the WRITE_ONCE() on line 11 came too late to affect the value returned by the READ_ONCE() on line 30. In this case, the fact that the exists clause can trigger means that the cycle is said to be allowed. In contrast, in cases where the exists clause cannot trigger, the cycle is said to be prohibited.
But what if we need to keep the exists clause on line 33 of Listing 15.18? One solution is to replace P0()’s smp_store_release() with an smp_mb(), which Table 15.3 shows to have not only cumulativity, but also propagation. The result is shown in Listing 15.19 (C-W+RWC+o-mb-o+a-o+o-mb-o.litmus).
For completeness, Figure 15.11 shows that the “winning” store among a group of stores to the same variable is not necessarily the store that started last. This should not come as a surprise to anyone who carefully examined Figure 15.5.
But sometimes time really is on our side. Read on!
Happens-Before
As shown in Figure 15.12, on platforms without user-visible speculation, if a load returns the value from a particular store, then, courtesy of the finite speed of light and the non-zero size of modern computing systems, the store absolutely has to have executed at an earlier time than did the load. This means that carefully constructed programs can rely on the passage of time itself as an memory-ordering operation.
Of course, just the passage of time by itself is not enough, as was seen in Listing 15.6, which has nothing but store-to-load links and, because it provides absolutely no ordering, still can trigger its exists clause. However, as long as each thread provides even the weakest possible ordering, exists clause would not be able to trigger. For example, Listing 15.21 (C-LB+a-o+o-data-o+o-data-o.litmus) shows P0() ordered with an smp_load_acquire() and both P1() and P2() ordered with data dependencies. These orderings, which are close to the top of Table 15.3, suffice to prevent the exists clause from triggering.
An important, to say nothing of more useful, use of time for ordering memory accesses is covered in the next section.
Release-Acquire Chains
A minimal release-acquire chain was shown in Listing 15.7 (C-LB+a-r+a-r+a-r+ar.litmus), but these chains can be much longer, as shown in Listing 15.22. The longer the release-acquire chain, the more ordering is gained from the passage of time, so that no matter how many threads are involved, the corresponding exists clause cannot trigger.
Although release-acquire chains are inherently store-to-load creatures, it turns out that they can tolerate one load-to-store step, despite such steps being counter-temporal, as shown in Figure 15.10. For example, Listing 15.23 (C-ISA2+o-r+a-r+a-r+ao.litmus) shows a three-step release-acquire chain, but where P3()’s final access is a READ_ONCE() from x0, which is accessed via WRITE_ONCE() by P0(), forming a non-temporal load-to-store link between these two processes. However, because P0()’s smp_store_release() (line 12) is cumulative, if P3()’s READ_ONCE() returns zero, this cumulativity will force the READ_ONCE() to be ordered before P0()’s smp_store_release(). In addition, the release-acquire chain (lines 12, 20, 21, 28, 29, and 37) forces P3()’s READ_ONCE() to be ordered after P0()’s smp_store_ release(). Because P3()’s READ_ONCE() cannot be both before and after P0()’s smp_store_release(), either or both of two things must be true:
P3()’s READ_ONCE() came after P0()’s WRITE_ONCE(), so that the READ_ ONCE() returned the value two, so that the exists clause’s 3:r2=0 is false.
The release-acquire chain did not form, that is, one or more of the exists clause’s 1:r2=2, 2:r2=2, or 3:r1=2 is false.
Either way, the exists clause cannot trigger, despite this litmus test containing a notorious load-to-store link between P3() and P0(). But never forget that releaseacquire chains can tolerate only one load-to-store link, as was seen in Listing 15.18.
Release-acquire chains can also tolerate a single store-to-store step, as shown in Listing 15.24 (C-Z6.2+o-r+a-r+a-r+a-o.litmus). As with the previous example, smp_store_release()’s cumulativity combined with the temporal nature of the release-acquire chain prevents the exists clause on line 36 from triggering. But beware: Adding a second store-to-store step would allow the correspondingly updated exists clause to trigger.
In short, properly constructed release-acquire chains form a peaceful island of intuitive bliss surrounded by a strongly counter-intuitive sea of more complex memoryordering constraints.
15.3 Compile-Time Consternation
Most languages, including C, were developed on uniprocessor systems by people with little or no parallel-programming experience. As a results, unless explicitly told otherwise, these languages assume that the current CPU is the only thing that is reading or writing memory. This in turn means that these languages’ compilers’ optimizers are ready, willing, and oh so able to make dramatic changes to the order, number, and sizes of memory references that your program executes. In fact, the reordering carried out by hardware can seem quite tame by comparison.
15.3.1 Memory-Reference Restrictions
Again, unless told otherwise, compilers assume that nothing else is affecting the variables being accessed by the generated code. This assumption is not simply some design error, but is instead enshrined in various standards. 9 This assumption means that compilers are within their rights (as defined by the standards) to optimize the following code so as to hoist the load from a out of the loop, at least in cases where the compiler can prove that do_something() does not modify a:
1 while (a)
2 do_something();
The optimized code might look something like this, essentially fusing an arbitrarily large number of intended loads into a single actual load:
1 if (a)
2 for (;;)
3 do_something();
This optimization might come as a fatal surprise to code elsewhere that expected to terminate this loop by storing a zero to a. Fortunately, there are several ways of avoiding this sort of problem:
- Volatile accesses.
- Atomic variables.
- Prohibitions against introducing data races.
The volatile restrictions are necessary to write reliable device drivers, and the atomic variables and prohibitions against introducing data races are necessary to write reliable concurrent code.
Starting with volatile accesses, the following code relies on the volatile casts in READ_ONCE() to prevent the unfortunate infinite-loop optimization:
1 while (READ_ONCE(a))
2 do_something();
READ_ONCE() marks the load with a volatile cast. Now volatile was originally designed for accessing memory-mapped I/O (MMIO) registers, which are accessed using the same load and store instructions that are used when accessing normal memory. However, MMIO registers need not act at all like normal memory. Storing a value to an MMIO register does not necessarily mean that a subsequent load from that register will return the value stored. Loading from an MMIO register might well have side effects, for example, changing the device state or affecting the response to subsequent loads and stores involving other MMIO registers. Loads and stores of different sizes to the same MMIO address might well have different effects.
This means that, even on a uniprocessor system, changing the order, number, or size of MMIO accesses is strictly forbidden. And this is exactly the purpose of the C-language volatile keyword, to constrain the compiler so as to allow implementation of reliable device drivers.
This is why READ_ONCE() prevents the destructive hoisting of the load from a out of the loop: Doing so changes the number of volatile loads from a, so this optimization is disallowed. However, note well that volatile does absolutely nothing to constrain the hardware. Therefore, if the code following the loop needs to see the result of any memory references preceding the store of zero that terminated the loop, you will instead need to use something like smp_store_release() to store the zero and smp_load_ acquire() in the loop condition. But if all you need is to reliably control the loop without any other ordering, READ_ONCE() can do the job.
Compilers can also replicate loads. For example, consider this all-too-real code fragment:
1 tmp = p;
2 if (tmp != NULL && tmp <= q)
3 do_something(tmp);
Here the intent is that the do_something() function is never passed a NULL pointer or a pointer that is greater than q. However, the compiler is within its rights to transform this into the following:
1 if (p != NULL && p <= q)
2 do_something(p);
In this transformed code, the value of p is loaded three separate times. This transformation might seem silly at first glance, but it is quite useful when the surrounding code has consumed all of the machine registers. It is possible that the current value of p passes the test on line 1, but that some other thread stores NULL to p before line 2 executes, and the resulting NULL pointer could be a fatal surprise to do_something(). 10 To prevent the compiler from replicating the load, use READ_ONCE(), for example as follows:
1 tmp = READ_ONCE(p);
2 if (tmp != NULL && tmp <= q)
3 do_something(tmp);
Alternatively, the variable p could be declared volatile.
Compilers can also fuse stores. The most infamous example is probably the progressbar example shown below:
1 while (!am_done()) {
2 do_something(p);
3 progress++;
4 }
If the compiler used a feedback-driven optimizer, it might well notice that the store to the shared variable progress was quite expensive, resulting in the following well-intentioned optimization:
1 while (!am_done()) {
2 do_something(p);
3 tmp++;
4 }
5 progress = tmp;
This might well slightly increase performance, but the poor user watching the progress bar might be forgiven for harboring significant ill will towards this particular optimization. The progress bar will after all be stuck at zero for a long time, then jump at the very end. The following code will usually prevent this problem:
1 while (!am_done()) {
2 do_something(p);
3 WRITE_ONCE(progress, progress + 1);
4 }
Exceptions can occur if the compiler is able to analyze do_something() and learn that it has no accesses to atomic or volatile variables. In these cases the compiler could produce two loops, one invoking do_something() and the other incrementing progress. It may be necessary to replace the WRITE_ONCE() with something like smp_store_release() in the unlikely event that this occurs. It is important to note that although the compiler is forbidden from changing the number, size, or order of volatile accesses, it is perfectly within its rights to reorder normal accesses with unrelated volatile accesses.
Oddly enough, the compiler is within its rights to use a variable as temporary storage just before a normal store to that variable, thus inventing stores to that variable. Fortunately, most compilers avoid this sort of thing, at least outside of stack variables. In any case, using WRITE_ONCE(), declaring the variable volatile, or declaring the variable atomic (in recent C and C++ compilers supporting atomics) will prevent this sort of thing.
The previous examples involved compiler optimizations that changed the number of accesses. Now, it might seem that preventing the compiler from changing the order of accesses is an act of futility, given that the underlying hardware is free to reorder them. However, modern machines have exact exceptions and exact interrupts, meaning that any interrupt or exception will appear to have happened at a specific place in the instruction stream, so that the handler will see the effect of all prior instructions, but won’t see the effect of any subsequent instructions. READ_ONCE() and WRITE_ONCE() can therefore be used to control communication between interrupted code and interrupt handlers. 11
This leaves changes to the size of accesses, which is known as load tearing and store tearing when the actual size is smaller than desired. For example, storing the constant 0x00010002 into a 32-bit variable might seem quite safe. However, there are CPUs that can store small immediate values directly into memory, and on such CPUs, the compiler can be expected to split this into two 16-bit stores in order to avoid the overhead of explicitly forming the 32-bit constant. This could come as a fatal surprise to another thread concurrently loading from this variable, which might not expect to see the result of a half-completed store. Use of READ_ONCE() and WRITE_ONCE() prevent the compiler from engaging in load tearing and store tearing, respectively.
In short, use of READ_ONCE(), WRITE_ONCE(), and volatile are valuable tools in preventing the compiler from optimizing your parallel algorithm out of existence. Compilers are starting to provide other mechanisms for avoiding load and store tearing, for example, memory_order_relaxed atomic loads and stores, however, volatile is still needed to avoid fusing and splitting of accesses.
Please note that, it is possible to overdo use of READ_ONCE() and WRITE_ONCE(). For example, if you have prevented a given variable from changing (perhaps by holding the lock guarding all updates to that variable), there is no point in using READ_ONCE(). Similarly, if you have prevented any other CPUs or threads from reading a given variable (perhaps because you are initializing that variable before any other CPU or thread has access to it), there is no point in using WRITE_ONCE(). However, in my experience, developers need to use things like READ_ONCE() and WRITE_ONCE() more often than they think that they do, the overhead of unnecessary uses is quite low. Furthermore, the penalty for failing to use them when needed is quite high.
15.3.2 Address- and Data-Dependency Difficulties
Compilers do not understand either address or data dependencies, although there are efforts underway to teach them, or at the very least, standardize the process of teaching them (MWB + 17, MRP + 17). In the meantime, it is necessary to be very careful in order to prevent your compiler from breaking your dependencies.
15.3.2.1 Give your dependency chain a good start
The load that heads your dependency chain must use proper ordering, for example, lockless_dereference(), rcu_dereference(), or a READ_ONCE() followed by smp_read_barrier_depends(). Failure to follow this rule can have serious side effects:
On DEC Alpha, a dependent load might not be ordered with the load heading the dependency chain, as described in Section 15.4.1.
If the load heading the dependency chain is a C11 non-volatile memory_order_ relaxed load, the compiler could omit the load, for example, by using a value that it loaded in the past.
If the load heading the dependency chain is a plain load, the compiler can omit the load, again by using a value that it loaded in the past. Worse yet, it could load twice instead of once, so that different parts of your code use different values—and compilers really do this, especially when under register pressure.
The value loaded by the head of the dependency chain must be a pointer. In theory, yes, you could load an integer, perhaps to use it as an array index. In practice, the compiler knows too much about integers, and thus has way too many opportunities to break your dependency chain (MWB + 17).
15.3.2.2 Avoid arithmetic dependency breakage
Although it is just fine to do some arithmetic operations on a pointer in your dependency chain, you need to be careful to avoid giving the compiler too much information. After all, if the compiler learns enough to determine the exact value of the pointer, it can use that exact value instead of the pointer itself. As soon as the compiler does that, the dependency is broken and all ordering is lost.
- Although it is permissible to compute offsets from a pointer, these offsets must not result in total cancellation. For example, given a char pointer cp, cp-(uintptr_ t)cp) will cancel and can allow the compiler to break your dependency chain.
On the other hand, canceling offset values with each other is perfectly safe and legal. For example, if a and b are equal, cp+a-b is an identity function, including preserving the dependency.
- Comparisons can break dependencies. Listing 15.26 shows how this can happen. Here global pointer gp points to a dynamically allocated integer, but if memory is low, it might instead point to the reserve_int variable. This reserve_ int case might need special handling, as shown on lines 6 and 7 of the listing. But the compiler could reasonably transform this code into the form shown in Listing 15.27, especially on systems where instructions with absolute addresses run faster than instructions using addresses supplied in registers. However, there is clearly no ordering between the pointer load on line 5 and the dereference on line 8. Please note that this is simply an example: There are a great many other ways to break dependency chains with comparisons.
Note that a series of inequality comparisons might, when taken together, give the compiler enough information to determine the exact value of the pointer, at which point the dependency is broken. Furthermore, the compiler might be able to combine information from even a single inequality comparison with other information to learn the exact value, again breaking the dependency. Pointers to elements in arrays are especially susceptible to this latter form of dependency breakage.
15.3.2.3 Safe comparison of dependent pointers
It turns out that there are several safe ways to compare dependent pointers:
Comparisons against the NULL pointer. In this case, all the compiler can learn is that the pointer is NULL, in which case you are not allowed to dereference it anyway.
The dependent pointer is never dereferenced, whether before or after the comparison.
The dependent pointer is compared to a pointer that references objects that were last modified a very long time ago, where the only unconditionally safe value of “a very long time ago” is “at compile time”. The key point is that there absolutely must be something other than the address or data dependency that guarantees ordering.
Comparisons between two pointers, each of which is carrying a good-enough dependency. For example, you have a pair of pointers, each carrying a dependency, and you want to avoid deadlock by acquiring locks of the pointed-to data elements in address order.
The comparison is not-equal, and the compiler does not have enough other information to deduce the value of the pointer carrying the dependency.
Pointer comparisons can be quite tricky, and so it is well worth working through the example shown in Listing 15.28. This example uses a simple struct foo shown on lines 1-5 and two global pointers, gp1 and gp2, shown on lines 6 and 7, respectively. This example uses two threads, namely updater() on lines 9-22 and reader() on lines 24-39.
The updater() thread allocates memory on line 13, and complains bitterly on line 14 if none is available. Lines 15-17 initialize the newly allocated structure, and then line 18 assigns the pointer to gp1. Lines 19 and 20 then update two of the structure’s fields, and does so after line 18 has made those fields visible to readers. Please note that unsynchronized update of reader-visible fields often constitutes a bug. Although there are legitimate use cases doing just this, such use cases require more care than is exercised in this example.
Finally, line 21 assigns the pointer to gp2.
The reader() thread first fetches gp2 on line 30, with lines 31 and 32 checking for NULL and returning if so. Line 33 then fetches field ->b. Now line 34 fetches gp1, and if line 35 sees that the pointers fetched on lines 30 and 34 are equal, line 36 fetches p->c. Note that line 36 uses pointer p fetched on line 30, not pointer q fetched on line 34.
But this difference might not matter. An equals comparison on line 35 might lead the compiler to (incorrectly) conclude that both pointers are equivalent, when in fact they carry different dependencies. This means that the compiler might well transform line 36 to instead be r2 = q->c, which might well cause the value 44 to be loaded instead of the expected value 144.
In short, some care is required in order to ensure that dependency chains in your source code are still dependency chains once the compiler has gotten done with them.
15.3.3 Control-Dependency Calamities
Control dependencies are especially tricky because current compilers do not understand them and can easily break them. The rules and examples in this section are intended to help you prevent your compiler’s ignorance from breaking your code.
A load-load control dependency requires a full read memory barrier, not simply a data dependency barrier. Consider the following bit of code:
1 q = READ_ONCE(x);
2 if (q) {
3 <data dependency barrier>
4 q = READ_ONCE(y);
5 }
This will not have the desired effect because there is no actual data dependency, but rather a control dependency that the CPU may short-circuit by attempting to predict the outcome in advance, so that other CPUs see the load from y as having happened before the load from x. In such a case what’s actually required is:
1 q = READ_ONCE(x);
2 if (q) {
3 <read barrier>
4 q = READ_ONCE(y);
5 }
However, stores are not speculated. This means that ordering is provided for loadstore control dependencies, as in the following example:
1 q = READ_ONCE(x);
2 if (q)
3 WRITE_ONCE(y, 1);
Control dependencies pair normally with other types of ordering operations. That said, please note that neither READ_ONCE() nor WRITE_ONCE() are optional! Without the READ_ONCE(), the compiler might combine the load from x with other loads from x. Without the WRITE_ONCE(), the compiler might combine the store to y with other stores to y. Either can result in highly counterintuitive effects on ordering.
Worse yet, if the compiler is able to prove (say) that the value of variable x is always non-zero, it would be well within its rights to optimize the original example by eliminating the “if” statement as follows:
1 q = READ_ONCE(x);
2 WRITE_ONCE(y, 1); /* BUG: CPU can reorder!!! */
It is tempting to try to enforce ordering on identical stores on both branches of the “if” statement as follows:
1 q = READ_ONCE(x);
2 if (q) {
3 barrier();
4 WRITE_ONCE(y, 1);
5 do_something();
6 } else {
7 barrier();
8 WRITE_ONCE(y, 1);
9 do_something_else();
10 }
Unfortunately, current compilers will transform this as follows at high optimization levels:
1 q = READ_ONCE(x);
2 barrier();
3 WRITE_ONCE(y, 1); /* BUG: No ordering!!! */
4 if (q) {
5 do_something();
6 } else {
7 do_something_else();
8 }
Now there is no conditional between the load from x and the store to y, which means that the CPU is within its rights to reorder them: The conditional is absolutely required, and must be present in the assembly code even after all compiler optimizations have been applied. Therefore, if you need ordering in this example, you need explicit memory-ordering operations, for example, a release store:
1 q = READ_ONCE(x);
2 if (q) {
3 smp_store_release(&y, 1);
4 do_something();
5 } else {
6 smp_store_release(&y, 1);
7 do_something_else();
8 }
The initial READ_ONCE() is still required to prevent the compiler from proving the value of x.
In addition, you need to be careful what you do with the local variable q, otherwise the compiler might be able to guess the value and again remove the needed conditional. For example:
1 q = READ_ONCE(x);
2 if (q % MAX) {
3 WRITE_ONCE(y, 1);
4 do_something();
5 } else {
6 WRITE_ONCE(y, 2);
7 do_something_else();
8 }
If MAX is defined to be 1, then the compiler knows that (q%MAX) is equal to zero, in which case the compiler is within its rights to transform the above code into the following:
1 q = READ_ONCE(x);
2 WRITE_ONCE(y, 2);
3 do_something_else();
Given this transformation, the CPU is not required to respect the ordering between the load from variable x and the store to variable y. It is tempting to add a barrier() to constrain the compiler, but this does not help. The conditional is gone, and the barrier() won’t bring it back. Therefore, if you are relying on this ordering, you should make sure that MAX is greater than one, perhaps as follows:
1 q = READ_ONCE(x);
2 BUILD_BUG_ON(MAX <= 1);
3 if (q % MAX) {
4 WRITE_ONCE(y, 1);
5 do_something();
6 } else {
7 WRITE_ONCE(y, 2);
8 do_something_else();
9 }
Please note once again that the stores to y differ. If they were identical, as noted earlier, the compiler could pull this store outside of the “if” statement.
You must also avoid excessive reliance on boolean short-circuit evaluation. Consider this example:
1 q = READ_ONCE(x);
2 if (q || 1 > 0)
3 WRITE_ONCE(y, 1);
Because the first condition cannot fault and the second condition is always true, the compiler can transform this example as following, defeating control dependency:
1 q = READ_ONCE(x);
2 WRITE_ONCE(y, 1);
This example underscores the need to ensure that the compiler cannot out-guess your code. More generally, although READ_ONCE() does force the compiler to actually emit code for a given load, it does not force the compiler to use the results.
In addition, control dependencies apply only to the then-clause and else-clause of the if-statement in question. In particular, it does not necessarily apply to code following the if-statement:
1 q = READ_ONCE(x);
2 if (q) {
3 WRITE_ONCE(y, 1);
4 } else {
5 WRITE_ONCE(y, 2);
6 }
7 WRITE_ONCE(z, 1); /* BUG: No ordering. */
It is tempting to argue that there in fact is ordering because the compiler cannot reorder volatile accesses and also cannot reorder the writes to y with the condition. Unfortunately for this line of reasoning, the compiler might compile the two writes to y as conditional-move instructions, as in this fanciful pseudo-assembly language:
1 ld r1,x
2 cmp r1,$0
3 cmov,ne r4,$1
4 cmov,eq r4,$2
5 st r4,y
6 st $1,z
A weakly ordered CPU would have no dependency of any sort between the load from x and the store to z. The control dependencies would extend only to the pair of cmov instructions and the store depending on them. In short, control dependencies apply only to the stores in the “then” and “else” of the “if” in question (including functions invoked by those two clauses), not to code following that “if”.
Finally, control dependencies do not provide cumulativity. 12 This is demonstrated by two related litmus tests, namely Listings 15.29 and 15.30 with the initial values of x and y both being zero.
The exists clause in the two-thread example of Listing 15.29 (C-LB+o-cgt-o+ocgt-o.litmus) will never trigger. If control dependencies guaranteed cumulativity (which they do not), then adding a thread to the example as in Listing 15.30 (CWWC+o-cgt-o+o-cgt-o+o.litmus) would guarantee the related exists clause never to trigger.
But because control dependencies do not provide cumulativity, the exists clause in the three-thread litmus test can trigger. If you need the three-thread example to provide ordering, you will need smp_mb() between the load and store in P0(), that is, just before or just after the “if” statements. Furthermore, the original two-thread example is very fragile and should be avoided.
The following list of rules summarizes the lessons of this section:
Compilers do not understand control dependencies, so it is your job to make sure that the compiler cannot break your code.
Control dependencies can order prior loads against later stores. However, they do not guarantee any other sort of ordering: Not prior loads against later loads, nor prior stores against later anything. If you need these other forms of ordering, use smp_rmb(), smp_wmb(), or, in the case of prior stores and later loads, smp_mb().
If both legs of the “if” statement begin with identical stores to the same variable, then those stores must be ordered, either by preceding both of them with smp_ mb() or by using smp_store_release() to carry out the stores. Please note that it is not sufficient to use barrier() at beginning of each leg of the “if” statement because, as shown by the example above, optimizing compilers can destroy the control dependency while respecting the letter of the barrier() law.
Control dependencies require at least one run-time conditional between the prior load and the subsequent store, and this conditional must involve the prior load. If the compiler is able to optimize the conditional away, it will have also optimized away the ordering. Careful use of READ_ONCE() and WRITE_ONCE() can help to preserve the needed conditional.
Control dependencies require that the compiler avoid reordering the dependency into nonexistence. Careful use of READ_ONCE(), atomic_read(), or atomic64_read() can help to preserve your control dependency.
Control dependencies apply only to the “then” and “else” of the “if” containing the control dependency, including any functions that these two clauses call. Control dependencies do not apply to code following the end of the “if” statement containing the control dependency.
Control dependencies pair normally with other types of memory-ordering operations.
Control dependencies do not provide cumulativity. If you need cumulativity, use smp_mb().
In short, many popular languages were designed primarily with single-threaded use in mind. Successfully using these languages to construct multi-threaded software requires that you pay special attention to your memory references and dependencies.
15.4 Hardware Specifics
略。
15.5. WHERE IS MEMORY ORDERING NEEDED?
Memory-ordering operations are only required where there is a possibility of interaction involving at least two variables between at least two threads. As always, if a single-threaded program will provide sufficient performance, why bother with parallelism? 15 After all, avoiding parallelism also avoids the added cost of memory-ordering operations.
If all thread-to-thread communication in a given cycle use store-to-load links (that is, the next thread’s load returning the value that the previous thread stored), minimal ordering suffices, as illustrated by Listings 15.12 and 15.13. Minimal ordering includes dependencies, acquires, and all stronger ordering operations.
If all but one of the links in a given cycle is a store-to-load link, it is sufficient to use release-acquire pairs for each of those store-to-load links, as illustrated by Listings 15.23 and 15.24. You can replace a given acquire with a a dependency in environments permitting this, keeping in mind that the C11 standard’s memory model does not permit this. Note also that a dependency leading to a load must be headed by a lockless_ dereference() or an rcu_dereference(): READ_ONCE() is not sufficient. Never forget to carefully review Sections 15.3.2 and 15.3.3, because a dependency broken by your compiler is no help at all! The two threads sharing the sole non-store-to-load link can usually substitute WRITE_ONCE() plus smp_wmb() for smp_store_release() on the one hand, and READ_ONCE() plus smp_rmb() for smp_load_acquire() on the other.
If a given cycle contains two or more non-store-to-load links (that is, a total of two or more load-to-store and store-to-store links), you will need at least one full barrier between each pair of non-store-to-load links in that cycle, as illustrated by Listing 15.19 as well as in the answer to Quick Quiz 15.23. Full barriers include smp_mb(), successful full-strength non-void atomic RMW operations, and other atomic RMW operations in conjunction with either smp_mb__before_atomic() or smp_mb__after_atomic(). Any of RCU’s grace-period-wait primitives (synchronize_rcu() and friends) also act as full barriers, but at even greater expense than smp_mb(). With strength comes expense, though the overhead of full barriers usually hurts performance more than it hurts scalability.
Note that these are the minimum guarantees. Different architectures may give more substantial guarantees, as discussed in Section 15.4, but they may not be relied upon outside of code specifically designed to run only on the corresponding architecture.
One final word of advice: Again, use of raw memory-ordering primitives is a last resort. It is almost always better to use existing primitives, such as locking or RCU, that take care of memory ordering for you.
3.16 - ENDIX-C-内存屏障
是什么原因,让疯狂的 CPU 设计者将内存屏障强加给可怜的 SMP 软件设计者?
简而言之,这是由于重排内存引用可以达到更好的性能。因此,在某些情况下,如在同步原语中,正确的操作结果依赖于按序的内存引用,这就需要内存屏障以强制保证内存顺序。
对于这个问题,要得到更详细的回答,需要很好理解 CPU 缓存是如何工作的,特别是要使缓存工作的更好,我们需要什么东西。
缓存结构
现代 CPU 的速度比现代内存系统的速度快的多。2006 年的 CPU 可以在的每纳秒内执行 10 条指令。但是需要多个 10ns 才能从物理内存中读取一条数据。它们的速度差异(超过两个数量级)已经导致在现代 CPU 中出现了数兆级别的缓存。这些缓存与 CPU 相关联,如果 C.1 中所示,典型的,可以在几个时钟周期内被访问。
CPU 缓存和内存之间的数据流是固定长度的块,称为“缓存行”,其大小通常是 2 的 N 次方。范围从 16 到 256 字节不等。当一个特定的数据项初次被 CPU 访问时,它在缓存中还不存在,这被称为“缓存缺失”(或者更精确的称为“首次缓存缺失”或者“运行时缓存缺失”)。“缓存缺失”意味着从物理内存中读取数据时,CPU 必须等待(或处于“停顿”状态)数百个 CPU 周期。但是,数据项被装载入 CPU 缓存,因此后续的访问将在缓存中找到,于是 CPU 可以全速运行。
经过一段时间后,CPU 的缓存被填满,后续的缓存缺失很可能需要换出缓存中现有的数据,以便为最近的访问项腾出统建。这种“缓存缺失”被称为“容量缺失”,因为它是由于缓存容量限制而造成的。但是,即便此时缓存还没有被填满,大量缓存也可能由于一个新数据被换出。这是由于大容量缓存是通过硬件哈希表来实现的,这样哈希表有固定长度的哈希桶(或者叫 “sets”,CPU 设计者是这样称呼的),如图 C.2。
该缓存有 16 个 sets 和两条“路”,共 32 个缓存行,每个节点包含一个 256 字节的“缓存行”,它是一个 256 字节对齐的内存块。对于大容量缓存来说,这个缓存行的长度稍小了点,但是这使得 16 禁止的运行更加简单。从硬件角度来说,这是一个两路组相连缓存,类似于带 16 个桶的软件哈希表,每个桶的哈西链被限制为最多两个元素。大小(本例中是32个缓存行)和相连性(本例中是2)都被称为缓存行的 germetry。由于缓存是硬件实现的,哈希函数非常简单,从地址中取出 4 位作为哈希键值。
在 C.2 中,每个方框对应一个缓存项,每个缓存项包含一个 256 字节的缓存行。不过,一个缓存项可能为空,在图中表现为空框。其他的块用它所包含的内存行的内存地址标记。由于缓存行必须是 256 字节对齐,因此每一个地址的低 8 位为 0。并且,硬件哈希函数的选择。意味着接下来的高 4 位匹配缓存行中的位置。
如果陈旭代码位于地址 0x43210E00 到 0x43210EFF,并且程序一次访问地址 0x1234500 到 0x12345EFF,图中的情况就可能发生。假设程序正在准备访问地址 0x12345F00,这个地址会哈希到 0xF 行,该行的两路都是空的,因此可以容纳对应的 256 字节缓存行。如果程序访问地址 0x1233000,将会被哈希到第 0 行,相应的 256 字节缓存行可以放到第一路。但是,如果程序访问地址 0x123E00,将会哈希到 0xE 行,其中一个已经存在于缓存中缓存行必须被替换出去,以腾出空间给新的缓存行。如果随后访问刚被替换出去的行,会产生一次“缓存缺失”,这样的缓存缺失被称为“关联性缺失”。
更进一步说,我们仅仅考虑了某个 CPU 读数据的情况。当写的时候会发生什么呢?由于让所有 CPU 都对特定数据项达成一致,这一点非常重要。因此,在一个特定的 CPU 写数据前,它必须首先从其他 CPU 缓存中移除,或者叫做“使无效”。一旦“使无效”操作完成,CPU 可以安全的修改数据项。如果数据存在于该 CPU 缓存中,但是是只读的,这个过程被称为“写缺失”、一旦某个特定的 CPU 完成了对某个数据项的“使无效”操作,该 CPU 可以反复的重新写(或读)该数据项。
随后,如果另外某个 CPU 视图访问数据项,将会引起一次缓存缺失,此时,由于第一个 CPU 为了写而使得缓存项无效,这种类型的缓存缺失被称为“通信缺失”、因为通常是由于几个 CPU 使用数据项进行通信造成的。比如,锁就是一个用于在 CPU 之间使用互斥算法进行通信的数据项。
很明显,必须小心确保,所有 CPU 报纸一致性数据视图。可以很容易想到,通过所有取数据、使无效、写操作。它操作的数据可能已经丢失,或者(也许更糟糕)在不同 CPU 缓存之间拥有冲突的值。这些问题由“缓存一致性”来防止,将在下一节介绍。
缓存一致性协议
缓存一致性协议管理缓存行的状态,以防止数据不一致或者丢失数据。这些协议可能十分复杂,可能有数十种状态。但是为了我们的目的,我们仅仅需要关心仅有 4 种状态的 MESI 协议。
MESI 状态
MESI 代表 modified、exclusive、shared、inbalid,特定缓存行可以使用该协议采用的四种状态。因此,使用该协议的缓存,在每一个缓存行中,维护一个两位的状态标记,这个标记附着在缓存行的物理地址和数据后面。
处于 modified 状态的缓存行,已经收到了来自于响应 CPU 最近进行的内存存储。并且相应的内存却白没有在其他 CPU 的缓存中出现。因此,除以 modified 状态的缓存行可以被认为为被 CPU 所“拥有”。由于该缓存行持有最新的数据复制,因此缓存最终有责任:要么将数据写回到内存,要么将数据转移给其他缓存,并且必须在重新使用该缓存行以持有其他数据之前完成这些事情。
exclusive 状态非常类似于 modified 状态,唯一的差别是,该缓存行还没有被相应的 CPU 修改,这也表示缓存行中对内存数据的复制是最新的。但是,由于 CPU 能够在任意时刻将数据存储到该行,而不考虑其他 CPU,因此,处于 exclusive 状态也可以认为被相应的 CPU 所“拥有”。也就是说,由于物理内存中相应的值是最新的,该缓存行可以直接丢弃而不用会写到内存,也不用将该缓存转移给其他 CPU 的缓存。
处于 shared 状态的缓存行可能被复制到至少一个其他 CPU 的缓存行中,这样在没有得到其他 CPU 的许可时,不能向缓存行存储数据。与 exclusive 状态相同,由于内存中的值是最新的,因此可以不用向内存回写值而直接丢弃缓存中的值,也不用将该缓存转移给其他 CPU。
处于 invalid 状态的行是空的,换句话说,他没有持有任何有效数据。当新数据进入缓存时,如果有可能,它就会被放置到一个处于 invalid 状态的缓存行。这个方法是首选的,因为替换其他状态的缓存行将引起开销昂贵的缓存缺失,这些被替换的行在将来会被引用。
由于所有 CPU 必须维护那些已经搬运进缓存行中的数据一致性视图,因此缓存一致性协议提供消息以协调系统中缓存行的动作。
MESI 协议消息
前面章节中描述的许多事务都需要在 CPU 之间通信。如果 CPU 位于单一共享总线上,只需要如下消息就足够了。
- 读消息:包含要读取的缓存行的物理地址。
- 读响应消息:包含之前的“读消息”所请求的数据。这个读响应消息要么由物理内存提供,要么由一个其他缓存提供。例如,如果某一缓存拥有处于 modified 状态的目标数据,那么该缓存必须提供读响应消息。
- 使无效消息:包含要使无效的缓存行的物理地址。所有其他缓存行必须从它们的缓存中移除相应的数据并且响应此消息。
- 使无效应答消息:一个接收到使无效消息的 CPU 必须在移除指定数据后响应一个使无效应答消息。
- 读使无效消息:包含要被读取的缓存行的物理地址。同时指示其他缓存移除其数据。因此,正如名字所示,它将读和使无效消息进行合并。读使无效消息同时需要一个读响应消息及一组使无效应答消息进行应答。
- 写回消息:包含要写回到物理内存的地址和数据(并且也会“嗅探”进其他 CPU 的缓存)。该消息允许缓存在必要时换出处于 modified 状态的数据以便为其他数据腾出空间。
有趣的是,共享内存的多核系统实际上是一个消息传递的计算机。这意味着:使用分布式共享内存的 SMP 机器集群,正在以两种不同级别的系统架构,使用消息传递来共享内存。
MESI 状态图
由于接受或发送协议消息,特定的缓存行状态会变换,如图 C.3 所示:
图中的转换弧如下:
- 转 a:缓存行被写回到物理内存,但是 CPU 仍然将它保留在缓存中,并进一步的保留修改它的权限。这个转换需要一个“写回”消息。
- 转换 b:CPU 将数据写到缓存行,该缓存目前处于排他访问。该转换不需要发送或者接收任何消息。
- 转换 c:CPU 收到一个针对某个缓存行的“使无效”消息,对应的缓存行已经被修改。CPU 必须使无效本地副本,然后同时响应“读响应”和“使无效应答”消息,同时发送数据给请求的 CPU,并且标示它的本地副本不再有效。
- 转换 d:CPU 对一个数据项进行一个原子读——修改——写操作,对应的数据没有在他的缓存中。它发送一个“使无效”消息,通过“读响应”消息接收数据。一旦他接收到一个完整的“使无效应答”响应集合,CPU 就完成此转换。
- 转换 e:CPU 对一个数据项进行一个原子读——修改——写操作,对应的数据在缓存中是只读的。它必须发送一个“使无效”消息,并在完成此转换前,它必须等待一个完整的“使无效应答”响应集合。
- 转换 f:其他某些 CPU 读取缓存行,其数据由本 CPU 提供,本 CPU 包含一个只读副本,也可能已经将其写回内存。这个转换开始于接收到一个读消息,并且本 CPU 响应一个包含了所请求数据的“读响应”消息。
- 转换 g:其他 CPU 读取位于本缓存行的数据,并且数据要么是从本 CPU 的缓存提供,要么是从物理内存提供。无论哪种情况,本 CPU 都会保留一个只读副本。该转换开始于接收到一个“读”消息,并且本 CPU 响应一个包含所请求数据的“读响应”消息。
- 转换 h:当前 CPU 意识到,它很快将要写入一些位于本 CPU 缓存行的数据项,于是发送一个“使无效”消息。直到它接收到完整的“使无效”应答消息集合,CPU 才完成转换。可选的,所有其他 CPU 通过写回消息,从其缓存行将数据取出(可能是为其他缓存行腾出空间)。这样,当前 CPU 就是最后一个缓存该数据的 CPU。
- 转换 i:其他某些 CPU 对某个数据项进行了一个原子读——修改——写操作,相应的缓存行仅仅被本地的 CPU 缓存所持有。因此本 CPU 将缓存行状态编程无效状态。这个转换开始于接收到“读使无效”消息,并且本 CPU 返回一个“读响应”消息及一个“使无效应答”消息。
- 转换 j:本 CPU 保存一个数据项到缓存行,但是数据还没有在其他缓存行中。因此发送一个“使读无效”消息。直到它接收到“读响应”消息及完整的“使无效应答”消息集合后,才完成转换。缓存行可能会很快转到“修改”状态,这是在存储完成后由交换 b 完成的。
- 转换 k:本 CPU 装在一个数据项到缓存中,但是数据项还没有在缓存行中。CPU 发送一个“读”消息,当它接收到相应的“读响应”消息后完成转换。
- 转换 l:其他 CPU 存储一个位于本 CPU 缓存行的数据项,但是由于其他 CPU 也持有该缓存行的原因,本 CPU 仅仅以只读方式持有该缓存行。这个转换开始于接收到一个“使无效”消息,并且本 CPU 返回一个“使无效应答”消息。
MESI 协议实例
现在,让我们从数据缓存行价值的角度来看这一点。最初,数据驻留在地址为 0 的物理内存中。在一个 4-CPU 的系统中,它在几个直接映射的单缓存行中移动,表 C.1 展示了数据流向。第一列是操作序号,第二列表示执行操作的 CPU,第三列表示执行的操作,接下来的四列表示每一个缓存行的状态(内存地址后紧跟 MESI 状态)。最后两列表示对应的内存内容是否是最新的。V 表示最新,I 表示非最新。
最初,将要驻留数据的的 CPU 缓存行处于 invalid 状态,对应的数据在物理内存中是无效的。当 CPU0 从地址0装载数据时,它在 CPU0 的缓存中进入 shared 状态,并且物理内存中的数据仍是有效的。CPU3 也是从地址0装载数据,这样两个 CPU 中的缓存都处于 shared 状态,并且内存中的数据仍然有效。接下来 CPU0 转载其他缓存行(地址8),这个操作通过使无效操作强制将地址0的数据换出缓存,将缓存中的数据被换成地址8的数据。现在,CPU2 装载地址0的数据,但是该 CPU 发现它很快就会存储该数据,因此它使用一个“使读无效”消息以获得一个独享副本,这样,将使 CPU3 缓存中的数据变为无效(但是内存中的数据依然是有效的)。接下来 CPU2 开始预期的存储操作,并将状态改为 modified。内存中的数据副本不再是最新的。CPU1 开始一个原子递增操作,使用一个“读使无效”消息从 CPU2 的缓存中窥探数据并使之无效,这样 CPU1 的缓存编程 modified 状态,(内存中的数据仍然不是最新的)。最后,CPU1 从地址 8 读取数据,它使用一个写回消息将地址0的数据会写到内存。
请注意,我们最终使数据位于某缓存行中。
存储导致不必要的停顿
对于特定的 CPU 反复读写特定的数据来说,图 C.1 显示的缓存结构提供了好的性能。但是对于特定缓存行的第一次写来说,其性能是不好的。要理解这一点,参考图 C.4,它显示了 CPU0 写数据到一个缓存行的时间线,而这个缓存行被 CPU1 缓存。在 CPU0 能够写数据前,它必须首先等到缓存行的数据到来。CPU0 不得不停顿额外的时间周期。
其他没有理由强制让 CPU0 延迟这么久,毕竟,不管 CPU1 发送给他的缓存数据是什么,CPU0 都会无条件的覆盖它。
存储缓冲
避免这种不必要的写停顿的方法之一,是在每个 CPU 和它的缓存之间,增加“存储缓冲”,如图 C.5。通过增加这些存储缓冲区,CPU0 可以简单的将要保存的数据放到存储缓冲区中,并且继续运行。当缓存行最终从 CPU1 转到 CPU0 时,数据将从存储缓冲区转到缓存行中。
这些存储缓冲对于特定 CPU 来说,是属于本地的。或者在硬件多线程系统中,对于特定核来说,是属于本地的。无论哪一种情况,一个特定 CPU 仅允许访问分配给它的存储缓冲。例如,在图 C.5 中,CPU0 不能访问 CPU0 的存储缓冲,反之亦然。通过将两者关注的点分开,该限制简化了硬件,存储缓冲区提升了连续写的性能,而在 CPU(核或其他可能的东西)之间的通信责任完全由缓存一致性协议承担。然而,及时有了这个限制,仍然有一些复杂的事情需要处理,将在下面两节中描述。
存储转发
第一个复杂的地方,违反了自身一致性。考虑变量 a 和 b 都初始化为 0,包含变量 a 的缓存行,最初被 CPU1 拥有,而包含变量 b 的缓存行最初被 CPU0 拥有。
1 a = 1;
2 b = a + 1;
3 assert(b == 2);
人们并不期望断言失败。可是,难道有谁足够愚蠢,以至于使用如果 C.5 所示的简单体系结构,这种体系结构是令人惊奇的。这样的系统可能看起来会按以下的事件顺序。
- CPU0 开始执行 a=1。
- CPU0 在缓存中查找 a,并且发现缓存缺失。
- 因此 CPU0 发送一个“读使无效”消息,以获得包含“a”的独享缓存行。
- CPU0 将 a 记录到存储缓冲区。
- CPU1 接收到“读使无效”消息,它通过发送缓存行数据,并从他的缓存行中移除数据来响应这个消息。
- CPU0 开始执行 b=a+1。
- CPU0 从 CPU1 接收到缓存行,它仍然拥有一个为 0 的 a 值。
- CPU0 从它的缓存中读取到 a 的值,发现其值为 0。
- CPU0 将存储队列中的条目应用到最近到达的缓存行,设置缓存行中的 a 的值为 1。
- CPU0 将前面加载的 a 值 0 加 1,并存储该值到包含 b 的缓存行中(假设已经被 CPU0 拥有)。
- CPU0 执行 assert(b==2),并引起错误。
问题在于我们拥有两个 a 的副本,一个在缓存中,另一个在存储缓冲区中。
这个例子破坏了一个重要的前提:即每个 CPU 将总是按照编程顺序看到他的操作。没有这个前提,结果将于直觉相反。因此,硬件设计者同情并实现了“存储转发”。在此,每个 CPU 在执行加载操作时,将考虑(或者嗅探)它的存储缓冲,如图 C.6。换句话说,一个特定的 CPU 存储操作直接转发给后续的读操作,而并不必然经过其缓存。
通过就地存储转发,在前面执行顺序的第 8 步,将在存储缓冲区中为 a 找到正确的值 1,因此最终 b 的值将是 2,这也正是我们期望的。
存储缓冲区及内存屏障
要明白第二个复杂性违反了全局内存序。开率如下的代码顺序,其中变量 a、b 的初始值为 0。
1 void foo(void)
2 {
3 a = 1;
4 b = 1;
5 }
6
7 void bar(void)
8 {
9 while (b == 0) continue;
10 assert(a == 1);
11 }
假设 CPU0 执行 foo 函数,CPU1 执行 bar 函数,再进一步假设包含 a 的缓存行仅仅位于 CPU1 的缓存中,包含 b 的缓存行被 CPU0 所拥有。那么操作属顺序可能如下:
- CPU0 执行 a=1、缓存行不在 CPU0 的缓存中,因此 CPU0 将 a 的新值放到存储缓冲区,并发送一个“读使无效”消息。
- CPU1 执行 while(b==0) continue,但是包含 b 的缓存行不再它的缓存内,因此发送一个“读”消息。
- CPU0 执行 b=1,它已经拥有了该缓存行(换句话说,缓存行要么处于 modified 要么处于 exclusive),因此它存储新的 b 值到它的缓存中。
- CPU0 接收到“读”消息,并且发送缓存行中的最近更新的 b 的值到 CPU1,同时将缓存行设置为 shared 状态。
- CPU1 接收到包含 b 值的缓存行,并将其值写到它的缓存行中。
- CPU1 现在结束执行 while(b==0) continue,引文它发现 b 的值为 1,它开始处理下一条语句。
- CPU1 执行 assert(a==1),并且,由于 CPU1 工作在旧的 a 的值,因此断言失败。
- CPU1 接收到 “读使无效”消息,并且发送包含 a 的缓存行到 CPU0,同时在它的缓存中,将该缓存行变成无效。但是已经太迟了。
- CPU0 接收到包含 a 的缓存行,并且及时将存储缓冲区的数据保存到缓存行中,CPU1 的断言失败受害于该缓存行。
在此,硬件设计者不能直接帮助我们,因为 CPU 没有方法识别那些相关联的变量,更不用说他们之间关联的方式。因此,硬件设计值提供了内存屏障指令,以允许软件告诉 CPU 这些关系的存在。程序必须修改以包含内存屏障。
1 void foo(void)
2 {
3 a = 1;
4 smp_mb();
5 b = 1;
6 }
7
8 void bar(void)
9 {
10 while (b == 0) continue;
11 assert(a == 1);
12 }
内存屏障 smp_mb 将导致 CPU 在刷新后续的存储到变量的缓存行之前,前面的存储缓冲被刷新。在继续处理之前,CPU 可能简单的停顿下来,直到存储缓冲区变为空;也可能是使用存储缓冲区来持有后续的存储操作,直到前面所有的存储缓冲区已经被保存到缓存行中。
后一种情况下,操作序列可能如下所示。
- CPU0 执行 a=1。缓存行不在 CPU0 的缓存内,因此 CPU0 将 a 的新值放到存储缓冲中,并发送一个“读使无效”消息。
- CPU1 执行 while(b==0) continue,但是包含 b 的缓存行不在它的缓存中,因此它发送一个“读”消息。
- CPU0 执行 smp_mb,并标记当前所有存储缓冲区的条目。即 a=1 这个条目。
- CPU0 执行 b=1。它已经拥有这个缓存行了。(即缓存行已经处于 modified 或 exclusive),但是在存储缓冲区中存在一个标记条目。因此,它不讲 b 的新值存放到缓存行,而是存放到存储缓冲区,这里 b 不是一个标记条目。
- CPU0 接收到“读”消息,随后发送包含原始 b 值的缓存行给 CPU1.它也标记该缓存行的复制为 shared 状态。
- CPU1 读取到包含 b 的缓存行,并将它复制到本地缓存中。
- CPU1 现在可以装在 b 的值了。但是,由于它发现其值仍然我 0,因此它重复执行 while 语句。b 的心智被安全的隐藏在 CPU0 的存储缓冲区中。
- CPU1 接收到“读使无效”消息,发送包含 a 的缓存行给 CPU0,并且是他的缓存无效。
- CPU0 接收到包含 a 的缓存行,使用存储缓冲区的值替换缓存行,将这一行设置为 modified 状态。
- 由于被存储的 a 是存储缓冲区中唯一被 smp_mb 标记的条目,因此 CPU0 能够存储 b 的新值到缓存行中,除非包含 b 的缓存行当前处于 shared 状态。
- CPU0 发送一个“使无效”消息给 CPU1。
- CPU1 接收到“使无效”消息,使包含 b 的缓存行无效,并且发送一个“使无效应答”消息给 CPU0.
- CPU1 执行 while(b==0) continue,但是包含 b 的缓存行不再它的缓存中,因此它发送一个“读”消息给 CPU0.
- CPU0 接收到“使无效应答”消息,将包含 b 的缓存行设置成 exclusive 状态。CPU0 现在存储新的 b 值到缓存行。
- CPU0 接收到“读”消息,同时发送包含新的 b 值的缓存行给 CPU1。他也标记该缓存行的复制为“shared”状态。
- CPU1 接收到包含 b 的缓存行,并将它复制到本地缓存中。
- CPU1 现在能够装载 b 的值了,由于它发现 b 的值为 1,它对出 while 循环并执行下一条语句。
- CPU1 执行 assert(a==1),但是包含 a 的缓存行不再它的缓存中。一旦它从 CPU0 获得这个缓存行,它将使用最新的 a 的值,因此断言语句将通过。
正如你看到的那样,这个过程涉及不少工作。即使某些事情从直觉上看是简单的操作,就像“加载 a 的值”这样的操作,都会包含大量复杂的步骤。
存储序列导致不必要的停顿
不幸的是,每一个存储缓冲区相对而言都比较小,这意味着执行一段较小的存储操作序列的 CPU,就可能填满它的存储缓冲区(比如当所有的这些结果发生了缓存缺失时)。从这一点来看,CPU 在能够继续执行前,必须再次等待刷新操作完成,其目的是为了清空它的存储缓冲。相同的情况可能在内存屏障之后发生,内存屏障之后的所有存储操作指令,都必须等待刷新操作完成,而不管这些后续存储是否存在缓存缺失。
这可以通过使用“使无效应答”消息更快到达 CPU 来得到改善。实现这一点的方法之一是使用每 CPU 的使无效消息队列,或者成为“使无效队列”。
使无效队列
“使无效应答”消息需要如此长的时间,其原因之一是它们必须确保相应的缓存行确实已经变成无效了。如果缓存比较忙的话,这个使无效操作可能被延迟。例如,如果 CPU 密集的装载或者存储数据,并且这些数据都在缓存中。另外,如果在一个较短的时间内,大量的“使无效”消息到达,一个特定的 CPU 会忙于处理它们。这会使得其他 CPU 陷入停顿。
但是,在发送应答前,CPU 不必真正使无效缓存行。它可以将使无效消息排队,在发送各国的关于该缓存行的消息前,需要处理这个消息。
使无效队列及使无效应答
图 C.7 显示了一个包含使无效队列的系统。只要将使无效消息放入队列,一个带有使无效队列的 CPU 就可以迅速应答使无效消息,而不必等待相应的缓存行真的变成无效状态。当然,CPU 必须在准备发送使无效消息前引用它的使无效队列。如果一个缓存行对应的条目在使无效队列中,则 CPU 不能立即发送使无效消息,它必须等待使无效队列中的条目被处理。
将一个条目放进使无效队列,实际上是由 CPU 承诺,在发送任何与该缓存行相关的 MESI 协议消息前处理该条目。只要相应的数据结构不存在大的竞争,CPU 会很出色的完成此事。
但是,消息能够被缓冲在使无效队列中,该事实带来了额外的内存乱序机会,浙江在下一节讨论。
使无效队列及内存屏障
我们假设 CPU 将使用使无效请求队列,并立即响应它们。这个方法使得执行存储操作的 CPU 看到的缓存使无效消息的延迟降到最小,但是这会将内存屏障失效,看看如下示例。
假设 a 和 b 都被初始化为 0,a 是只读的(MESI 状态为 shared),b 被 CPU0 拥有(MESI 状态为 exclusive 或 modified)。然后假设 CPU0 执行 foo 而 CPU1 执行 bar,代码片段如下。
1 void foo(void)
2 {
3 a = 1;
4 smp_mb();
5 b = 1;
6 }
7
8 void bar(void)
9 {
10 while (b == 0) continue;
11 assert(a == 1);
12 }
操作顺序可能如下:
- CPU0 执行 a=1。在 CPU0 中,对应的缓存行是只读的,因此 CPU0 将 a 的新值放入存储缓冲区,并发送一个“使无效”消息,这是为了使 CPU1 的缓存中的对应缓存行失效。
- CPU1 执行 while(b==0) continue,但是包含 b 的缓存行不在它的缓存中,因此发送一个“读”消息。
- CPU1 接收到 CPU0 的“使无效”消息,将其排队并立即响应该消息。
- CPU0 接收到来自于 CPU1 的使无效消息,因此它放心的通过第 4 行的 smp_mb,从存储缓冲区移动 a 的值到缓存行。
- CPU0 执行 b=1。它已经拥有这个缓存行(也即是说,缓存行已经处于 modified 或者 exclusive 状态),因此它将 b 的新值存储到缓存行中。
- CPU0 接收到“读”消息,并且发送包含 b 的新值的缓存行到 CPU1,同时在自己的缓存中标记缓存行为“shared”状态。
- CPU1 接收到包含 b 的缓存行并且将其应用到本地缓存。
- CPU1 现在可以完成 while(b==0) continue,因为它发现 b 的值为 1,因此开始处理下一条语句。
- CPU1 执行 assert(a==1),并且,由于旧的 a 值还在 CPU1 的缓存中,因此陷入错误。
- 虽然陷入错误,CPU1 处理已经排队的使无效消息,并且(迟到)在自己的缓存中刷新包含 a 值的缓存行。
如果加速使无效响应会导致内存屏障被忽略,那么就没有什么意义了。但是,内存屏障指令能够与使无效队列交互,这样,当一个特定的 CPU 执行一个内存屏障时,它标记无效队列中的所有条目,并强制所有后续的装载操作进行等待,直到所有标记的条目都保存到 CPU 的缓存中。因此,我们可以在 bar 函数中添加一个内存屏障,具体如下:
1 void foo(void)
2 {
3 a = 1;
4 smp_mb();
5 b = 1;
6 }
7
8 void bar(void)
9 {
10 while (b == 0) continue;
11 smp_mb();
12 assert(a == 1);
13 }
有了这个变化之后,操作顺序可能如下:
- CPU0 执行 a=1。对应的缓存行在 CPU0 的缓存中是只读的,因此 CPU0 将 a 的新值放入它的存储缓冲区,并且发送一个使无效消息以刷新 CPU1 对应的缓存行。
- CPU1 执行 while(b==0) continue,但是包含 b 的缓存行不再它的缓存中,因此它发送一个 读 消息。
- CPU1 接收到 CPU0 的使无效消息,将其排队并立即响应它。
- CPU0 接收到 CPU1 的响应,因此它放心的通过第 4 行 smp_mb 语句将 a 从他的存储缓冲区移到缓存行。
- CPU0 执行 b=1。它已经拥有该缓存行(MESI 状态为 modified 或 exclusive),因此它存储 b 的新值到缓存行。
- CPU0 接收到读消息,并且发送包含新的 b 值的缓存行给 CPU1,同时在自己的缓存行中,标记缓存行为 shared 状态。
- CPU1 接收到包含 b 的缓存行并更新到它的缓存中。
- CPU1 现在结束执行 while 循环,引文它发现 b 的值为 1,因此开始处理小一条语句,这是一条内存屏障指令。
- CPU1 必须停顿,知道它处理完使无效队列中的所有消息。
- CPU1 处理已经入队的 使无效 消息,从它的缓存中使无效包含 a 的缓存行。
- CPU1 执行 assert(a==1),由于包含 a 的缓存行已经不在它的缓存中,它发送一个 读 消息。
- CPU0 使用包含新的 a 值的缓存行来响应该读消息。
- CPU1 接收到该缓存行,它包含新的 a 的值 1,因此断言不会被触发。
即使有很多 MESI 消息传递,CPU 最终都会正确的应答。下一节阐述了 CPU 设计者为什么必须格外小心的处理它的缓存一致性优化操作。
读和写内存屏障
在前一节,内存屏障用来标记存储缓冲区和使无效队列中的条目。但是在我们的代码片段中 foo 没有必要进行使无效队列相关的任何操作,类似的,bar 也没有必要进行与存储缓冲区相关的任何操作。
因此,很多 CPU 体系结构提供更弱的内存屏障指令,这些指令仅仅做其中一项或者几项工作。不准确的说,一个“读内存屏障”仅仅标记它的使无效队列,一个“写内存屏障”仅仅标记它的存储缓冲区,而完整的内存屏障同时标记使无效队列和存储缓冲区。
这样的效果是,读内存屏障仅仅保证执行该指令的 CPU 上面的装载顺序,因此所有在读内存平展之前的装载,将在所有随后的装载前完成。类似的,写内存屏障仅仅保证写之间的属性怒,也是针对执行该指令的 CPU 来说。同样的,所有在内存屏障之前的存储操作,将在其后的存储操作完成之前完成。完整的内存屏障同时保证写和读之间的顺序,这也仅仅针对执行该内存屏障的 CPU 来说的。
我们修改 foo 好 bar,以使用读和写内存屏障,如下所示:
1 void foo(void)
2 {
3 a = 1;
4 smp_wmb();
5 b = 1;
6 }
7
8 void bar(void)
9 {
10 while (b == 0) continue;
11 smp_rmb();
12 assert(a == 1);
13 }
某些计算机甚至拥有更多的内存屏障,理解这三个屏障通常能让我们更好的理解内存屏障。
内存屏障示例
本节提供了一些有趣的、但是稍显不同的内存屏障用法。虽然他们能在大多数时候正常工作,但是其中一些仅能在特定 CPU 上运行。如果目的是为了产生哪些能在所有 CPU 上都能运行的代码,那么这些用法是必须要避免的。为了能够更好理解他们之间的细微差别,我们首先要关注乱序体系结构。
乱序体系结构
一定数量的乱序计算机系统已经被生产了数十年。不过乱序问题的实质十分微秒,真正理解它需要非常丰富的特定硬件方面的知识。与其针对一个特定的硬件厂商说事,这会把读者带到详细的技术规范中去,不如让我们设计一个虚构的、最大限度的乱序体系结构。
这个硬件必须遵循以下顺序约束:
- 单个 CPU 总是按照编程顺序来感知它自己的内存访问。
- 仅仅在操作不同地址时,CPU 才对给定的存储操作进行重排序。
- 一个特定的 CPU,在内存屏障之前的所有装载操作(smp_rmb)将在所有读内存屏障后面的操作之前被其他 CPU 所感知。
- 一个特定的 CPU,所有在写内存屏障之前的写操作(smp_wmb)都将在所有内存屏障之后的写操作之前被其他 CPU 所感知。
- 一个特定的 CPU,所有在内存屏障之前的内存访问(装载和存储)(smp_mb)都将在所有内存屏障之后的内存访问之前,被所有其他 CPU 感知。
假设一个大的非一致性缓存体系(NUCA)系统,为了给特定节点内部的 CPU 提供一个公平的内部访问带宽,在每一个节点的内连接口提供了一个每 CPU 队列,如图 C.8。虽然一个特定 CPU 的访问是由内存屏障排序的,但是,一堆相关 CPU 的相关访问顺序被严重的重排,正如我们将要看到的。
示例 1
表 C.2 展示了三个代码片段,被 CPU 0/1/2 并发执行,a、b、c 都被初始化为 0。
假设 CPU0 刚经过很多缓存缺失,因此它的消息队列是满的,但是 CPU1 在它的缓存中独占性运行,因此它的消息队列是空的。那么 CPU0 在向 a/b 赋值时,看起来节点 0 的缓存是立即生效的(因此对 CPU1 来说也是可见的),但是将阻塞于 CPU0 之前的流量。与之相对的是,CPU1 向 c 赋值时,由于 CPU1 的消息队列为空,因此可以很快执行。因此,CPU2 将在看到 CPU0 对 a 的赋值前,先看到 CPU1 对 c 的赋值,这将导致验证失败,即使有内存屏障也会如此。
可移植代码不能认为断言不会触发。由于编译器和 CPU 都能够重排代码,因此可能触发断言。
示例 2
表 C.3 展示了代码片段,在 CPU 0/1/2 上并行执行,a/b 均被赋值为 0。
我们再一次假设 CPU0 刚遇到很多缓存缺失,因此它的消息队列满了,但是 CPU1 在它的缓存中独占性运行,因此它的消息是空的。那么,CPU0 给 a 赋值将立即反映在及诶单 0 上,(因此对于 CPU1 来说也是立即可见的),但是将阻塞于 CPU0 之前的流量。相对的,CPU1 对 b 的赋值将于 CPU1 的空队列进行工作。因此,CPU2 在看到 CPU2 对 a 赋值前,可以看到 CPU1 对 b 的赋值。这将导致断言失败,尽管存在内存屏障。
从原理上来说,编写可移植代码不能用上面的例子,但是,正如前面一样,实际上这段代码可以在大多数主流的计算机上正常运行。
示例 3
表 C.4 展示了三个代码片段,在 CPU 0/1/2 上并行执行。所有变量均被初始化为 0。
请注意,不管是 CPU1 还是 CPU2 都要看到 CPU0 在第三行对 b 的赋值后,才能处理第 5 行,一旦 CPU 1 和 2 已经执行了第 4 行的内存屏障,他们就能看到 CPU0 在第 2 行的内存屏障前的所有赋值。类似的,CPU0 在第 8 行的内存屏障与 CPU1 和 CPU2 在第 4 行的内存屏障是一对内存屏障,因此 CPU0 将不会执行第 9 行的内存赋值,直到它对 a 的赋值被其他 CPU 可见。因此,CPU2 在第 9 行的 assert 将不会触发。
Linux 内核中的 synchronize_rcu 原语使用了类似于本地中的算法。
内存屏障是永恒的吗
已经有不少最近的系统,他们对于通常的乱序执行,特别是对乱序内存引用不大积极。这个趋势将会持续下去以至于将内存屏障变为历史吗?
赞成这个观点的人会拿大规模多线程硬件体系说事,这样一来每个线程都必须等待内存就绪,在此期间,可能有数十个、数百个甚至数千个线程在继续运行。在这样的体系结构中,没有必要使用内存屏障了。因为一个特定的线程在处理下一条指令前,将简单的等待所有外部操作全部完成。由于可能有数千个其他线程,CPU 将被完全利用,没有 CPU 周期会被浪费。
反对者则会说,极少量的应用有能力扩展到上千个线程。除此以外,还有越来越严重的实时响应需求,对某些应用来说,其响应需求是数十毫秒。在这种系统中,实时响应需求是难以实现的。而且,对于大规模多线程场景来说,机器低的单线程吞吐量更难以实现。
另一种支持的观点认为,更多的减少延迟的硬件实现技术会给 CPU 一种假象,使得 CPU 觉得按全频率、一致性的运行,这几乎提供了与乱序执行一样的性能优势。反对的观点则会认为,对于电池供电的设备及环境责任来说,这将带来严重的能耗需求。
没法下结论谁是对的,因此咱们还是准备同时接受两者吧。
对硬件设计者的建议
硬件设计者可以做很多事情,这些事情给软件开发者带来了困难。以下是我们在过去遇到的一些事情,在此列出来,希望能够帮助你防止在将来出现下列问题。
IO 设备忽略了缓存一致性
这个糟糕的特性将导致从内存中机械能 DMA 会丢失刚从输出缓冲区中对他进行的修改。同样不好的是,也导致输入缓冲区在 DMA 完成后被 CPU 缓存中的内容覆盖。要使你的系统在这样的情况下正常工作,必须在为 IO 设备准备 DMA 缓冲区时,小心刷新 CPU 缓存。类似的,在 DMA 操作完成后,你需要刷新所有位于 DMA 缓冲区的缓存。而且,你需要非常小心的避免指针方面的 BUG,因为错误的读取输入缓冲区可能会导致对输入数据的破坏。
外部总线错误的发送缓存一致性数据
该问题是上一问题的一个更难缠的变种,导致设备组甚至是在内存自身不能遵从缓存一致性。我痛苦的责任是通知你:随着嵌入式系统转移到多核体系,不用怀疑,这样的问题会越来越多。希望这些问题能在 2015 年得到处理。
设备中忽略了缓存一致性
这听起来真的无辜,毕竟中断不是内存引用,对吧?但是假设一个 CPU 有一个分区的缓存,其中一个缓存带非常忙,因此一直持有输入缓冲的最后一个缓存行。如果对应的 IO 完成中断到达这个 CPU,在 CPU 中引用这个缓存行的内存引用将返回旧值,再导致数据被破坏,在随后以异常转储的形式被发现。但是,当系统对引起错误的输入缓冲区进行转储时,DMA 很可能已经完成了。
核间中断(IPI)忽略了缓存一致性
当位于对应的消息缓冲区的所有缓存行,他们被提交到内存之前,IPI 就已经到达该目标 CPU,这可能会有问题。
上下文切换领先于缓存一致性
如果内存访问可以完全乱序,那么上下文切换就很麻烦。如果任务从一个 CPU 切迁移到另一个 CPU,而原 CPU 上的内存访问在目标 CPU 上还不完全可见,那么任务就会发现,它看到的变量还是以前的值,这会扰乱大多数算法。
过度宽松的模拟器和仿真器
编写模拟器或者仿真器来模拟内存乱序是很困难的。因此在这些环境上面运行得很好的软件,在实际硬件上运行时,将得到令人惊讶的结果。不幸的是,规则仍然是,硬件比模拟器和仿真器更复杂,但是我们这种状况能够改变。
我们再次鼓励硬件设计者避免这些做法。
4 - 七并发模型
4.1 - CH01-概述
基于摩尔定律的“免费午餐”时代已结束,为了让代码运行的更快,现在需要以软件的形式利用多核优势,发掘并行编程的潜力。
并发 vs. 并行
并发程序含有多个逻辑上的独立执行块,它们可以独立的并行执行,也可以串行执行。
并行程序解决问题的速度往往比串行程序快的多,因为它可以同时执行整个任务的多个部分。并行程序可能有多个独立执行块,也可能仅有一个。
并发是问题域中的概念——程序需要被设计成能够处理多个同时(或者几乎同时)发生的事件;而并行则是方法域中的概念——通过将问题中的多个部分并行执行来加速解决问题。
来自 Rob Pike 的经典描述:
- 并发是同一时间应对(dealing with)多件事情的能力。
- 并行是同一时间动手做(doing)多件事情的能力。
并发与并行经常被混淆的原因之一是,传统的“线程与锁”模型并没有显式的支持并行。如果要用线程与锁模型为多核进行开发,唯一的选择就是写一个并发的程序,然后并行的运行在多核上。
并发程序的执行通常是不确定的,它会随着事件时序的改变而给出不同的结果。对于真正的并发程序,不确定性是与生俱来且伴随始终的属性。与之相反,并行程序可能是确定的——比如将数组中的每个数都加倍,一种做法是将数组分为两个部分然后分别交给两个核处理,这种做法的结果是确定的。
并行架构
人们通常认为并行等同于多核,但现代计算机在不同层次上使用了并行技术。比如在由多个晶体管组成的单个核内,可以在位级和指令级两个层次上并行使用这些晶体管资源。
位级并行
因为并行,32 位计算机要比 8 位计算机的运算速度快。对于两个 32 位数的加法运算,8 位计算机必须进行多次 8 位运算,而 32 位计算机可以一步完成。计算机经历了 8、16、32 位时代,目前处于 64 位时代,由于位升级带来的性能提升存在瓶颈,因此我们短期内无法进入 128 位时代。
指令级并行
现代 CPU 的并行度很高,其中使用的技术包括流水线、乱序执行和分支预测等。
开发者可以不用关心 CPU 的内部并行细节,因为尽管 CPU 内部的并行度很高,但是经过精心设计,从外部看上去所有处理器都像是串行的。
但这种看上去像是串行的设计也逐渐变得不再适用。CPU 的设计者们为单核提升速度变得越来越困难。进入多核时代后我们必须要面对的情况是:无论表面上还是实质上,指令都不再是串行执行了。
数据级
数据级并行(也称单指令多数据,SIMD)架构,可以并行的在大量数据上施加同一操作。这并不适合解决所有问题,但在有些场景可以大展身手。
比如图像处理,为了增加图片亮度就需要增加每一个像素的亮度,现代 GPU 也因图像处理的特点演化成了极其强大的数据并行处理器。
任务级
这也是大家所认为的并行形式——多处理器。从开发者的角度看,多处理器架构最明显的分类特征是其内存模型(共享内存模型或分布式内存模型)。
- 对于共享内存模型,每个处理器都能访问整个内存,处理器之间的通信也通过内存完成。
- 对于分布式内存模型,每个处理器都拥有自己的内存,处理器之间的通信主要通过网络完成。
通过内存通信比通过网络通信更加简单快速,因此使用共享内存模型编写也更容易。但是当处理器个数不断增加,共享内存模型就会遇到瓶颈——这时不得不转向分布式内存模型。如果要开发一个容错系统,就要使用多台计算机以规避硬件故障对系统的影响,此时也必须借助分布式内存模型。
并发,不只是多核
并发的目的不仅仅在于让程序以并行方式运行以发挥多核优势。如果能够正确的使用并发,程序还能获得以下优点:及时响应、高效、容错、简单。
并发的真实世界
世界是并发的,为了与其有效交互,软件也应该是并发的。并发是系统及时响应的关键。比如,文件下载可以在后台运行,用户就不必等待鼠标上的沙漏了。再比如,Web 服务器可以并发的处理多个连接请求,一个慢请求不会影响服务器对其他请求的响应。
分布式世界
我们有时候需要解决地理分布问题。软件在非同步运行的多台计算机上分布式的运行,其本质也是并发。
此外,分布式软件还具有容错性。其中一台或一个区域内的几台机器宕机后,剩余机器仍然能够提供服务。
不可预测的世界
任何软件都无法避免 BUG 的存在,即便是没有 BUG,也无法完全避免硬件故障。为了增强软件的容错性,并发代码的关键是独立性和故障检测。
- 独立性:一个故障不会影响到故障任务以外的其他任务。
- 故障检测:当一个任务失败时,需要通知负责处理故障的其他任务来解决。
而串行程序的容错性远不如并发程序。
复杂的世界
在选对编程语言和工具的情况下,一个并发的解决方案要比串行方案简单清晰。
用串行方案解决一个现实世界的并发问题往往需要付出额外的代价,而且最终方案会晦涩难懂。如果解决方案有着与现实问题类似的并发结构,事情就会变得简单很多。
七种模型
- 线程与锁:存在很多总所周知的不足,但它是其他模型的基础,也是很多并发软件的首选。
- 函数式编程:函数式编程逐渐变得重要的原因之一,是其对并发和并行编程提供了良好的支持。函数式编程消除了可变状态,所以从根本上就是线程安全的,而且易于并行执行。
- 分离标识与状态:Clojure 是一种指令式与函数式混搭的语言,在两种编码方式上取得了微秒的平衡来发挥两者的优势。
- Actor:是一种适用性很广的并发编程模型,适用于共享内存模型和分布式内存模型,也适合解决地理分布问题,能提供强大的容错性。
- 通讯顺序进程(CSP):CSP 与 Actor 模型在表面上很相似,两者都基于消息传递。但是 CSP 模型侧重于传递信息的通道,而 Actor 模型侧重于通道两端的实体,因此基于 CSP 模型的代码会有明显不同的风格。
- 数据级并行:GPU 利用了数据级并行,不仅可以快速的处理图像,还可以用于更加广阔的领域。
- Lambda 架构:Lambda 架构综合了 MapReduce 和流式处理的特点,是一种可以处理多种大数据问题的架构。
后续将针对每种模型详细讨论以下问题:
- 该模型适用于解决并发问题?还是并行问题?还是两者都适用?
- 该模型适用于哪种并行架构?
- 该模型是否有利于写出高容错的代码,或是能够解决分布式问题的代码?
4.2 - CH02-线程与锁
虽然该模型稍显原始且难以驾驭、不够可靠还有点危险,但仍然是并发编程的首选项,也是其他并发模型的基石。
对硬件运行过程形式化
该模型其实是对底层硬件运行过程的形式化。这种形式化既是该模型的最大优点、也是其最大的缺点。
该模型非常简单直接,几乎所有编程语言都提供了对该模型的支持,且不对其使用方式加以限制。
互斥与内存模型
互斥:使用锁来保证某一时间仅有一个线程可以访问数据。它会带来竟态条件和死锁。
乱序执行的来源:
- 编译器和静态优化
- JVM 动态优化
- 底层硬件优化
从直觉上来说,编译器、JVM、硬件都不应该修改原有的代码逻辑,但是近几年的运行效率提升,尤其是共享内存架构的运行效率提升,均基于此类代码的优化。
Java 内存模型为这类优化提供了标准。
内存可见性
Java 内存模型定义了一个线程对内存的修改何时对另一个线程可见。基本原则是,如果读线程和写线程不进行同步,就不能保证可见性。
多把锁
很容易得出一个结论:让多线程代码安全运行的方法只能是让所有的代码都同步。但是这么做有两个缺点:
- 效率低下:如果每个方法都同步,大多数线程会频繁阻塞,也就失去了并发的意义。
- 死锁:哲学家就餐问题。
总结
- 对共享变量的所有访问都需要同步化。
- 读线程、写线程都需要同步化。
- 按照约定的全局顺序来获取多把锁。
- 当持有锁时避免调用外部方法(无法确保线程安全性)。
- 持有锁的时间尽可能的短。
更多同步机制
内置锁的限制
- 一个线程因为等待内置锁而进入阻塞之后,就无法中断该线程了。
- 尝试获得内置锁时无法设置超时。
- 必须通过 synchronized 块来获取内置锁。
可中断的锁
ReentrantLock 提供了显式的加解锁方法,可以在代码的不同位置来实现加解锁逻辑,这是 synchronized 块无法做到的。
同时,ReentrantLock 提供的 lockInterruptibly 方法可以用于终止死锁线程。
超时设置
ReentrantLock 还可以为获取锁的超时设置超时时间。
交替锁
设想我们要在链表插入一个节点,一种做法是用锁保护整个链表,但链表加锁时其他使用者无法访问该链表。而交替锁可以做到仅锁住链表的一部分,允许不涉及被锁部分的其他线程继续自由的访问链表。同样可以由 ReentrantLock 实现。
条件变量
并发编程经常需要等待某个事件发生。比如从队列删除元素前需要等待队列非空、向缓存添加数据前需要等待缓存拥有足够的空间。这时就需要条件变量 Condition。
一个条件变量需要与一把锁关联,线程在开始等待条件之前必须先获取这把锁。获取锁后,线程检查所有等待的条件是否为真。如果条件为真,线程将解锁并继续执行。
如果条件不为真,线程会调优 await 方法,它将原子的解锁并阻塞等待该条件。
当另一个线程调用了 signal 或 signalAll,意味着对应的条件可能已变为真,await 方法将原子的恢复运行并重新加锁。
原子变量
比如 AtomicInteger。与锁相比,原子变量有很多好处。首先,我们不会忘记在正确的时候获取锁;其次,由于没有锁的参与,对原子变量的操作不会引发死锁;最后,原子变量是无锁(lock-free)非阻塞(non-blocking)算法的基础,这种算法可以不使用锁和阻塞来达到同步的目的。
无锁代码比起有锁代码更加复杂,JUC 中的类都尽量使用了无锁代码。
总结
ReentrantLock 和 JUC.atomic 突破了使用内置锁的限制,可以利用它们做到:
- 在线程获取锁时将其中断。
- 设置线程获取锁时的超时时间。
- 按任意顺序获取和释放锁。
- 用条件变量来等待某个条件变为真。
- 使用原子变量来避免使用锁。
利用已有工具
线程池
比如,编写服务端应用时为每个连接请求创建一个线程,这样存在两个隐患:
- 创建线程是有代价的。
- 连接数的增长会使得线程数不断增长,而系统资源(如内存)是有限的。
可以使用线程池来对线程进行复用,JUC 提供了各种类型的线程池。
写时复制
比如 CopyOnWriteArrayList,它使用了保护性复制策略。它并非在遍历链表前进行复制,而是在链表被修改时复制,已经投入使用的迭代器会使用当时的旧副本。
其他概念
- 使用线程构建“生产者——消费者模型”。
- 毒丸(Poison Pill) 是一个特殊对象,告诉消费者“数据已取完,你可以退出了”。
- 使用线程构建“单生产者——多消费者模型”。
- 使用并发集合汇总多个消费者并发生成的结果。
- 使用线程池来优化线程的使用。
- 使用 ConcurrentHashMap 的分段锁优势,避免过多线程对单个资源的过度竞争。
- 为各个消费者提供各自的结构缓存,最后再汇总这些缓存,以避免没有必要的数据竞争。
总结
- 使用线程池,而不是直接创建线程。
- 使用写时复制让监听器先关的代码更简单高效。
- 使用同步队列构建生产者消费者模型。
- ConcurrentHashMap 提供了更好的并发访问。
本章总结
优点
- 适用面广,是许多其他技术的基础,更加接近于本质——近似对硬件工作方式的形式化,真确应用可以得到很高的效率。能够解决从小达到不同粒度的问题。
- 该模型可以被集成到大多数编程语言中。语言设计者可以轻易让一门指令式语言或 OO 语言支持该模型。
缺点
- 该模型没有为并行提供直接的支持。
- 该模型仅支持共享内存模型。如果要支持分布式内存模型则需要借助其他工具。
- 最大的缺点在于“无助”,应用开发者在编程语言层面没有得到足够的帮助。
隐性错误
应用多线程的难点不在编程,而在于难以测试。而测试中的一个大问题是难以复现。
随着项目的迭代和时间的流式,复杂的多线程代码会变得难以维护。
4.3 - CH03-函数式编程
CH03-函数式编程
命令式编程的代码由一些列该变全局状态的语句构成,而函数是编程则是将计算过程抽象成表达式求值。这些表达式由纯数学函数构成,这些作为一类对象(可以像操作数值一样操作函数)的数学函数没有副作用。因为没有副作用,函数式编程可以更容易做到线程安全,因此尤其适合用于并发编程。同时,函数式编程也是一个直接支持并行的模型。
函数式
线程与锁的模型中,核心是共享可变状态。而对不可变的数据,多线程不使用锁就可以安全的访问。
抛弃可变状态
- 可变状态的风险
- 被隐藏的可变状态
- 逃逸的可变状态
惰性
惰性序列不仅意味着仅在需要时候才生成尾元素,还意味着序列的头元素在使用后可以被丢弃。
函数式并行
基于 Clojure 的语言特性。
总结
- pmap 可以将映射操作并行化,构造一个半懒惰的 map。
- 利用 partition-all 可以对并行的映射操作执行批处理,从而提高效率。
- fold 使用分而治之的策略,可以将 reduce 操作并行化。
- clojure.core.reducers 包内提供的类似 map、mapcat、filter 的函数返回的不是序列,而是 reduciable,这是简化操作的关键。
函数式并发
在 Java 这类命令式语言中,求值顺序与源码的语句顺序紧密相关,虽然编译器和运行时都可能造成一些乱序,但一般来说,求值顺序与其在代码中的顺序基本一致。
函数式语言更有一种声明式的风格。函数式程序并不是描述“如何求值以得到结果”,而是描述“结构应当是什么样的”。因此,在函数式编程中,如何安排求值顺序来获得最终结果是相对自由的,这正是函数式编程可以轻松实现并行的关键所在。
引用透明性
指的是,在任何调用函数的位置,都可以使用函数运行的结构来替换函数的调用,而不会对程序产生影响。
虽然 Java 中有些操作也可以达到这样的效果,但函数式编程中的每个函数都具有引用透明性。当然,除了带有副作用的函数。
数据流
如上图中所示的数据流,由于 (+ 1 2) 和 (+ 3 4) 之间没有依赖关系,所以理论上这两步求值能以任意顺序进行,包括同时执行。前两步求值得到结果后,最优异步加法才能进行。
理论上,运行时可以从这幅图的左端出发,向右端推进数据。当一个函数所依赖的数据都可用时,该函数就可以执行了。至少在理论上所有函数都可以同时执行。这种方式被称为“数据流式编程”。
Clojure 语言中提供了 future 和 promise 来支持这种执行方式。即以操作数据流的形式完成并发编程任务。
总结
许多人对并行编程的理解存在一个误区:认为并行一定会伴随着不确定性,如果不串行执行就不能依赖某一种执行顺序的结果,必须时刻警惕竟态条件。
当然,有一些并发程序一定会带有不确定性。这对它们来说是不可避免的——有一些场景天生就依赖时序。但这并不意味着所有的并行程序都具有不确定性。
在使用线程与锁模型的程序中,大多数潜藏的竟态条件并不来自于问题本身的不确定性,而是因此在解决方案的细节中。
函数式编程具有引用透明性,因此可以随意改变其执行顺序,而不会对最终结果产生影响。我们可以顺理成章的让互相独立的函数并行执行。
关于函数式
开发者对编程语言的偏好很大程度上取决于语言的类型系统。使用 Java、Scala 之类的静态类型语言,与使用 Python、Ruby 之类的动态类型语言的体验是完全不同的。
静态类型语言强迫开发者在早期就必须选择正确的类型。只有付出这样的代价,编译器才能确保运行时不会发生类型错误,同时类型系统还可以优化执行效率。
在函数式编程中也存在这样的分歧。像 Haskell 这种静态类型的函数式语言利用 单子 和 幺半群 等数学概念为类型系统增加了以下能力:明确限制了某些函数和某些值可以使用的位置,在保持函数性的同时能够检测代码的副作用。
但 Clojure 并不拥有静态类型系统。
想要深入学习函数式理论可以尝试学习 Haskell,如《趣学 Haskell》。
想要在生产中应用函数式编程则可以尝试学习 Scala,如《Scala 函数式编程》。
优点
函数式编程的最大好处是我们可以确信程序会按照我们预想的方式运行。一旦上手,比起等价的命令式程序,函数式会更加简单、更易推理、更易测试。
如果采用了函数式解决方案,利用函数式的引用透明性,可以轻松将程序并行化,或者将程序应用于并发环境。由于函数式的不可变特性,大部分存在于线程与锁的 BUG 将销声匿迹。
缺点
很多人认为函数式代码比起命令式代码的效率低。对于某些场景确实存在性能损失,但大部分性能损失是远低于预期的。而且用少许性能损失来换取健壮性和扩展性的提升是值得的。
而且,函数式的优点也远远不止于体现在并发编程上。
4.4 - CH04-分离标识与状态
在此要特别强调不纯粹的函数是语言与命令式语言的区别。在命令式语言中,变量默认都是状态易变的,代码会经常修改变量。而在不纯粹的函数式语言中,变量默认是状态不易变的,代码仅在十分必要时才修改变量。
本节将介绍如何使用可变量和持久数据结构来分离状态与标识。采用这些技术,多线程可以不使用锁来访问可变量,同时也不会出现隐藏可变状态或逃逸可变状态。
基本组件
原子变量
持久数据结构
这里所说的持久并不是指将数据持久化到磁盘或保存到数据库中,而是指数据结构在被修改时总是保留其之前的版本,从而为代码提供一致的数据视角。
持久数据结构在被修改时,看上去就像是创建了一个完整的副本。如果持久数据结构在实现时也是创建完整的副本,将会非常低效且带来很大的使用限制。幸运的是,持久数据结构选择了更精巧的方法,即“共享结构”。
比如创建一个列表:
(def listv1 (list 1 2 3))
先现在使用 cons 创建一个上述列表的修改版,cons 返回列表的副本并在副本的首部添加一个元素:
(def listv2 (cons 4 listv1))
新列表可以完全共享原列表的元素——不需要进行复制,如上图所示。
下面再尝试创建一个修改版:
(def listv3 (cons 5 (rest listv1)))
这时仅共享了原始列表的部分元素,但扔不需要进行复制。有些情况下是无法避免复制的。有共同尾端的列表可以共享结构——如果两个列表拥有不同的尾端,就只能进行复制了。
(def listv1 (list 1 2 3 4))
(def listv2 (take 2 listv1))
在 Clojure 中集合都是持久的。持久的 vector、map、set 在实现上都比列表复杂,但它们都使用了共享结构,且与 Ruby 或 Java 中对应的数据结构心梗接近。
标识与状态
如果一个线程引用了持久数据结构,那么其他线程对数据结构的修改对该线程就是不可见的。因此持久数据结构对并发编程的意义非比寻常,其分离了标识(inentity)与状态(state)。
油箱中有多少油呢?现在可能有一半油,一段时间后可能就空了,再后来可能又满了。“油箱中有多少油”是一个标识,其状态是一直在改变的,也就是说,实际上它是一系列不同的值。
命令式语言中,变量混合了标识与状态——一个标识只能拥有一个值。这让我们很容易忽略一个事实:状态实际上是随时间变化的一系列值。持久化数据结构将标识与状态进行了分离——如果获取了一个标识的当前状态,无论将来对这个标识怎样修改,获取的那个状态将不会改变。
重试
由于 Clojure 是函数式语言,其原子是无锁的——内部使用了 JUC.AtomicReference 提供的 compareAndSet 方法。因此使用原子变量的效率很高且不会发生阻塞,因此也不会有死锁。
但这要求 swap!(用于更新原子变量的值)需要处理这种情况:当 swap! 调用其参数函数来生成新值、但尚未修改原子变量的值时,其他线程就修改了原子变量的值。如果发生了这种情况,swap! 就需要重试。swap! 将放弃从参数函数中生成的值,并使用原子变量的新值来重新调用参数函数。因此这要求该参数函数必须没有副作用——否则,多次重试时也会多次引起这些副作用。
校验器
在创建原子变量时可以提供一个校验器。校验器是一个函数,当改变原子变量的值时就会调用它。如果校验器返回 true,就允许本次修改,否则就放弃本次修改。
校验器在原子变量的值改变生效之前被调用。与“重试”机制中传给 swap! 的参数函数类似,当 swap! 进行重试时,校验器可能会被调用多次,因此校验器不能有副作用。
监视器
可以为原子变量添加一个监视器。添加监视器时需要提供一个键值和一个监视函数。键值用于区分不同的监视器。原子变量的值被改变时会调用监视器。监视器接收四个参数——调用 add-watch 时指定的键值、原子变量的引用、原子变量的旧值、原子变量的新值。
与校验器不同,监视器是在原子变量的值改变之后才被调用,且无论 swap! 重试多少次,监视器仅会被触发一次。因此监视器可以拥有副作用。注意:监视器被调用时,原子变量的值可能已被再次改变,因此监视器必须使用参数中提供的(触发时的)新值,而不能通过对原子变量进行解引用来获取(当前的)新值。
代理与软件事务内存
下面介绍两种可变数据类型:代理(agent) 和引用(ref)。与原子变量性质相同,代理和引用都可以用于并发,也能与持久数据结构一起使用,以实现标识与状态的分离。学习引用时将介绍 Clojure 中实现的对软件事务内存的支持,使变量在无锁的情况下可以被并行的修改,同时仍保持一致性。
代理
与原子变量类似,代理包含了对一个值的引用。可以通过 deref 或 @ 获取其值。与 swap! 类似,send 接受一个函数,并用代理的当前值作为参数来调用该函数,函数的返回值再作为代理的新值。
send 与 swap! 的区别是,前者会(在代理的值更新之前)立即返回——传给 send 的函数将在某个时间点被调用。如果多个线程同时调用 send,传给 send 的函数将被串行调用:同一事件只会调用一个。也就是说该函数不会进行重试,并且可以具有副作用。
与 Actor 相似?两者存在很大的差异:
- 通过 deref 可以获得代理的值,而 actor 没有提供直接获取值的方式。
- actor 可以包含行为,而代理不可以:对数据的操作函数必须由调用者提供。
- actor 提供了复杂的错误检测和错误恢复机制,而代理仅提供了简单的错误报告机制。
- 使用多个 actor 可能会引起死锁,但使用多个代理不会。
send 的异步更新机制相比同步优势明显,尤其是当更新操作会发生阻塞或需要持续很久时。但异步更新也很复杂,尤其是在错误处理方面。
在 Clojure 中,一旦代理发生错误,就会进入失效状态,之后对代理数据的任何操作都会失败。
创建代理时其默认的错误处理模式为 fail。也可以将错误处理模式设置为 continue,这意味着失效状态的代理不再需要通过 restart-agent 重置就可以继续新的操作。如果设置了错误处理函数,错误处理模式会被默认设置为 continue,代理出现错误时则会调用错误处理函数。
软件事务内存
引用(ref)比原子变量或代理更加复杂,通过引用可以实现软件事务内存(STM)。通过原子变量和代理每次仅能修改一个变量,而通过 STM 可以多多个变量进程并发一致的修改,就像数据库中的事务可以对多行数据进行并发一致的修改一样。
引用也是包装了对一个值的引用,使用 deref 或 @ 获取值;使用 alter 函数来修改引用的值,但不同于 swap! 或 send,使用时不能只是简单的被调用。因为只能在一个事务中才能修改引用的值。
事务
STM 事务具有原子性、一致性、隔离性。
- 原子性:在其他事务看来,当前事务的副作用要么全部发生,要么都不发生。
- 一致性:事务保证全程遵守校验器定义的规范,如果事务的一系列修改中存在一个校验失败,那么所有的修改都不会发生。
- 隔离性:多个事务可以同时运行,但同时运行的事务的结果,与串行运行这些事务的结构应当完全一样。
这三个性质是许多数据库支持的 ACID 特性中的前三个,唯一遗漏的性质是——持久性,STM 的数据在电源故障或系统崩溃时会丢失。如果需要用到持久性则完全可以直接使用数据库。
隔离性选择
大多数场景适合使用完全隔离的事务,但对于有些场景来说,隔离性是个过强的约束。如果使用 commute 替换 alter,就可以得到不那么强的隔离性。
多个引用
事务通常会涉及多个引用,否则应该使用原子变量或代理。
对,你猜对了,又是银行转账的例子。
如果 STM 运行期间检测到多个并发事务的修改发生冲突,那其中一个或几个事务将进行重试。就像修改原子变量一样,需要保证事务没有副作用(除了更新引用的值意外的其操作)。
重试事务
基于无锁的重试,可以避免死锁。
事务的安全副作用
代理具有事务性。如果在事务中使用 send 来更新一个代理,那么 send 仅会在事务成功时生效。如果需要在事务成功时产生一些副作用,那 send 将是最佳选择。
适用场景
Clojure 对共享可变状态的三种支持机制:
- 原子变量:可以对一个值进行同步更新,同步的意思是当 swap! 调用返回时更新已经完成。无法对多个变量进行一致性更新。
- 代理:对一个值进行异步更新,异步的意思是更新可能在 send 返回后完成。对多个代理不能一致更新。
- 引用:可以对多个值进行一致的、同步的更新。
原子变量还是 STM
当解决一个涉及多个值需要一致更新的问题时,即可以使用多个引用并通过 STM 来保证一致性,也可以将这些值整合到一个数据结构中并用一个原子变量管理这个单个数据结构的访问一致性。
该如何选择呢?答案是因人而异,两种方案都正确,尽量选择简单的,比如数据结构肯能会很复杂。在性能上,根据使用场景的特点和数据访问模式的不同,肯定会存在差异,所以需要有效的压力测试进行评估。
虽然 STM 带有很多光芒,但就 Clojure 而言,由于语言的函数性减少了对可变量的使用,因此大部分问题都可以使用原子变量来解决。而更简单的方案通常会更有效。
总结
优点
传统的命令式语言混淆了标识与状态这两个概念,而 Clojure 的持久数据结构将可变量的标识与状态分离开来。这解决了基于锁的方案的大部分缺点。
缺点
基于 Clojure 方式的并发编程不支持分布式编程,因此也无法直接提供容错性。好在 Clojure 运行于 JVM,可以使用一些第三方库来解决该问题,比如 Akka。
其他语言
Haskell 提供了类似本章的功能,不过作为一种纯粹的函数式语言,它的风格会带来一种非常不同的编程体验。值得一提的是 Haskell 提供了完整的 STM 实现。可以参考 Beautiful Concurrency。
另外,大部分主流语言都提供了 STM 实现,包括 GCC 支持的编程语言。但是有证据表明,STM 模型并不适合于命令式编程语言。
4.5 - CH05-Actor
使用 Actor 就像租车——如果我们需要,可以很快速的租到一辆;如果车辆发生故障,也不需要自己修理,直接换一辆即可。
Actor 模型是一种适用性非常好的通用并发编程模型。它可以应用于共享内存架构和分布式内存架构,适合解决地理分布问题,同时还能提供很好的容错性。
更加面向对象
函数式编程使用可变状态,也就避免了共享可变状态带来的一系列问题。相比之下,使用 Actor 模型保留了可变状态,但不将其共享。
Actor 类似于 OOP 中的对象——其中封装了状态,并通过消息与其他 Actor 通信。两者的区别是所有 Actor 可以同时运行,而且,与 OO 式的“消息传递(实质上是方法调用)”不同,actor 之间是真实的在传递消息。
Actor 模型是一个通用的并发编程模型,几乎可以用在任何一种编程语言里,最典型的是 Erlang。而我们将使用 Elixir 来介绍 actor 模型,它是 Erlang 虚拟机(BEAM)上一种较新的编程语言。
与 Clojure 相比,Elixir 是一种不纯粹的、动态类型的函数式语言。
消息与信箱
在 Elixir 中,进程是一个轻量级的概念,比操作系统的线程还要轻,它消耗更少的资源且创建代价很低。Elixir 程序可以毫不困难的创建数千个进程,通常不需要依赖线程池技术。
对列式信箱
异步的发送消息是使用 actor 模型的重要特性之一。消息并非直接发送到一个 actor,而是发送到一个 mailbox。
这样的设计解耦了 actor 之间的关系——actor 都以自己的步调运行,发送消息时也不会被阻塞。
虽然所有 Actor 可以同时运行,但它们都按照信箱接收到消息的顺序来依次处理消息,且仅在当前消息处理完成之后才会开始处理下一条消息,因此我们只需要关心发送消息时的并发问题即可。
接收消息
def loop do
receive do
{:greet, name} -> IO.puts("Hello #{name}")
{:praise, name} -> IO.puts("#{name}, you're amazing")
{:celebrate, name, age} -> IO.puts("Here's to another #{age} years, #{name}")
end
loop
end
通常 actor 会进行无限循环,通过 receive 等待接收消息,并进行消息处理。在 Elixir 的 actor 实现中,内部的一个函数通过递归调用自己来进行无限循环,用 receive 来等待一个消息,通过模式匹配来决定如何处理消息。这
Elixir 实现了尾调用消除,即,如果函数在最后调用了自己,那么递归调用将被替换成一个简单的跳转,这样可以避免递归引起的堆栈移除。
连接到进程
为了彻底关闭一个 actor,需要满足两个条件。第一个是需要告诉 actor 在完成消息处理后就关闭;第二个是需要知道 actor 何时完成关闭。
首先,通过接收一个显式的关闭消息来满足第一个条件:
receive do
...
{:shutdown} -> exit(:normal)
...
然后,通过一个方法来获知 actor 是否完全关闭。下面的代码将 :trap_exit
设为 true,并用 spawn_link 替换 spawn 以连接到进程:
Process.flag(:trap_exit, true)
pid = spawn_link(&Talker.loop/0)
现在当创建的进程关闭时,就会得到一个通知(是一个系统产生的消息)。
双向通信
Actor 是以异步的方式发送消息的——发送者因此不会被阻塞。那么如何获得一个消息的回复呢?
Actor 模型没有提供直接回复消息的机制,但我们可以轻松实现:将发送进程的标示符包含在消息中,接收者接收到消息后提取其中的标识符,然后向该标识符表示的进程发送回复消息。
为进程命名
将一个消息发送给某个进程时,需要知道进程的标示符。当我们自己创建进程时没有问题,但如何向别人创建的进程发送消息呢?最简单的方式就是为进程命名。
错误处理与容错
错误检测
前面我们使用 spawn_link 建立了两个进程之间的连接,这样就可以检测到某个进程的终止。Linking 是 Elixir 编程中的一个重要概念。
- 进程的异常终止通过连接进行传播。
- 连接是双向的。
- 正常终止时不影响相连接的其他进程。
- 通过设置 trap_exit 标识可以让一个进程捕获到另一个进程的终止消息,即,将该进程转化为系统进程。
管理进程
可以创建一个系统进程来管理其他若干个进程。
错误处理内核模式
Tony Hoare 有一句名言: 软件设计有两种方式:一种是使软件过于简单,明显的没有缺陷;另一种是使软件过于复杂,没有明显的缺陷。
Actor 提供了一种容错的方式:错误处理内核模式。在两者之间找到了一种平衡。
一个软件系统如何应用了错误处理内核模式,那么使该系统正确运行的前提是其错误处理内核必须能够正确运行。程序的程序通常使用尽可能小的错误处理内核——小而简单到明显没有缺陷。
对于一个使用 actor 模型的程序,其错误处理内核是顶层的管理者,管理着子进程——对子进程进行启动、停止、重启等操作。
程序的每个模块都有自己的错误处理内核——模块正确运行的前提是其错误处理内核必须正确运行。子模块也会拥有自己的错误处理内核,依次类推。这就构成了一个错误处理的层级树,较危险的操作都会被下放给底层的 actor 执行。
错误处理内核机制主要解决了防御式编程中碰到的一些棘手问题。
任其崩溃
防御式编程主要通过预言可能出现的缺陷来实现容错性。使用 actor 模型并不需要使用防御式编程,而是遵循“任其崩溃”的哲学,让 actor 的上层管理者来处理这些问题。这样做的优势在于:
- 代码会变得更加简洁从而易于理解,可以清晰区分稳定代码和脆弱代码。
- 多个 actor 之间是相互独立的,并不共享状态,因此一个 actor 的崩溃不太会殃及到其他 actor。尤其重要的是一个 actor 的崩溃不会影响到其管理者,这样管理者才能正确处理此次崩溃。
- 管理者也可以选择不处理崩溃,而是记录崩溃的原因,这样我们就会得到崩溃通知并进行后续处理。
分布式
相比已经介绍过的并发模型,actor 模型的一个重大优点是它支持分布式——它可以将消息发送到另外一台计算机,就像发送到本地计算机上的 actor 一样。这被称为地理位置透明。
OTP
上面演示的代码过于底层,而 OTP 为使用 Actor 模型提供更多工具。
- 更简便的消息匹配。
- 进程管理。
- 更好的重启逻辑。
- 调试与日志。
- 代码热升级。
- 发布管理、故障切换、自动扩容等。
主要概念包括:
- 节点
- 连接节点
- 远程执行
- 远程消息
- 等等。
总结
优点
- 消息传递与封装
- 容错
- 分布式编程
缺点
Actor 除了优点,也会带来它独有的一些问题。
Actor 模型并没有直接提供并行支持,事实上可以自己构造,但由于 actor 之间不共享状态,仅通过消息传递进行交流会不太适合实施细粒度的并行。
个人认为应用 Actor 模型的最大障碍是开发者们的思维方式转变,尤其对于一个以 Java 这种命令式语言作为生产语言的团队来说。
另外,如果想要深入理解 Actor 模型,可以直接参考 Erlang/OTP,如果想要在生产中构建基于 Actor 模型的项目,推荐使用运行于 JVM 的 Akka,当然,如果同时使用 Scala 语言就更好了,因为 Java 中一贯的编程思路如共享可变状态、对象可变等不利于使用 Actor 模型。
4.6 - CH06-CSP
CSP 看上去类似于 Actor,但最大的区别在于:actor 模型的重点在于参与交流的实体,而 CSP 模型的重点在于用于交流的通道。
大家都在跌跌不休的争论涡轮增压与自然吸气孰优孰劣,让中置发动机布局与前置发动机布局一较高下,却忘记了最重要的方面其实与车辆本身无关。你能去往何方、能多快到达目的地,首要的决定因素是道路网络而不是车辆本身。
消息传递系统与之类似,决定其特性和功能的首要因素并不是用于传递消息的代码或消息的内容,而是消息的传输通道。
万物皆通信
使用 actor 模型的程序是由独立的、并发执行的实体组成,这些实体之间通过发送消息进行通信。每个 actor 都有一个信箱,用于保存已经收到但尚未被处理的消息。
与 actor 模型类似,CSP 模型也是由独立的、并发执行的实体组成,实体之间也是通过发送消息进行通信。但两种模型的重要差别在于:CSP 模型不关注发送消息的实体,而是关注发送消息时使用的 channel(通道)。通道是第一类对象,它不想 actor 的信箱一样与实体紧耦合,而是可以单独创建和读写,并在进程之间传递。
与函数式编程和 actor 模型类似,CSP 模型也是正在复兴的古董。由于近来 Go 语言的兴起,CSP 模型又流行了起来。
channel 与 go block
core.async 库将 Go 的并发模型引入了 Clojure,channel 与 go block 是其提供的主要工具。在大小有限的线程池中,go block 允许多个并发任务复用线程资源。
channel
一个 channel 就是一个线程安全的队列——任何任务只要持有 channel 的引用,就可以向其一端添加消息,也可以从另一端删除消息。在 actor 模型中,消息是从指定的 actor 发往指定的另一个 actor;与之不同,使用 channel 发送消息时发送者并不知道谁是接收者,反之亦然。
缓存区
默认情况下,channel 是同步的(或称无缓存的)——一个任务向 channel 写入消息的操作会一直阻塞到另一个任务从 channel 中删除该消息。
如果向创建 channel 的 chan 函数传入缓存区大小,就可以创建一个有缓存的 channel。当缓存没有被消息填满时,向其写入消息会理解返回,不会阻塞。
关闭
close!
可以关闭一个 channel。从已经关闭的空的 channel 中读出消息将会得到 nil;向已经关闭的 channel 写入消息时,消息将会被丢弃,写入 nil 则会报错。
缓存已满
默认情况下,向一个缓存已满的 channel 写入消息将会一直被阻塞。但通过向 chan 函数传入缓冲区来改变这个策略。
- default:阻塞
- dropping-buffer:满时丢弃,不再阻塞
- sliding-buffer:启用已有消息,使用新消息填充,不再阻塞
go block
线程创建与启动都会带来开销,这也正是使用线程池的原因。但是线程池并非总是适用,尤其是当线程可能会被阻塞时,使用线程池则可能会带来麻烦。
阻塞问题
线程池技术是处理 CPU 密集型任务的利器——任务进行时会占用某个线程,任务结束后将线程返还给线程池,以使得线程能够被复用。但涉及线程通信时使用线程池是否合适呢?如果线程被阻塞,那么它将被无限期占用,这就削弱了使用线程池技术的优势。
这种问题是存在解决方案的,但通常会对代码风格加以限制,使之变成事件驱动式编程。事件驱动是一种编程风格。
虽然这些方案能够解决问题,但破坏了控制流的自然表达形式,让代码变得难以阅读和理解。更糟的是,这些方案还会大量使用全局状态,因为事件处理器需要保存一些数据,以便之后的事件处理器使用。我们已经学习过这个结论了:状态与并发不要混用。
go block 提供了一种两全其美的解决方案——既可以写出事件驱动的代码来解决目前遇到的阻塞问题,又可以不牺牲代码的结构性和可读性。其原理是 go block 在底层将串行的代码透明的重写成了事件驱动的形式。
控制反转
与其他 Lisp 方言类似,Clojure 有一套强大的宏系统。如果你使用过其他语言的宏系统,就会觉得 Lisp 的宏更像是魔法,它可以进行神奇的代码变换。go 宏就是其中一个小魔法。
go block 中的代码会被转换成一个状态机。当从 channel 中读出消息或向 channel 中写入消息时,状态机将暂停,并释放它所占用的线程的控制权。当代码可以继续运行时,状态机进行一次状态转换,并可能在另一个线程中继续运行。
通过这样的控制反转,core.async 运行时可以在有限的线程池中高效的运行多个 go block。
状态机暂停
channels.core=> (def ch (chan))
#'channels.core/ch
channels.core=> (go
#_=> (let [x (<! ch)
#_=> y (<! ch)]
#_=> (println "Sum:" (+ x y))))
#<ManyToManyChannel clojure.core.async.impl.channels.ManyToManyChannel@13ac7b98>
channels.core=> (>!! ch 3)
nil
channels.core=> (>!! ch 4) nil
Sum: 7
这段代码首先创建了一个名为 ch 的 channel。然后创建了一个 go block,用来从 ch 中读取两个值,再输出两个值之和。虽然看上去 go block 从 channel 中读取数据时应当阻塞,实际上却发生了有趣的事情。
这段代码并没有使用 <!!
从 channel 中读取数据,而是使用了 <!
。单个谈好意味着本次读 channel 是进行暂停操作,而不是进行阻塞操作。
如下图所示,go block 将串行的代码转换成拥有 3 个状态的状态机:
该状态机包含以下 3 个状态:
- 初始状态会直接暂停,等待 ch 中有数据可以被读取。满足条件时进入状态 2。
- 状态机首先从将 ch 读取的值绑定到 x 上,然后暂停,等待 ch 中下一个可以被读取的数据。满足条件时,进入状态 3。
- 状态机将从 ch 中读取的值绑定到 y 上。输出计算结构,然后终止。
go block 的成本很低
go block 的只要意义在于其效率。与使用线程不同,使用 go block 的成本很低,因此可以创建很多个而不用担心耗尽资源。这看上去是个小小的改进,但实际上不用担心资源而能随意创建并发任务有着革命性的意义。
你可能已经注意到 go block 返回的是一个 channel,go block 运行完成时会将结果写入到这个 channel 中。
经过试验,创建并运行 10 万个 go block 仅需 3/4 秒。这意味着 go block 的性能比起 Elixir 的进程毫不逊色——该成绩非常优秀,因为 Elixir 运行在以并发性能为设计主旨的 Erlang 虚拟机中,而 Clojure 却运行于 JVM。
总结
优点
与 Actor 模型相比,CSP 模型的最大优点是灵活性。使用 actor 模型时,负责通信的媒介与执行单元是紧耦合的——即 actor 的信箱。而使用 CSP 模型时,channel 是第一类对象,可以被独立的创建、写入、读取,也可以在不同的执行单元中传递。
Clojure 语言的创始人 Rich Hickey 解释了他选择 CSP 而非 actor 的原因:
我个人对 actor 模型并不感兴趣。在 actor 模型中,生产者与消费者还是紧耦合在一起的。诚然,我们可以使用 actor 模型实现消息通信用的队列,但是 actor 模型本身就已经使用了队列,用它来实现基础的消息通信用的队列未免显得画蛇添足。
从更务实的角度来说,现在的 CSP 模型的实现,比如 core.async 库,使用了控制反转技术,不仅提高了异步程序的效率,还为原本使用回调函数来解决的应用领域提供了一种显著改进的编程模型。
缺点
基于 CSP 模型的编程语言也可以支持分布式和容错性,但与基于 actor 模型的编程语言不通,这两个主题没有得到足够多的重视和支持——也没有基于 CSP 模型实现的 OTP。
与使用线程锁模型和 actor 模型一样,CSP 模型也容易受到死锁影响,且没有提供直接的并行支持。使用 CSP 模型时,并行需要建立在并发的基础上,这也就引入了不确定性。
结语
CSP 模型和 Actor 模型各自的开发社区侧重点不同并各自发展,从而形成了两者之间的诸多差异。Actor 模型的开发社区侧重于容错性和分布式,而 CSP 模型的开发社区侧重于效率和代码表达的流畅性。
如果为 Actor 模型引入 CSP 形式的流畅性呢?
4.7 - CH07-数据并行
数据并行就像是八车道的高速公路,虽然每辆车的速度相对平缓,但由于多辆车可以同时行进,所以通过某一点的车流量可以很大。
到目前为止,我们讨论的每一项技术都可以用于解决多种编程问题。相比之下,数据并行只适用于很窄的范围。顾名思义,数据并行是并行编程技术,而不是并发编程技术。
GPGPU
图形处理单元(GPU)是隐藏在电脑中的超级计算机。现代 GPU 是一个强力的数据并行处理器,其用于数学计算时的性能超越 CPU,这种做法称为基于图形处理器的通用计算,即 GPGPU。
图形处理与数据并行
计算机图形学主要研究如何处理数据、如何处理大量数据以及如何快速处理大量数据。3D 游戏的一个场景是由无数个小三角构成的,每个三角形都需根据与视点的透视关系计算出其在屏幕上的位置,并进行裁剪、光照处理、修饰纹理等,这些操作每秒钟都要进行 25 次以上。
虽然需要处理的计算量是很大的,但它有一个非常好的特性:施加在数据上的操作都s’s是相对姜丹的向量操作或矩阵操作。因此这种场景非常适合数据并行——多个计算资源会在不同的数据上并行施加相同的操作。
现代 GPU 是十分复杂但非常强力的并行处理器,其 1 秒钟可以处理几十亿个三角形。虽然设计 GPU 的主要目的是为了满足图形计算的需要,但是 GPU 也可用于更广的领域。
数据并行可以通过多种方式来实现,我们要学习其中两种:流水线和多 ALU。
流水线
虽然看上去两数相乘是一个原子操作,但如果从芯片上的门电路角度看,这个操作实际上是分几步完成的。这些操作通常被排列成流水线型,如下图:
上图是一个拥有 5 个步骤的流水线,如果每一步需要一个时钟周期来完成,那将一组数(两个数)相乘就需要 5 个时钟周期。但如果有多组数相乘,就可以通过让流水线饱和来获得更好的性能,如下图:
如果需要将 1000 组数相乘,每组数需要 5 个时钟周期,看上去总共需要 5000 个时钟周期,而如上图所示,仅需要略多于 1000 个时钟周期即可完成。
多 ALU
CPU 中负责进行乘法运算的组件称为算术逻辑单元,即 ALU,如下图:
只需要搭配足够多的内存总线,多个 ALU 就可以同时获取多个操作数,这样施加在大量数据上的运算就可以并行了,如下图:
GPU 的内存总线通常有 256 位或更宽,也就是说一次可以获取 8 个或更多个 32 位的浮点数。
混乱的局面
为了获得更好的性能,现实中的 GPU 会综合使用流水线、多 ALU 以及许多本书尚未提及的技术,这就进一步增加了理解 GPU 的难度。更遗憾的是,不同的 GPU 之间的共性很少,如果必须针对某个 GPU 架构开发代码,GPGPU 编程并非最佳选择。
OpenCL 定义了一种类 C 的语言,可以针对多种架构抽象的进行编程。不过的 GPU 厂商会提供各自的编译器和驱动程序,使代码可以被编译并运行在对应的 GPU 上。
总结
优点
数据并行非常适用于处理大量数值数据,尤其适合用于科学计算、工程计算及仿真领域,比如流体力学、有限元分析、N 体模型、模拟退火、蚁群优化、神经网络等。
GPU 不仅是强大的数据并行处理器,在能耗方面也变现出众,比传统的 CPU 有更加优秀的 GFLOPS/watt 指标。世界上最快的超级计算机都广泛使用 GPU 或专用数据并行协处理器,其中能耗指标低是一个重要的原因。
缺点
数据并行编程,更准确的说是 GPGPU 编程,在其适合领域内所向披靡。但并不适用于所有问题领域。值得一提的是,虽然用数据并行可以解决一些非数值问题(如自然语言处理),但这样做并不容易——现今的工具集绝大多数关注的是数值处理。
对 OpenCL 内核的调优是个技术活,理解底层架构的细节才能有效的进行调优。如果要写出高效的跨平台代码,就会变得异常复杂。在解决某些问题时,从主机往设备上复制数据会消耗大量时间,这会减弱甚至低效我们从事并行计算中获得的收益…..
4.8 - CH08-Lambda 架构
如果需要将一大批货物从国家的一端运送到另一端,18 轮的打开车是不二之选。如果紧要运行一个包裹,大卡车就不太适用了,因此综合性的航运公司也会适用一些小型货车进行本地的货物收发。
Lambda 架构采用了类似的方法,既使用了可以进行大规模数据批处理的 MapReduce 技术,也使用了可以快速处理数据并及时反馈的流处理技术,这样混搭能够为大数据问题提供扩展性、响应性、容错性都很优秀的解决方案。
并行计算
不同于传统数据处理,大数据领域广泛使用了并行计算——只要有足够的计算资源就可以处理 TB 级别的数据。Lambda 架构是一种大数据处理技术。
与上一章讨论的 GPGPU 编程类似,Lambda 架构也使用了数据并行技术。与 GPGPU 不同的是,Lambda 架构站在大规模场景的角度来解决问题,它可以将数据和计算分布到几十台或几百台机器构建的集群上运行。这种技术不但解决了之间因为规模庞大而无法解决的问题,还可以构建出对硬件错误和认为错误进行容错的系统。
Lambda 架构包含了很多内如,本章只侧重于其并发和分布式特性(更多内如可以参考 Big Data 一书)。对于 Lambda 架构中的诸多组件,本书侧重介绍两个主要的层:批处理层和加速层。
批处理层使用 MapReduce 这类批处理技术从历史数据中对批处理视图进行预计算。这种计算效率高但延迟也高,所以又增加了一个加速层,使用流处理等低延迟技术从接收到的新数据中计算实时视图。合并这两种视图,就可以获得最终的计算结果。
这是本书中最复杂的专题。它以很多其他技术作为基石,其中最重要的就是 MapReduce。
MapReduce
MapReduce 是一个多语义的术语。它可以指代一类算法,这类算法分为两个步骤:对一个数据首先进行映射操作(map),然后进行化简(reduce)操作。
MapReduce 还可以指代一类系统:这类系统使用了上述算法,将计算过程高效的分布到一个集群上。这类系统不仅可以将数据和数据处理过程分不到集群的多台机器上,还可以在一台或多个计算机崩溃时继续运转。
当 MapReduce 指代一类系统时,可以说它是 Google 发明的。除了 Google,最流行的 MapReduce 框架是 Hadoop。
Hadoop 基础
Hadoop 就是用来处理大量数据的工具。如果你的数据不是以 GB 或更大的单位来度量的,那就不适用使用 Hadoop。Hadoop 的效率源自于它将数据分块后分别交给多台计算机进行处理。
我们很容易猜到,一个 MapReduce 任务由两种主要的组件构成:mapper 和 reducer。mapper 负责将某种输入格式映射为许多键值对;reducer 负责将这些键值对转换为最终的输出格式。mapper 和 reducer 可以分布在很多不同的机器上。
输入通常由一个或多个大文本文件构成。Hadoop 对这些文件进行分片(每一片的大小是可配置的),并将每个分片发送个一个 mapper,mapper 将输出一系列键值对,Hadoop 再将这些键值对发送给 reducer。
一个 mapper 产生的键值对可以发送给多个 reducer。键值对的键决定了那个 reducer 会接收这个键值对——Hadoop 确保具有相同键的键值对(无论由哪个 mapper 产生)都会发送给同一个 reducer 处理。这个阶段通常被称为洗牌(shuffle)。
Hadoop 为每个键调用一次 reducer,并传入所有与该键对应的值。reducer 将这些值合并,再生产最终的输出结果。
批处理层
传统数据系统的缺陷
数据系统不是一个新概念——从计算机发明之初,数据库就一直负责存储和处理数据。传统数据库适用于一台计算机,但随着要处理的数据量越来越大,数据库就必须使用多台机器。
扩展性
利用一些技术(如复制、分片等)可以将传统数据库扩展到多台机器上,但随着计算机数量和数据量的增长,这种方案会变得越来越困难。超过一定程度,增加计算机资源将无法继续提升性能。
维护成本
维护一个跨越多台计算机的数据库的成本是比较高的。如果要求维护时不能停机,那么维护将变得更加困难——比如对数据库进行重新分片。随着数据量和查询数量的增加,容错、备份、确保数据一致性等工作的难度会呈几何级数增长。
复杂度
复制和分片通常要求应用层提供一些支持——应用需要知道将请求发送给哪一台机器,以及应该更行哪一个数据分片。开发者习惯使用的许多特性(比如事务)在数据库分片后将无法使用。也就是说开发者必须显式处理失败的事务并进行重试。这都增加了传统数据库的复杂性,也增加了出错的可能。
认为错误
讨论容错性时很容易被忽略的就是认为错误。许多数据故障不是由于存储故障引起的,而是由于管理员或开发人员的认为错误引起的。如果运气比较好,这类错误可以被快速定位,并通过还原备份来恢复,但不是所有错误都可以被轻易解决。设想一下,如果有一个隐藏了几周的数据错误突然引发了大面积崩溃,我们又该如何修复数据库呢?
有时,我们可以分析错误影响的范围,并使用临时的脚本来修复数据库。有时我们可以通过重放数据库日志来回滚这个错误。有时,我们只能承认运气不佳。每次一来运气可不是长久之计。
报表与分析
传统数据库擅长于运营支持,即处理日常的业务数据。如果要处理历史数据,比如生成报表或进行数据分析,传统数据库的效率就比较低了。
典型的解决方案是在独立的数据仓库中使用另一种格式来维护历史数据。数据从业务数据库向数据仓库的迁移过程就是著名的 ETL。这种方案不仅复杂,而且需要准确预测将来需要什么样的信息。有时会碰到这种情况:由于缺乏必要的信息或者信息格式不对,无法生成所需报表或进行某些分析。
永恒的真相
我们可以将信息分为两类——原始数据和(从原始数据生成的)衍生数据。原始数据是永恒的真相,也是 Lambda 架构的基础。
加速层
上图展示了批处理层与加速层的协作方式。有新数据生成时,一方面给将其添加到原始数据中,这样批处理层就可以进行处理;另一方面将其传递给加速层,加速层会生成一个实时视图,实时视图会和批处理视图合并来满足对最新数据的查询。
实时视图仅包含最后一次生成批处理视图后产生的原始数据所对应的衍生信息,当这部分数据被批处理层处理后,该实时视图被弃用。
设计加速层
不同的应用对实时性的要求是不同的——有一些要求数据在妙级可用,有些甚至是毫秒。
由于加速层需要使用增量算法,因此要比构建批处理层复杂的多。这意味着加速层不能只处理原始数据,也就享受不到原始数据的原始特性了。我们必须重新面对传统数据库的特性:随机写、复杂的锁机制和事务机制。
从好的方面来看,加速层只需要处理一部分数据,就是那部分还未被批处理层处理的珊瑚橘。一旦批处理层赶上进度,旧的数据就会从加速层移除。
同步与异步
最容易想到的构建加速层的方式是模仿传统的同步数据库。其实可以将传统数据库看做是 Lambda 架构的一种退化特例(没有批处理层)。
在这种模型中,客户端直接和数据库通信,并在数据库进行更新操作时阻塞。这种模型非常合理,在某些场景下这是唯一能满足特定需求的方法。不过在另一些场景中,异步架构更合适一些。
在这种模型中,客户端将更新操作添加到队列(如 Kafka),这一步是无阻塞的。流处理器将串行的处理这些更新操作并对数据库进行更新。
用队列将客户端和数据库解耦,会使更新操作变得更加复杂。不过,根据异步的特性,如果可以接受异步方案,也会获得非常显著的好处:
- 客户端不会阻塞,所以少量的客户端就能处理大量数据,从而提高吞吐。
- 业务压力激增会导致客户端或数据库超载,也会导致同步系统超时或丢失一些更新。而异步系统则不同,只需要将未处理的更新操作保持在队列中,在业务压力恢复稳定后可逐渐赶上进度。
- 稍后我们将了解到:流处理器可以被并行化,也可以在多台计算机上进行分布式计算,即改善了性能也提供了容错。
如何让数据过期
假设批处理层需要两个小时处理数据,那么很容易就会认为加速层需要保留这两个小时以内的数据。实际上加速层需要保留两倍的数据,如下图所示。
假设 N-1 次批处理刚刚结束,第 N 次批处理正要开始。如果每次批处理需要运行两个小时,这意味着批处理视图会落后两个小时。因此加速层需要保持这落后的两个小时数据,还要保持批处理层运行的两个小时中所有的新数据,总共需要保持 4 个小时的数据。
当第 N 次批处理结束时,需要让最早两个小时的数据过期,但仍保存其后两个小时的数据。有多种方法可以达到目的,不过最容易的就是同时维护两个加速层,并交替使用它们,如下图:
当一次梳理完成时,批处理视图中的新数据就变得可用,就可以将当前用户处理请求的加速层换到另一个加速层上。切换后闲置的加速层会清理其数据库,并在新的批处理开始时重新建立视图。
这种做法的好处是,一方面不需要费心识别加速层的数据库中那些数据需要被过期清理,另一方面由于每次切换后加速层都是从一个空数据库开始运行,因此达到了更好的性能和可靠性。当然为此付出的代价是必须维护两份加速层的数据并且消耗两份计算资源,不过考虑到加速层仅仅处理总数据量中很小的一部分,因此付出的代价不那么大。
总结
Lambda 架构将我们已经学到的一些内容进行了融合:
- 原始数据是永恒的真相,这让我们想到分离状态与标识的做法。
- Hadoop 并行化解问题的方法是先将数据切分,在进行化简操作,这类似于并行函数式编程的做法。
- 类似于 actor 模型,Lambda 架构将处理过程分布大集群上,既改善了性能又提供了容错。
- Storm 的元组流类似于 Actor 和 CSP 中的消息机制。
优点
Lambda 架构非常适合报表与分析——以前则是使用数仓来完成这类任务。
缺点
仅适合大数据量。
显然本书仅涉及了 Lambda 架构,或者说是大数据处理问题的一些皮毛。但好的一点是将其他并发编程模型与 Lambda 架构进行了相似性关联,在构建大数据处理系统时可以借鉴已有的、微观的并发编程模型,从思路上、从形式上。
4.9 - CH09-未来方向
未来趋势
- 未来是“不变”的
- 未来是分布式的
未尽之路
- Fork/Join 模型和 Wokr-Stealing 算法
- PS. 这是 Akka 的核心
- 数据流
- Reactive Programming
- Functional Reactive Programming
- 网格计算
- 元组空间