mirror of
https://github.com/oldratlee/translations.git
synced 2026-04-08 04:59:14 +08:00
improve wording
This commit is contained in:
@@ -10,16 +10,18 @@ _Doug Lea_ 大神关于`Java 7`引入的他写的`Fork/Join`框架的论文。
|
||||
|
||||
[反应式编程](https://www.reactivemanifesto.org/zh-CN)(`Reactive Programming` / `RP`)作为一种范式在整个业界正在逐步受到认可和落地,是对过往系统的业务需求理解梳理之后对系统技术设计/架构模式的提升总结。`Java`作为一个成熟平台,对于趋势一向有些稳健的接纳和跟进能力,有着令人惊叹的生命活力:
|
||||
|
||||
1. `Java 7`提供了`ForkJoinPool`,支持了`Java 8`提供的`Stream`。
|
||||
1. `Java 7`提供了`ForkJoinPool`,支持了`Java 8`提供的`Stream`。
|
||||
更准确地说是,支持`Java 8`的`Parallel Stream`(并行流)。
|
||||
1. 另外`Java 8`还提供了`Lambda`(有效地表达和使用`RP`需要`FP`的语言构件和理念)。
|
||||
1. 有了前面的这些稳健但不失时机的准备,在`Java 9`中提供了面向`RP`的官方[`Flow API`](https://community.oracle.com/docs/DOC-1006738),实际上是直接把[`Reactive Streams`](http://www.reactive-streams.org/)的接口加在`Java`标准库中,即[`Reactive Streams`规范](https://github.com/reactive-streams/reactive-streams-jvm#specification)转正了,`Reactive Streams`是`RP`的基础核心组件。`Flow API`标志着`RP`由集市式的自由探索阶段 向 教堂式的统一使用的转变。
|
||||
1. 有了前面的这些稳健但不失时机的准备,在`Java 9`中提供了面向`RP`的官方[`Flow API`](https://community.oracle.com/docs/DOC-1006738),实际上是直接把[`Reactive Streams`](http://www.reactive-streams.org/)的接口加在`Java`标准库中,即[`Reactive Streams`规范](https://github.com/reactive-streams/reactive-streams-jvm#specification)转正了。
|
||||
`Reactive Streams`是`RP`的基础核心组件,`Java`提供了`Flow API` 标志着 `RP`完成了由 **集市**式的自由探索阶段 向 **教堂**式的规范统一阶段 的转变。
|
||||
|
||||
通过上面这些说明,可以看到`ForkJoinPool`的基础重要性。
|
||||
|
||||
对了,另外提一下`Java 9`的`Flow API`的`@author`也是 _Doug Lee_ 哦~
|
||||
|
||||
PS:
|
||||
[自己](http://weibo.com/oldratlee)理解粗浅,翻译中肯定会有不少不足和不对之处,欢迎建议([提交Issue](https://github.com/oldratlee/translations/issues))和指正([Fork后提交代码](https://github.com/oldratlee/translations/fork))! 💕
|
||||
[自己](http://weibo.com/oldratlee)理解粗浅,翻译中肯定会有不少不足和不对之处,欢迎建议([提交Issue](https://github.com/oldratlee/translations/issues))和指正([Fork后提交代码](https://github.com/oldratlee/translations/fork))! 💕
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
@@ -34,7 +36,7 @@ PS:
|
||||
- [2. 设计](#2-%E8%AE%BE%E8%AE%A1)
|
||||
- [2.1 `work−stealing`](#21-work%E2%88%92stealing)
|
||||
- [3. 实现](#3-%E5%AE%9E%E7%8E%B0)
|
||||
- [3.1 双端队列](#31-%E5%8F%8C%E7%AB%AF%E9%98%9F%E5%88%97)
|
||||
- [3.1 `deque`](#31-deque)
|
||||
- [3.2 抢断和闲置](#32-%E6%8A%A2%E6%96%AD%E5%92%8C%E9%97%B2%E7%BD%AE)
|
||||
- [4. 性能](#4-%E6%80%A7%E8%83%BD)
|
||||
- [4.1 加速效果](#41-%E5%8A%A0%E9%80%9F%E6%95%88%E6%9E%9C)
|
||||
@@ -53,13 +55,13 @@ PS:
|
||||
|
||||
# 0. 摘要
|
||||
|
||||
这篇论文描述了`Fork/Join`框架的设计、实现以及性能,这个框架通过(递归的)把问题划分为子任务,然后并行的执行这些子任务,等所有的子任务都结束的时候,再合并最终结果的这种方式来支持并行计算编程。总体的设计参考了为`Cilk`设计的`work-stealing`框架。就设计层面来说主要是围绕如何高效的去构建和管理任务队列以及工作线程来展开的。性能测试的数据显示良好的并行计算程序将会提升大部分应用,同时也暗示了一些潜在的可以提升的空间。
|
||||
本论文描述了一个`Java`框架的设计、实现以及性能,这个框架了一种并行编程风格(`a style of parallel programming`):解决问题的方式是通过把问题(递归地)分拆为子任务,并行地解决这些子任务,等这些子任务完成,然后合并子任务的结果。总体的设计代签自为`Cilk`设计的`work-stealing`框架。在主要的实现技术(`implementation techniques`)上是围绕如何高效地构建和管理任务队列(`tasks queues`)和工作线程(`worker threads`)展开的。测量到的性能数据即显示出了对于多数据程序都有良好的并行加速效果,同时也给出了一些后续可能的优化。
|
||||
|
||||
> 校注1: `Cilk`是英特尔`Cilk`语言。英特尔`C++`编辑器的新功能`Cilk`语言扩展技术,为`C/C++`语言增加了细粒度任务支持,使其为新的和现有的软件增加并行性来充分发掘多处理器能力变得更加容易。
|
||||
> 【译注】: `Cilk`是英特尔`Cilk`语言。英特尔`C++`编辑器的新功能`Cilk`语言扩展技术,为`C/C++`语言增加了细粒度任务支持,使其为新的和现有的软件增加并行性来充分发掘多处理器能力变得更加容易。
|
||||
|
||||
# 1. 简介
|
||||
|
||||
`Fork/Join`并行方式是获取良好的并行计算性能的一种最简单同时也是最有效的设计技术。`Fork/Join`并行算法是我们所熟悉的分治算法的并行版本,典型的用法如下:
|
||||
`Fork/Join`并行方式是获取良好的并行计算性能的一种最简单同时也是最有效的设计技术。`fork/join`并行算法是我们所熟悉的分治算法的并行版本,典型的用法如下:
|
||||
|
||||
```java
|
||||
Result solve(Problem problem) {
|
||||
@@ -74,29 +76,29 @@ Result solve(Problem problem) {
|
||||
}
|
||||
```
|
||||
|
||||
`fork`操作将会启动一个新的并行`Fork/Join`子任务。`join`操作会一直等待直到所有的子任务都结束。`Fork/Join`算法,如同其他分治算法一样,总是会递归的、反复的划分子任务,直到这些子任务可以用足够简单的、短小的顺序方法来执行。
|
||||
`fork`操作将会启动一个新的并行`fork/join`子任务。`join`操作会一直等待直到所有的子任务都结束。`fork/join`算法,如同其他分治算法一样,总是会递归的、反复的划分子任务,直到这些子任务可以用足够简单的、短小的顺序方法来执行。
|
||||
|
||||
一些相关的编程技术和实例在[《`Java`并发编程 —— 设计原则与模式 第二版》](https://book.douban.com/subject/1244021/)<sup>[7]</sup> 4.4章节中已经讨论过。这篇论文将讨论`FJTask`的设计(第2节)、实现(第3节)以及性能(第4节),它是一个支持并行编程方式的`Java™`框架。`FJTask` 作为`util.concurrent`软件包的一部分,目前可以在 http://gee.cs.oswego.edu/ 获取到。
|
||||
|
||||
# 2. 设计
|
||||
|
||||
`Fork/Join`程序可以在任何支持以下特性的框架之上运行:框架能够让构建的子任务并行执行,并且拥有一种等待子任务运行结束的机制。然而,`java.lang.Thread`类(同时也包括`POSIX pthread`,这些也是`Java`线程所基于的基础)对`Fork/Join`程序来说并不是最优的选择:
|
||||
`fork/join`程序可以在任何支持以下特性的框架之上运行:框架能够让构建的子任务并行执行,并且拥有一种等待子任务运行结束的机制。然而,`java.lang.Thread`类(同时也包括`POSIX pthread`,这些也是`Java`线程所基于的基础)对`fork/join`程序来说并不是最优的选择:
|
||||
|
||||
- `Fork/Join`任务对同步和管理有简单的和常规的需求。相对于常规的线程来说,`Fork/Join`任务所展示的计算布局将会带来更加灵活的调度策略。例如,`Fork/Join`任务除了等待子任务外,其他情况下是不需要阻塞的。因此传统的用于跟踪记录阻塞线程的代价在这种情况下实际上是一种浪费。
|
||||
- `fork/join`任务对同步和管理有简单的和常规的需求。相对于常规的线程来说,`fork/join`任务所展示的计算布局将会带来更加灵活的调度策略。例如,`fork/join`任务除了等待子任务外,其他情况下是不需要阻塞的。因此传统的用于跟踪记录阻塞线程的代价在这种情况下实际上是一种浪费。
|
||||
- 对于一个合理的基础任务粒度来说,构建和管理一个线程的代价甚至可以比任务执行本身所花费的代价更大。尽管粒度是应该随着应用程序在不同特定平台上运行而做出相应调整的。但是超过线程开销的极端粗粒度会限制并行的发挥。
|
||||
|
||||
简而言之,`Java`标准的线程框架对`Fork/Join`程序而言太笨重了。但是既然线程构成了很多其他的并发和并行编程的基础,完全消除这种代价或者为了这种方式而调整线程调度是不可能(或者说不切实际的)。
|
||||
简而言之,`Java`标准的线程框架对`fork/join`程序而言太笨重了。但是既然线程构成了很多其他的并发和并行编程的基础,完全消除这种代价或者为了这种方式而调整线程调度是不可能(或者说不切实际的)。
|
||||
|
||||
尽管这种思想已经存在了很长时间了,但是第一个发布的能系统解决这些问题的框架是`Cilk`<sup>[5]</sup>。`Cilk`和其他轻量级的框架是基于操作系统的基本的线程和进程机制来支持特殊用途的`Fork/Join`程序。这种策略同样适用于`Java`,尽管`Java`线程是基于低级别的操作系统的能力来实现的。创造这样一个轻量级的执行框架的主要优势是能够让`Fork/Join`程序以一种更直观的方式编写,进而能够在各种支持`JVM`的系统上运行。
|
||||
尽管这种思想已经存在了很长时间了,但是第一个发布的能系统解决这些问题的框架是`Cilk`<sup>[5]</sup>。`Cilk`和其他轻量级的框架是基于操作系统的基本的线程和进程机制来支持特殊用途的`fork/join`程序。这种策略同样适用于`Java`,尽管`Java`线程是基于低级别的操作系统的能力来实现的。创造这样一个轻量级的执行框架的主要优势是能够让`fork/join`程序以一种更直观的方式编写,进而能够在各种支持`JVM`的系统上运行。
|
||||
|
||||

|
||||
|
||||
`FJTask`框架是基于`Cilk`设计的一种演变。其他的类似框架有`Hood`<sup>[4]</sup>、`Filaments`<sup>[8]</sup>、`Stackthreads`<sup>[10]</sup>以及一些依赖于轻量级执行任务的相关系统。所有这些框架都采用和操作系统把线程映射到`CPU`上相同的方式来把任务映射到线程上。只是他们会使用`Fork/Join`程序的简单性、常规性以及一致性来执行这种映射。尽管这些框架都能适应不同形式的并行程序,他们优化了`Fork/Join`的设计:
|
||||
`FJTask`框架是基于`Cilk`设计的一种演变。其他的类似框架有`Hood`<sup>[4]</sup>、`Filaments`<sup>[8]</sup>、`Stackthreads`<sup>[10]</sup>以及一些依赖于轻量级执行任务的相关系统。所有这些框架都采用和操作系统把线程映射到`CPU`上相同的方式来把任务映射到线程上。只是他们会使用`fork/join`程序的简单性、常规性以及一致性来执行这种映射。尽管这些框架都能适应不同形式的并行程序,他们优化了`fork/join`的设计:
|
||||
|
||||
- 一组工作者线程池是准备好的。每个工作线程都是标准的(『重量级』)处理存放在队列中任务的线程(这地方指的是`Thread`类的子类`FJTaskRunner`的实例对象)。通常情况下,工作线程应该与系统的处理器数量一致。对于一些原生的框架例如说`Cilk`,他们首先将映射成内核线程或者是轻量级的进程,然后再在处理器上面运行。在`Java`中,虚拟机和操作系统需要相互结合来完成线程到处理器的映射。然后对于计算密集型的运算来说,这种映射对于操作系统来说是一种相对简单的任务。任何合理的映射策略都会导致线程映射到不同的处理器。
|
||||
- 所有的`Fork/Join`任务都是轻量级执行类的实例,而不是线程实例。在`Java`中,独立的可执行任务必须要实现`Runnable`接口并重写`run`方法。在`FJTask`框架中,这些任务将作为子类继承`FJTask`而不是`Thread`,它们都实现了`Runnable`接口。(对于上面两种情况来说,一个类也可以选择实现`Runnable`接口,类的实例对象既可以在任务中执行也可以在线程中执行。因为任务执行受到来自`FJTask`方法严厉规则的制约,子类化`FJTask`相对来说更加方便,也能够直接调用它们。)
|
||||
- 所有的`fork/join`任务都是轻量级执行类的实例,而不是线程实例。在`Java`中,独立的可执行任务必须要实现`Runnable`接口并重写`run`方法。在`FJTask`框架中,这些任务将作为子类继承`FJTask`而不是`Thread`,它们都实现了`Runnable`接口。(对于上面两种情况来说,一个类也可以选择实现`Runnable`接口,类的实例对象既可以在任务中执行也可以在线程中执行。因为任务执行受到来自`FJTask`方法严厉规则的制约,子类化`FJTask`相对来说更加方便,也能够直接调用它们。)
|
||||
- 我们将采用一个特殊的队列和调度原则来管理任务并通过工作线程来执行任务。这些机制是由任务类中提供的相关方式实现的:主要是由`fork`、`join`、`isDone`(一个结束状态的标示符),和一些其他方便的方法,例如调用`coInvoke`来分解合并两个或两个以上的任务。
|
||||
- 一个简单的控制和管理类(这里指的是`FJTaskRunnerGroup`)来启动工作线程池,并初始化执行一个由正常的线程调用所触发的`Fork/Join`任务(就类似于`Java`程序中的`main`方法)。
|
||||
- 一个简单的控制和管理类(这里指的是`FJTaskRunnerGroup`)来启动工作线程池,并初始化执行一个由正常的线程调用所触发的`fork/join`任务(就类似于`Java`程序中的`main`方法)。
|
||||
|
||||
作为一个给程序员演示这个框架如何运行的标准实例,这是一个计算法斐波那契函数的类。
|
||||
|
||||
@@ -149,66 +151,66 @@ class Fib extends FJTask {
|
||||
|
||||
这个版本在第4节中所提到的平台上的运行速度至少比每个任务都在`Thread`类中运行快30倍。在保持性能的同时这个程序仍然维持着`Java`多线程程序的可移植性。对程序员来说通常有两个参数值的他们关注:
|
||||
|
||||
- 对于工作线程的创建数量,通常情况下可以与平台所拥有的处理器数量保持一致(或者更少,用于处理其他相关的任务,或者有些情况下更多,来提升非计算密集型任务的性能)。
|
||||
- 一个粒度参数代表了创建任务的代价会大于并行化所带来的潜在的性能提升的临界点。这个参数更多的是取决于算法而不是平台。通常在单处理器上运行良好的临界点,在多处理器平台上也会发挥很好的效果。作为一种附带的效益,这种方式能够与`Java`虚拟机的动态编译机制很好的结合,而这种机制在对小块方法的优化方面相对于单块的程序来说要好。这样,加上数据本地化的优势,`Fork/Join`算法的性能即使在单处理器上面的性能都较其他算法要好。
|
||||
- 对于工作线程的创建数量,通常情况下可以与平台所拥有的处理器数量保持一致(或者更少,用于处理其他相关的任务;或者有些情况下更多,来提升非计算密集型任务的性能)。
|
||||
- 一个粒度参数代表了创建任务的代价会大于并行化所带来的潜在的性能提升的临界点。这个参数更多的是取决于算法而不是平台。通常在单处理器上运行良好的临界点,在多处理器平台上也会发挥很好的效果。作为一种附带的效益,这种方式能够与`Java`虚拟机的动态编译机制很好的结合,而这种机制在对小块方法的优化方面相对于单块的程序来说要好。这样,加上数据本地化的优势,`fork/join`算法的性能即使在单处理器上面的性能都较其他算法要好。
|
||||
|
||||
## 2.1 `work−stealing`
|
||||
|
||||
`Fork/Join`框架的核心在于轻量级调度机制。`FJTask`采用了`Cilk`的`work-stealing`所采用的基本调度策略:
|
||||
`fork/join`框架的核心在于轻量级调度机制。`FJTask`采用了`Cilk`的`work-stealing`所采用的基本调度策略:
|
||||
|
||||
<img src="figure2-1.png" width="40%" align="right" />
|
||||
<img src="figure2-1.png" width="35%" align="right" />
|
||||
|
||||
- 每一个工作线程维护自己的调度队列中的可运行任务。
|
||||
- 队列以双端队列的形式被维护(注:`deques`通常读作『decks』),不仅支持后进先出 —— `LIFO`的`push`和`pop`操作,还支持先进先出 —— `FIFO`的`take`操作。
|
||||
- 对于一个给定的工作线程来说,任务所产生的子任务将会被放入到工作者自己的双端队列中。
|
||||
- 工作线程使用后进先出 —— `LIFO`(最新的元素优先)的顺序,通过弹出任务来处理队列中的任务。
|
||||
- 当一个工作线程的本地没有任务去运行的时候,它将使用先进先出 —— `FIFO`的规则尝试随机的从别的工作线程中拿(『窃取』)一个任务去运行。
|
||||
- 当一个工作线程触及了`join`操作,如果可能的话它将处理其他任务,直到目标任务被告知已经结束(通过`isDone`方法)。所有的任务都会无阻塞的完成。
|
||||
- 当一个工作线程无法再从其他线程中获取任务和失败处理的时候,它就会退出(通过`yield`、`sleep`和/或者优先级调整,参考第3节)并经过一段时间之后再度尝试直到所有的工作线程都被告知他们都处于空闲的状态。在这种情况下,他们都会阻塞直到其他的任务再度被上层调用。
|
||||
- 维护队列是用双端队列(即`deques`,通常读作『decks』),同时支持`LIFO`(后进先出)的`push`、`pop`操作和`FIFO`(先进先出)的`take`操作。
|
||||
- 在工作线程中运行任务所产生的子任务将会被`push`到工作者自己的`deque`中。
|
||||
- 工作线程使用`LIFO`(最新的元素优先)的顺序,通过`pop`任务来处理队列中的任务。
|
||||
- 当工作线程的本地没有任务去运行的时候,它将使用`FIFO`(最旧的元素优先)的规则尝试从另一个随机挑选的工作线程中`take`(『**_窃取(`steal`)_**』)一个任务去运行。
|
||||
- 当工作线程碰到`join`操作时,如果可能的话它将处理其他任务,直到目标任务被告知已经结束(通过`isDone`)。所有的任务都会无阻塞的完成。
|
||||
- 当工作线程无法再从其他线程中获取任务和失败处理的时候,它就会退出(通过`yield`、`sleep`和/或者优先级调整,参考第3节)并经过一段时间之后再度尝试直到所有的工作线程都被告知他们都处于空闲的状态。在这种情况下,他们都会阻塞直到其他的任务再度被上层调用。
|
||||
|
||||
使用后进先出 —— `LIFO`用来处理每个工作线程的自己任务,但是使用先进先出 —— `FIFO`规则用于获取别的任务,这是一种被广泛使用的进行递归`Fork/Join`设计的一种调优手段。引用<sup>[5]</sup>讨论了详细讨论了里面的细节。
|
||||
正如参考文献<sup>[5]</sup>中的更详细的讨论,使用`LIFO`(后出先出)规则来处理各个工作线程的自己任务,而用于`steal`其它任务的`FIFO`(先进先出)规则是在递归`fork/join`设计中广泛使用的一个优化。这样的方案有2个的优势,用不太正式的方式表述如下:
|
||||
|
||||
让窃取任务的线程从队列拥有者相反的方向进行操作会减少线程竞争。同样体现了递归分治算法的大任务优先策略。因此,更早期被窃取的任务有可能会提供一个更大的单元任务,从而使得窃取线程能够在将来进行递归分解。
|
||||
窃取任务的线程,相对队列拥有者,操作的是队列的另一头,这样减少了竞争。这样的做法也充分利用了会先产生『大』任务的递归分治算法的特点。也就是,被窃取的更早生成的任务可能包含更大的任务单元,能够让窃取线程在进行进一步的递归分解处理。
|
||||
|
||||
作为上述规则的一个后果,对于一些基础的操作而言,使用相对较小粒度的任务比那些仅仅使用粗粒度划分的任务以及那些没有使用递归分解的任务的运行速度要快。尽管相关的少数任务在大多数的`Fork/Join`框架中会被其他工作线程窃取,但是创建许多组织良好的任务意味着只要有一个工作线程处于可运行的状态,那么这个任务就有可能被执行。
|
||||
应用上述规则的一个效果是:相比对基础操作仅仅使用粗任务粒度分片的程序或没有使用递归分解的程序,使用相对较小任务粒度的程序倾向于运行得更快。尽管对于多数的`fork/join`程序只有相对少数任务会被其他工作线程窃取,但是创建很多粒度合适(`fine-grained`)的任务意味着:只要有工作线程处于可运行任务的状态,那么一个任务就有可能被执行。
|
||||
|
||||
# 3. 实现
|
||||
|
||||
这个框架是由大约800行纯`Java`代码组成,主要的类是`FJTaskRunner`,它是`java.lang.Thread`的子类。`FJTask`自己仅仅维持一个关于结束状态的布尔值,所有其他的操作都是通过当前的工作线程来代理完成的。`JFTaskRunnerGroup`类用于创建工作线程,维护一些共享的状态(例如:所有工作线程的标示符,在窃取操作时需要),同时还要协调启动和关闭。
|
||||
|
||||
更多实现的细节文档可以在`util.concurrent`并发包中查看。这一节只着重讨论两类问题以及在实现这个框架的时候所形成的一些解决方案:支持高效的双端列表操作(`push`、`pop`和`take`), 并且当工作线程在尝试获取新的任务时维持窃取的协议。
|
||||
更多实现的细节文档可以在`util.concurrent`并发包中查看。这一节只着重讨论两类问题以及在实现这个框架的时候所形成的一些解决方案:如何支持高效的`deque`操作(`push`、`pop`和`take`), 和当工作线程在尝试获取新的任务时如何维护窃取协议。
|
||||
|
||||
## 3.1 双端队列
|
||||
## 3.1 `deque`
|
||||
|
||||
_(校注:双端队列中的元素可以从两端弹出,其限定插入和删除操作在队列的两端进行。)_
|
||||
> 【校注】:`deque`即是双端队列,其中的元素可以从两端弹出,其限定插入和删除操作在队列的两端进行。
|
||||
|
||||
为了能够获得高效以及可扩展的执行任务,任务管理需要越快越好。创建、发布、和弹出(或者出现频率很少的获取)任务在顺序编程模式中会引发程序调用开销。更低的开销可以使得程序员能够构建更小粒度的任务,最终也能更好的利用并行所带来的益处。
|
||||
为了能够获得高效以及可扩展的执行任务,任务管理需要越快越好。创建(`creating`)、`push`以及之后的`pop`(或是使用频率更低的`take`)任务操作就像是在顺序程序(`sequential programs`)中的过程调用(`procedure call`)这样的额外开销(`overhead`)。更低的开销可以使得程序员能够构建更小的任务粒度,进而反进来也能更好的充分利用并行。
|
||||
|
||||
`Java`虚拟机会负责任务的内存分配。`Java`垃圾回收器使我们不需要再去编写一个特殊的内存分配器去维护任务。相对于其他语言的类似框架,这个原因使我们大大降低了实现`FJTask`的复杂性以及所需要的代码数。
|
||||
任务的分配由`Java`虚拟机会负责。`Java`的垃圾回收使我们不再需要去编写专用的内存分配器去维护任务。相比于其他语言的类似框架,这使实现`FJTask`的复杂性以及所需的代码行数大大减少。
|
||||
|
||||
双端队列的基本结构采用了很常规的一个结构 —— 使用一个数组(尽管是可变长的)来表示每个队列,同时附带两个索引:`top`索引就类似于数组中的栈指针,通过`push`和`pop`操作来改变。`base`索引只能通过`take`操作来改变。鉴于`FJTaskRunner`操作都是无缝的绑定到双端队列的细节之中,(例如,`fork`直接调用`push`),所以这个数据结构直接放在类之中,而不是作为一个单独的组件。
|
||||
`deque`的基本结构采用了很常规的结构 —— 每个队列使用一个数组(尽管是可变长的),同时附带两个索引:`top`索引就类似于数组中的栈指针,通过`push`和`pop`操作来改变。`base`索引只能通过`take`操作来改变。鉴于`FJTaskRunner`操作都是无缝的绑定到`deque`的细节之中(例如,`fork`直接调用`push`),所以这个数据结构直接放在类之中,而不是作为一个单独的组件。
|
||||
|
||||
但是双端队列的元素会被多线程并发的访问,在缺乏足够同步的情况下,而且单个的`Java`数组元素也不能声明为`volatile`变量(校注:[声明成`volatile`的数组](http://ifeve.com/volatile-array-visiblity/ "volatile是否能保证数组中元素的可见性?"),其元素并不具备`volatile`语意),每个数组元素实际上都是一个固定的引用,这个引用指向了一个维护着单个`volatile`引用的转发对象。一开始做出这个决定主要是考虑到`Java`内存模型的一致性。但是在这个级别它所需要的间接寻址被证明在一些测试过的平台上能够提升性能。可能是因为访问邻近的元素而降低了缓存争用,这样内存里面的间接寻址会更快一点。
|
||||
由于`deque`中的数组会被多线程并发的访问,在缺乏足够同步的情况下,而且单个的`Java`数组元素也不能声明为`volatile`变量(【译注】:[声明成`volatile`的数组](http://ifeve.com/volatile-array-visiblity/ "volatile是否能保证数组中元素的可见性?"),其元素并不具备`volatile`语意),每个数组元素实际上都是一个固定的引用,这个引用指向了一个维护着单个`volatile`引用的转发对象。一开始做出这个决定主要是考虑到`Java`内存模型的一致性。但是在这个级别它所需要的间接寻址被证明在一些测试过的平台上能够提升性能。可能是因为访问邻近的元素而降低了缓存争用,这样内存里面的间接寻址会更快一点。
|
||||
|
||||
实现双端队列的主要挑战来自于同步和他的撤销。尽管在`Java`虚拟机上使用经过优化过的同步工具,对于每个`push`和`pop`操作都需要获取锁还是让这一切成为性能瓶颈。然后根据以下的观察结果我们可以修改`Clik`中的策略,从而为我们提供一种可行的解决方案:
|
||||
实现`deque`的主要挑战来自于同步和他的撤销。尽管在`Java`虚拟机上使用经过优化过的同步工具,对于每个`push`和`pop`操作都需要获取锁还是让这一切成为性能瓶颈。然后根据以下的观察结果我们可以修改`Clik`中的策略,从而为我们提供一种可行的解决方案:
|
||||
|
||||
- `push`和`pop`操作仅可以被工作线程的拥有者所调用。
|
||||
- 对`take`的操作很容易会由于窃取任务线程在某一时间对`take`操作加锁而限制。(双端队列在必要的时间也可以禁止`take`操作。)这样,控制冲突将被降低为两个部分同步的层次。
|
||||
- `pop`和`take`操作只有在双端队列为空的时候才会发生冲突,否则的话,队列会保证他们在不同的数组元素上面操作。
|
||||
- 通过一把在`take`操作上入口锁(`an entry lock`)可以很容易限制,对`take`操作的访问一次只有一个`stealing`线程(【译注】:即`take`操作执行通过一把锁序列化而不会并发,一个时间点至多只有1个在执行)。(这把`deque`的锁在必要时也可以用于禁止`take`操作。)这样,冲突控制被简化成了两方参与的同步问题。
|
||||
- 只有在`deque`为空时,`pop`和`take`操作候才会发生冲突;其它时候,这2个操作肯定在不同的数组元素上。
|
||||
|
||||
把`top`和`base`索引定义为`volatile`变量可以保证当队列中元素不止一个时,`pop`和`take`操作可以在不加锁的情况下进行。这是通过一种类似于`Dekker`算法来实现的。当`push`预递减到`top`时:
|
||||
把`top`和`base`索引定义为`volatile`变量可以保证当队列中元素不止一个时,`pop`和`take`操作可以在不加锁的情况下进行。这是通过类似于`Dekker`的算法来实现的,对于`push`操作预先递减`top`:
|
||||
|
||||
```java
|
||||
if (–top >= base) ...
|
||||
if (--top >= base) ...
|
||||
```
|
||||
|
||||
和`take`预递减到`base`时:
|
||||
对于`take`操作预先递增`base`:
|
||||
|
||||
```java
|
||||
if (++base < top) ...
|
||||
```
|
||||
|
||||
在上述每种情况下他们都通过比较两个索引来检查这样是否会导致双端队列变成一个空队列。一个不对称的规则将用于防止潜在的冲突:`pop`会重新检查状态并在获取锁之后继续(对`take`所持有的也一样),直到队列真的为空才退出。而`take`操作会立即退出,特别是当尝试去获得另外一个任务。与其他类似使用`Clik`的`THE`协议一样,这种不对称性是唯一重要的改变。
|
||||
在上述每种情况下他们都通过比较两个索引来检查这样是否会导致`deque`变成一个空队列。一个不对称的规则将用于防止潜在的冲突:`pop`会重新检查状态并在获取锁之后继续(对`take`所持有的也一样),直到队列真的为空才退出。而`take`操作会立即退出,特别是当尝试去获得另外一个任务。与其他类似使用`Clik`的`THE`协议一样,这种不对称性是唯一重要的改变。
|
||||
|
||||
使用`volatile`变量索引`push`操作在队列没有满的情况下不需要同步就可以进行。如果队列将要溢出,那么它首先必须要获得队列锁来重新设置队列的长度。其他情况下,只要确保`top`操作排在队列数组槽盛在抑制干涉带之后更新。
|
||||
|
||||
@@ -216,7 +218,7 @@ if (++base < top) ...
|
||||
|
||||
## 3.2 抢断和闲置
|
||||
|
||||
在抢断式工作框架中,工作线程对于他们所运行的程序对同步的要求一无所知。他们只是构建、发布、弹出、获取、管理状态和执行任务。这种简单的方案使得当所有的线程都拥有很多任务需要去执行的时候,它的效率很高。然而这种方式是有代价的,当没有足够的工作的时候它将依赖于试探法。也就是说,在启动一个主任务,直到它结束,在有些`Fork/Join`算法中都使用了全面停止的同步指针。
|
||||
在抢断式工作框架中,工作线程对于他们所运行的程序对同步的要求一无所知。他们只是产生(`generate`)、`push`、`pop`、`take`任务、管理任务状态和执行任务。这种简单的方案使得当所有的线程都拥有很多任务需要去执行的时候,它的效率很高。然而这种方式是有代价的,当没有足够的工作的时候它将依赖于试探法。比如,在启动主任务的过程,直到主任务完成,以及一些`fork/join`算法中用到的全局的全停止(`full−stop`)同步指针所涉及整个过程。
|
||||
|
||||
主要的问题在于当一个工作线程既无本地任务也不能从别的线程中抢断任务时怎么办。如果程序运行在专业的多核处理器上面,那么可以依赖于硬件的忙等待自旋循环的去尝试抢断一个任务。然而,即使这样,尝试抢断还是会增加竞争,甚至会导致那些不是闲置的工作线程降低效率(由于锁协议,3.1节中)。除此之外,在一个更适合此框架运行的场景中,操作系统应该能够很自信的去运行那些不相关并可运行的进程和线程。
|
||||
|
||||
@@ -224,9 +226,9 @@ if (++base < top) ...
|
||||
|
||||
# 4. 性能
|
||||
|
||||
如今,随着编译器与`Java`虚拟机性能的不断提升,性能测试结果也仅仅只能适用一时。但是,本节中所提到的测试结果数据却能揭示`Fork/Join`框架的基本特性。
|
||||
如今,随着编译器与`Java`虚拟机性能的不断提升,性能测试结果也仅仅只能适用一时。但是,本节中所提到的测试结果数据却能揭示`fork/join`框架的基本特性。
|
||||
|
||||
下面表格中简单介绍了在下文将会用到的一组`Fork/Join`测试程序。这些程序是从`util.concurrent`包里的示例代码改编而来,用来展示`Fork/Join`框架在解决不同类型的问题模型时所表现的差异,同时得到该框架在一些常见的并行测试程序上的测试结果。
|
||||
下面表格中简单介绍了在下文将会用到的一组`fork/join`测试程序。这些程序是从`util.concurrent`包里的示例代码改编而来,用来展示`fork/join`框架在解决不同类型的问题模型时所表现的差异,同时得到该框架在一些常见的并行测试程序上的测试结果。
|
||||
|
||||
程序 | 描述
|
||||
-------------- | ------------------
|
||||
@@ -238,7 +240,7 @@ if (++base < top) ...
|
||||
`LU`(矩阵分解) | 4096 X 4096的`double`类型的矩阵进行分解
|
||||
`Jacobi`(雅克比迭代法) | 对一个4096 X 4096的`double`矩阵使用迭代方法进行矩阵松弛,迭代次数上限为100
|
||||
|
||||
下文提到的主要的测试,其测试程序都是运行在`Sun Enterprise 10000`服务器上,该服务器拥有30个`CPU`,操作系统为`Solaris 7`系统,运行`Solaris`商业版`1.2` `JVM`(`2.2.2_05`发布版本的一个早期版本)。同时,`Java`虚拟机的关于线程映射的环境参数选择为『`bound threads`』(译者注:`XX:+UseBoundThreads`,绑定用户级别的线程到内核线程,只与`Solaris`有关),而关于虚拟机的内存参数设置在4.2章节讨论。另外,需要注意的是下文提到的部分测试则是运行在拥有4 `CPU`的`Sun Enterprise 450`服务器上。
|
||||
下文提到的主要的测试,其测试程序都是运行在`Sun Enterprise 10000`服务器上,该服务器拥有30个`CPU`,操作系统为`Solaris 7`系统,运行`Solaris`商业版`1.2` `JVM`(`2.2.2_05`发布版本的一个早期版本)。同时,`Java`虚拟机的关于线程映射的环境参数选择为『`bound threads`』(【译注】:`XX:+UseBoundThreads`,绑定用户级别的线程到内核线程,只与`Solaris`有关),而关于虚拟机的内存参数设置在4.2章节讨论。另外,需要注意的是下文提到的部分测试则是运行在拥有4 `CPU`的`Sun Enterprise 450`服务器上。
|
||||
|
||||

|
||||
|
||||
@@ -260,7 +262,7 @@ if (++base < top) ...
|
||||
|
||||
## 4.2 垃圾回收
|
||||
|
||||
总的来说,现在的垃圾回收机制的性能是能够与`Fork/Join`框架所匹配的:`Fork/Join`程序在运行时会产生巨大数量的任务单元,然而这些任务在被执行之后又会很快转变为内存垃圾。相比较于顺序执行的单线程程序,在任何时候,其对应的`Fork/Join`程序需要最多`p`倍的内存空间(其中`p`为线程数目)。基于分代的半空间拷贝垃圾回收器(也就是本文中测试程序所使用的`Java`虚拟机所应用的垃圾回收器)能够很好的处理这种情况,因为这种垃圾回收机制在进行内存回收的时候仅仅拷贝非垃圾内存单元。这样做,就避免了在手工并发内存管理上的一个复杂的问题,即跟踪那些被一个线程分配却在另一个线程中使用的内存单元。这种垃圾回收机制并不需要知道内存分配的源头,因此也就无需处理这个棘手的问题。
|
||||
总的来说,现在的垃圾回收机制的性能是能够与`fork/join`框架所匹配的:`fork/join`程序在运行时会产生巨大数量的任务单元,然而这些任务在被执行之后又会很快转变为内存垃圾。相比较于顺序执行的单线程程序,在任何时候,其对应的`fork/join`程序需要最多`p`倍的内存空间(其中`p`为线程数目)。基于分代的半空间拷贝垃圾回收器(也就是本文中测试程序所使用的`Java`虚拟机所应用的垃圾回收器)能够很好的处理这种情况,因为这种垃圾回收机制在进行内存回收的时候仅仅拷贝非垃圾内存单元。这样做,就避免了在手工并发内存管理上的一个复杂的问题,即跟踪那些被一个线程分配却在另一个线程中使用的内存单元。这种垃圾回收机制并不需要知道内存分配的源头,因此也就无需处理这个棘手的问题。
|
||||
|
||||
这种垃圾回收机制优势的一个典型体现:使用这种垃圾回收机制,四个线程运行的`Fib`程序耗时仅为5.1秒钟,而如果在`Java`虚拟机设置关闭代拷贝回收(这种情况下使用的就是标记清除(`mark−sweep`)垃圾回收机制了),耗时需要9.1秒钟。
|
||||
|
||||
@@ -286,7 +288,7 @@ if (++base < top) ...
|
||||
|
||||
## 4.5 任务局部性
|
||||
|
||||
`FJTask`,或者说其他的`Fork/Join`框架在任务分配上都是做了优化的,尽可能多的使工作线程处理自己分解产生的任务。因为如果不这样做,程序的性能会受到影响,原因有二:
|
||||
`FJTask`,或者说其他的`fork/join`框架在任务分配上都是做了优化的,尽可能多的使工作线程处理自己分解产生的任务。因为如果不这样做,程序的性能会受到影响,原因有二:
|
||||
|
||||
1. 从其他队列窃取任务的开销要比在自己队列执行`pop`操作的开销大。
|
||||
1. 在大多数程序中,任务操作操作的是一个共享的数据单元,如果只运行自己部分的任务可以获得更好的局部数据访问。
|
||||
@@ -314,7 +316,7 @@ if (++base < top) ...
|
||||
尽管所展示的可伸缩性结果针对的是单个`JVM`,但根据经验这些主要的发现在更一般的情况下应该仍然成立:
|
||||
|
||||
- 尽管分代`GC`(`generational GC`)通常与并行协作得很好,但当垃圾生成速度很快而迫使`GC`很频繁时会阻碍程序的伸缩性。在这样的`JVM`上,这个底层原因看起来会导致为了`GC`导致停止线程的花费的时间大致与运行的线程数量成正比。因为运行的线程越多那么单位时间内生成的垃圾也就越多,开销的增加大致与线程数的平方。即使如此,只有在`GC`频度相对高时,才会对性能有明显的影响。当然,这个问题需要进一步的研究和开发并行`GC`算法。本文的结果也说明了,在多处理器`JVM`上提供优化选项(`tuning options`)和适应机制(`adaptive mechanisms`)以让内存可以按活跃`CPU`数目扩展是有必要的。
|
||||
- 大多数的伸缩性问题只有当运行的程序所用的`CPU`多于多数设备上可用`CPU`时,才会显现出来。`FJTask`(以及其它`Fork/Join`框架)在常见的2路、4路和8路的`SMP`机器上表现出接近理想情况加速效果。对于为`stock multiprocessor`设计的运行在多于16个`CPU`上的`Fork/Join`框架,本文可能是第一篇给出系统化报告结果的论文。在其它框架中这个结果中的模式是否仍然成立需要进一步的测量。
|
||||
- 大多数的伸缩性问题只有当运行的程序所用的`CPU`多于多数设备上可用`CPU`时,才会显现出来。`FJTask`(以及其它`fork/join`框架)在常见的2路、4路和8路的`SMP`机器上表现出接近理想情况加速效果。对于为`stock multiprocessor`设计的运行在多于16个`CPU`上的`fork/join`框架,本文可能是第一篇给出系统化报告结果的论文。在其它框架中这个结果中的模式是否仍然成立需要进一步的测量。
|
||||
- 应用程序的特征(包括内存局部性、任务局部性和全局同步的使用)常常比框架、`JVM`或是底层`OS`的特征对于伸缩性和绝对性能的影响更大。举个例子,在非正式的测试中可以看到,精心避免`deques`上同步操作(在3.1节中讨论过)对于生成任务相对少的程序(如`LU`)完全没有改善。然而,把任务管理上开销减至最小却可以拓宽框架及其相关设计和编程技巧的适用范围和效用。
|
||||
|
||||
除了对于框架做渐进性的改良,未来可以做的包括在框架上构建有用的应用(而不是Demo和测试)、在生产环境的应用负载下的后续评估、在不同的`JVM`上测量以及为搭载多处理器的集群的方便使用开发扩展。
|
||||
|
||||
Reference in New Issue
Block a user