只有计算机才能完成的小学数学作业

题图 | @JUN


记得在上个月,微博上有一则热议得新闻:小学数学老师布置作业,要求“数一亿粒米”。

网友大多数是以吐槽的态度去看待这件事,也有人指出能用估算的方法,这是一道考察发散思维的题目。

一开始我也觉得这个题目很荒唐,似乎是不可能完成的任务。但这个细细想来值得玩味,我在思考一个问题:如果从计算机的角度去看,如何才能最快速地数一亿粒米呢?

首先我们先将问题简单地抽象一下:

抽象过程

作为有煮饭经验的我来说,米中是存在一些杂质的,所以数米应该不仅仅是单纯的数数,其中还有一个判断是米还是杂质的过程。

那么可以将其视作一个长度为L的数组(L大于一亿),这个数组是随机生成的,但是满足数组的每个元素是一个整型类型的数字(0或1)。约定:元素如果为1,则视作有效的“米”;如果为0,则视作无效的“杂质”。

为了更快地完成计算,并行的效率应该是比串行来得高。

那么我们将一个人视作一个工作线程,全家一起数米的情景可以视作并发情况。

有了以上的铺垫,接下来就是最核心的问题,如何才能最快地数一亿粒米。我不妨假设以下的几种情景:

情景一:串行

今天刚上小学四年级的小季放学回家,妈妈正在做饭,爸爸正在沙发上刷公众号「字节流」。

小季说:“妈妈,今天老师布置了一项作业,要数一亿粒米。”

妈妈:“找你爸去。”

爸爸:“?”

于是爸爸一个人开始数米,开启一个循环,遍历整个数组进行计算。

以下是单线程执行的代码。

首先定义一个计算接口:

  1. public interface Counter {

  2.  long count(double[] riceArray);

  3. }

爸爸循环数米:

  1. public class FatherCounter implements Counter {

  2.  @Override

  3.  public long count(double[] riceArray) {

  4.    long total = 0;

  5.    for (double i : riceArray){

  6.      if (i == 1)

  7.        total += 1;

  8.      if (total >= 1e8)

  9.        break

  10.    }

  11.    return total;

  12.  }

  13. }

主函数:

  1. public static void main(String[] args) {

  2.        long length = (long) 1.2e8;

  3.        double[] riceArray = createArray(length);

  4.        Counter counter = new FatherCounter();

  5.        long startTime = System.currentTimeMillis();

  6.        long value = counter.count(riceArray);

  7.        long endTime = System.currentTimeMillis();

  8.        System.out.println("消耗时间(毫秒):" + (endTime - startTime));

  9.    }

最后的运算结果:

  1. 消耗时间(毫秒):190

我运行了多次,最后的消耗时间都在190ms左右。这个单线程循环计算平平无奇,没有什么值得深究的地方。由于大量的计算机资源都在闲置,我猜测,这肯定不是最优的解法。

情景二:并行

线程池ExecutorService

爸爸一个人数了一会,觉得自己一个人数米实在是太慢了,家里有这么多人,为什么不大家一起分摊一点任务呢?每个人数一部分,最后再合并。

于是小季全家总动员,一起来完成作业。

除去三大姑八大姨,现在到场的有爸爸、妈妈、哥哥、姐姐、爷爷、奶奶、外公、外婆八位主要家庭成员(8个CPU的计算机)。

小季说:既然要数1亿粒米,那么就你们每人数12500000粒米,然后再合并一起吧!

爸爸说:崽子,别想偷懒,我刚刚数过了,现在换你去,我来给你们分配任务。(主线程)

大家说干就干,各自埋头工作起来。

以下是使用ExecutorService方式的代码:

还是同一个接口:

  1. public interface Counter {

  2.  long count(double[] riceArray);

  3. }

创建一个新的实现类:

  1. public class FamilyCounter implements Counter{

  2.  private int familyMember;

  3.  private ExecutorService pool;

  4.  public FamilyCounter() {

  5.    this.familyMember = 8;

  6.    this.pool = Executors.newFixedThreadPool(this.familyMember);

  7.  }

  8.  private static class CounterRiceTask implements Callable<Long>{

  9.    private double[] riceArray;

  10.    private int from;

  11.    private int to;

  12.    public CounterRiceTask(double[] riceArray, int from, int to) {

  13.      this.riceArray = riceArray;

  14.      this.from = from;

  15.      this.to = to;

  16.    }

  17.    @Override

  18.    public Long call() throws Exception {

  19.      long total = 0;

  20.      for (int i = from; i<= to; i++){

  21.        if (riceArray[i] == 1)

  22.          total += 1;

  23.        if (total >= 0.125e8)

  24.          break;

  25.      }

  26.      return total;

  27.    }

  28.  }

  29.  @Override

  30.  public long count(double[] riceArray) {

  31.    long total = 0;

  32.    List<Future<Long>> results = new ArrayList<>();

  33.    int part = riceArray.length / familyMember;

  34.    for (int i = 0; i < familyMember; i++){

  35.      results.add(pool.submit(new CounterRiceTask(riceArray, i * part, (i + 1) * part)));

  36.    }

  37.    for (Future<Long> j : results){

  38.      try {

  39.        total += j.get();

  40.      } catch (InterruptedException e) {

  41.        e.printStackTrace();

  42.      } catch (ExecutionException ignore) {

  43.      }

  44.    }

  45.    return total;

  46.  }

  47. }

主函数依旧是原来的配方:

  1.    public static void main(String[] args) {

  2.        long length = (long) 1.2e8;

  3.        double[] riceArray = createArray(length);

  4. //        Counter counter = new FatherCounter();

  5.        Counter counter = new FamilyCounter();

  6.        long startTime = System.currentTimeMillis();

  7.        long total = counter.count(riceArray);

  8.        long endTime = System.currentTimeMillis();

  9.        System.out.println("消耗时间(毫秒):" + (endTime - startTime));

  10.        System.out.println(total);

  11.    }

最终输出:

  1. 消耗时间(毫秒):46

我运行了多次,结果都在46ms左右,说明这个结果具有一般性。那么有一个问题来了,既然一个人数米花费了190ms,那么照理来说8个人同时工作,最终应该只需要190/8=23ms呀,为什么结果是46ms?

因为线程池、线程的创建以及结果的合并计算都是需要消耗时间的(因为我的计算机是8核,所以这里应该不存在线程切换带来的消耗)

假如小季请来更多的亲戚,能够以更快的速度数完一亿粒米吗?我猜不可以,反而会适得其反。我将线程池的核心线程数调至16,再次运行,输出结果为:

  1. 消耗时间(毫秒):62

可见线程之前的切换消耗了一定的资源,所以很多情况下并非“人多好办事”,人多所带来的团队协调等问题,可能会降低整个团队的工作效率。

到这里,小季已经颇为满意,毕竟计算时间从一开始的190ms,优化到现在的46ms,效率提升了四倍之多。但是爸爸眉头一锁,发现事情并没有这么简单,以他常年看公众号「字节流」的经验来看,此事还有蹊跷。

线程池ForkJoinPool

在之前大家埋头数米的过程中,爸爸作为任务的分配者,也在观察着大家。

他发现,爷爷奶奶由于年纪大了,数米速度完全比不上眼疾手快的哥哥姐姐。哥哥姐姐完成自己的任务就出去玩了,最后只剩爷爷奶奶还在工作。年轻人居然不为老人分忧,成何体统!

小季(内心OS):爸爸,好像只有你一直在玩。

于是,爸爸在想能不能有一个算法,当线程池中的某个线程完成自己工作队列中的任务后,并不是直接挂起,而是能帮助其他线程。

有了,这不就是work-stealing算法吗?爸爸决定试试ForkJoinPool。

什么是工作窃取算法(work-stealing)呢?当我们需要完成一个很庞大的任务时(比如这里的数一亿粒米),我们可以将这个大任务分割为一些互不相关的子任务,为了减少线程间的竞争,将其放在线程的独立工作队列中。当某个线程完成自己工作队列中的任务时,可以从头部窃取其他线程的工作队列中的任务(双端队列,线程本身是从队列尾部获取任务处理,这样进一步避免了线程的竞争)就像下图:

如何划分子任务呢?Fork/Pool采用递归的形式,先将整个数组一分为二,分为left和right,然后对left和right进行相同的操作,直到数组的长度到达一个我们设定的阈值(这个阈值十分重要,可以影响程序的效率,假设为1000),然后对这个长度的数组进行计算,返回计算结果。上层的任务收到下层任务完成的消息后,开始执行,以此传递,直到任务全部完成。

以下是使用ForkJoinPool方式的代码:

  1. public class TogetherCounter implements Counter {

  2.  private int familyMember;

  3.  private ForkJoinPool pool;

  4.  private static final int THRESHOLD = 3000;

  5.  public TogetherCounter() {

  6.    this.familyMember = 8;

  7.    this.pool = new ForkJoinPool(this.familyMember);

  8.  }

  9.  private static class CounterRiceTask extends RecursiveTask<Long> {

  10.    private double[] riceArray;

  11.    private int from;

  12.    private int to;

  13.    public CounterRiceTask(double[] riceArray, int from, int to) {

  14.      this.riceArray = riceArray;

  15.      this.from = from;

  16.      this.to = to;

  17.    }

  18.    @Override

  19.    protected Long compute() {

  20.      long total = 0;

  21.      if (to - from <= THRESHOLD){

  22.        for(int i = from; i < to; i++){

  23.          if (riceArray[1] == 1)

  24.            total += 1;

  25.        }

  26.        return total;

  27.      }else {

  28.        int mid = (from + to) /2;

  29.        CounterRiceTask left = new CounterRiceTask(riceArray, from, mid);

  30.        left.fork();

  31.        CounterRiceTask right = new CounterRiceTask(riceArray, mid + 1, to);

  32.        right.fork();

  33.        return left.join() + right.join();

  34.      }

  35.    }

  36.  }

  37.  @Override

  38.  public long count(double[] riceArray) {

  39.    return pool.invoke(new CounterRiceTask(riceArray, 0, riceArray.length - 1));

  40.  }

  41. }

当我把阈值设置在7000-8000的时候,计算时间缩短到了惊人的15ms,效率又提升了3倍之多!

  1. 消耗时间(毫秒):15

得到这个结果,爸爸十分满意。此时小季却疑惑了,同样是并行,为什么效率相差这么大呢?

爸爸摸着小季的头,说道:这个还是需要看具体的场景。并不是所有情况下,ForkJoinPool都比ExecutorService出色。

ForkJoinPool主要使用了分治法的思想。

它有两个最大的特点:

  • 能够将一个大型任务分割成小任务,并以先进后出的规则(LIFO)来执行,在有些并发中,当任务需要按照一定的顺序来执行时,ForkJoin将发挥其能力。ExecutorService是无法做到的,因为ExecutorService不能决定任务的执行顺序。

  • ForkJoinPool的偷窃算法,能够在应对任务量不均衡的情况下,或者任务完成存在快慢的情况下,使闲置的线程去帮助正在工作的线程,保证资源的利用率,并且减少线程间的竞争。

  • 爸爸喝了口咖啡,继续说道:在JDK8中,ForkJoinPool添加了一个通用线程池,这个线程池用来处理那些没有被显式提交到任何线程池的任务。这也是为什么Arrays.sort()快排速度非常快的原因,因为引入了自动并行化(Automatic Parallelization)。

    小季若有所思:爸爸,我完全听不懂啊,我还是只是个四年级的孩子。

    爸爸责备道:四年级不早了!人家的孩子一岁就读paper了,哎,不过智力低也怪不了你,毕竟是我生的。有空去关注一下「字节流」这个公众号吧,里面写得比较浅显一些,适合你这种刚入门的。

    扫一扫,关注公众号

    ©著作权归作者所有,转载或内容合作请联系作者
    • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
      沈念sama阅读 204,189评论 6 478
    • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
      沈念sama阅读 85,577评论 2 381
    • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
      开封第一讲书人阅读 150,857评论 0 337
    • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
      开封第一讲书人阅读 54,703评论 1 276
    • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
      茶点故事阅读 63,705评论 5 366
    • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
      开封第一讲书人阅读 48,620评论 1 281
    • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
      沈念sama阅读 37,995评论 3 396
    • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
      开封第一讲书人阅读 36,656评论 0 258
    • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
      沈念sama阅读 40,898评论 1 298
    • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
      茶点故事阅读 35,639评论 2 321
    • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
      茶点故事阅读 37,720评论 1 330
    • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
      沈念sama阅读 33,395评论 4 319
    • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
      茶点故事阅读 38,982评论 3 307
    • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
      开封第一讲书人阅读 29,953评论 0 19
    • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
      开封第一讲书人阅读 31,195评论 1 260
    • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
      沈念sama阅读 44,907评论 2 349
    • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
      茶点故事阅读 42,472评论 2 342

    推荐阅读更多精彩内容

    • 本文是我自己在秋招复习时的读书笔记,整理的知识点,也是为了防止忘记,尊重劳动成果,转载注明出处哦!如果你也喜欢,那...
      波波波先森阅读 11,232评论 4 56
    • 进程和线程 进程 所有运行中的任务通常对应一个进程,当一个程序进入内存运行时,即变成一个进程.进程是处于运行过程中...
      胜浩_ae28阅读 5,084评论 0 23
    •   一个任务通常就是一个程序,每个运行中的程序就是一个进程。当一个程序运行时,内部可能包含了多个顺序执行流,每个顺...
      OmaiMoon阅读 1,662评论 0 12
    • 进程和线程 进程 所有运行中的任务通常对应一个进程,当一个程序进入内存运行时,即变成一个进程.进程是处于运行过程中...
      小徐andorid阅读 2,797评论 3 53
    • 易和关注人们饮食健康卫生问题在外就餐一定要选择优质餐饮商家 最近微博上的一条信息引起了许多人的热议,事件缘起几个大...
      魅惑微风阅读 294评论 0 0