对于通常的developer(特别是那些具备并行计算/多线程背景知识的developer)来讲,js的异步处理着实称得上诡异。而这个诡异从结果上讲,是由js的“单线程”这个特性所导致的。
我曾尝试用“先定义后展开”的教科书方式去讲解这一块的内容,但发现极其痛苦。因为要理清楚这个东西背后的细节,并将其泛化、以更高的视角来看问题,着实涉及非常多的基础知识。等到我把这些知识讲清楚、讲完,无异于逼迫读者抱着操作系统、计算机网络这样的催眠书看上好个几章节,着实沉闷而乏味。
并且更关键的是,在走到那一步的时候,读者的精力早已消耗殆尽,完全没有心力再去关心这个最开始的问题——js的异步处理为何诡异。
所以,我决定反过来,让我们像一个初学者那样,从一无所知开始,
先使用“错误的理念”去开始我们的讨论,然后用代码去发现和理念相违背的地方。
再做出一些修正,再考察一些例子,想想是否还有不大满意和清楚的地方,再调整。如此往复,我们会像侦探那样,先从一个不大正确的假设开始,不断寻找证据,不断修正假设,一步步追寻下去,直到抵达最后完整的真相。
我想,这样的写作方式,更符合一个人真正的求知和研究过程,并能够为你带来更多关于“探索问题”的启发。我想,这样的思维方式和研究理念,比普通的知识更为重要。它能够让你成为知识的猎人,有能力独立地觅食,而不必被迫成为婴孩,只能坐等他人喂食。
好了,让我们先从一块js代码,开始我们的探索之旅。
console.log('No. 1');setTimeout(function(){ console.log('setTimeout callback');}, 5000);console.log('No. 2');输出结果是:
No. 1No. 2setTimeout callback这块代码中几乎没什么复杂的东西,全是打印语句。唯一的特别是函数setTimeout,根据粗略的网上资料显示,它接受两个参数:
第一个参数是callback函数,就是让它执行完之后,回过头来调用的函数。
另一个是时间参数,用于指定多少微妙之后,执行callback函数。这里我们使用了5000微妙,也即是5秒钟。
另一个重点是,setTimeout是一个异步函数,意思是我的主程序不必去等待setTimeout执行完毕,将它的运行过程扔到别的地方执行,然后主程序继续往下走。也即是,主程序是一个步调、setTimeout是另一个步调,即是“异步”的方式跑代码。
如果你有一些并行计算或者多线程编程的背景知识,那么上面的语句就再熟悉不过了。如果在多线程环境,无非是另起一根线程去运行打印语句console.log('setTimeout callback')。然后主线程继续往下走,新线程去负责打印语句,清晰明了。
所以综合起来,这段代码的意思是,主线程执行到语句setTimeout时,就把它交给“其它地方”,让这个“其它地方”等待5秒钟之后运行。而主线程继续往下走,去执行“No. 2”的打印。所以,由于其它部分要等待5秒钟之后才运行,而主线程立刻往下运行了“No. 2”的打印,最终的输出结果才会是先打印“No. 2”,再打印“setTimeout callback”。
嗯,so far so good。一切看来都比较美好。
如果我们对上述程序做一点变动呢?例如,我可不可以让“setTimeout callback”这个信息先被打印出来呢?因为在并行计算中,我们经常遇到的问题便是,由于你不知道多个线程之间谁执行得快、谁执行得慢,所以我们无法判定最终的语句执行顺序。这里我们让“setTimeout callback”停留了5秒钟,时间太长了,要不短一点?
console.log('No. 1');setTimeout(function(){ console.log('setTimeout callback');}, 1);console.log('No. 2');我们将传递给setTimeout的参数改成了1毫秒。多次运行后会发现,结果竟然没有改变?!似乎有点反常,要不再改小一点?改成0?
console.log('No. 1');setTimeout(function(){ console.log('setTimeout callback');}, 0);console.log('No. 2');多次运行后,发现依旧无法改变。这其实是有点奇怪了。因为通常的并行计算、多线程编程中,通过多次运行,你其实是可以看到各种无法预期的结果的。在这里,竟然神奇地得到了相同的执行顺序结果。这就反常了。
但我们还无法完全下一个肯定的结论,可不可能因为是setTimeout的启动时间太长,而导致“No. 2”这条语句先被执行呢?为了做进一步的验证,我们可以在“No. 2”这条打印语句之前,加上一个for循环,给setTimeout充分的时间去启动。
console.log('No. 1');setTimeout(function(){ console.log('setTimeout callback');}, 0);for (let i = 0; i < 10e8; i++) {}console.log('No. 2');运行这段代码,我们发现,“No. 1”这条打印语句很快地显示到了浏览器命令行,等了一秒钟左右,接着输出了
No. 2setTimeout callback诶?!这不就更加奇怪了吗?!setTimeout不是等待0秒钟后立刻运行吗,就算启动再慢,也不至于等待一秒钟之后,还是无法正常显示吧?况且,在加入这个for循环之前,“setTimeout callback”这条输出不是立刻就显示了吗?
综合这些现象,我们有理由怀疑,似乎“setTimeout callback”一定是在“No. 2”后显示的,也即是:setTimeout的callback函数,一定是在console.log('No. 2')之后执行的。为了验证它,我们可以做一个危险一点的测试,将这个for循环,更改为无限while循环。
console.log('No. 1');setTimeout(function(){ console.log('setTimeout callback');}, 0);while {} // dangerouse testingconsole.log('No. 2');如果setTimeout的callback函数是按照自己的步调做的运行,那么它就有可能在某个时刻打印出“setTimeout callback”。而如果真的是按照我们猜测的那样,“setTimeout callback”必须排在“No. 2”之后,那么浏览器命令行就永远不会出现“setTimeout callback”。
运行后发现,在浏览器近乎要临近崩溃、达到内存溢出的情形下,“setTimeout callback”依旧没有打印出来。这也就证明了我们的猜测!
这里,我们第一次出现了理念和现实的矛盾。按照通常并行计算的理念,被扔到”其它地方“的setTimeoutcallback函数,应该被同时运行。可事实却是,这个”其它地方“并没有和后一条打印”No. 2“的语句共同执行。这时候,我们就必须要回到基础,回到js这门语言底层的实现方式上去追查,以此来挖掘清楚这后面的猫腻。
js的特性之一是”单线程“,也即是从头到尾,js都在同一根线程下运行。或许这是一个值得调查深入的点。想来,如果是多线程,那么setTimeout也就该按照我们原有的理念做执行了,但事实却不是。而这两者的不同,便在于单线程和多线程上。
找到了这个不同点,我们就可以更深入去思考一些细节。细想起来,所谓”异步“,就是要开辟某个”别的地方“,让”别的地方“和你的主运行路线一起运行。可是,如果现在是单线程,也就意味着计算资源有且只有一份,请问,你如何做到”同时运行“呢?
这就好比是,如果你去某个办事大厅,去缴纳水费、电费、天然气。那么,我们可以粗略地将它们分为水费柜台、电费柜台、天然气柜台。那么,如果我们依次地“先在水费柜台办理业务,等到水费的明细打印完毕、缴纳完费用后;再跑去电费柜台打印明细、缴纳费用;再跑去天然气柜台打印明细、缴纳费用”,这就是一个同步过程,必须等待上一个步骤做完后,才能做下一步。
而异步呢,就是说我们不必在某个环节浪费时间瞎等待。比如,我们可以在“打印水费明细”的空闲时间,跑到电费和天然气柜台去办理业务,将“电费明细、天然气明细的打印”这两个任务提前启动起来。再回过头去缴纳水费、缴纳电费、缴纳天然气费用。其实,这就是华罗庚推广优选法的时候举的例子,烧水、倒茶叶、泡茶,如何安排他们的顺序为高效。
显然,异步地去做任务更高效。但这要有一个前提,就是你做任务的资源,也即是干活的人或者机器,得有多份才行。同样按照上面的例子来展开讨论,虽然有水费、电费、天然气这三个柜台,可如果这三个柜台背后的办事人员其实只有一个呢?比如你启动了办理水费的业务,然后想要在办理水费业务的等待期,去电费柜台办理电费业务。表面上,你去电费柜台下了申请单,请求办理电费业务,可却发现根本没有办事员去接收你的这个业务!为何?因为这有且只有一个的办事员,还正在办理你的水费业务啊!这时候,你的这个所谓的“异步”,有何意义?!
所以从这个角度来看,当计算资源只有一份的时候,你做“异步”其实是没什么意义的。因为干活的资源只有一份,就算在表面做了名义上的“异步”,可最终就像上面的多柜台单一办事员那样,到了执行任务层面,还是会一个接一个地完成任务,这就没有意义了。
那么,js的特性是“单线程”+“异步”,不就正是我们讨论的“没有意义”的情况吗?!那又为何要多次一举,干一些没有意义的事情呢?
嗯......事情变得越来越有趣了。
通常来讲,如果一个事件出现了神奇和怪异的地方,基本上都是因为我们忽略了某个细节,或者对某个细节存在误解或是错误理解。要想把问题解决,我们就必须不断地回顾已有材料,在不断地重复检验中,发现那几根我们忽略的猫腻。
让我们回顾一下关于js异步的宣传片。通常为了说明js异步的必要性,会举出浏览器的资源加载和页面渲染这个矛盾。
渲染,可以比较粗糙地理解为将“画面”画出来的过程。例如,浏览器要将页面上的按钮、图片显示出来,就必须有一个将“图片”在网页上画出来的动作。又或是,操作系统要将“桌面”这个图形界面显示在显示器上,就必须要把它相应的这个“画面”在显示器上画出来的动作。归结起来,这个“画出来”的过程,就被称之为“渲染”。
例如,你点击页面上的一个button,让浏览器去后端数据库将数据报表取出来,在网页上把数字显示出来。而如果js不支持异步的话,整个网页的就会停留,也即是“卡”,在鼠标点击按钮这一个动作上,页面无法完成后续的渲染工作。一直要等到后端把数据返回到了前端,程序流才能够继续跑下去。
所以这里,js的“异步”其实是为了让浏览器将“加载”这个任务分给“其它地方”,让“加载过程”和“渲染过程”同步进行下去。
等等,又是这个“其它地方”?!!
我擦,不是说js是单线程而么,计算资源不是只有一份么,怎么又可以“一边加载、一边渲染”了?!WTF,你这是在逗我玩儿么?!
艹,到底这里面哪句话是真的?!到底js是单线程是真的?还是说浏览器可以同时做“一边加载、一边渲染”这个事情是真的?!
如何才能解决这个疑惑?!很显然,我们必须要深入到浏览器的内部,去看一看它到底是怎么样被设计的。
在搜索引擎中,做一些关于浏览器和js的搜索,我们不难得到一些基本信息。js并不是浏览器的全部,浏览器要掌管的事情太多了,掌管js的只是浏览器的一个组件,叫做js引擎。而最出名的、并在Chrome中使用的,就是大名鼎鼎的V8引擎,它负责js的解析和运行。
另一方面我们还知道,使用js的一个很大原因,是因为它能够自由地去操控DOM元素、去执行Ajax异步请求、能够像我们最开始举的例子那样,使用setTimeout做异步任务分配。这些都是js优秀特性。
可令人惊讶的事情来了,当我们去探索这个掌管js一切的V8引擎的时候,我们却发现,它并不提供DOM的操控、Ajax的执行以及setTimeout的特性:
上图来自Alexander Zlatkov,它的结构是:
JS引擎
Memory Heap
Call Stack
Web APIs
DOM (Document)
Ajax (XMLHttpRequest)
Timeout (setTimeout)
Callback Queue
Event Loop
明明是js的特性,为什么这些职能却不是由js的引擎来掌管呢?嗯,interesting~~~
诶!不是“单线程”么,不是加载过程被扔到其它地方么?!js是单线程,也即是js在js引擎中是单线程、只能够分到一份计算资源,可是,加载数据的Ajax这个feature不是没有被放到js引擎么?!
艹!真TM是老狐狸啊!还以为“单线程”和“一边加载、一边渲染”这两种说法只有一种是对的,可结果是,都对!为什么呢?因为只说了js是单线程,可没说浏览器本身是单线程啊!所以咯,渲染相关的js部分可以和数据加载的Ajax部分是可以同时进行的,因为它们根本就在两个模块,即两个线程嘛!所以当然可以并行啊!WTF!
诶~等等,让我们再仔细看看上面这张图呢?!Ajax不在js引擎里,可是setTimeout也不在js引擎里面啊!!如果Web APIs这部分是在不同于js引擎的另外一根线程里,它们不就可以实现真正意义上的并行吗?!那为何我们开头的打印信息“setTimeout callback”,无法按照并行的方式,优先于“No. 2”打印出来呢?
嗯......真是interesting......事情果然没有那么简单。
未完待续......
(由于公众号不支持外链,可点击下方“阅读原文”查看)
近期回顾
《没有idea这把米,怎么炊熟创业这碗饭》
《2018年08月写字总结》
《财务自由所虚构的妄念》
如果你喜欢我的文章或分享,请长按下面的二维码关注我的微信公众号,谢谢!
26
更多信息交流和观点分享,可加入知识星球: