事件队列和事件循环

JavaScript是单线程的

  JavaScript 从出生以来就是单线程的,这可能和 JavaScript 一切从简有关,毕竟是人家只花了10天就开发出的语言。也可能是受限于 JavaScript 的用途,JavaScript 作为脚本语言控制着页面的行为,如果有多个线程同时对页面的某一个元素进行操作,比如对同一个 DOM 元素进行增删操作,那浏览器要听哪个线程的?所以如果是多线程的话,浏览器在响应时就会变得很复杂了。(难道要像多进程那样引入锁??)

  而单线程意味着主线程只能一次执行一个任务,不能同时执行多个,这就导致了像 http 请求等耗时长的任务很可能会长时间占用着主线程,堵塞了执行栈中其他任务的执行。互联网上有一个八秒准则(当然这个八秒也只是一个虚数而已),即用户在执行操作后如果页面在 8s 之内没有得到响应,进入假死状态,则用户会失去耐心直接关闭页面(不知道你们会不会这样,反正我就是这样的,不过可能我耐心好一点所以我会等上个十几秒)。所以考虑到主线程堵塞的问题,JavaScript 异步操作就应运而生了。异步任务不会在执行期间长时间占用主线程,而是会先被主线程挂起,等达到一定条件比如 http 请求得到响应或到了代码指定的延迟时间后,通过调用回调函数的方式来回头完成代码的执行,而在这段时间内主线程就可以去执行其他的任务了,这样即使 http 请求花费了很长时间甚至是一直没有响应也不会堵塞到其他任务的执行。

事件队列

  当程序在执行过程中遇到异步任务时会先把异步任务挂起,交由其他相关的浏览器线程处理。注意,这里指的是浏览器线程,引用一下相关的描述

浏览器内核是多线程,在内核控制下各线程相互配合以保持同步,一个浏览器通常由以下常驻线程组成:

  • GUI 渲染线程
  • JavaScript引擎线程
  • 定时触发器线程
  • 事件触发线程
  • 异步http请求线程

GUI渲染线程:
  GUI渲染线程负责渲染浏览器界面HTML元素,当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。在Javascript引擎运行脚本期间,GUI渲染线程都是处于挂起状态的,也就是说被“冻结”了(与 JavaScript 引擎线程互斥,即两个线程不能同时运行)。

Javascript引擎线程:
  Javascript 引擎,也可以称为JS内核,主要负责处理 Javascript 脚本程序,例如V8引擎。Javascript 引擎线程理所当然是负责解析 Javascript 脚本,运行代码。

定时触发器线程:
  浏览器定时计数器并不是由 JavaScript 引擎计数的, 因为 JavaScript 引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确, 因此通过单独线程来计时并触发定时是更为合理的方案。

事件触发线程:
  当一个事件被触发时该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理。这些事件可以是当前执行的代码块如定时任务、也可来自浏览器内核的其他线程如鼠标点击、AJAX异步请求等,但由于JS的单线程关系所有这些事件都得排队等待JS引擎处理。

异步http请求线程:
  在 XMLHttpRequest 在连接后是通过浏览器新开一个线程请求, 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到 JavaScript 引擎的处理队列中等待处理。

  以setTimeout为例,当Javascript引擎运行到setTimeout时,因为setTimeout是一个异步事件,所以会将setTimeout交由相关的定时触发器线程进行计时操作。在setTimeout被挂起的这段时间之内,主线程依然会马不停蹄地接着往下执行代码而不会等待setTimeout。当定时触发器线程计时达到setTimeout指定的延迟时间后,就会把setTimeout的回调函数推入到事件队列中。注意,这里只是把回调函数推入到事件队列,并不意味着会马上执行它,只有等到执行栈中所有的同步事件都执行完毕后,才会去读取事件队列中的事件并将其放到执行栈中执行(既然是一个队列,事件队列自然就有 queue 先进先出的特性了)。因为得先执行完执行栈中所有的同步事件才会去读取事件队列中的事件,所以这也决定了setTimeout 指定的执行延迟时间是不准确的,这发生在执行栈中所有事件执行完毕的所需时间大于延迟时间的时候,因此 HTML5 也规定了setTimeout的最小延迟时间不能小于 4ms(当然并不是所有的浏览器都遵循了这个标准)。我们可以用代码说话:

console.log("start", new Date());
let time = new Date();
setTimeout(() => {
  console.log("执行定时器的事件", new Date())
}, 1000);
while(new Date() - time < 2000) {} 
console.log("end", new Date());

/* 
  start 2019-04-07T11:56:51.537Z
  end 2019-04-07T11:56:53.541Z
  执行定时器的事件 2019-04-07T11:56:53.542Z
*/

  我们可以分析一下上面代码的执行过程,JavaScript引擎开始解析代码,先生成全局执行上下文并把它压入到执行栈中(当然其中还有生成 VO、确定 this 指向和建立作用域链的环节,这不是本文重点就略过不提了。遇到console.log("start", new Date())发现这是一个同步事件后马上执行并输出 start 和当前的时间点。接着给 time 变量赋值后(注意是赋值,而不是声明,因为在创建全局执行上下文的时候就已经变量提前声明了)遇到了setTimeout事件,因为这是一个异步事件所以 JavaScript 引擎会把它挂起交由定时触发器线程进行计时操作。接着进入到 while 循环,我们手动地给 while 循环的持续时间设定为 2000ms,而在这 2000ms 内定时触发器线程已经完成了对setTimeout事件的计时并把它的回调函数放入到事件队列中了。但这时候因为执行栈中还有 while 循环在执行,所以setTimeout的回调函数还是留在事件队列中没能进入执行栈。2000ms 过后 JavaScript引擎继续向下解析,输出 end 和当前的时间点,之后执行栈中所有的同步事件便都执行完毕了,开始读取事件队列发现有setTimeout便把它取出放到执行栈中执行,所以最后输出“执行定时器的事件”和当前的时间点。

  通过上面的分析以及输出的三个时间点,我们可以发现setTimeout指定的执行延迟时间确实是不确切的,这依赖于执行栈中所有同步事件执行完毕所需的时间,执行setTimeout回调函数的延迟时间只会大于指定的延迟时间而不会小于延迟时间。

  setTimeout 设置的等待时间还有一个最大值限制,浏览器包括 IE、Chrome、Safari、Firefox 以 32 个字节来存储等待时间。这就会导致如果设置的等待时间大于 2^31 - 1 (大约 24.8 天)时就会溢出,导致定时器将会被立即执行。(参考 MDN

  在我写这篇博客的时候,我还有一个疑惑。我看到许多的博客说的都是异步事件的回调函数进入事件队列后,是等执行栈为空的时候才会取出事件队列中的事件放入到执行栈中去执行,不知到各位看客对此有没有什么疑问。如果事件队列中的事件是否进入执行栈的判断标准是执行栈是否为空的话,那就涉及到执行栈是否会为空了?想必大家都知道执行上下文吧,其中全局执行上下文在程序一开始执行就被创建,并且会被压入到执行栈中并一直存在执行栈底直到页面被关闭。是的,全局执行上下文一直存在执行栈底,这点应该没有什么争议。既然如此,是否意味着执行栈一直非空呢?这样的话事件队列中的事件还怎么通过判断执行栈是否为空来进入执行栈,不就一直进入不了执行栈了吗,这两者就冲突了啊!如果按执行栈是否为空为标准来判断事件队列中的事件是否进入执行栈,则势必要理清执行栈到底会不会为空的问题,如果不会为空的话怎么解释事件队列中的事件只有等到执行栈为空时才会进入执行栈中,如果会为空的话怎么解释全局执行上下文会一直存在执行栈栈底?所以我觉得以执行栈是否为空为标准来判断事件队列中的事件是否进入执行栈并不恰当,而是以执行栈中的同步事件是否都执行完毕为标准来判断(只剩下全局执行上下文)会更容易理解,这样就没有上面说的和全局执行上下文一直存在执行栈底的冲突了。

事件循环

  事件循环是建立在事件队列的基础上的,当执行栈中所有的同步事件都执行完毕后就会去读取事件队列中的事件,如果事件队列中有事件存在则取出放到执行栈中执行,否则的话就继续读取事件队列,这样不断地读取、执行、读取、执行的周而复始就成了事件循环。

  引用一张经典的图辅助吧:

  上图中,执行栈执行其中的同步任务,若代码中调用了外部异步的 Web API 如 DOM 事件、Ajax请求setTimeout等,就将它们的回调函数放入到事件队列中,等执行栈中的同步事件都执行完毕后就读取事件队列,取出其中的事件到执行栈中执行,在执行栈和事件队列之间不断循环。不过我觉得这图里缺少了异步事件的回调函数是如何放到事件队列中的过程,比如setTimeoutAjax 请求setTimeout事件是马上就被放入到事件队列中的,还是等到了指定的延迟时间后才会推入到事件队列。如果是后者的话,在到达指定的延迟时间这段时间之内,setTimeout事件既然不在执行栈也不在事件队列中那是在哪里,是怎么进行计时的?(前文有解释到,定时触发器线程负责了setTimeout事件的计时,到达指定事件后就把其回调函数推入到事件队列中)

  要声明的是,上述的事件循环是指浏览器环境下的事件循环,跟 nodeJS 的事件循环机制是不一样的。而 nodeJS 的事件循环是怎样的,我现在也还不清楚,就等以后我再写一篇博客介绍吧。

宏任务和微任务

  其实异步任务还有再细分为两种的,分别是宏任务(macro task)和微任务(micro task)。其中宏任务主要有setTimeoutsetIntervalsetImmediateI/OUI rendering等, 微任务主要有Promiseprocess.nextTick(process 是只存在于 Node 环境中的全局变量)和MutationObserver。前文介绍的是,异步事件的回调函数会被推入到事件队列中,但实际上还会根据这个异步事件的类型再推入到相应的宏任务事件队列或微任务事件队列中去。当执行栈中所有的同步事件都执行完毕后主线程会先读取微任务事件队列,如果其中有事件存在则会依次将它们放入到执行栈中去执行,接着才会去读取宏任务队列中的事件(即同一次事件循环中微任务永远在宏任务之前执行),如此周而复始慢慢循环。我们总结一下事件循环的执行过程就是,在一趟事件循环中:

  1. 把执行栈中所有的同步任务都执行完。
  2. 先处理所有微任务事件队列中的事件。
  3. 在宏任务事件队列中取出一个事件放入到执行栈中执行。
  4. 不断重复 1、2、3 的过程。

  我还看到一个点:浏览器是在每一次事件循环之间渲染页面的。不过这点我也不知道怎么验证,就暂且先记着吧。

Node10.x 和浏览器环境下的不同

  说完宏任务和微任务的运行机制后就要敲黑板了:上面所述是针对浏览器环境而言的!上面所述是针对浏览器环境而言的!上面所述是针对浏览器环境而言的!重要的事情说三次!为什么说是针对浏览器环境而言呢?因为在 node 环境下,宏任务和微任务的运行机制是不同的!这里的 node 环境指的是 node10.x,node10 以下的我就不清楚了(原谅我缺乏打破砂锅问到底的精神就不回退版本去验证了Orz,有兴趣的看客可以自行验证,不过我估计跟 node10.x 是一样的)。当前 node 最新版本是 node11.x,在 node11.x 就将宏任务和微任务的运行机制统一得跟浏览器环境一样了

  强调完宏任务和微任务的运行机制在浏览器环境和 node10.x中不一样后,我们再来看看究竟有何不同。前面我们说到当执行栈中的同步任务都执行完毕后,会先清空微任务事件队列中的所有事件,再从宏任务事件队列中取出一个事件放到执行栈中执行,这是在浏览器环境下的。而在 node10.x 环境下,清空微任务事件队列中的所有事件后,有时候会跟浏览器环境一样再从宏任务事件队列中取出一个事件执行,有时候则是清空宏任务事件队列中的所有事件。你没有看错,就是有时候,具体采取哪一种运行机制我也不知道(不过根据多次的运行结果,node10.x 采取后者的概率要比前者大得多),这也造成了同一段代码在 node 环境下运行结果有时候会是不一样的。我们先看一段简单的代码吧。

setTimeout(() => {
  console.log("timer1");
  Promise.resolve().then(function() {
      console.log("promise1");
  });
}, 0);
setTimeout(() => {
  console.log("timer2");
  Promise.resolve().then(function() {
      console.log("promise2");
  });
}, 0);

  我们分别在浏览器环境和 node 环境去运行上面的代码,可以看到在浏览器环境下无论你运行多少次,结果输出的顺序都只会是 timer1 promise1 timer2 promise2 。而在 node10.x 环境下,输出的结果往往会是 timer1 timer2 promise1 promise2,但如果你多运行几次的话就会发现结果有时候跟在浏览器环境下是一样的,这也证明了 node10.x 环境下对宏任务和微任务采取的运行机制后者比前者要大得多(发现这点的时候我其实是 Orz 的,一个程序的结果还能有不确定性的,真的是 too young to simple)。

  简要解释一下node 环境下为什么会输出 timer1 timer2 promise1 promise2 吧,浏览器环境下的结果应该没什么好解释的了,前面应该说的够清楚了吧。node10.x 环境下在清空执行栈中所有的同步任务并清空所有的微任务后,有时候只是从宏任务队列中取出一个事件放到执行栈中执行而已(这也是浏览器下的运行机制,node11.x 也是如此),但更多时候并不只是取出一个事件,而是会执行完宏任务事件队列中的所有事件。也就是说取出第一个setTimeout定时器执行完,并不会马上执行 Promise 的回调函数,而是会继续执行同轮循环下其他的宏任务,所有在输出 timer1 后就跟着输出了 timer2,最后才去清空微任务事件队列。

2020.4.25 补充:

这里具体使用哪个方案,得看第一个定时器执行完后,第二个定时器是否在完成队列中(也就是是否已经到了指定时间)。是的话则会直接执行该宏任务,否则就还是先执行微任务。

process.nextTick具有优先权

  process是 node 环境下的全局变量,浏览器环境下是不具有的,因此process.nextTick自然也只能在 node 环境下使用了。上文有说到,process.nextTick是属于微任务事件队列的,但需要注意的是,在 node 环境下process.nextTick 在微任务事件队列中的优先级会大于其他的微任务,即清空微任务事件队列时会先执行所有的process.nextTick事件。我们实践一下:

Promise.resolve().then(() => console.log("2"));
process.nextTick(() => console.log("1"));

  上面的代码不管是在 node10.x 还是在 node11.x 都会先执行 process.nextTick 再执行 promise 的回调函数!

setTimeout 和 setImmediate 的优先顺序

  setImmediate的功能和setTimeout其实是一样的,不同点在于setImmediate会马上调用,相当于设置了 0ms 延迟的 setTimeout。其中的坑点在于:setImmediate和设置 0ms 延迟的setTimeout的执行顺序是不确定的。

setTimeout(() => {
  console.log('timeout');
}, 0);
setImmediate(() => {
  console.log('immediate');
});

  上面的代码,可能先输出 timeout 也可能先输出 immediate,node 10.x 和 node11.x 都是如此。不过好像有一个规律是:当 script 整体代码执行完的时候,如果已经过了setTimeout指定的延迟时间的话则会先执行setTimeout,即使setImmediate出现在setTimeout之前,反之若整体代码执行完后setTimeout还没有到指定的延迟时间,则会先执行setImmediate。我们修改一下上面的代码试试。

console.time("耗时:");
setImmediate(function () {
    console.log('1'); 
});
setTimeout(function () {
    console.log('2'); 
}, 0);
// for(let i=1; i<=1000000; i++) {}
console.timeEnd("耗时:");

  emmm,好像只有在执行完 script 所有代码所需的时间和 setTimeout的指定延迟时间相差较大(差不多得差上 5ms 左右吧)时,上述的规律才会成立,我们可以使用一个空的 for 循环来增加执行 script 所有代码所需的时间。不过这所谓的规律也只是猜想而已,不一定正确,具体的还是得去看 node 的源码啊Orz。等以后有时间了再去看吧,感觉现在还不太适合去看源码,更何况还是 node 的源码。现阶段还是先继续把 JS 的基础打好,毕竟框架啥的也都是离不开 JS 的,基础还是很重要滴,不能只会调用 api 只知概念而不知原理吧。


  最后再留下一段代码当做课后习题吧,各位看客有兴趣的可以自个先分析后再上机跑一下结果。提醒一下,浏览器环境和 node10.x 环境结果是不一样的。如果你在浏览器中跑这段代码得到将会是Uncaught ReferenceError: process is not defined,至于原因看客们自己想想,想不到就把这篇博客再看一遍吧。要验证浏览器环境下的输出,看客可以使用 node11.x 的环境运行,前面也有提到,node11.x 和 浏览器环境下的运行机制是统一的。

console.log('1');
setTimeout(function() {
  console.log('2');
  process.nextTick(function() {
    console.log('3');
  })
  new Promise(function(resolve) {
    console.log('4');
    resolve();
  }).then(function() {
    console.log('5')
  })
})
process.nextTick(function() {
  console.log('6');
})
new Promise(function(resolve) {
  console.log('7');
  resolve();
}).then(function() {
  console.log('8')
})
setTimeout(function() {
  console.log('9');
  process.nextTick(function() {
    console.log('10');
  })
  new Promise(function(resolve) {
    console.log('11');
    resolve();
  }).then(function() {
    console.log('12')
  })
})

更多资料

面试题:说说事件循环机制(满分答案来了)


  转载请注明: DangoSky 事件队列和事件循环

 上一篇
事件模型和事件委托 事件模型和事件委托
  这回说的是事件模型,跟上篇博客说的事件循环关系不大。事件循环主要是同异步事件在内部环境的执行过程,而事件模型主要是涉及到事件的生成过程,在实践中的应用比较多,比如说常见的事件委托(也叫做事件代理)。 事件模型&em
2019-04-12
下一篇 
深拷贝的实现 深拷贝的实现
浅拷贝和深拷贝  先用简单的两句话概括深拷贝和浅拷贝的区别吧。  浅复制:只将对象的各个属性进行一层复制,因此对于引用数据类型而言复制的是对象地址,导致了“牵一发而动全身”。  
2019-04-01
  目录