Libx

Javascript事件循环机制

字数统计: 3,267阅读时长: 12 min
2017/10/17 Share

前言

最近在学习Node.JS,而node实用的是单线程模型,对于所有的I/O都使用了异步式的请求方式。并使用事件驱动+异步式I/O来带来可观的性能提升。node大量使用了异步的处理方式,所以想了解下具体的原理,看了很多相关的博客以及文档,现在来总结一下。

从同步与异步说起

学过JS的估计都知道了,JS的执行环境是单线程。所谓”单线程”,就是一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务,以此类推。这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。

JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去.

  • “同步任务(synchronous)”:后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺序是一致的、同步的;
  • “异步任务(asynchronous)”:则完全不同,每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行后一个任务,而是执行回调函数,后一个任务则是不等前一个任务结束就执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。不进入主线程、而进入”任务队列”(task queue)的任务,只有”任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

而异步编程的主要方式基本有这几种:

  1. 改写回调函数
  2. 事件监听
  3. 发布/订阅(这种不了解。。好像跟设计模式有点关系,以后再去了解)
  4. Promises对象
  5. ES7中的await和async

函数调用栈与任务队列

主要有以下两个解释:

解释一

  1. Javascript有一个main thread 主进程和call-stack(一个调用堆栈),在对一个调用堆栈中的task处理的时候,其他的都要等着。当在执行过程中遇到一些类似于setTimeout等异步操作的时候,会交给浏览器的其他模块(以webkit为例,是webcore模块)进行处理,当到达setTimeout指定的延时执行的时间之后,task(回调函数)会放入到任务队列之中。一般不同的异步任务的回调函数会放入不同的任务队列之中。等到调用栈中所有task执行完毕之后,接着去执行任务队列之中的task(回调函数)。

在上图中,调用栈中遇到DOM操作、ajax请求以及setTimeout等WebAPIs的时候就会交给浏览器内核的其他模块进行处理,webkit内核在Javasctipt执行引擎之外,有一个重要的模块是webcore模块。对于图中WebAPIs提到的三种API,webcore分别提供了DOM Binding、network、timer模块来处理底层实现。等到这些模块处理完这些操作的时候将回调函数放入任务队列中,之后等栈中的task执行完之后再去执行任务队列之中的回调函数。
(内容来自@柳兮)

解释二

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。(stack的第二种含义是“调用栈”(call stack),表示函数或子例程像堆积木一样存放,以实现层层调用。)
  2. 主线程之外,还存在一个”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
  3. 一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  4. 主线程不断重复上面的第三步。
    (内容来自@阮一峰

以上是网上比较典型的两种说法。对此我有一个不理解的地方就是:”当到达setTimeout指定的延时执行的时间之后,task(回调函数)会放入到任务队列之中。”and “等到调用栈中所有task执行完毕之后,接着去执行任务队列之中的task(回调函数)” 这两种说法看起来好像是矛盾的,如何确定到达set的时间内调用栈内的任务执行完成呢?
事实证明:setTimeout和setInterval的运行机制是,将指定的代码移出本次执行,等到下一轮Event Loop时,再检查是否到了指定时间。如果到了,就执行对应的代码;如果不到,就等到再下一轮Event Loop时重新判断。这意味着,setTimeout指定的代码,必须等到本次执行的所有代码都执行完,才会执行。
每一轮Event Loop时,都会将“任务队列”中需要执行的任务,一次执行完。setTimeout和setInterval都是把任务添加到“任务队列”的尾部。因此,它们实际上要等到当前脚本的所有同步任务执行完,然后再等到本次Event Loop的“任务队列”的所有任务执行完,才会开始执行。由于前面的任务到底需要多少时间执行完,是不确定的,所以没有办法保证,setTimeout和setInterval指定的任务,一定会按照预定时间执行。(之前听说过settimeout的时间可能会不完全准确,现在终于知道其中的原因了)

setTimeout(someTask,100);
veryLongTask();

上面代码的setTimeout,指定100毫秒以后运行一个任务。但是,如果后面立即运行的任务(当前脚本的同步任务))非常耗时,过了100毫秒还无法结束,那么被推迟运行的someTask就只有等着,等到前面的veryLongTask运行结束,才轮到它执行。

所以说这两种说法是并不矛盾的。

同步与异步运行机制

目前比较浅显的解释是:”Javascript的事件分为同步任务和异步任务,遇到同步任务就放在执行栈中执行,而碰到异步任务就放到任务队列之中,等到执行栈执行完毕之后再去执行任务队列之中的事件。”
以上对于函数调用栈亿级任务队列的解释已经很详细了,现在来结合实例来具体的解释一下

以settimeout为例:

首先main()函数的执行上下文入栈

代码接着执行,遇到console.log(‘Hi’),此时log(‘Hi’)入栈,console.log方法只是一个webkit内核支持的普通的方法,所以log(‘Hi’)方法立即被执行。此时输出’Hi’。

当遇到setTimeout的时候,执行引擎将其添加到栈中。

调用栈发现setTimeout是之前提到的WebAPIs中的API,因此将其出栈之后将延时执行的函数交给浏览器的timer模块进行处理。

timer模块去处理延时执行的函数,此时执行引擎接着执行将log(‘SJS’)添加到栈中,此时输出’SJS’。

当timer模块中延时方法规定的时间到了之后就将其放入到任务队列之中,此时调用栈中的task已经全部执行完毕。

调用栈中的task执行完毕之后,执行引擎会接着看执行任务队列中是否有需要执行的回调函数。这里的cb函数被执行引擎添加到调用栈中,接着执行里面的代码,输出’there’。等到执行结束之后再出栈。

内容来自Philip Roberts: Help, I’m stuck in an event-loop.

实例

实例一

网上有很多关于这个的面试题,其中有一道很经典的面试题:

for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(new Date, i);
}, 1000);
}
console.log(new Date, i);

问题不大问题不大。

先来分析一下:
i=0时,满足条件,执行栈执行循环体里面的代码,发现是setTimeout,将其出栈之后把延时执行的函数交给Timer模块进行处理。

当i=1,2,3,4时,均满足条件,情况和i=0时相同,因此timer模块里面有5个相同的延时执行的函数。

当i=5的时候,不满足条件,for循环结束,console.log(new Date, i)入栈,此时的i已经变成了5。输出5。

此时1s已经过去,timer模块将5个回调函数按照注册的顺序返回给任务队列。

执行引擎去执行任务队列中的函数,5个function依次入栈执行之后再出栈,此时的i已经变成了5。因此几乎同时输出5个5。

因此等待的1s的时间其实只有输出第一个5之后需要等待1s,这1s的时间是timer模块需要等到的规定的1s时间之后才将回调函数交给任务队列。等执行栈执行完毕之后再去执行任务对列中的5个回调函数。这期间是不需要等待1s的。因此输出的状态就是:5 -> 5,5,5,5,5,即第1个 5 直接输出,1s之后,输出 5个5;

来控制台跑一下:

for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(new Date, i);
}, 1000);
}
console.log(new Date, i);
raven.b7b3066ebc21a63ef339.js:1 Tue Oct 17 2017 16:12:39 GMT+0800 (中国标准时间) 5
undefined
raven.b7b3066ebc21a63ef339.js:1 Tue Oct 17 2017 16:12:40 GMT+0800 (中国标准时间) 5
raven.b7b3066ebc21a63ef339.js:1 Tue Oct 17 2017 16:12:40 GMT+0800 (中国标准时间) 5
raven.b7b3066ebc21a63ef339.js:1 Tue Oct 17 2017 16:12:40 GMT+0800 (中国标准时间) 5
raven.b7b3066ebc21a63ef339.js:1 Tue Oct 17 2017 16:12:40 GMT+0800 (中国标准时间) 5
raven.b7b3066ebc21a63ef339.js:1 Tue Oct 17 2017 16:12:40 GMT+0800 (中国标准时间) 5

well~

那么要如何的得到想要的效果呢?

  • 方法一:利用匿名函数的作用域

    for (var i=1; i<=9; i++) {
    (function(j){
    setTimeout( function timer(){
    console.log( j );
    }, 1000 );
    })( i );
    }
  • 方法二;使用let就好了

    for (let i=1; i<=9; i++) {
    setTimeout( function timer(){
    console.log( i );
    }, 1000 );
    }

实例二(更加深入)

(function test() {
setTimeout(function() {console.log(4)}, 0);
new Promise(function executor(resolve) {
console.log(1);
for( var i=0 ; i<10000 ; i++ ) {
i == 9999 && resolve();
}
console.log(2);
}).then(function() {
console.log(5);
});
console.log(3);
})()

promise的task会放在不同的任务队列里面,那么setTimeout的任务队列和promise的任务队列的执行顺序又是怎么的呢?

这里先把结论放出来:
一个线程中,事件循环是唯一的,但是任务队列可以拥有多个。

  1. 任务队列又分为macro-task(宏任务)与micro-task(微任务),在最新标准中,它们被分别称为task与jobs。

  2. macro-task大概包括:script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering。

  3. micro-task大概包括: process.nextTick, Promise, Object.observe(已废弃), MutationObserver(html5新特性)

  4. setTimeout/Promise等我们称之为任务源。而进入任务队列的是他们指定的具体执行任务。

    // setTimeout中的回调函数才是进入任务队列的任务
    setTimeout(function() {
    console.log('xxxx');
    })
    // setTimeout作为一个任务分发器,这个函数会立即执行,而它所要分发的任务,也就是它的第一个参数,才是延迟执行
  5. 来自不同任务源的任务会进入到不同的任务队列。其中setTimeout与setInterval是同源的。

  6. 事件循环的顺序,决定了JavaScript代码的执行顺序。它从script(整体代码)开始第一次循环。之后全局上下文进入函数调用栈。直到调用栈清空(只剩全局),然后执行所有的micro-task。当所有可执行的micro-task执行完毕之后。循环再次从macro-task开始,找到其中一个任务队列执行完毕,然后再执行所有的micro-task,这样一直循环下去。

  7. 其中每一个任务的执行,无论是macro-task还是micro-task,都是借助函数调用栈来完成。

这是一个挺复杂的东西了。。写的有点累了,先放一篇好文深入核心,详解事件循环机制,回头再来继续。

参考文章:

CATALOG
  1. 1. 前言
  2. 2. 从同步与异步说起
  3. 3. 函数调用栈与任务队列
    1. 3.1. 解释一
    2. 3.2. 解释二
  4. 4. 同步与异步运行机制
  5. 5. 实例
    1. 5.1. 实例一
    2. 5.2. 实例二(更加深入)