JavaScript-事件循环

参考
JavaScript运行机制之事件循环(Event Loop)详解
从setTimeout说事件循环模型

单线程

JavaScript语言是单线程的,这事因为作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。这是这门语言的核心特征。

为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM,并没有改变JavaScript单线程的本质。

任务队列

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。JavaScript就有两种执行方式:一种是CPU按顺序执行,前一个任务结束,再执行下一个任务,这叫做同步执行;另一种是CPU跳过等待时间长的任务,先处理后面的任务,这叫做异步执行。
异步执行的运行机制如下(同步执行也是如此,因为它可以被视为没有异步任务的异步执行):

  • 所有任务(同步和异步的)都在主线程上执行,形成一个”执行栈”(execution context stack)
  • 主程之外,还存在一个”任务队列”(task queue),系统把异步任务放到”任务队列”之中,然后继续执行后续的任务
  • 一旦”执行栈”中的所有任务执行完毕,系统就会读取”任务队列”,如果这个时候,异步任务已经结束了等待状态,就会从”任务队列”进入执行栈,恢复执行
  • 主线程不断重复上面的第三步,只要主线程空了,就会去读取”任务队列”,这就是JavaScript的运行机制

事件循环

“任务队列”实质上是一个事件的队列(也可以理解成消息的队列),主线程读取”任务队列”,就是读取里面有哪些事件。如IO设备完成一项任务,就在”任务队列”中添加一个事件,表示相关的异步任务可以进入”执行栈”了;鼠标点击、页面滚动等只要指定过回调函数,这些事件发生时就会进入”任务队列”,等待主线程读取。

所谓”回调函数”(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当异步任务从”任务队列”回到执行栈,回调函数就会执行。

Javascript执行引擎的主线程运行的时候,产生堆和栈。程序中代码依次进入栈中等待执行。栈中的代码调用各种外部API,在”任务队列”中加入各种事件,只要栈中的代码执行完毕,主线程就会去读取”任务队列”,依次执行那些事件所对应的回调函数。执行栈中的代码,总是在读取”任务队列”之前执行。不同的操作添加到任务队列的时机也不同。

onclick 由浏览器内核的DOM Binding模块来处理,当事件触发的时候,回调函数会立即添加到任务队列中。
setTimeout 会由浏览器内核的timer模块来进行延时处理,当时间到达的时候,才会将回调函数添加到任务队列中。
ajax 则会由浏览器内核的network模块来处理,在网络请求完成返回之后,才将回调添加到任务队列中。

主线程从”任务队列”中读取事件,只要执行栈一清空,”任务队列”上第一位的事件就自动返回主线程,这个过程是循环不断的,所以整个的这种运行机制又称为事件循环(Event Loop)。

示例一输出结果都是4,表明程序是先运行完4次循环后,再进入setTimeout的。
实例二输出结果是1001,表明程序是向下执行完while循环后,再进入setTimeout的。

1
2
3
4
5
6
// 示例一
for(var i = 0; i<=3; i++){
setTimeout(function() {
console.log(i); // 输出4
}, 0);
}
1
2
3
4
5
6
7
// 实例二
var startDate = new Date();
setTimeout(function() {
var endDate = new Date();
console.log(endDate - startDate); // 输出1001
}, 500);
while(new Date() - startDate < 1000){};

定时器

“任务队列”除了放置异步任务,还可以放置定时事件,即指定某些代码在多少时间之后执行,即到达设置的延时时间时被添加至任务队列里。定时器功能主要由setTimeout()和setInterval()这两个函数来完成,它们的内部运行机制完全一样,区别在于前者指定的代码是一次性执行,后者则为反复执行。如果将setTimeout()的第二个参数设为0,就表示当前代码执行完(执行栈清空)以后,立即执行(0毫秒间隔)指定的回调函数。

需要注意的是,setTimeout()只是将事件插入了”任务队列”,必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在setTimeout()指定的时间执行。

HTML5标准规定了setTimeout()的第二个参数的最小值(最短间隔),不得低于4毫秒,如果低于这个值,就会自动增加。在此之前,老版本的浏览器都将最短间隔设为10毫秒。另外,对于那些DOM的变动(尤其是涉及页面重新渲染的部分),通常不会立即执行,而是每16毫秒执行一次。这时使用requestAnimationFrame()的效果要好于setTimeout()。

Node.js除了setTimeout和setInterval这两个方法,还提供了另外两个与”任务队列”有关的方法:process.nextTick和setImmediate。

  • process.nextTick方法可以在当前”执行栈”的尾部,也就是主线程下一次读取”任务队列”之前,触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。
  • setImmediate方法则是在当前”任务队列”的尾部触发回调函数,也就是说,它指定的任务总是在主线程下一次读取”任务队列”时执行,这与setTimeout很像,但一次”事件循环”只能触发一个由setImmediate指定的回调函数。