背景
某日线上hdfs集群namenode状态切换出现长时间延迟情况,排查zkfc log发现有调用namenode 的切换状态的rpc方法超时的报错。通过打印切换过程时的火焰图发现几乎所有的调用时间都花费在了递归更新namespace quota上。梳理具体的代码逻辑, namenode端处理zkfc transitionToActive切换状态请求,需要保证所有的editlog已加载完成,并调用递归方法updateCountForQuotaRecursively更新整个fsimage下的配额和使用量信息,因为现在逻辑是单线程递归更新,在fsimage 比较大情况下处理会比较慢。参考hadoop jira,hadoop2.8.0版本上更新配额和使用量逻辑已更改为fork-join并发处理模式,在集群文件数量比较高的情况下大大减少时间消耗。
分治任务模型
首先介绍下分治任务模型,分治任务模型可分为两个阶段:一个阶段是任务分解,也就是将任务迭代地分解为子任务,直至子任务可以直接计算出结果;另一个阶段是结果合并,即逐层合并子任务的执行结果,直至获得最终结果。如下图分治任务模型举例。
在这个分治任务模型里,任务和分解后的子任务具有相似性,这种相似性往往体现在任务和子任务的算法是相同的,但是计算的数据规模是不同的。具备这种相似性的问题,我们往往都采用递归算法,大数据体系下比较经典的mapreduce执行原理就是利用这种思想,Java里的Fork/Join框架也是利用这种思想,下面我们来介绍下。
Fork/Join
简介
Fork/Join框架是Java 7提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。顾名思义: Fork 对应的是分治任务模型里的任务分解,Join 对应的是结果合并。具体解释来说,Fork/Join框架要完成两件事情:
1.任务分割:首先Fork/Join框架需要把大的任务分割成足够小的子任务,如果子任务比较大的话还要对子任务进行继续分割。
2.执行任务并合并结果:分割的子任务分别放到双端队列里,然后几个启动线程分别从双端队列里获取任务执行。子任务执行完的结果都放在另外一个队列里,启动一个线程从队列里取数据,然后合并这些数据。
所以Fork/Join框架就是利用分治任务模型思想实现的。
具体使用:
Fork/Join 计算框架的具体实现中主要包含两部分,一部分是分治任务的线程池 ForkJoinPool,另一部分是分治任务 ForkJoinTask。这两部分的关系类似于 ThreadPoolExecutor 和 Runnable 的关系,都可以理解为提交任务到线程池,只不过分治任务有自己独特类型 ForkJoinTask。
Step1: 创建任务
ForkJoinTask是一个抽象类,最核心的是 fork() 方法和 join() 方法,其中 fork() 方法会异步地执行一个子任务,而 join() 方法则会阻塞当前线程来等待子任务的执行结果,我们不需要去继承ForkJoinTask进行使用,ForkJoin框架为我们提供了RecursiveAction和RecursiveTask,我们只需要继承ForkJoin为我们提供的抽象类的其中一个并且实现compute方法。二者的区别是RecursiveAction用于没有返回结果的任务,而RecursiveTask用于有返回结果的任务
Step2: 执行任务
ForkJoinTask需要通过ForkJoinPool来执行,任务分割出的子任务会添加到当前工作线程所维护的双端队列中,进入队列的头部。当一个工作线程的队列里暂时没有任务时,它会随机从其他工作线程的队列的尾部获取一个任务(工作窃取算法)。
Java 官方示例代码:利用Fork/Join 框架计算斐波那契数列。首先我们需要创建一个分治任务线程池以及计算斐波那契数列的分治任务,之后通过调用分治任务线程池的 invoke() 方法来启动分治任务。由于计算斐波那契数列需要有返回值,所以 Fibonacci 继承自 RecursiveTask。分治任务 Fibonacci 需要实现 compute() 方法,这个方法里面的逻辑和普通计算斐波那契数列非常类似,区别之处在于计算 Fibonacci(n - 1) 使用了异步子任务,这是通过 f1.fork() 这条语句实现的。
static void main(String[] args){
//创建分治任务线程池
ForkJoinPool fjp = new ForkJoinPool(4);
//创建分治任务
Fibonacci fib = new Fibonacci(30);
//启动分治任务
Integer result = fjp.invoke(fib);
//输出结果
System.out.println(result);
}
//递归任务
static class Fibonacci extends RecursiveTask<Integer>{
final int n;
Fibonacci(int n){this.n = n;}
protected Integer compute(){
if (n <= 1)
return n;
Fibonacci f1 = new Fibonacci(n - 1);
//创建子任务
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
//等待子任务结果,并合并结果
return f2.compute() + f1.join();
}
}
简单原理介绍
Fork/Join 并行计算的核心组件是 ForkJoinPool,ForkJoinPool由ForkJoinTask数组和ForkJoinWorkerThread数组组成,ForkJoinTask数组负责将存放程序提交给ForkJoinPool,而ForkJoinWorkerThread负责执行这些任务。
和ThreadPoolExecutor一样,ForkJoinPool 本质上也是一个生产者 - 消费者的实现, 不同的是ThreadPoolExecutor 内部只有一个任务队列,而 ForkJoinPool 内部有多个任务队列,当我们通过 ForkJoinPool 的 invoke() 或者 submit() 方法提交任务时,ForkJoinPool 根据一定的路由规则把任务提交到一个任务队列中,如果任务在执行过程中会创建出子任务,那么子任务会提交到工作线程对应的任务队列中。
ForkJoinPool 支持“任务窃取”的机制,如果工作线程空闲了,那它可以“窃取”其他工作任务队列里的任务,ForkJoinPool 中的任务队列采用的是双端队列,工作线程正常获取任务和“窃取任务”分别是从任务队列不同的端消费,这样能避免很多不必要的数据竞争。
如上图示例:ThreadB 对应的任务队列已经空了,它可以“窃取”ThreadA对应的任务队列的任务,可以保证不会有空闲的工作线程,提高执行效率。
总结
- Fork/Join 并行计算框架利用分治任务模型思想实现,使用ForkJoin将相同的计算任务通过多线程的进行并行执行,从而能提高数据的计算速度。
- Fork/Join 并行计算框架的核心组件是 ForkJoinPool。ForkJoinPool 支持任务窃取机制,能够让所有线程的工作量基本均衡,不会出现有的线程很忙,而有的线程很闲的状况,所以性能很好。
- 注意数据量小的情况下,没有必要使用Fork/Join框架。因为多线程会涉及到上下文的切换,所以数据量不大的时候使用串行比使用多线程快。