JavaScript 运行机制详解:再谈Event Loop
一年前,我写了一篇 《什么是 Event Loop?》,谈了我对 Event Loop 的理解。
上个月,我偶然看到了 Philip Roberts 的演讲《Help, I'm stuck in an event-loop》。这才尴尬地发现,自己的理解是错的。我决定重写这个题目,详细、完整、正确地描述 JavaScript 引擎的内部运行机制。下面就是我的重写。
进入正文之前,插播一条消息。我的新书《ECMAScript 6 入门》出版了(版权页,内页1,内页2),铜版纸全彩印刷,非常精美,还附有索引(当然价格也比同类书籍略贵一点点)。预览和购买点击这里。
一、为什么 JavaScript 是单线程?
JavaScript 语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那么,为什么 JavaScript 不能有多个线程呢?这样能提高效率啊。
JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。
二、任务队列
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
如果排队是因为计算量大,CPU 忙不过来,倒也算了,但是很多时候 CPU 是闲着的,因为 IO 设备(输入输出设备)很慢(比如 Ajax 操作从网络读取数据),不得不等着结果出来,再往下执行。
JavaScript 语言的设计者意识到,这时 CPU 完全可以不管 IO 设备,挂起处于等待中的任务,先运行排在后面的任务。等到 IO 设备返回了结果,再回过头,把挂起的任务继续执行下去。
于是,JavaScript 就有了两种执行方式:一种是 CPU 按顺序执行,前一个任务结束,再执行下一个任务,这叫做同步执行;另一种是 CPU 跳过等待时间长的任务,先处理后面的任务,这叫做异步执行。程序员自主选择,采用哪种执行方式。
具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)
(1)所有任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。系统把异步任务放到"任务队列"之中,然后继续执行后续的任务。
(3)一旦"执行栈"中的所有任务执行完毕,系统就会检查"任务队列"。如果这个时候,异步任务已经结束了等待状态,就会从"任务队列"进入执行栈,恢复执行。
(4)主线程不断重复上面的第三步。
下图就是主线程和任务队列的示意图。
只要主线程空了,就会去检查"任务队列",这就是 JavaScript 的运行机制。这个过程会不断重复。
三、事件和回调函数
"任务队列"实质上是一个事件的队列(也可以理解成消息的队列),IO 设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程检查"任务队列",就是检查里面有哪些事件。
"任务队列"中的事件,除了 IO 设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。
所谓"回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当异步任务从"任务队列"回到执行栈,回调函数就会执行。
四、Event Loop
主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为 Event Loop(事件循环)。
为了更好地理解 Event Loop,请看下图(转引自 Philip Roberts 的演讲《Help, I'm stuck in an event-loop》)。
上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部 API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去检查"任务队列",看看哪些事件已 经完成了,并执行对应的回调函数。
执行栈中的代码,总是在异步任务之前执行。请看下面这个例子。
var req = new XMLHttpRequest (); req.open ('GET', url); req.onload = function (){}; req.onerror = function (){}; req.send ();
上面代码中的 req.send 方法是 Ajax 操作向服务器发送数据,它是一个异步任务,意味着只有当前脚本的所有代码执行完,系统才会去"任务队列"检查是否有返回结果。所以,它与下面的写法等价。
var req = new XMLHttpRequest (); req.open ('GET', url); req.send (); req.onload = function (){}; req.onerror = function (){};
也就是说,指定回调函数的部分(onload 和 onerror),在 send ()方法的前面或后面无关紧要,因为它们属于执行栈的一部分,系统总是执行完它们,才会去检查"任务队列"。
五、定时器
除了插入异步任务,"任务队列"还有一个作用,就是可以插入定时事件,即指定某些代码在多少时间之后执行。这叫做"定时器"(timer)功能,即定时执行代码。
定时器功能主要由 setTimeout ()和 setInterval ()这两个函数来完成,它们的内部运行机制完全一样,区别在于前者指定的代码是一次性执行,后者则为反复执行。以下主要讨论 setTimeout ()。
setTimeout ()接受两个参数,第一个是回调函数,第二个是推迟执行的毫秒数。
console.log (1); setTimeout (function(){ console.log (2); },1000); console.log (3);
上面代码的执行结果是1,3,2,因为 setTimeout ()将第二行推迟到 1000 毫秒之后执行。
如果将 setTimeout ()的第二个参数设为0,就表示当前代码执行完,立即执行(0 毫秒间隔)指定的回调函数。
setTimeout (function(){ console.log (1); }, 0); console.log (2);
上面代码的执行结果总是2,1,因为只有在执行完第二行以后,系统才会去执行"任务队列"中的回调函数。
有些浏览器规定了 setTimeout ()的第二个参数的最小值,比如 Firefox 规定不得低于 4 毫秒,如果低于这个值,就会自动调整。另外,对于那些 DOM 的变动(尤其是涉及页面重新渲染的部分),通常不会立即执行,而是每 16 毫秒执行一次。这时使用 requestAnimFrame ()的效果要好于 setTimeout ()。
需要注意的是,setTimeout ()只是将事件插入了"任务队列",必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以 并没有办法保证,回调函数一定会在 setTimeout ()指定的时间执行。
六、Node.js 的 Event Loop
Node.js 也是单线程的 Event Loop,但是它的运行机制不同于浏览器环境。
请看下面的示意图(作者@BusyRich)。
根据上图,Node.js 的运行机制如下。
(1)V8 引擎解析 JavaScript 脚本。
(2)解析后的代码,调用 Node API。
(3)libuv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎。
(4)V8 引擎再将结果返回给用户。
Node.js 有一个 process.nextTick ()方法,可以将指定事件推迟到 Event Loop 的下一次执行,也就是当前的执行栈清空之后立即执行。
function foo () { console.error (1); }process.nextTick (foo); console.log (2);// 2 // 1
process.nextTick (foo)的作用,与 setTimeout (foo, 0) 很相似,但是执行效率高得多。