死磕是编程的捷径
题图 | @XXuAn
如果严谨一点的话,我需要给标题加状语:我觉得死磕是编程的捷径之一。
前几天写的一片文章《
只有计算机才能完成的小学数学作业
》的得到了几个大号的转发,阅读量比较可观,但其实鲜有人真正将代码复制到本地调试运行。因为代码中存在一处错误,这不是我刻意为之,而是我的纰漏。
先来看看根据读者反馈的在 TogetherCounter.java
中的错误:
@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();
}
}
}
发现了吗?在循环中,我将判断条件错误地设置为了 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,来磨练你的性格。所以呀,《变形计》这种节目,完全不用去乡村体验生活,直接让主人公学编程即可,真香。