只有計算機才能完成的小學數學作業
記得在上個月,微博上有一則熱議得新聞:小學數學老師佈置作業,要求“數一億粒米”。
網友大多數是以吐槽的態度去看待這件事,也有人指出能用估算的方法,這是一道考察發散思維的題目。
一開始我也覺得這個題目很荒唐,似乎是不可能完成的任務。但這個細細想來值得玩味,我在思考一個問題:如果從計算機的角度去看,如何才能最快速地數一億粒米呢?
首先我們先將問題簡單地抽象一下:
抽象過程
作為有煮飯經驗的我來說,米中是存在一些雜質的,所以數米應該不僅僅是單純的數數,其中還有一個判斷是米還是雜質的過程。
那麼可以將其視作一個長度為L的陣列(L大於一億),這個陣列是隨機生成的,但是滿足陣列的每個元素是一個整型型別的數字(0或1)。約定:元素如果為1,則視作有效的“米”;如果為0,則視作無效的“雜質”。
為了更快地完成計算,並行的效率應該是比序列來得高。
那麼我們將一個人視作一個工作執行緒,全家一起數米的情景可以視作併發情況。
有了以上的鋪墊,接下來就是最核心的問題,如何才能最快地數一億粒米。我不妨假設以下的幾種情景:
情景一:序列
今天剛上小學四年級的小季放學回家,媽媽正在做飯,爸爸正在沙發上刷公眾號「位元組流」。
小季說:“媽媽,今天老師佈置了一項作業,要數一億粒米。”
媽媽:“找你爸去。”
爸爸:“?”
於是爸爸一個人開始數米,開啟一個迴圈,遍歷整個陣列進行計算。
以下是單執行緒執行的程式碼。
首先定義一個計算介面:
public interface Counter {
long count(double[] riceArray);
}
複製程式碼
爸爸迴圈數米:
public class FatherCounter implements Counter {
@Override
public long count(double[] riceArray) {
long total = 0;
for (double i : riceArray){
if (i == 1)
total += 1;
if (total >= 1e8)
break
}
return total;
}
}
複製程式碼
主函式:
public static void main(String[] args) {
long length = (long) 1.2e8;
double[] riceArray = createArray(length);
Counter counter = new FatherCounter();
long startTime = System.currentTimeMillis();
long value = counter.count(riceArray);
long endTime = System.currentTimeMillis();
System.out.println("消耗時間(毫秒):" + (endTime - startTime));
}
複製程式碼
最後的運算結果:
消耗時間(毫秒):190
複製程式碼
我運行了多次,最後的消耗時間都在190ms左右。這個單執行緒迴圈計算平平無奇,沒有什麼值得深究的地方。由於大量的計算機資源都在閒置,我猜測,這肯定不是最優的解法。
情景二:並行
執行緒池ExecutorService
爸爸一個人數了一會,覺得自己一個人數米實在是太慢了,家裡有這麼多人,為什麼不大家一起分攤一點任務呢?每個人數一部分,最後再合併。
於是小季全家總動員,一起來完成作業。
除去三大姑八大姨,現在到場的有爸爸、媽媽、哥哥、姐姐、爺爺、奶奶、外公、外婆八位主要家庭成員(8個CPU的計算機)。
小季說:既然要數1億粒米,那麼就你們每人數12500000粒米,然後再合併一起吧!
爸爸說:崽子,別想偷懶,我剛剛數過了,現在換你去,我來給你們分配任務。(主執行緒)
大家說幹就幹,各自埋頭工作起來。
以下是使用ExecutorService方式的程式碼:
還是同一個介面:
public interface Counter {
long count(double[] riceArray);
}
複製程式碼
建立一個新的實現類:
public class FamilyCounter implements Counter{
private int familyMember;
private ExecutorService pool;
public FamilyCounter() {
this.familyMember = 8;
this.pool = Executors.newFixedThreadPool(this.familyMember);
}
private static class CounterRiceTask implements Callable<Long>{
private double[] riceArray;
private int from;
private int to;
public CounterRiceTask(double[] riceArray, int from, int to) {
this.riceArray = riceArray;
this.from = from;
this.to = to;
}
@Override
public Long call() throws Exception {
long total = 0;
for (int i = from; i<= to; i++){
if (riceArray[i] == 1)
total += 1;
if (total >= 0.125e8)
break;
}
return total;
}
}
@Override
public long count(double[] riceArray) {
long total = 0;
List<Future<Long>> results = new ArrayList<>();
int part = riceArray.length / familyMember;
for (int i = 0; i < familyMember; i++){
results.add(pool.submit(new CounterRiceTask(riceArray, i * part, (i + 1) * part)));
}
for (Future<Long> j : results){
try {
total += j.get();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException ignore) {
}
}
return total;
}
}
複製程式碼
主函式依舊是原來的配方:
public static void main(String[] args) {
long length = (long) 1.2e8;
double[] riceArray = createArray(length);
// Counter counter = new FatherCounter();
Counter counter = new FamilyCounter();
long startTime = System.currentTimeMillis();
long total = counter.count(riceArray);
long endTime = System.currentTimeMillis();
System.out.println("消耗時間(毫秒):" + (endTime - startTime));
System.out.println(total);
}
複製程式碼
最終輸出:
消耗時間(毫秒):46
複製程式碼
我運行了多次,結果都在46ms左右,說明這個結果具有一般性。那麼有一個問題來了,既然一個人數米花費了190ms,那麼照理來說8個人同時工作,最終應該只需要190/8=23ms呀,為什麼結果是46ms?
因為執行緒池、執行緒的建立以及結果的合併計算都是需要消耗時間的(因為我的計算機是8核,所以這裡應該不存線上程切換帶來的消耗)
假如小季請來更多的親戚,能夠以更快的速度數完一億粒米嗎?我猜不可以,反而會適得其反。我將執行緒池的核心執行緒數調至16,再次執行,輸出結果為:
消耗時間(毫秒):62
複製程式碼
可見執行緒之前的切換消耗了一定的資源,所以很多情況下並非“人多好辦事”,人多所帶來的團隊協調等問題,可能會降低整個團隊的工作效率。
到這裡,小季已經頗為滿意,畢竟計算時間從一開始的190ms,優化到現在的46ms,效率提升了四倍之多。但是爸爸眉頭一鎖,發現事情並沒有這麼簡單,以他常年看公眾號「位元組流」的經驗來看,此事還有蹊蹺。
執行緒池ForkJoinPool
在之前大家埋頭數米的過程中,爸爸作為任務的分配者,也在觀察著大家。
他發現,爺爺奶奶由於年紀大了,數米速度完全比不上眼疾手快的哥哥姐姐。哥哥姐姐完成自己的任務就出去玩了,最後只剩爺爺奶奶還在工作。年輕人居然不為老人分憂,成何體統!
小季(內心OS):爸爸,好像只有你一直在玩。
於是,爸爸在想能不能有一個演算法,當執行緒池中的某個執行緒完成自己工作佇列中的任務後,並不是直接掛起,而是能幫助其他執行緒。
有了,這不就是work-stealing演算法嗎?爸爸決定試試ForkJoinPool。
什麼是工作竊取演算法(work-stealing)呢?當我們需要完成一個很龐大的任務時(比如這裡的數一億粒米),我們可以將這個大任務分割為一些互不相關的子任務,為了減少執行緒間的競爭,將其放線上程的獨立工作佇列中。當某個執行緒完成自己工作佇列中的任務時,可以從頭部竊取其他執行緒的工作佇列中的任務(雙端佇列,執行緒本身是從佇列尾部獲取任務處理,這樣進一步避免了執行緒的競爭)就像下圖:
如何劃分子任務呢?Fork/Pool採用遞迴的形式,先將整個陣列一分為二,分為left和right,然後對left和right進行相同的操作,直到陣列的長度到達一個我們設定的閾值(這個閾值十分重要,可以影響程式的效率,假設為1000),然後對這個長度的陣列進行計算,返回計算結果。上層的任務收到下層任務完成的訊息後,開始執行,以此傳遞,直到任務全部完成。
以下是使用ForkJoinPool方式的程式碼:
public class TogetherCounter implements Counter {
private int familyMember;
private ForkJoinPool pool;
private static final int THRESHOLD = 3000;
public TogetherCounter() {
this.familyMember = 8;
this.pool = new ForkJoinPool(this.familyMember);
}
private static class CounterRiceTask extends RecursiveTask<Long> {
private double[] riceArray;
private int from;
private int to;
public CounterRiceTask(double[] riceArray, int from, int to) {
this.riceArray = riceArray;
this.from = from;
this.to = to;
}
@Override
protected Long compute() {
long total = 0;
if (to - from <= THRESHOLD){
for(int i = from; i < to; i++){
if (riceArray[1] == 1)
total += 1;
}
return total;
}else {
int mid = (from + to) /2;
CounterRiceTask left = new CounterRiceTask(riceArray, from, mid);
left.fork();
CounterRiceTask right = new CounterRiceTask(riceArray, mid + 1, to);
right.fork();
return left.join() + right.join();
}
}
}
@Override
public long count(double[] riceArray) {
return pool.invoke(new CounterRiceTask(riceArray, 0, riceArray.length - 1));
}
}
複製程式碼
當我把閾值設定在7000-8000的時候,計算時間縮短到了驚人的15ms,效率又提升了3倍之多!
消耗時間(毫秒):15
複製程式碼
得到這個結果,爸爸十分滿意。此時小季卻疑惑了,同樣是並行,為什麼效率相差這麼大呢?
爸爸摸著小季的頭,說道:這個還是需要看具體的場景。並不是所有情況下,ForkJoinPool都比ExecutorService出色。
ForkJoinPool主要使用了分治法的思想。
它有兩個最大的特點:
-
能夠將一個大型任務分割成小任務,並以先進後出的規則(LIFO)來執行,在有些併發中,當任務需要按照一定的順序來執行時,ForkJoin將發揮其能力。ExecutorService是無法做到的,因為ExecutorService不能決定任務的執行順序。
-
ForkJoinPool的偷竊演算法,能夠在應對任務量不均衡的情況下,或者任務完成存在快慢的情況下,使閒置的執行緒去幫助正在工作的執行緒,保證資源的利用率,並且減少執行緒間的競爭。
爸爸喝了口咖啡,繼續說道:在JDK8中,ForkJoinPool添加了一個通用執行緒池,這個執行緒池用來處理那些沒有被顯式提交到任何執行緒池的任務。這也是為什麼Arrays.sort()快排速度非常快的原因,因為引入了自動並行化(Automatic Parallelization)。
小季若有所思:爸爸,我完全聽不懂啊,我還是隻是個四年級的孩子。
爸爸責備道:四年級不早了!人家的孩子一歲就讀paper了,哎,不過智力低也怪不了你,畢竟是我生的。有空去關注一下「位元組流」這個公眾號吧,裡面寫得比較淺顯一些,適合你這種剛入門的。