JavaScript中的事件循环与消息队列

2019-4-01更新:采纳JSC引擎的术语,我们把宿主(浏览器、Node环境)发起的任务称为宏任务(如SetTimeout),把JavaScript引擎发起的任务称为微观任务(如Promise)。


我们在接触到JavaScript语言的时候就经常听到别人介绍JavaScript 是单线程、异步、非阻塞、解释型脚本语言。
究竟应该如何理解这句话呢?

确切的说,对于开发者的开发过程来说,js确实只有一个线程(由JS引擎维护),这个线程用来负责解释和执行JavaScript代码,我们可以称其为主线程。例如在控制台输入如下代码:

console.log("a");
console.log("b");
console.log("c");
//依次输出a,b,c

可以看出,这段代码在主线程上是按照顺序执行的。但是我们平时的任务处理可能并不会直接获取到结果,这种情况下如果仍然使用同步方法,例如发起一个ajax请求,大概500ms后受到响应,在这个过程中,后面的任务就会被阻塞,浏览器页面就会阻塞所有用户交互,呈“卡死”状态。

console.log("a");
$.ajax({
  url:"xxx",
  async:false, //同步请求ajax
  success:function(){
    console.log("b");
  }
})
console.log("c");

这种同步的方式对于用户操作非常不友好,所以大部分耗时的任务在JS中都会通过异步的方式实现。虽然js引擎只维护一个主线程用来解释执行JS代码,但实际上浏览器环境中还存在其他的线程,例如处理AJAX,DOM,定时器等,我们可以称他们为工作线程。同时浏览器中还维护了一个消息队列,主线程会将执行过程中遇到的异步请求发送给这个消息队列,等到主线程空闲时再来执行消息队列中的任务。
同步任务的缺点是阻塞,异步任务的缺点是会使代码执行顺序难以判断。
两者比较一下我们还是更倾向于后者。
到目前为止,我们已经涉及到了几个名词,主线程,js引擎,事件循环,消息队列等。接下来会对这些名词一一进行解释。

js引擎

我们所熟悉的引擎是chrome浏览器中和node.js中使用的V8引擎。它的大致组成如图:


v8引擎

这个引擎主要由两个部分组成,内存堆和调用栈。(只负责取消息,不负责生产消息)
内存堆:进行内存分配。如变量赋值。
调用栈:这是代码在栈帧中执行的地方。调用栈中顺序执行主线程的代码,当调用栈中为空时,js引擎会去消息队列取消息。取到后就执行。JavaScript是单线程的编程语言,意味着它有一个单一的调用栈。因此它只能在同一时间做一件事情。调用栈是一种数据结构,它基本上记录了我们在程序中的什么位置。如果我们步入一个函数中,我们会把这些数据放在堆栈的顶部。如果我们从一个函数中返回,这些数据将会从栈顶弹出。这就是堆栈的用途。调用栈中的每个条目叫做栈帧。当我们在chrome调试窗口中看到抛出的错误时,就能够看到大致的调用顺序。


js运行时

运行时

我们经常使用的一些API并不是js引擎中提供的,例如setTimeout。它们其实是在浏览器中提供的,也就是运行时提供的,因此,实际上除了JavaScript引擎以外,还有其他的组件。其中有个组件就是由浏览器提供的,叫Web APIs,像DOM,AJAX,setTimeout等等。
然后还有就是非常受欢迎的事件循环和回调队列。
运行时负责给引擎线程发送消息,只负责生产消息,不负责取消息。

消息队列和事件循环

主线程在执行过程中遇到了异步任务,就发起函数或者称为注册函数,通过event loop线程通知相应的工作线程(如ajax,dom,setTimout等),同时主线程继续向后执行,不会等待。等到工作线程完成了任务,eventloop线程会将消息添加到消息队列中,如果此时主线程上调用栈为空就执行消息队列中排在最前面的消息,依次执行。
新的消息进入队列的时候,会自动排在队列的尾端。
单线程意味着js任务需要排队,如果前一个任务出现大量的耗时操作,后面的任务得不到执行,任务的积累会导致页面的“假死”。这也是js编程一直在强调需要回避的“坑”。
主线程会循环上述步骤,事件循环就是主线程重复从消息队列中取消息、执行的过程。
需要注意的是 GUI渲染线程与JS引擎是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。因此页面渲染都是在js引擎主线程调用栈为空时进行的。

其实 事件循环 机制和 消息队列 的维护是由事件触发线程控制的。

事件触发线程 同样是浏览器渲染引擎提供的,它会维护一个 消息队列

JS引擎线程遇到异步(DOM事件监听、网络请求、setTimeout计时器等...),会交给相应的线程单独去维护异步任务,等待某个时机(计时器结束、网络请求成功、用户点击DOM),然后由 事件触发线程 将异步对应的 回调函数 加入到消息队列中,消息队列中的回调函数等待被执行。

同时,JS引擎线程会维护一个 执行栈,同步代码会依次加入执行栈然后执行,结束会退出执行栈。
如果执行栈里的任务执行完成,即执行栈为空的时候(即JS引擎线程空闲),事件触发线程才会从消息队列取出一个任务(即异步的回调函数)放入执行栈中执行。

执行顺序

了解了事件循环和消息队列之后,接下来就是弄清楚当同步任务和异步任务都存在时,代码执行的顺序究竟是怎么样的。
举个例子:

console.log("a");
setTimeout(function(){
  console.log("b")},1000
);
console.log("c");

相信所有人都知道执行顺序是 a, c , b。
如果变化一下:

console.log("a");
setTimeout(function(){
  console.log("b")},0
);
console.log("c");

相信通过上面的内容,大部分人也都知道执行顺序还是a,c,b。setTimeout在主线程执行时被添加到了消息队列中,等待主线程调用栈为空时,再从消息队列中取出执行。因此setTimeout中的延时时间并非确切的执行时间,实际上应该理解为添加到消息队列中的延迟时间。以上述代码为例,如果console.log("c")处是一个计算量很大的任务,或者消息队列中已经存在了若干个等待处理的消息。setTimeout都将延迟都将大于设置的延迟时间。

ES6 Promise

以上的内容在ES6之前就基本cover了执行顺序的问题,但是在ES6引入了promise后,产生了一个新的名词”微任务(microtask)“。微任务的执行顺序与之前我们所说的任务(我们可以称之为”宏任务“)是不同的。

console.log('script start')

setTimeout(function() {
    console.log('timer over')
}, 0)

Promise.resolve().then(function() {
    console.log('promise1')
}).then(function() {
    console.log('promise2')
})

console.log('script end')

输出的结果是:
script start
script end
promise1
promise2
timer over
你答对了吗?
我猜这里让你困惑的一定是为什么promise1和promise2在timer over之前输出了。下面我们来解释一下微任务这个概念。

  • 一个线程中,事件循环是唯一的,但是任务队列可以拥有多个。
  • 任务队列又分为macro-task(宏任务)与micro-task(微任务),在最新标准中,它们被分别称为task与jobs。
  • macro-task大概包括:script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering。
  • micro-task大概包括: process.nextTick, Promise, Object.observe(已废弃), MutationObserver(H5新特性)
  • setTimeout/Promise等我们称之为任务源。而进入任务队列的是他们指定的具体执行任务。
  • 来自不同任务源的任务会进入到不同的任务队列。其中setTimeout与setInterval是同源的。
  • 事件循环的顺序,决定了JavaScript代码的执行顺序。它从script(整体代码)开始第一次循环。之后全局上下文进入函数调用栈。直到调用栈清空(只剩全局),然后执行所有的micro-task。当所有可执行的micro-task执行完毕之后。循环再次从macro-task开始,找到其中一个任务队列执行完毕,然后再执行所有的micro-task,这样一直循环下去。
  • 其中每一个任务的执行,无论是macro-task还是micro-task,都是借助函数调用栈来完成。

举个例子:

setTimeout(function() {
    console.log('timeout1');
})
 
new Promise(function(resolve) {
    console.log('promise1');
    for(var i = 0; i < 1000; i++) {
        i == 99 && resolve();
    }
    console.log('promise2');
}).then(function() {
    console.log('then1');
})
 
console.log('global1');

执行结果为:
promise1
promise2
global1
then1
timeout1
分析一下代码,首先程序开始执行,遇到setTimeout时将它添加到消息队列,等待后续处理,遇到Promise时会创建微任务(.then()里面的回调),注意此时new promise构造函数中的代码还是同步执行的,只有.then中的回调会被添加到微任务队列。因此会连续输出promise1和promise2。继续执行到console.log('global1')输出global1,到此调用栈中已经为空。此时微任务队列里有一个任务.then,宏任务队列里也有一个任务setTimout。
microtask必然是在某个宏任务执行的时候创建的,而在下一个宏任务开始之前,浏览器会对页面重新渲染(task >> 渲染 >> 下一个task(从任务队列中取一个))。同时,在上一个宏任务执行完成后,渲染页面之前,会执行当前微任务队列中的所有微任务。也就是说,在某一个宏任务执行完后,在重新渲染与开始下一个宏任务之前,就会将在它执行期间产生的所有微任务都执行完毕(在渲染前)。因此会执行.then输出then1,然后进行下一轮事件循环,取出任务队列中的setTimeout输出timeout1。
总结一下执行机制:

  1. 执行一个宏任务(栈中没有就从事件队列中获取)

  2. 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中

  3. 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)

  4. 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染

  5. 渲染完毕后,JS引擎线程继续,开始下一个宏任务(从宏任务队列中获取)

参考文章:
https://www.cnblogs.com/jymz/p/7900439.html
https://juejin.im/post/5a6547d0f265da3e283a1df7#heading-6
https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,012评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,628评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,653评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,485评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,574评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,590评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,596评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,340评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,794评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,102评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,276评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,940评论 5 339
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,583评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,201评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,441评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,173评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,136评论 2 352

推荐阅读更多精彩内容