Event-Loop

760 阅读7分钟

ps写在前面:这一篇是紧跟上一节的异步。不过值得一说的是以我现在的知识掌握程度要把node的libuv知识解释清楚,那我怕是在做梦(没办法菜是原罪)。故虽然我事前也查阅了许许多多的博客专栏书籍,但是呢查到的越多越不敢写。故只能已最基础的表述。如有误,望指教。感恩!

开始之前先来看这么一段代码

console.log(111)

setTimeout(function() {
    console.log(444)

}, 0)
new Promise(function(resolve, reject) {
    console.log(222)
    resolve(333)
}).then(function(res) {
    console.log(res)
})

console.log(555)

promise中的then和setTimeout的代码都是异步执行的那么上面这个代码段的输出顺序是怎么样的呢

我们知道了代码在执行时,异步函数不会跟同步函数一样的在调用栈中执行。这些个异步函数均会被放在一个异步的任务队列中。

并且,根据异步函数的不同。异步队列被划分为宏任务队列和微任务队列

来看一下常见的

macro-task:setTimeout、setInterval、 setImmediate、 script(整体代码)、I/O 操作等。

micro-task: process.nextTick、Promise、MutationObserver 等

值得注意的是在浏览器环境和node环境它们的机制是不一样的

写在正文之前 知识储备 回顾一下执行上下文与执行栈

执行上下文是什么?

简单来说执行上下文就是代码执行的环境,种类分为三种全局上下文,函数上下文,eval执行上下文

向with,eval这样的词法作用域欺骗语法就十分消耗性能的本身就不建议使用,故eval执行上下文在这里也没有前两者重要故不作介绍了

从全局上下文开始:

当一段js代码被执行,js引擎首先会创建一个全局的执行上下文并推入执行栈

那么开始的全局上下文中有什么东西呢?

答:如果此时我们的脚本中没有代码,则此时全局上下文中只有两个东西

1.全局变量2.this

js引擎创建执行上下文是有两个阶段的

  • 创建阶段
  • 执行阶段

举例

如果此时js脚本中的代码是这个样子的。

let a=1
const fn=function(){}
const foo={}

简单画一下创建阶段此时的执行上下文中的情况

那么创建阶段做了哪些事情呢?

  • 确定this
  • LexicalEnvironment(词法环境)
  • VariableEnvironment(变量环境)

简单理解:就是这个时候创建了全局对象(浏览器的话就是window),创建this并指向全局对象,存放变量和函数(简单的去栈复杂去堆),变量默认undefind,创建作用域链

这时候想一眼变量提升。我们就明白它是为啥提升的了吧

执行阶段它们变量与值之间的映射就完成了

函数上下文

js引擎会为每个函数都创建一个执行上下文,它与全局上下文的内容基本是一致的,不同之处在于函数上下文不创建全局对象而是创建一个参数对象(我们的arguments),再者就是this的指向,和我们平常一下这个this也是要指向该函数调用者,而不是像全局上下文那样直接指向全局

执行栈

用于存储代码指向期间创建的所有执行上下文

跑起来就是开始一个 全局上下文入栈,遇到一个函数,创建函数上下文入栈。如果此函数中又调用了其他函数,则又有一个函数上下文入栈,否则此函数执行完出栈。依次往复

再提一点:想一下闭包,因为闭包的存在导致外函数始终不能执行完毕。故它就一直在执行栈中了,故仍能访问其词法作用域链或者说是执行上文中的资源

好了开始步入正题咯

浏览器中的Event-Loop

浏览器的事件循环比较简单,从文章开始的那段代码来说

console.log(111)

setTimeout(function() {
    console.log(444)

}, 0)
new Promise(function(resolve, reject) {
    console.log(222)
    resolve(333)
}).then(function(res) {
    console.log(res)
})

console.log(555)

已经说了整个脚本也是一个宏任务,这段脚本首先从宏任务队列中出队并执行。console.log(111)执行完遇到一个setTimeout,在一个地方执行完(webapis)后进入宏任务队列。js的非阻塞就体现在这,它不会在这浪费时间。继续往下走,promise构造函数会马上执行(上节已经手写过了)故console.log(222)同步执行这时遇到微任务的promise,在一个地方执行完进入微任务队列,往下走执行完console.log(555)

此时执行栈为空了,这个时候主线程会去微任务队列检测有没有东西有的化微任务队列的回调进执行栈。注意不管此时微任务队列有多少它都会移出执行完,微任务队列么东西了。再去宏任务队列,注意宏任务队列中的东西可不是和微任务队列一样一串执行完。它每次执行完一个宏任务队列的回调,主线程便会再又去检查微任务队列是否有东西

就是说微任务队列的东西是亲儿子,在执行中优先级高一点

故上面的代码我们一眼便可以看出它的执行结果了吧

Node中的Event-Loop

继续看node中的事件循环,比浏览器复杂多一点哈

先来了解一下node的底层依赖

  • v8引擎
  • libuv事件引擎

伟大的v8引擎大家应该都熟悉一下,libuv就是不那么出名了

但在这里它异步这块它却是绝对的主角

看图:

官方介绍:libuv使用异步,事件驱动的编程方式,核心是提供i/o事件循环与异步回调。libuv的API包含有时间,非阻塞网络,异步文件操作,子进程等等

接下来看官网给的libuv引擎事件循环模型

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

接下来分别对个个阶段进行解释

  • timers(重点):在此阶段执行 setTimeout 和 setInterval 中的回调
  • pending callback:在此阶段处理网络i/o或者文件i/o中出错的回调
  • idle,prepare:系统使用,我们略过
  • poll(重点):在此阶段执行i/o回调,计算应该阻塞并且轮询i/o的时间(如:setTimeout 定时器的时间)
  • check(重点):在此阶段处理 setImmediate 的回调
  • close callbacks:处理socket.on('close', ...) socket.destroy()这种关闭的回调

在node中比较注意的是setImmediate 和 process.nextTick

大体捋一下node中事件循环的流程

v8将解析的代码给了事件引擎,循环直接进入poll阶段(看上面的模型)。poll阶段有两个主要功能。1. 计算应该阻塞并轮询i/o的事件 2. 处理轮询队列中的事件

接下来呢?

还是看官方的的解释吧,我觉得这就很好理解了

如果笔者绘图绘的好肯定拿出一个流程图了,但是遗憾。总之重点放到times,poll,check阶段就好了,其实还是蛮容易理解的。

换种方式说:

开始先执行全局js脚本,然后将微任务队列清空。且值得注意的是node中有两类微任务队列。即next-tick队列和其他普通微任务队列。在处理时,next-tick队列优于其他

接下来开始执行宏任务队列,且与浏览器中不同的是。这里的宏任务队列的东西也是全部执行完

setTimeout 与 setImmediate

setTimeout我们熟悉,这里主要看setImmediate。setTimeout的回调执行时机可以由我们指定,但是setImmediate就没有这么听话了。它的回调执行时机在当前poll完成

看下面一段代码

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

setImmediate(function() {
    console.log(222);
})

这是一个非常经典的栗子:它们输出结果在不了解下面知识的情况下你可以说出正确的结果吗?

先说结果:首先上面代码片段的1和2的输出顺序是不确定的

验证:

为什么会出现这样的结果呢?

有两点我们应该事先知道,1.虽然setTimeout中我们指定的时间是0但是这显然是不可能的;2.事件循环也是需要初始时间的。

这里显然有两个不可控的时间间隔,并且timers的执行顺序根据调用它们的上下文而有所不同。

本来笔者对这一块的解释还是比较有自信的,但是为了想解释的更加完善一点。我崩了,查了不少的文档,10个人的东西8个人写的都不一样。

官网给出的结论是受性能的约束(这个解释也是忒标准了)。

故这里我所能给出的解释就是上面存在的两种不可控的时间导致的。

但是呢,如果上面的代码在一个i/o周期中。那边是可以确定的了

如这样:

const fs = require('fs');

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

即此时的poll阶段又派发的两个任务,poll的下一阶段check马上就是执行了setImmediate的回调timers就得往后稍稍了。

细究一下poll:

当poll 队列本来就是空的时候,它首先会检查有没有待执行的 setImmediate 任务,如果有,则往下走、进入到 check 阶段开始处理 setImmediate;如果没有 setImmediate 任务,那么再去检查一下有没有到期的 setTimeout 任务需要处理,若有,则跳转到 timers 阶段

nextTick与promise

这个就相对简单多了,上面也说过。node中的两个微任务队列,next-tick是优于普通队列的

即process.nextTick的回调先执行

注意一下node的新版本变化

从node11开始,timers 阶段的setTimeout、setInterval等函数派发的任务、包括 setImmediate 派发的任务,都被修改为:一旦执行完当前阶段的一个任务,就立刻执行微任务队列。

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


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

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

如这段代码:按node之前。开始全是宏任务,那么直接一批走完

即1243

但是现在1没事2被输出完后微任务队列就有数据了走3最后4

参考文献