📒【异步】1. 事件循环 & ES6 任务队列

事件循环与任务队列是JS中比较重要的两个概念。这两个概念在ES5和ES6两个标准中有不同的实现。尤其在ES6标准中,清楚的区分宏观任务队列和微观任务队列才能解释Promise一些看似奇怪的表现。

JS引擎本身所做的只不过是在需要的时候,在给定的任意时刻执行程序中的单个代码块。JS引擎并不是独立运行的,它运行在宿主环境:Web浏览器、Node.js、机器人等各种设备中。所有这些环境都有一个共同点(thread,线程),即它们都提供了一种机制来处理程序中多个块的执行,且执行每块时调用JavaScript引擎,这种机制被称为事件循环。
所以说,JavaScript引擎本身并没有时间的概念,只是一个按需执行JavaScript任意代码片段的环境。“事件”(JavaScript代码执行)调度总是由包含它的环境进行。

直到ES6,JavaScript才真正内建有直接的异步概念,即ES6从本质上改变了在哪里管理事件循环,ES6精确指定了事件循环的工作细节,这意味着在技术上将其纳入JavaScript引擎的势力范围,而不是只由宿主环境来管理。
ES6中Promise的引入,这项技术要求对事件循环队列的调度运行能够直接进行精细控制。

事件循环

一旦有事件需要运行,事件循环就会运行,直到队列清空。事件循环的每一轮称为一个tick。用户交互、IO、定时器会向事件队列中加入事件。

var eventLoop = []; //用做队列的数组,先进先出
var event;

while(true){ //永远执行
  if(eventLoop.length > 0){ //一次tick
    event = eventLoop.shift(); //拿到队列中的下一个事件
    try {
      event();
    } catch (err) {
      reportError(err);
    }
  }
}

在浏览器的事件循环中,首先大家要认清楚 3 个角色:函数调用栈宏任务(macro-task)队列微任务(micro-task)队列

  • 函数调用栈:当引擎第一次遇到 JS 代码时,会产生一个全局执行上下文并压入调用栈。后面每遇到一个函数调用,就会往栈中压入一个新的函数上下文。JS引擎会执行栈顶的函数,执行完毕后,弹出对应的上下文。
  • 任务队列:对于一些异步的任务,不需要立刻被执行,属于待执行的任务,会按照一定规则排队,等待被推入调用栈的时刻到来。这个队列就是任务队列。

Q:为什么需要 Event Loop?

javascript的一个特点就是单线程,但是很多时候我们仍然需要在不同的时间去执行不同的任务,例如给元素添加点击事件,设置一个定时器,或者发起Ajax请求。因此需要一个异步机制来达到这样的目的,事件循环机制也因此而来。

var a = 1;
var b = 2;
function foo(){
  a++;
  b = b * a;
  a = b + 3;
}
function bar(){
  b--;
  a = 8 + b;
  b = a * 2;
}

ajax("http://some.url.1", foo);
ajax("http://some.url.2", bar);

由于JavaScript的单线程特性,foo()以及bar()中的代码具有原子性。也就是说,一旦foo()开始运行,它的所有代码都会在bar()中的任意代码运行之间完成,或者相反。这称为完整运行特性
由于foo()不会被bar()中断,bar()也不会被foo()中断,所以这个程序只有两个可能的输出,取决于这两个函数哪个先运行。如果存在多线程,且foo()和bar()中的语句可以交替运行的话(并行线程,共享内存),可能输出的情况会增加不少。
同一段代码有两个可能输出意味着存在不确定性!但是,这种不确定性是在函数(事件)顺序级别上,而不是多线程情况下的语句顺序级别。因此,这一确定性要高于多线程情况。
在JavaScript的特性中,这种函数顺序的不确定性就是通常所说的竞态条件,foo()和bar()相互竞争,看谁先运行。具体来说,因为无法可靠预测a和b的最终结果,所以才是竞态条件。

🤔:如果JavaScript中某个函数由于某种原因不具有完整运行特性,那么可能的结果就会多得多,对吧?实际上,ES6就引入了这么一个东西!

  • 异步:是关于现在和将来的时间间隙;(事件循环把自身的工作分成一个个任务并顺序执行,不允许对共享内存的并行访问和修改)
    JavaScript程序总是至少分为两个块:第一块现在运行;下一块将来运行,以响应某个事件。尽管程序是一块一块执行的,但是所有这些块共享对程序作用域和状态的访问,所以对状态的修改都是在之前累积的修改之上进行的。
  • 并行:是关于能够同时发生的事情;(并行计算的最常见工具就是进程和线程)并行线程
  • 并发:两个或多个“进程”同时执行就出现了并发,不管组成它们的单个运行是否并行执行(在独立的处理器或处理器核心上同时运行)。并发可以看作是“进程”级(或任务级)的并行。
    并发指两个或多个事件链随时间发展交替执行,以至于从更高的层次来看,就像是同时在运行(尽管在任意时刻只处理一个事件)。
    单线程事件循环是并发的一种形式。

任务队列

在ES6中,有一个新的概念建立在事件循环队列之上,叫作任务队列。这个概念给大家带来的最大影响可能是Promise的异步特性。
任务队列,是挂在事件循环队列的每个tick之后的一个队列。在事件循环的每个tick中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前tick的任务队列末尾添加一个项目(一个任务)。

  • 事件循环队列:玩过了一个游戏之后,需要重新到队尾排队才能再玩一次;
  • 任务队列:玩过了游戏之后,插队接着继续玩;

JavaScript 代码的执行过程中,除了依靠函数调用栈来搞定函数的执行顺序外,还依靠任务队列(task queue)来搞定另外一些代码的执行:

  • 一个线程中,事件循环是唯一的,但任务队列可以拥有多个;
  • 任务队列又分为 macro-task(宏任务)与 micro-task(微任务),在最新标准中,它们被分别称为 task 与 jobs ;
  • 来自不同源的任务会进入到不同的任务队列,其中 setTimeout 和 setInterval 是同源的,常见的任务队列如下:
不同源的任务会进入到不同的任务队列.png
  • 事件循环的顺序,决定了JavaScript代码的执行顺序。它从script(整体代码)开始第一次循环。之后全局上下文进入函数调用栈。直到调用栈清空(只剩全局),然后执行所有的micro-task。当所有可执行的micro-task执行完毕之后。循环再次从macro-task开始,找到其中一个任务队列执行完毕,然后再执行所有的micro-task,这样一直循环下去;
  • 其中每一个任务的执行,无论是macro-task还是micro-task,都是借助函数调用栈来完成;

宏任务 macro-task(task):

  • script(整体代码)
  • setTimeout
  • setInterval
  • setImmediate(仅存在于Node.js环境中)
  • I/O操作
  • request / AnimationFrame
  • UI render

setTimeout 是宿主环境提供的API,其作为一个任务分发器,这个函数会立即执行,而它所要分发但任务,也就是它的第一个参数,才是延迟执行。

setTimeout 函数的返回值是一个整数,返回的是一个 ID号,从1开始。clearTimeout(timeid) 会清除这个定时器,但不会改版id值。

当我们在执行setTimeout任务中遇到setTimeout时,它仍然会将对应的任务分发到setTimeout队列中去,但是该任务就得等到下一轮事件循环执行了。

微任务 micro-task(job):

  • process.nextTick(仅存在于Node11之后 是微任务的一种)
  • MutationObserver(html5新特性)
  • Object.observe(已废弃)
  • Promise
  • Async/Await(实际上就是promise)

nextTick 队列会比 Promise 先执行。nextTick中的可执行任务执行完毕之后,才会开始执行Promise队列中的任务。

Node.js VS 浏览器环境中的事件循环机制

  1. 浏览器中有事件循环,Node.js 中也有。事件循环是Node处理非阻塞I/O操作的机制,Node.js 中事件循环的实现是依靠的libuv引擎。由于 Node.js 11 之后,事件循环的一些原理发生了变化,因此 Node11事件循环已与浏览器事件循环机制趋同
  2. chrome浏览器中新标准中的事件循环机制与 Node.js 类似,都有宏任务和微任务之分。但是有些API只有 Node.js 中有,而浏览器中没有,比如 process.nextTicksetImmediate
  3. 浏览器中的微任务是在每个相应的宏任务中执行的。而 Node.js 中的微任务是在不同阶段之间执行的。
  4. 浏览器的 Event-Loop 由各个浏览器自己实现;而 Node 的 Event-Loop 由 libuv 来实现。
  5. 在浏览器中,只有一个微任务队列需要接受处理;在Node中,有两类微任务队列:next-tick队列和其他队列。 其中这个 next-tick 队列,专门用来收敛 process.nextTick 派发的异步任务。在清空队列时,优先清空 next-tick 队列中的任务,随后才会清空其它微任务。
  6. 在浏览器中,我们每次出队并执行一个宏任务;而在 Node 中,我们每次会尝试清空当前阶段对应宏任务队列里的所有任务(除非达到了系统限制);

Node.js 技术架构

Node整体上由这三部分组成:

  1. 应用层:这一层就是大家最熟悉的 Node.js 代码,包括 Node 应用以及一些标准库。
  2. 桥接层:Node 底层是用 C++ 来实现的。桥接层负责封装底层依赖的 C++ 模块的能力,将其简化为 API 向应用层提供服务。
  3. 底层依赖:这里就是最最底层的 C++ 库了,支撑 Node 运行的最基本能力在此汇聚。其中需要特别引起大家注意的就是 V8 和 libuv:
  • V8 是 JS 的运行引擎,它负责把 JavaScript 代码转换成 C++,然后去跑这层 C++ 代码。
  • libuv:它对跨平台的异步I/O能力进行封装,同时也是我们本节的主角:Node 中的事件循环就是由 libuv 来初始化的。
    浏览器的 Event-Loop 由各个浏览器自己实现;而 Node 的 Event-Loop 由 libuv 来实现。
    Node.js技术架构.png

libuv 中 Event-Loop 实现

libuv 主导循环机制共有六个循环阶段:

libuv中Event-Loop.png

  • timers阶段:执行 setTimeout 和 setInterval 中定义的回调;
  • pending callbacks:直译过来是“被挂起的回调”,如果网络I/O或者文件I/O的过程中出现了错误,就会在这个阶段处理错误的回调(比较少见,可以略过);
  • idle, prepare:仅系统内部使用。这个阶段我们开发者不需要操心。(可以略过);
  • poll (轮询阶段):重点阶段,这个阶段会执行I/O回调,同时还会检查定时器是否到期;在 poll 阶段处理的回调中,如果既派发了 setImmediate、又派发了 setTimeout,那么这个顺序是板上钉钉的——一定是先执行 setImmediate,再执行 setTimeout。
  • check(检查阶段):处理 setImmediate 中定义的回调;
  • close callbacks:处理一些“关闭”的回调,比如socket.on('close', ...)就会在这个阶段被触发。

Node11前后的变化

setTimeout(() => {
  console.log('timeout1');
}, 0);   

setTimeout(() => {
  console.log('timeout2');
  Promise.resolve().then(function() {
    console.log('promise1');
  });
}, 0);

setTimeout(() => {
  console.log('timeout3')
}, 0)

Node11开始,timers 阶段的setTimeout、setInterval等函数派发的任务、包括 setImmediate 派发的任务,都被修改为:一旦执行完当前阶段的一个任务,就立刻执行微任务队列。
上述代码输出结果:

  • Node v9.3.0:timeout1 timeout2 timeout3 promise1 在 timers 阶段,依次执行了所有的 setTimeout 回调、清空了队列。
  • Node v12.4.1:timeout1 timeout2 promise1 timeout3
  • 浏览器:timeout1 timeout2 promise1 timeout3

测试一下

例1:

console.log('script start');
async function async1(){
    await async2();
    console.log('async1 end');
}
async function async2(){
    console.log('async2 end');
}
async1();

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

new Promise(resolve => {
    console.log('Promise');
    resolve();
}).then(function(){
    console.log('promise1');
}).then(function(){
    console.log('promise2');
})
console.log('script end');
// script start => async2 end => Promise => script end => async1 end => promise1 => promise2 => setTimeout 
  • 首先,事件循环从宏任务队列开始,此时宏任务队列中只有一个script(整体代码)任务。所以执行代码,输出 script start
  • 执行 async1(),会调用 async2(),输出 async2 end。此时会保留 async1 函数的上下文,将await后面的代码注册为一个微任务,然后跳出 async1 函数;
  • 遇到 setTimeout,产生一个宏任务
  • 遇到Promise实例,Promise构造函数中的第一个参数,是在new的时候执行,因此不会进入任何其他的队列,而是直接在当前任务直接执行了,输出 Promise,后续的 .then 产生第二个微任务,会被分发到 micro-task 的Promise队列中;
  • 继续执行代码,输出 script end
  • 代码逻辑执行完成(当前宏任务执行完毕),开始执行当前宏任务产生的微任务队列,输出async1 end,继续执行下一个微任务输出 promise1,该微任务遇到 then,产生一个新的微任务
  • 执行产生的微任务,输出 promise2,当前微任务队列执行完毕;
  • 执行下一个宏任务,输出 setTimeout

例2:

console.log('script start')
async function async1() {
    await async2()
    console.log('async1 end')
}
async function async2() {
    console.log('async2 end')
    return Promise.resolve().then(()=>{
        console.log('async2 end1')
    })
}
async1()

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

new Promise(resolve => {
    console.log('Promise')
    resolve()
})
.then(function() {
    console.log('promise1')
})
.then(function() {
    console.log('promise2')
})
console.log('script end')
// script start => async2 end => Promise => script end => async2 end1 => promise1 => promise2 => async1 end => setTimeout 

此时执行完awit并不先把await后面的代码注册到微任务队列中去,而是执行完await之后,直接跳出async1函数,执行其他代码。然后遇到promise的时候,把promise.then注册为微任务。

其他

浏览器&Node中的事件循环
JS事件机制可视化
理解Promise之任务队列

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