Event Loop到底是什么鬼?

2,173 阅读20分钟

该文章是对Philip Roberts在JSConfEU演讲的意译和整理。如有误导,请放弃阅读。原文

演讲视频

演讲视频

演讲前言

javascript程序员喜欢说各种高逼格的术语:“event-loop”,"non-blocking","callback","asynchronous","single-threaded"和“concurrency”等(译者注:确实如此,一些所谓的面试官在自己对这些概念一知半解的情况下也喜欢问这些概念)。

我们总是装出一胸有成竹的样子说着这样的话:“不要阻塞event loop”,"你要确保你的代码以60FPS(frames-per-second)的速度去运行",“你这么搞法肯定是不行啦,那个函数是一个异步执行的callback啊”。

如果你是像我一样的怂货,你肯定会点点头,表示十分认同的样子。尽管你也不知道他说得对不对,因为你不知道这些术语到底是啥意思。与此同时的事实是,找到一些讲述javascript如何被解释执行的好资料也是相对艰难的。(如今我花了18个月去做了这方面的研究),所以,我们一起学习一下吧。

在一些便利的可视化工具的帮助下,我们可以很直观地理解到javascript的运行时到底发生了什么。

演讲正文

大家好,感谢大家莅临side track(译者注:side track是啥?)。it is awesomen to see it packed out in here。大家请容许我伸伸懒腰,这样一来,我整个人看起来不至于那么僵硬。我想跟大家谈谈event loop-event loop到底是什么鬼, 尤其是javascript的event loop到底是什么鬼?

首先,请容许我自我介绍一下。就像他(指主持人)说的那样,我在AdnYet工作。AdnYet是美国的一个很不错的软件小厂。如果大家在实时软件方面有需求的话,可以联系我们。我们最擅长这方面的研发了。

言归正传。18个月前,我是一个专业的在职javascript开发者。我常常自问:“javascript是如何运行的呢?”。每到这个时候,我的内心都没有一个很确定的答案。我听过v8团队,也听过v8作为javascript在chrome的运行时而存在。除此之外,我就一无所知了。我完全不知道v8具体意味着什么(应该是指v8在浏览器内核的中分工角色是什么),具体又是做什么的。我听过比如“single threaded”这些术语,我也知道我自己目前在用的就是叫“callback”。但是callback的运行原理是怎样的呢?为了对这些习以为常的概念进行深究,我开始了我的探索旅程。探索过程中,我一边确定探索主题,一边查阅资料和在浏览器上做试验。其过程用拟人手法可以简单描述为如下的对话形式:

  • 我:“javascript,你是谁?”。
  • javascript:“我?我是一门单线程,可并发的编程语言啊”。
  • 我:“哦,算你狠(作无语状)”。
  • javascript:“好吧,我说得更具体点吧。我有一个call stack,一个event loop,一个callback queue和其它的一些API之类的东西”。
  • 我喃喃自语:“叼,我又不是学CS(computer sciense)的,鬼知道你说的这些术语是什么。”

后来,我听闻了v8,并且也知道,除了v8和chrome,外界还有其它各种javascript的运行时和浏览器。我就跑去问v8了。

  • 我:“v8,v8,你是不是有一个call stack,一个event loop,一个callback queue和其它的一些API之类的东西”。
  • v8:"我有一个call stack和一个heap,你说的其它那些东西我就不知道了。"
  • 我:“乜你甘得意噶”。

基本上就是这样,18个月过去了。经过这18个月的上下求索,我现在终于搞清楚了。而这18个月研究所得出的干货,就是我今天要分享给你们的东西了。我希望这些干货能够帮助到那些javascript新手去理解“相比于其它编程语言,为什么javascript显得那么怪异呢?”,“为什么callback让我们又爱又恨,但是又不可或缺呢?”。如果你是经验丰富的javascript开发者,希望这次分享能为你提供一个视角去理解你当前所用的运行时是如何工作的。而后,你能够更好地编排你的代码。

当我们把Chrome中的javascript运行时平台v8单独拎出来看的话,我们发现它是很好的javascript运行时学习对象。v8主要包含两部分:heap和callstack。heap,是内存分配所发生的地方。call stack就是你的stack frame所在的地方。但是,如果你把v8的源码下载下来,然后在里面全局搜索一下“setTimeout”,“DOM”或者“HTTP request”等字眼,你会发现,它们根本就不在v8的源码里面。这是一路研究下来最让我意外的发现。当我们提到异步机制时第一反应所想到的东西竟然不在那个你自以为是的v8里面。知道真相的你,不知道眼泪有没有掉下来呢?

18个月的探索下来,我发现我所要研究的主题所涉及到的真的真的是一张很大的知识网。正因为知识网之巨大,之有价值,我才特别地希望你跟我一道学习学习。这样一来,你就会明白各种围绕v8零散的知识点是什么?我们已经有了一个v8。现在,给你介绍一个由浏览器提供实现的,叫“web API”的东西,比如DOM,AJAX和 time out(setTimeout和setInterval)之类的东西就是归属于“web API”这里面的。同时,我们还有神神秘秘的event loop和callback queue。我相信你之前也肯定听说过这些术语,但也许你不太懂得这些术语是如何关联起来,有机组成一个知识网的。我打算从最基础,最常见的一些术语开始讲。你们的一部分人可能已经了理解透,另外一部分可能并没有。我觉得大部分人应该是属于后者的。如果你是前者,那么忍受一下我的唠叨吧。

again,javascript是一门single threaded的编程语言。这个single threaded的编程语言的runtime有一个single call stack。一个时间段中,call stack只能做一件事。一个时间段中,程序只能执行一片代码片段,这就是所谓的“single threaded”的意思。“single threaded”,“single call stack”和“do one thing at a time”这三者的关系如下:

single threaded === single call stack === do one thing at a time

现在,让我们开始尝试通过可视化将我们脑海里面零散理解组织起来。如果我们有你们左边的代码(演讲者指向屏幕的ppt):

function multiply(a,b){
    return a * b;
}

function square(n) {
    return multiply(n,n);
}

function printSquare(n) {
    var squared = square(n);
    console.log(squared);
}

printSquare(4);

这里面一个叫multiply的函数,负责将两个数字相乘。一个叫square的函数,负责调用multiply来实现一个数的平方。然后有一个叫printSquare的函数负责将调用square的结果用console.log方法打印出来。最后,我们在文件的底部开始调用print函数。

这些代码应该没写错吧?理解起来没问题吧?嗯,那我们开始把它跑起来。我们上一个ppt。可以这么说,call stack基本上是一种用来告诉我们当前是执行到程序的哪里的数据结构。如果我们执行到一个函数调用,那么我们就把一些东西放到栈顶,如果我们从一个函数里面return出去,那么我们就把栈顶的那些东西pop出来。这就是call stack能做的事情。当你运行这个文件的时候,我可以把这个文件本身当作是整个程序的main函数。所以,一开始,我们把main函数放进去。从文件的顶部开始看,接下来是一些函数声明,they are just like defining the state of the world。最后,我们来到了printSquare函数的调用。一提到函数调用,我们得马上把它push 到call stack的顶部。而在执行printSquare函数的过程中,首先遇到了square函数调用,所以,我们马上又把square函数push到call stack的顶部。而在执行square过程中,它又调用multiply函数。与此类推,我们又把multiply函数push进去。现在call stack顶部的是multiply函数,那么我们就先执行它。它对A和B执行乘法操作后,就把结果return出去。一旦我们遇到return语句,我们就把call stack顶部的函数pop出来。所以,我们把multiply函数pop走,现在程序返回到square函数,因为遇到了return语句,与此类推,我们把square从call stack顶部pop走,回到了printSquare函数。最后,我们调用console.log把结果打印出来。很明显,这里已经没有return了,我们到了函数的底部了。通过这种可视化的方式来讲解,不知道你们明白了没?(yes,Phil(译者注:演讲者自己用女生的声明模仿观众说了这话))。即使你之前脑海里面没有call stack这个清晰的概念,但是如果你做过浏览器端的开发的话,那么你已经遇到过它了。在举个例子,如果我们有一下代码:

function Foo(){
    throw new Error('Oops');
}
function bar(){
    Foo()
}
function baz(){
    bar()
}

baz()

如果我们把以上代码在Chrome浏览上运行的话,我们在console就会看到打印出来的stack trace。是的,stack trace就是执行出错的时候call stack的当前状态。

从上往下看,我们依此看到:

unaugth error: Oops
Foo
bar
baz
anonymous

最后的那个anonymous function就是我们的main函数。

同样的,你也许听过这样的术语“blowing the stack”,那下面就是一个例子:

function Foo(){
    Foo()
}

在这个例子当中,main函数调用了Foo函数,然后在Foo函数又调用了Foo函数,与此类推。当我们执行这段代码的时候,chrome会说,递归调用Foo函数16000次可能不是你的本意,我现在报个错,杀死掉进程,好让你定位到bug的所在。

所以,虽然我可能会让你看到了call stack的新的一面,但是其实在实际的开发中你多少对它已经有点印象了。

我们讲完了call stack。接下来的大主题就是blocking(阻塞)。我们将会讨论什么是blocking和blocking的行为表现是如何的。

实际上,对于blocking和非blocking这两个概念并没有十分严格的定义。在我看来,blocking就是指执行得十分缓慢的代码而已。举个例子,console.log不慢,但是如果执行一个1到100亿的循环后去做console.log,那么我们可以说这个console.log是很慢的。网络请求是慢的,图片请求也是慢的。当一个被push进call stack的函数执行起来很慢的话,我们就可以说这个函数是blocking的。 这就是我们平时提到的blocking的实际含义。在这里,我们有一个用伪代码写成的小例子。

 var foo = $.getSync('//foo.com');
 var bar = $.getSync('//bar.com');
 var qux = $.getSync('//qux.com');
 
 console.log(foo);
 console.log(bar);
 console.log(qux);

里面有个jQuery AJAX请求风格的getSync方法。如果这些getSync方法执行起来都是同步的,那么会发生什么呢?我们暂时先忘记异步callback其实本质也是同步的这个事实。按照我们的写法,执行起来的结果将会是这样的:我们调用了getSync方法,然后接下里就是等待。因为我们正在发起了网络请求,而网络请求是于计算机硬件相关联的,它们往往是很慢的。好在,我们的第一个网络请求终于完成了。我们接着发起第二个网络请求,然后我们接下来还是等待。第二个网络请求完了,我们马上发起第三个......与此类推。只要其中的一个网络请求永远都不会完成的话,那么我觉得我现在可以回家洗洗睡了。最终,这三个网络请求的blocking行为完成了,call stack得以清空掉,是吧?所以,如果在一个single threaded的编程语言里面,你是不能像ruby那样使用线程的话,那么,就会出现像这个例子所演示的情形那样-我们发起了一个网络请求,在它完成之前,我们只有干等待。除此之外,我们无计可施啊。为什么我们将这种结果当作一个问题而存在呢?一切的一切都是因为我们的代码跑在浏览器端。

下面我再举一个例子进行说明。

这是chrome浏览器,而这是我们上一个例子用过的代码。我们假设浏览器没有为我们提供同步的AJAX请求(好吧,事实上是有提供的),那么我们就使用一个耗时5秒的大循环来模拟一下这种同步请求。接下里,我们打开console,我们将会看到发生了什么。我们看到,当我们在请求foo.com的时候,我们对界面做的什么操作都没有得到响应。即使是我刚才点击过的按钮还是处于凹陷的状态,还没完成它的重新渲染。是的,浏览器被阻塞住了,它被卡得像便秘的屎一样,一动不动。在这些网络请求完成之前,浏览器做不了任何事情。浏览器虽然知道了我在界面进行了一些操作,但是它自己对于我的操作无法作出响应,也即是说它无法进行有效的界面渲染。这是因为,我们的call stack到目前为止还是没有处于清空状态,所以浏览器无法对界面进行重渲染,也执行不了其它代码。界面被卡住了,我们十分不开心,是吧?

作为开发者,如果我们想用户看到一个好看的,流畅的界面的话,那么我们就不能阻塞call stack。我们应该怎么去解决这个问题呢?好吧,最简单的解决方案就是异步callback方案。在浏览器中,几乎没有哪个函数是blocking的,在nodejs里面也是一样的。所有会阻塞call stack的函数都会被改成异步的。“将函数改成异步”基本是是这样的模式:我们需要执行某些代码,与此同时,我们会提供一个callback给当前的运行时。当需要执行的代码完成了,运行时就会执行我们提供的callback。如果你见过javascript,那么你也就见过了异步callback的样子了。

下面是一个简单的例子:

console.log('hi');

setTimeout(function(){
    console.log('there');
},5000);

console.log('JSConfEU');

我们用这个简单的例子提醒一下大家我们讲到哪里。我们console.log "hi",然后执行setTimeout。但是这个setTimeout的执行会把console.log入队到未来某个时刻去执行。我们跳过这个log,转而去log“JSConfEU”。5秒钟之后,我们会看到一个“there”的log,是吧?这个过程没问题吧?不错。基本上,我们都知道这就是setTimeout要做的事情。显然,异步callback是跟我们之前见到的call stack有关的。那么是如何相关法呢?我们先把代码跑起来。首先console.log('hi')入栈,然后是setTimeout。我们知道这个setTimeout是不会马上执行的,而是会在5秒钟之后才执行。我们不能把它推入到栈中。因为我们目前还没有找到恰当的方式去描述它,所以就暂时假设它无缘无故消失了。当然,我们在后面会回来讲它的。setTimeout无缘无故消失后,我们就将console.log('JSConfEU')入栈,然后log“JSConfEU”。最后,call stack被清空了。5秒钟之后,console.log('there')神奇地出现在call stack上面了。这是怎么回事呢?这时候该是event loop和concurrency出场的时候了。是的,我已经反反复复跟你说,javascript在一个时间段里面只能做一件事件。打个比方,你不能在执行其它代码的时候让它(译者注:它指的是javascript运行时)发起一个AJAX请求。你不能在执行其它代码的时候执行setTimeout。而我们之所以能以并行的方式去做事情,那是因为浏览器的能力远在javascript运行时之上(译者注:浏览器是javascript运行时的超集,javascript运行时之外还有别的东西)。记住下面这种图:

确实,javascript运行时在一个时间段里面只能做一件事,但是浏览器给了我们其它的东西。它给了我们一个叫webAPI的东西,这些都是一些有效的线程。你可以调用它们,它们能够知道并发的发生,并承接住这些并发。如果你是后端开发人员。这张图所阐述的机制跟nodejs差不多。在nodejs里面,webAPI得换成了c++ API了。当然,还有一些被c++隐藏起来的线程。既然,我们已经有了这张图,我们不妨以“浏览器”这个全局的视角来看看这段代码是如何被执行的。

跟之前演示的那样,执行console.log“hi”,把“hi”打印到控制台,接下来,来看看当我们调用setTimeout的时候到底发生了什么。我们把一个callback函数和需要延迟的毫秒数传入到setTimeout调用中。现在,对于我们来说,setTimeoout就是一个由浏览器提供给我们的API。它的实现源码并不在v8的源码里面。这是正在运行中的javascript运行时之外的东西。浏览器会另外给你设定一个倒计时。浏览器中的这部分代码负责为你倒数时间。这也就是说,setTimeout调用已经完成了,我们二话不说就把它从call stack中pop出去。接着是log“JSConfEU”。我们已经在webAPI中启动了一个5秒中倒计时。

现在,webAPI不能直接修改你的代码。它不能在自己准备好的时候直接将一些代码块推入到call stack中。如果它真的这么干的话,这些代码块就会随机地插入到你当前代码的中间,这岂不是乱套了。到了这里,是时候让task queue或者说callback queue(译者注:之后统一采用“task queue”的叫法)参与进来了。任何一个web API一旦它完成了自己的任务后,就会把相关联的callback推入到task queue中。最终的最终,我们终于凑齐了讲解了event loop,本次演讲的题目:“what the heck is the event loop?”的所有要素了。

那到底什么是event loop呢?event loop就好像是whole equation(整个方程?)中的一个最简单的小片段。它有一个简单的任务。那就是同时监视call stack和task queue。如果call stack当前处于清空状态的话,那么event loop就会把task queue的排在第一个东西(callback)pop出来,推入到call stack中去运行。 回归到这个演示中来。我们看到当前call stack是为空的,而且有个一callback在task queue上。看到自己来活了,event loop马上就运作起来的。它把这个callback推入到call stack中。时刻记住,call stack是javascript的地盘,它背靠v8这颗大树。它的地盘它做主。于是乎,这个刚出现的callback马上就被执行了-console.log('there')被执行了,在控制台,“there”被打印出来。到这,call stack被清空了,我们的代码彻彻底底被执行完了。大家理解了没呢?everyone,where me?(译者注:意思是大家都没睡着吧,看到我吗?)完美!

好的,我们已经看到了event loop的基本工作流程了。在你跟异步编程打交道的过程中,你遇到的众多情景中的其中一个是这样的:人家不跟你说明到底啥原因,就是让你用0毫秒去调用setTimeout。这时候你可能心里在想:“稍等,你让我在0毫秒后执行这个函数。我为什么要把我的代码包裹在一个0毫米的setTimeout里面呢?这样做意义何在呢?”。当你第一次遇到这种情况的时候,应该跟我一样,肯定很困惑。我们都知道这种写法肯定是做了什么,但是不知道其中的具体原因。这里也不卖关子了,具体原因就是人们想要把代码延迟到call stack清空后才执行。那么下面我们来演示一下用0毫秒去调用setTimeout的情况。如果你写过javascript代码,你就会知道跟上面的例子(非0毫秒去调用setTimeout)的结果是一样的。 我们将会看到依次打印“hi”和“JSConfEU”,“there”将会在最后打印。下面我们看看具体的演示。在call stack以0毫秒去调用setTimeout,setTimeout马上就会从call stack中pop走。接着它的callback会马上被webAPIpush到task queue中。请记住我说过关于event loop的话。event loop必须等到当前call stack清空之后才能把task queue中的callback推入到call stack去的。所以,当前没有清空的call stack会继续执行。于是乎,我们先看到打印"hi",然后看到打印“JSConfEU”,此时call stack已经清空了,是时候event loop参与进来的,最后调用了你的callback。不管基于何总原因,我们都可以把代码的执行延迟到call stack最后一帧或者等到call stack清空后再执行。上面提到的以0毫秒去调用setTimeout只是这种做法的一个示例而已。所有的webAPI都是以相同的原理在工作。现在假设我们要准备着callback向一个URL发起一个AJAX请求。这代码的执行原理跟上面提到的setTimeout都是一样的。oops,对不起。console打印“hi”,然后浏览器使用webAPI发起了AJAX请求。记住,真正实现发起AJAX请求的源代码不在javascript的运行时中,而是在浏览器对webAPI的实现源码里面。所以,我们保存着allback,把小菊花转起来,默默等待。然后,继续执行我们在call stack中代码。直到AJAX请求完成或者永远都完成不了。现在咱们假设这个AJAX请求完成了,那么它对应的callback会马上被推入到task queue中,因为此时call stack清空了,所以,它被event loop选中了,推入到call stack中跑起来了。javascript的异步调用背后所发生的事情大概就是这么多了。

下面,让我们来跑一个疯狂的,复杂的示例吧。我希望这段代码能够跑起来。可能你们不知道吧,这次演讲的所有PPT我都是在keynote上完成了。单单是当前这张幻灯片就有大概500个动画步骤(代码爆炸,化为灰烬,观众大笑)。

译者注:外国友人很少会把PPT称为“PPT”,正统的叫法应该“Slides(片子)”或是“Deck”,前者意指单张或几张PPT页,后者意指一整套PPT报告(就像a deck of cards)。

哇,真糸得意。我在这里给出个链接。嗯....大伙们,我放得够大吗?大家能看见吗?好的。基本这次演讲思路和PPT都是沿用了今年初Scotlan JS上的东西。在那次演讲之后,我损坏了大半部分的PPT。然后,自己又没有耐心去重做那些PPT。因为用keynote做PPT真的是一件(坐到)屁股疼的事情。所以,我选择了一条捷径。我自己写了一个工具用来可视化javascript的运行时机制。这个工具叫做loop。下面,我们用这个新工具把这个例子跑起来吧。

console.log('Started');

$.on('button', 'click', function onClick(){
    console.log('Clicked');
});

setTimeout(function onTimeout(){
    console.log('Timeout finished');
}, 5000);

console.log('Done');

这个例子基本上跟前面的例子差不多,只不过我没有使用shim过的XHR。使用shim过的XHR来演示完全可行,只不过我这里没有这么干而已。正如你所看到的那样,我们的代码是就是为了log点东西出来。首先,$.on()是对addEventListener的一个封装,然后是以5000毫秒调用setTimeout。最后,log一个“Done”。下面我们真正把代码跑起来,看看到底发生了什么。

代码开始后,打印个“Started”后,刚入栈的$.on()和setTimeout都会马上被pop出去,然后加入到webAPI的地盘中去。call stack中的代码继续执行。5000毫秒到了,我们就把callback推入到task queue中去。此时call stack处于清空状态,所以callback会被推入到call stack去执行。最后打印出“Timeout finished”,代码执行完毕。如果我点击一下页面的按钮,那么浏览器就会触发webAPI的执行,把点击事件的callback推入到task queue中去。call stack为空,然后把callback推入到call stack中执行。如果我们连续点击100多次,我们能看到会发生什么。在我点击按钮之后,click的callback不会被马上执行,而是推入到task queue。当event loop开始安排task queue的时候,click的callback才能被处理到。是吧?我还有好几个例子。我们将通过这些例子来谈谈你跟异步API打交道过程可能遇到的但是没有去深究的东西。我将会使用这个loupe的工具来进行演示。

首先第一个例子是以1秒的延迟调用setTimeout四次,每次都是在1秒后打印“hi”:

setTimeout(function onTimeout(){
    console.log('hi');
}, 1000);

setTimeout(function onTimeout(){
    console.log('hi');
}, 1000);

setTimeout(function onTimeout(){
    console.log('hi');
}, 1000);

setTimeout(function onTimeout(){
    console.log('hi');
}, 1000);

等到所有的callback被入队到task queue之时,没有任何一个callback被执行。此时,第四个callback还没有执行,时间已经超出了它所要求延迟的1秒了。是吧。这个例子说明的是javascript中的time out的实质含义。我们传入延迟时间(以毫秒为单位)代表的正是可执行的最小时间,而不是确切的时间。浏览器并不能保证在你传入的延迟时间内执行你的callback。以0毫秒调用setTimeout也是一样的。你的callback不会马上,立即被执行,而是被承诺为尽快地执行。是吧。

下面这个例子,我想来谈论一下callback。callback这个概念的定义取决于跟谁说和它们如何被解析的。callback这个概念可以有两种定义。第一种是,凡是被别的函数所调用的函数都可以称之为callback;第二种是,更加明确地指那种提供给异步操作的,最终会被推入到task queue的函数。下面这一点代码会演示一下着两者的不同:

// 同步版本
[1,2,3,4].forEach(function(i){
    console.log(i);
});

// 异步版本
function asyncForEach(array, cb){
    array.forEach(function(i){
        setTimeout(cb.bind(null,i), 0);
    })
};

asyncForEach([1,2,3,4],function(i){
    console.log(i);
});

对于同步版本的代码,我们在数组上调用了forEach方法,forEach接受一个函数作为参数,尽管这个函数不是被异步执行的,但是它是跑在当前的call stack中,你也可以称这个函数为callback。我将会运行这段示例代码,让我们看看这两者之间的差异到底是什么。首先,同步版本的代码先运行了。整个代码块将会推入到call stack中,它就在这里占用并阻塞着call stack,是吧?直到当前call stack清空,才轮到异步版本的代码执行。是的,执行速度降下来了。实际上,我们将好几个callback推入到了task queue中了。等到当前call stack清空了,我们才能依次地从task queue中把callback弹出来,推入到call stack 中运行,最终打印出所遍历的元素。在这个例子中,console.log()的执行足够快,所以使用异步方式来遍历数组的好处并没有体现出来。假设,你现在在遍历数组的过程中对数组的元素做一些比较耗时的操作的时候,那么,异步方式的写法的好处就会体现出来。我好像有在哪里写过这种例子,噢,不,我应该记错了,我没有这样的例子。好吧,我们改造一下上面这个例子来进行说明:

function delay() {
// just do the slow thing    
}

// 同步版本
[1,2,3,4].forEach(function(i){
    console.log("Processing sync");
    delay();
});

// 异步版本
function asyncForEach(array, cb){
    array.forEach(function(i){
        setTimeout(cb.bind(null,i), 0);
    })
};

asyncForEach([1,2,3,4],function(i){
    console.log("Processing async");
    delay();
});

好的,现在我打算打开一个模拟浏览器repaint或者说render的开关。这个开关是我这个早上比较匆忙整合都这个loop工具的。目前,我没有提到的一点是所有的这一切(call stack, webAPI,task queue和event loop)是如何界面渲染打交道的。好吧,我貌似提到过,但是没有去深入解释它。可以这么说,浏览器的运行是受你当前正在执行的javascript代码所约束的。理想状态下,浏览器想要以1秒60帧的速度(也就是花16毫秒去完成一帧的渲染)去渲染屏幕的。60FPS是浏览器所能达到的刷新界面的最快的速度了。这里再次强调,60FPS这种理想状态是受正在执行的javascript代码所约束的。如果当前call stack不为空的话,实际上浏览器是无法进行界面重绘的。 你可以把界面渲染理解为一个有callback与之对应的异步操作。这种render callback也是需要等待call stack清空之后才能执行的。render callback与普通的入队到task queue的callback相比,不同之处在于,render callback比普通的callback的优先级要高。每隔16毫秒,浏览器就会往render queue上入队一个render callback。这些render callback必须等到当前的call stack清空才能被执行。这里所提到的“render queue”是因为要解释界面渲染机制而模拟出来的。这就好像,每隔1秒,render queue就会问浏览器:“我可以做界面渲染了吗?”。浏览器回答:“是的,你可以。”,然后又再下一秒进行如此类推的对话。

是的,因为当前我们的代码并没有做一些阻塞call stack的事情。如果我把上面的这个同步版本的代码跑起来的话,你可以看到,当我们通过同步执行的loop对数组进行很耗时的遍历的时候,我们的界面渲染工作是被阻塞掉的。一旦界面渲染工作被阻塞掉的话,那么你就不能在界面上对文本进行选中,你也不能看到任何点击之后的界面反应。是吧,这种情况我们在早先的那个例子又看到过。虽然,在同步版本中也会阻塞当前call stack。但是因为time out的时间为0,所以阻塞的时间是相当的短的。而在此之后,在执行两个个数组元素的操作的缝隙,我们给了render queue一个时机去执行render callback。这是因为故意把对数组元素进行操作的callback编排为异步callback。它们最终被入队到task queue中去。鉴于render queue的优先级比task queue高,所有render callback能跟操作数组元素的callback交替进行,从而避免了界面较长时间里面无法得到刷新。不知道大家理解不?虽然,上面引入render queue是为了可视化地解释界面渲染的工作原理。但是基本上它能正确地指出一个事实。那就是,当人们说“不要阻塞event loop”的时候,他们实际在说“不要一些耗时的代码放在call stack上,以免它们长期占用call stack”。因为,一旦你这么做了,call stack长时间内得不到清空,那么浏览器也就没法完成一些它们需要急着做的事情了-比如说,创建一个流畅的界面浏览体验。这也是为什么当你用javascript在做一些图片处理工作或者动画而又没有好好地编排你的代码的时候,界面上的很多东西都会变得很慢的原因。

下面就是一个因为javascript代码没有得到很好编排而导致界面变慢的例子。这是一个处理scroll事件的例子。

DOM中的scroll事件的触发是很频繁的。怎么个频繁法呢?我相信它们的触发速度跟最佳界面刷新率60FPS是一样的,也是16毫秒触发一次。假如,我有以下代码。我在document对象上对scroll事件做了监听,同时监听的callback主要负责做一些动画或者其它一些耗时的活。当我们滚动页面的时候,那么就会有大量的callback涌入到task queue中,是吧 ?

最后浏览器都必须处理一个个地处理这些callback。每个callback执行起来都是挺慢的。这个时候,你不是阻塞call stack,你是直接用callback“淹没”了task queue。这种情况跟将触发大量callback背后的情况进行可视化很像。当然,通过debounce来减少callback的执行从而增加call stack的空闲时间,这种方式也是可行的。但是,我采用的另外一种方式。我将这些event callback全数推入task queue,但是我们每隔几秒钟或者等用户停止滚动后的一小段时间后就做一些耗时的操作,这种方式也是可行的。我会有另外一个专门来讲述这个可视化loop工具的实现原理的演讲。这实现原理大体是这样的:当代码运行在运行时的时候,我通过使用一个叫Esprima的javascript parser来执行这段代码来降低代码的执行速度。具体通过往里面插入一段耗时半秒中的大while循环来达到的。把Esprima放在了web worker上,同时在上面完成了可视化这段代码执行流程所需要做的所有事情。我的那个完整的演讲将会深入探究其中细节。对于这次演讲的到来,我感到十分的兴奋。到那个时候,我将会跟大家好好唠唠这事,届时就功德圆满了。好吧,就这样。谢谢大家。

总结

  • 关于“single threaded”的理解
single threaded === single call stack === do one thing at a time
  • 关于“blocking”的理解: 对于blocking和非blocking这两个概念并没有十分严格的定义。在我看来,blocking就是指执行得十分缓慢的代码而已。

  • 这种图包含了几个知识点:

    • 浏览器(内核)的能力是javascript运行时的超集。
    • webAPI一般用于处理异步操作
    • event loop把javascript运行时,webAPI和task queue统筹起来了,使之能协调工作。简单而言,就是监听call stack。一旦call stack为空,就将task queue的第一callback推入其中执行。
  • javascript中的time out(setTimeout/setInterval)的实质含义是 “可执行的最小时间”,而不是“确切的时间”。

  • callback这个概念可以有两种定义。第一种是,凡是被别的函数所调用的函数都可以称之为callback;第二种是,更加明确地指那种提供给异步操作的,最终会被推入到task queue的函数。

  • 浏览器作为一个界面终端而存在,这就决定了创造一个流畅的界面浏览体验是其天生的职责之所在。创造一个流畅的界面浏览体验具体是是指尽可能地以60FPS的速率去刷新界面。而为尽可能地以60FPS的速率去刷新界面,那么我们就要尽可能地不要长时间占用call stack。为了不要长时间占用call stack,那么我们必须学会编排我们执行耗时较长的代码。具体来说,就是把执行耗时较长的代码改为异步代码,push到task queue中。这样子,虽然render callback跟普通的callback是交替执行,但是相比于同步方式,界面能够得到尽快的刷新,故浏览体验大胜一筹。