只有計算機才能完成的小學數學作業

寒食君發表於2018-10-15

記得在上個月,微博上有一則熱議得新聞:小學數學老師佈置作業,要求“數一億粒米”。

image.png

網友大多數是以吐槽的態度去看待這件事,也有人指出能用估算的方法,這是一道考察發散思維的題目。

一開始我也覺得這個題目很荒唐,似乎是不可能完成的任務。但這個細細想來值得玩味,我在思考一個問題:如果從計算機的角度去看,如何才能最快速地數一億粒米呢?

首先我們先將問題簡單地抽象一下:

抽象過程

作為有煮飯經驗的我來說,米中是存在一些雜質的,所以數米應該不僅僅是單純的數數,其中還有一個判斷是米還是雜質的過程。

那麼可以將其視作一個長度為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)呢?當我們需要完成一個很龐大的任務時(比如這裡的數一億粒米),我們可以將這個大任務分割為一些互不相關的子任務,為了減少執行緒間的競爭,將其放線上程的獨立工作佇列中。當某個執行緒完成自己工作佇列中的任務時,可以從頭部竊取其他執行緒的工作佇列中的任務(雙端佇列,執行緒本身是從佇列尾部獲取任務處理,這樣進一步避免了執行緒的競爭)就像下圖:

image.png

如何劃分子任務呢?Fork/Pool採用遞迴的形式,先將整個陣列一分為二,分為left和right,然後對left和right進行相同的操作,直到陣列的長度到達一個我們設定的閾值(這個閾值十分重要,可以影響程式的效率,假設為1000),然後對這個長度的陣列進行計算,返回計算結果。上層的任務收到下層任務完成的訊息後,開始執行,以此傳遞,直到任務全部完成。

image.png

以下是使用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了,哎,不過智力低也怪不了你,畢竟是我生的。有空去關注一下「位元組流」這個公眾號吧,裡面寫得比較淺顯一些,適合你這種剛入門的。

image.png

只有計算機才能完成的小學數學作業

相關文章