Java的Fork/Join框架

Doug Lea 的 A Java Fork/Join Framework论文

在有一类并发编程中,问题被(递归地)分解为多个子问题,每个子问题被并发处理,所有子问题结果的组合则是该问题的解决结果。Fork/Join框架可以支持该类并发编程,其主要的实现技术围绕高效构建和管理任务队列和工作线程。

简绍

Fork/Join并行技术是分治算法的并行版本,其典型形式如下:

Result solve(Problem problem) {
  if (problem is small) 
    directly solve problem
  else {
    split problem into independent parts
    fork new subtasks to solve each part
    join all subtasks
    compose result from subresults
  }
}

fork操作用于开启一个新的并行的Fork/Join子任务。

join操作用于阻塞当前任务以等待所有被fork的子任务的完成。

与分治算法总是使用递归类似,Fork/Join算法对任务不断分解,直到子任务小到可以用简单的、短的串行方式解决。

设计

Fork/Join程序可以运行在支持子任务的构造、并行执行以及等待子任务运行完成的机制的框架上。然而,java.lang.Thread(通常基于POSIX pthreads)并不是支持fork/Join程序的最好工具,原因如下:

  • Fork/Join的任务只需要简单而常规的同步和管理。除了等待子任务外,Fork/Join任务不需要阻塞。因此,如果把Fork/Join线程当成普通线程处理,跟踪阻塞线程所需的开销被浪费了。
  • 当任务的粒度较小时,构造和管理线程所需的计算时间可能大于任务本身的计算时间。如果任务的粒度过大,又不能充分利用并行特性。

简而言之,标准线程框架过于笨重,无法支持大多数Fork/Join程序。但是,由于线程也构成了许多其他并发和并行编程风格的基础,因此仅为了支持这种风格而消除开销或调优线程本身的调度是不可能的。

虽然这些想法具有较长的历史,但第一个为这些问题提供系统解决方案的框架是Cilk[1]。Cilk和其他轻量级可执行框架层对Fork/Join的支持是在操作系统的基本线程或进程机制的基础上。这一策略同样适用于Java,即使Java线程反过来是分层到低层级的操作系统中。创建这样一个Java轻量级执行框架的主要优点是使Fork/Join程序能够以一种更可移植的方式编写,并且能够在广泛范围内运行的jvm。

FJTask框架是基于Cilk的设计的一种变体。其他的变体也存在于Hood[2],Filaments[3],stackthreads[4]和依赖于轻量级可执行任务的相关的系统。这些框架将任务映射到线程的方式与操作系统将线程映射到CPUs的方式大体相同,但是充分利用了Fork/Join程序的简单性、规律性以及相关约束。虽然这些框架都可以在不同程度上容纳以不同风格编写的并行程序,但它们都针对Fork/Join设计进行了优化:

  • 一个工作线程池。每个工作线程都是一个标准的“重”线程(例如Thread子类的一个实例FJTaskRunner),用于处理队列中的任务。通常,系统上的工作线程与CUP的数量一样多。在Cilk之类的原生框架中,这些工作线程被映射到内核线程或轻量级进程,然后映射到CPU。在Java中,必须通过JVM和OS来将这些工作线程映射到CPU。由于这些线程是计算密集型的,因此任何合理的映射策略都将把这些线程映射到不同的CPU。
  • 所有Fork/Join任务都是轻量级可执行类的实例,而不是线程的实例。在Java中,独立的可执行任务必须实现Runnable接口并定义一个run方法。在FJTask框架中,这些任务是FJTask的子类,而不是Thread的子类,两者都实现了Runnable接口。在这两种情况下,类可以选择实现Runnable接口,然后提供要在执行任务中运行的实例或者线程。因为FJTask类支持任务在受限规则下运行,将任务转换为FJTask的子类能够很方便地直接调用。
  • 一个特殊用途的队列和调度策略用于管理和通过工作线程执行任务(下一节会详细讲述)。这些机制是由Task类中提供的几个方法触发的:主要是forkjoinisDone(一个完成状态指示器)和一些提供方便的方法,比如coInvoke方法能够实现fork之后join两个或多个任务。
  • 一个简单的控制和管理设施(例如FJTaskRunnerGroup),用于设置工作池以及在从普通线程(比如Java程序中的主线程)调用时初始化给定的Fork/Join任务的执行。

这个计算斐波那契函数的类将作为使用这个框架的例子:

class Fib extends FJTask {
    static final int threshold = 13;
    volatile int number; // arg/result

    Fib(int n) {
        number = n;
    }

    int getAnswer() {
        if (!isDone()) throw new IllegalStateException();
        return number;
    }

    public void run() {
        int n = number;
        if (n <= threshold) // granularity ctl
            number = seqFib(n);
        else {
            Fib f1 = new Fib(n − 1);
            Fib f2 = new Fib(n − 2);
            coInvoke(f1, f2);
            number = f1.number + f2.number;
        }
    }

    public static void main(String[] args) {
        try {
            int groupSize = 2; // for example 
            FJTaskRunnerGroup group = new FJTaskRunnerGroup(groupSize);
            Fib f = new Fib(35); // for example
            group.invoke(f);
            int result = f.getAnswer();
            System.out.println("Answer: " + result);
        } catch (InterruptedException ex) {
        }
    }

    int seqFib(int n) {
        if (n <= 1) return n;
        else return seqFib(n−1) + seqFib(n−2);
    }
}

如果程序中的每个新任务都在一个新的java.lang.Thread中运行,那么这个版本的程序的运行速度至少是原来程序的30倍,并且保持了多线程Java程序的内在可移植性。对于程序员来说,只有需要关注两个调优参数:

  1. 要构造的工作线程的数量,通常应该与一个平台上可用CPU的数量相对应(或者更少,以便为其他不相关的目的保留处理,或者偶尔更多,以便吸收非计算空闲)。
  2. 粒度参数,表示生成任务的开销超过潜在并行性好处的零界点。这个参数的设置通常更依赖于算法而不是平台。在单处理器上运行时,通常可以确定一个阈值,从而获得良好的结果。即时存在多个CUP时,这个阈值依然有用。作为一个附带的好处,这种方法与JVM动态编译机制能够很好结合,这种机制对细粒度的方法的优化比粗粒度的方法更好。考虑到数据局部性优势,可以使Fork/Join算法甚至在单处理器上优于其他类型的算法。

工作窃取

轻量级的调度机制是Fork/Join框架的核心。FJTask接受了Cilk工作窃取调度程序的基本思想:

  • 每个工作线程在自己的调度队列中维护所有可运行的任务。
  • 队列使用双端队列(double-ended queue, deque)实现,既支持LIFO操作(push()pop())也支持FIFO(addFirst()removeLast())操作。
  • 工作线程运行的任务生成的子任务被push到该工作线程的deque上。
  • 工作线程通过pop取出任务,按后进先出(LIFO)顺序处理自己的deque。
  • 当工作线程没有本地任务要运行时,会尝试使用先进先出(FIFO)规则随机选择另一个工作线程窃取一个任务。
  • 当一个工作线程遇到一个join操作时,会处理其他未处理的任务,直到目标任务被发现到已经完成(通过isDone)。所有任务将在不阻塞的情况下运行至完成。
  • 当工作线程没有任务并且窃取其他工作线程的任务失败,它支持稍后再试(通过yieldsleep或优先级调整,详情见下一节)。如果所有工作线程都空闲,那么都会被阻塞,直到有一个任务被更高层级调用。

正如文献[1]中更详细地讨论的,每个工作线程处理自己的任务使用LIFO,窃取其他工作线程的任务使用FIFO,这对于大部分递归的Fork/Join框架是最优的设计。通俗地说,该方案提供了两个基本地优势:

  • 通过让窃取线程在被窃取线程的deque的另一端来窃取以减少争用。同时利用了递归分治算法前期生成的任务更大的特性,被窃取的任务能够提供更大的工作单元,从而能进一步进行递归分解。
  • 作为这些规则的一个结果,使用相对较小任务粒度的程序往往比那些只使用粗粒度分区或不使用递归分解的程序运行得更快。尽管在大多数Fork/Join程序中,被窃取的任务相对较少,但创建许多细粒度的任务意味着工作线程可以一直工作。

实现

Fork/Join框架大约由800行纯Java代码实现。主要的实现类是java.lang.Thread的子类FJTaskRunnerFJTask类仅仅维持一个关于结束状态的布尔值,所有其他的操作都是委托给当前的工作线程。JFTaskRunnerGroup类用于创建工作线程,维护一些共享的状态(例如,工作线程的标识符),以及协调启动和关闭。

util.concurrent包中提供了更多详细的实现文档。本节将讨论实现此框架时遇到的两个问题和解决方案:实现高效的双端列表操作(pushpoptake), 管理窃取协议

双端队列(Deque)

任务管理应该尽可能的高效和可扩展。创建、发布、和弹出(或者出现频率很小的获取)任务在顺序编程模式中会产生程序调用开销。更低的开销可以使得程序员能够构建更小粒度的任务,最终也能更好的利用并行所带来的益处。

任务的内存分配由Java虚拟机负责。Java垃圾回收器得存在使得编写一个特殊的内存分配器去维护任务失去意义。相对于其他语言的类似框架,这大大降低了实现FJTask的复杂性以及所需要的代码数。

双端队列的基本结构使用了一个数组(尽管大小可变)来表示每个队列,同时附带两个索引:top索引就类似于数组中的栈指针,通过pushpop操作来修改。base索引则通过take操作来修改。因为FJTaskRunner操作都与双端队列的操作有关(例如,fork直接调用push),所以这个数据结构直接放在类之中而不是作为一个单独的组件。

因为双端队列的元素会被多线程并发的访问,在缺乏足够同步的情况下,并且单个的Java数组元素也不能声明为volatile变量,因此每个数组元素实际上都是一个固定的引用,这个引用指向了一个维护着单个volatile引用的转发对象。这个决定最初是确保Java内存模型的一致性。但是所需要的间接寻址层级被测试平台证明能够提升性能。可能是因为访问邻近的元素而降低了缓存争用,这样内存里面的间接寻址会更快一点。

实现双端队列的主要挑战来自于同步。尽管可以在Java虚拟机上使用经过优化过的同步工具[5],对于每个pushpop操作都需要获取锁仍然是一个瓶颈。解决方案是对Cilk中所采用的策略的调整,并且基于以下的观察:

  • pushpop操作仅被工作线程的拥有者所调用。
  • take操作加锁可以保证在某一时间内只有一个窃取线程对take操作(双端队列锁也可以禁止take操作)。这样,控制冲突被降低为两个部分的同步。
  • poptake操作只有在双端队列即将为空的时候才会发生冲突,否则的话,队列会保证poptake操作不同的数组元素。

把top和base索引定义为volatile变量可以保证当队列中元素不止一个时,poptake操作可以在不加锁的情况下进行,这是通过一种类似于Dekker算法来实现的。

push预递减top索引时:

if (−−top >= base) {...}

take预递增base索引时:

if (++base < top) {...}

在任何情况下都需要通过比较这两个索引来检查双端队列是否会变成一个空队列。一个不对称的规则被用于防止潜在的冲突:pop会重新检查状态并在获取锁之后继续,直到队列真的为空才退出;而take操作会立即退出,特别是当尝试去获得另外一个任务。这种不对称性显著区别于Cilk使用的THE协议。

使用volatile关键字使得push操作在队列没有满的情况下不需要同步就可以进行。如果队列将要溢出,那么必须要获得队列锁来重新设置队列的长度。其他情况下,只要确保队列数组中有数据,top操作就不会收到take操作的干扰。

在随后的实现中,发现有好几种JVM并不符合Java内存模型[6]中正确读取写入volatile变量的规则。作为一个变通方法,pop在被锁的情况下重试的条件已经被调整为:有两个或者更少的元素,并且take操作加了第二把锁以确保内存屏障。只要工作线程的拥有者最多只错过了一次索引更改就足够了(对于在读取volatile字段时保持适当内存顺序的平台成立),并且只会导致性能略微下降。

窃取和空转

在工作窃取框架中,工作线程不知道所运行的程序对同步的要求。工作线程只是构建、发布、弹出、获取、管理状态和执行任务。当所有的线程都拥有很多任务时,这种简单性能提高效率。然而这种方式是有代价的,当没有足够的工作的时候它将依赖于启发式算法。例如当启动一个主任务,直到它结束,一些Fork/Join算法中使用了全局的全面停止(full-stop)同步指针。

主要的问题在于如何处理一个工作线程既无本地任务也不能从别的线程中抢断任务的情况。如果程序运行在专业的多核处理器上面,那么可以依赖于硬件的忙等待自旋循环的去尝试窃取一个任务。然而即使这样,尝试窃取一个任务还是会增加竞争,甚至会导致那些不是闲置的工作线程效率降低。此外,在一个更典型的运行的场景中,操作系统应该尝试运行其他不相关的可运行进程或线程。

Java中并没有健壮的工具来实现这个,但是在实际中是可以让人接受的。一个窃取失败的线程在尝试另外的窃取之前会降低自己的优先级,在尝试窃取操作之间执行Thread.yeild操作,然后将自己的状态在FJTaskRunnerGroup中注册为不活跃的。如果所有的工作线程变得不活跃,他们会一直阻塞直到有新的主任务。否则,在进行一定的自旋次数之后,线程在尝试窃取操作之间将会sleep而不是yield。强化的休眠机制会给人造成一种需要花费很长时间去划分任务的假象。但是这似乎是最好的也是通用的折中方案。框架的未来版本也许会支持额外的控制方法,以便于让程序员在感觉性能受到影响时可以重写默认的实现。

参考文献

[1] Frigo, Matteo, Charles Leiserson, and Keith Randall. The Implementation of the Cilk−5 Multithreaded Language. In Proceedings of 1998 ACM SIGPLAN Conference on Programming Language Design and Implementation (PLDI), 1998.

[2] Blumofe, Robert D. and Dionisios Papadopoulos. Hood: A User−Level Threads Library for Multiprogrammed Multiprocessors. Technical Report, University of Texas at Austin, 1999.

[3] Lowenthal, David K., Vincent W. Freeh, and Gregory R.Andrews. Efficient Fine−Grain Parallelism on Shared−Memory Machines. Concurrency−Practice and Experience,10,3:157−173, 1998.

[4]Taura, Kenjiro, Kunio Tabata, and Akinori Yonezawa. “Stackthreads/MP: Integrating Futures into Calling Standards.” In Proceedings of ACM SIGPLAN Symposium on Principles & Practice of Parallel Programming (PPoPP), 1999.

[5] Agesen, Ole, David Detlefs, Alex Garthwaite, Ross Knippel, Y.S. Ramakrishna, and Derek White. An Efficient Meta−lock for Implementing Ubiquitous Synchronization. In Proceedings of OOPSLA ’99, ACM, 1999.

[6] Gosling, James, Bill Joy, and Guy Steele. The Java Language Specification, Addison−Wesley, 1996.

实验

归并排序中使用Fork/Join框架与普通多线程的比较

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值