在本文的示例中,我会分别对线性方式、线程池和ForkJoin处理模式进行性能测试。测试代码托管在Github:https://github.com/fbunau/javaadvent-forkjoin。
实际问题
设想一下我们有这样一个系统,系统中部分组建需要时刻保留每一时刻最终的股价数据。这些数据可以用一个整型数组保存在内存中(如果我们用bps,字节/秒来统计)。这个组件的客户端会发起一些请求,比如:在time1和time2之前,哪个时间点的股价是最低的?请求的发起可以是自动的算法,也可以是操作员在操作界面上使用鼠标进行框选。
示例中的7次查询请求然后我们不妨假设,我们得到了来自同一个客户端的若干个这种请求,所有请求组成了一个查询任务。这样的合并可能是为了减少网络传输和通信的往返时间。
接下来,我们的服务组建就会得到大小不等的任务包,例如10次查询(由手工操作形成的查询),或者是100次查询的任务甚至10000000 次查询的任务(由算法生成的查询)。同时,我们还有很多这样的客户端程序,每个客户端程序都在发出不同大小的查询任务。参考Task.TaskType。
核心问题和解决方案
我们要解决的核心问题就是RMQ问题。下面是Wikipedia对RMQ的解释[2]:
“给定一组对象,这些对象来自有完善的大小,顺序定义的集合(比如数字)。一个从数组索引i到索引j的范围最小查询(RMQ)就是查找在子数组A[i, j]中最小元素的下标。”
“例如,有数组 A = [0, 5, 2, 5, 4, 3, 1, 6, 3],则 RMQ A[3, 8] 的结果就是 7,因为 A[3, 8] = [2, 5, 4, 3, 1, 6]。其中最小值1的下标在数组A中是7。”
我们有一个非常高效的数据结构来解决这个问题,那就是“分段树”(Segment Tree)。我不会详细讲什么是分段树,有兴趣的读者可以参考经典的Topcoder文章[3]。
因为这些细节对于我的ForkJoin示例并不是很重要,我提及分段树是因为它远比简单的加法要有意思,而且它的核心思想与fork-join相同:这就是分治的原理,把任务拆散进行计算,最后合并结果!
这种数据结构的初始化时间复杂度为O(n),之后的查询复杂度为O(log N),N就是我们单位时间内数组中的价格总数。
假设一个任务T包含了M个需要执行的查询。如果按照计算机科学中的学术方式进行执行,你就会说我们就用这种数据结构依次处理每一个任务,然后我们会得到如下的时间复杂度:
难道我们不能更有效率吗!?在一个理论上的冯 · 诺依曼计算机上,这已经是最有效率的了,但是我们在实践中可以更有效率。
一个很容易造成混淆的时间复杂度比较是因为学术上认为O(n/4) == O(n),所以有人会以为上面对N除以一个常数不会对最终执行效率造成多大影响,但是影响确实存在!不妨停下来想一想,等待10分钟、10小时、10年,还是40分钟、40小时、40年,能一样吗?
并行计算
所以就我们面对的问题而言,我们怎么计算能更快一些?因为现在的计算机都有多个核心可以进行独立计算,所以我们可以利用这一点让他们同时做不同的计算。借助ForkJoin框架,我们很容易实现这一点。
我一开始试图改变一些RMQ的数据结构然后将一些操作并行化,这些操作已经达到了log N 的负责度。当然,我的尝试失败了,调度上的开销对于这些简短的逻辑运算来说太高昂了。
最后,我发现正确的途径是针对M_i常数参数进行并行化处理。
线程池
在我们演示如何使用ForkJoin之前,我们先来看看如果是线程池的话,我们的实现会是怎样的。代码见:TaskProcessorPool.java
我们可以有一个4个worker线程的线程池,当一个任务到达时,我们把任务放到队列里。当一个worker线程空闲时,这个worker就从队头取一个待执行的任务并且执行。
这种方式对于大小相同,且任务大小适中可控的任务来说是不错的。但是当任务大小不一致的时候就会遇到问题。就是说,一个worker可能被缠在冗长的任务中,然后其他的worker闲着没事做。
图中显示线程池的实现在4个单位时间内,且没有更多的的任务加入到队列中的情况下,4个线程在最多可能的完成16任务的情况下完成了9 个任务。(效率是56%)
分治合并
Fork Join方式在你可以把一个大任务分成若干个小任务时非常的有用。
Fork-join线程池的特别之处在于它是一个可以分配任务的线程池。每一个worker线程维护一个本地的任务双向队列。当执行一个新的任务时,它可以做如下事情:
- 将任务拆分成更小的子任务。
- 如果任务足够小就直接执行。
当一个线程发现它本地双向队列中没有任务,则它将随机的从其他一个worker线程中的对头中“偷取”一个任务,加到自己的队列中。这时, 有很大的可能这个拿来的任务还没有进行拆分,这样它就会有很多事情需要做。与线程池相比较,与其说其他线程等着新任务被假如,不如有人把现有的大任务拆分,然后所有线程一起来执行。回到我们刚才说的问题,一批很多的操作可以进一步拆分成若干批少量的操作。代码:TaskProcessorFJ.java
我们遇到的大多数的问题都像上述例子中那样包含一系列线性的操作,其本质上并不是一个很特别的并发问题,也就是说我们并非一定要用什么特殊的并发操作算法来发挥多核的优势。
另一个问题是,怎么把握拆分的度?一般来讲,你可以一直进行任务拆分,直到继续拆分也不能带来性能的提升为止。比方说:(拆分 + 线程获取任务 + 程序调度的开销比单独执行这个完整任务还有大的时候,就不要拆分了)
对于超大的任务,比如一个任务包含了1000000个查询操作。我们可以给它分成2个500000个操作的任务来并发执行。500000操作还是太多?是的,我们可以继续拆分。我们可以把一组10000个操作的任务作为拆分的阈值,当低于这个阈值时,我们就不需要进行拆分了,直接让线程执行所有操作就可以了。
Fork join并不是预先拆分所有任务,而是在执行时动态的决定拆分。
性能测试结果
我对于每种实现,进行了4轮测试。实验用机的配置为i5-2500 CPU@3。30GHz4核心/4线程。实验前机器进行了重启。下面是测试结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
TaskProcessorSimple: 7963 TaskProcessorSimple: 7757 TaskProcessorSimple: 7748 TaskProcessorSimple: 7744 TaskProcessorPool: 3933 TaskProcessorPool: 2906 TaskProcessorPool: 4477 TaskProcessorPool: 4160 TaskProcessorFJ: 2498 TaskProcessorFJ: 2498 TaskProcessorFJ: 2524 TaskProcessorFJ: 2511 Test completed。 |
结语
就算你选择了最优的算法和数据结构,但如果你没有利用好所有的资源的话,那在性能提升上显然是不够的。例如,充分利用多核。
ForkJoin绝对是对线程池的一个很好的改进,尤其是在一些特定问题上,这也意味着我们将见到越来越多的并行代码。今天你可以随便买到一个有12核/24线程的处理器。所以我们只需要写些代码来充分利用这些我们已经拥有的或者很快拥有的超赞的硬件资源。
示例代码在这里:github: https://github.com/fbunau/javaadvent-forkjoin你可以自己试试。