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

記得在上個月,微博上有一則熱議得新聞:小學數學老師佈置做業,要求「數一億粒米」。算法

image.png

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

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

首先咱們先將問題簡單地抽象一下:併發

抽象過程

做爲有煮飯經驗的我來講,米中是存在一些雜質的,因此數米應該不只僅是單純的數數,其中還有一個判斷是米仍是雜質的過程。ide

那麼能夠將其視做一個長度爲L的數組(L大於一億),這個數組是隨機生成的,可是知足數組的每一個元素是一個整型類型的數字(0或1)。約定:元素若是爲1,則視做有效的「米」;若是爲0,則視做無效的「雜質」。函數

爲了更快地完成計算,並行的效率應該是比串行來得高。優化

那麼咱們將一我的視做一個工做線程,全家一塊兒數米的情景能夠視做併發狀況。ui

有了以上的鋪墊,接下來就是最核心的問題,如何才能最快地數一億粒米。我不妨假設如下的幾種情景:this

情景一:串行

今天剛上小學四年級的小季放學回家,媽媽正在作飯,爸爸正在沙發上刷公衆號「字節流」。spa

小季說:「媽媽,今天老師佈置了一項做業,要數一億粒米。」

媽媽:「找你爸去。」

爸爸:「?」

因而爸爸一我的開始數米,開啓一個循環,遍歷整個數組進行計算。

如下是單線程執行的代碼。

首先定義一個計算接口:

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

相關文章
相關標籤/搜索