死磕是编程的捷径

2018-11-14  本文已影响16人  寒食君

    题图 | @XXuAn

如果严谨一点的话,我需要给标题加状语:我觉得死磕是编程的捷径之一。

前几天写的一片文章《

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

》的得到了几个大号的转发,阅读量比较可观,但其实鲜有人真正将代码复制到本地调试运行。因为代码中存在一处错误,这不是我刻意为之,而是我的纰漏。

先来看看根据读者反馈的在 TogetherCounter.java中的错误:

  1.  @Override

  2.    protected Long compute() {

  3.      long total = 0;

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

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

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

  7.            total += 1;

  8.        }

  9.        return total;

  10.      }else {

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

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

  13.        left.fork();

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

  15.        right.fork();

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

  17.      }

  18.    }

  19.  }

发现了吗?在循环中,我将判断条件错误地设置为了 if(riceArray[1]==1),而正确的应该是 if(riceArray[i]==1),这完全是因为我的手残导致的,但是仅仅这个字符,却让程序产生了让我误以为正确合理的结果。

因为 if(riceArray[1]==1)这个判断条件相当于已被写死,所以数组减少了寻址过程,使得程序运行时间降低,而我以为是提高了效率。

当我修正为 if(riceArray[i]==1)后运行,程序消耗的时间发而比使用 ExcutorService的时间更多。一个小小的字符错误,仿佛使整篇文章的结论都崩塌了。

我又陷入另一种疑惑,因为文章基于的 ForkJoinPool理论是正确的,为什么效率没有得到提升呢?这不科学啊!有人说可能是线程争用导致了效率降低,但是我是在八核上运行八个线程,应该没有这个问题。于是我重新调试代码,翻看文档。

后来我发现,这次调试我疏忽了之前提到的阈值,变量 THRESHOLD,在调整 THRESHOLD之后,我又得到了一个接近最佳的运行时间:47ms。这个数字很眼熟,不正是使用 ExcutorService的运行时间吗?难道在这是一种巧合?恐怕未必。

在上篇文章的结论中:

ForkJoin有两个最大的特点:

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

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

爸爸喝了口Java,继续说道:在这次测试中,由于分配给各个线程的任务数是相同的,而且每个任务都是非常简单的计算。按照我的理解,应该是线程启动先后的微小时间差异,导致有的线程留下了短暂的闲置时间,而这极短的时间也被利用起来了,所以才会看到最终的优化结果。若是更复杂的任务,效果将会更加明显。

其中,“按照我的理解,应该是线程启动先后的微小时间差异,导致有的线程留下了短暂的闲置时间,而这极短的时间也被利用起来了,所以才会看到最终的优化结果。若是更复杂的任务,效果将会更加明显。”这句话是在给「码农翻身」投稿时,和刘欣老师讨论后增加上去的。因为给线程分配的任务量是一样的,各个cpu的运算速度也是一样的,应该不会产生大量的work-stealing事件,所以当时将效率的提升假设为线程启动的时间差(可见刘老师是真的严谨)

而这次修改确实也证实了,在任务分配均衡的时候,几乎没有触发work-stealing,所以无论使用 ForkJoin还是 ExecutorService,效率都是一样的。

那么,能不能更改代码,刻意让程序触发work-stealing,来证实之前的观点呢?我觉得是可以的,于是我对代码做出了修改。

如何才能使任务分配是不均匀的呢?

我将 intmid=(from+to)/2;改为: intmid=(to-from)/3+from;,这样,切分任务时会产生如下的改变:

变为

我们先使用一个较小的数字进行测试,我将总数设为100,阈值设为10,任务按照1/3,2/3切分。我使用这行代码来将运行细节打印到控制台 System.out.println(Thread.currentThread().getName()+":"+from+","+to+"");。以下是打印的日志数据,我们可以清晰地看到此程序的运行过程:

现在我们来测试一下一亿个数字,使用不均匀的划分:

答案是相同的,在任务均匀的情况下使用 ExecutorService,消耗时间为46ms左右(你可以试一下手动控制为不均匀,会比这个数字大得多),而在任务均匀或不均匀的情况下, ForkJoin消耗的时间都是47ms左右,可见work-stealing被触发了。

至此,我的勘误与验证流程已经差不多了。对于这个程序的死磕也告一段落。但我还是不能保证程序是完全正确的,如有错误,欢迎交流。

这让我回想起刚接触编程的时候,遇到报错信息完全一筹莫展,又没有人可以直接给我解答,没办法,只能死磕。可以复制关键的异常信息进行搜索,也可以打满断点一步步地debug,更多地还是需要思考为什么会出错,可能产生问题的地方来快速定位。

相对于一开始写程序时的诚惶诚恐,生怕一不小心弄疼了程序产生报错,到现在可以静下心来慢慢地与bug死磕,我的性格也渐渐发生了变化。改掉了以前的那种浮躁,到现在能冷静地去拆解一个问题。

这让我受益很大,因为机器是死的,1就是1,0就是0,几乎所有产生的问题,都来源于开发者本身,需要你慢慢地去和他交流。与此同时,在生活中的矛盾冲突,也能更多地去反省自我。

为什么说人人都需要学一点编程?

我觉得一是锻炼你的思维,另外一点人们忽视的,可能就是通过一些可控的和近乎玄学的BUG,来磨练你的性格。所以呀,《变形计》这种节目,完全不用去乡村体验生活,直接让主人公学编程即可,真香。

上一篇下一篇

猜你喜欢

热点阅读