阅读 78

如果你问我什么是 Event Loop

原文地址:github.com/iinitd/blog…

Event Loop

Event Loop 是主线程之外的异步任务调度模型。

js 用 Event Loop 解决单线程带来的一些问题。

浏览器

浏览器的 Event Loop 模型在HTML5标准中有明确定义,但每个浏览器有稍微不同的具体实现。

根据任务的差异,将异步任务分为两类,并放入不同的队列:

  • Task(macroTask):setTimeout, setInterval, setImmediate, I/O, UI rendering,dispatch
  • microtask:Promise, process.nextTick, Object.observe, MutationObserver, MutaionObserver

两个队列有不同的调度方式。Event Loop 具体调度规则如下:

  • 清空 microtask 队列,直到没有新的microtask加入。
  • 取出最早的一个task,执行。
  • 在执行 task 过程中,如果 call stack 为空,也立即清空 microtask。

EV的触发时机是主线程 call stack 为空。

在浏览器的 EV 中,最难以把握的行为其实是 Timer API。

对于 Timer API,有两个最基本的注意点:

  • HTML5标准规定的 setTimeout 最短间隔不得低于4毫秒。但浏览器有可能有不同实现。
  • setTimeout 第二个参数传入的时间,只是将回调放入 microtask 队列的时间,不是执行的时间。如果 call stack 一直不为空,那就一直没有机会执行。

这是一个经典案例(tasks-microtasks-queues-and-schedules

// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// Let's listen for attribute changes on the
// outer element
new MutationObserver(function() {
  console.log('mutate');
}).observe(outer, {
  attributes: true
});

// Here's a click listener…
function onClick() {
  console.log('click');

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

  Promise.resolve().then(function() {
    console.log('promise');
  });

  outer.setAttribute('data-random', Math.random());
}

// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);
复制代码

如果我们点击 inner div,以下事情将会发生:

  1. main script 执行完毕,call stack 为空
  2. 点击 inner 后,onclick dispatch 事件进入task。
  3. 【call stack 为空】第一轮 EV:
    1. microtask 为空,执行第一个 task。
    2. 执行 dispatch,将 onclick1 函数加入 call stack。
    3. 输出click。
    4. 将 setTimeout cb 放入 task。
    5. 将 promise cb 放入 microtask。
    6. 将 mutation cb 放入 microtask。
    7. 尽管我们还在 某个task中,由于此时 onclick1 执行完毕,call stack 为空,立即清空 microtask。
    8. 输出 promise。
    9. 输出 mutation。
    10. microtask 为空。
    11. 将 onclick2 函数加入 call stack。
    12. 重复 b ~ f。
    13. 执行完一个 task,且 microtask 为空,则退出本次 EV。
  4. 【call stack 为空】第二轮 EV:
    1. microtask为空。
    2. 执行第一个 task。输出 timeout。
    3. 执行第二个 task。输出 timeout。

如果我们不是点击,而是执行 inner.click() 呢?事情和上面有什么不一样吗? 最重要的区别在于,在冒泡结束完之前,call stack 不会为空!也就是意味着上面的 3.f. 步骤不会发生。

我们完整地重新来一遍,如果执行 inner.click(),以下事情将会发生:

  1. inner.click 入栈并执行。
  2. onclick1 入栈并执行。
    1. 输出click。
    2. 将 setTimeout cb 放入 task。
    3. 将 promise cb 放入 microtask。
    4. 将 mutation cb 放入 microtask。
  3. onclick1 出栈,onclick2 入栈并执行。
    1. 输出click。
    2. 将 setTimeout cb 放入 task。
    3. 将 promise cb 放入 microtask。
    4. 因为有一个还没执行的 mutation,因此不加入新的。
  4. onclick2 出栈,inner.click 出栈。
  5. 【call stack 为空】第一轮 EV:
    1. 输出 promise。
    2. 输出 mutation。
    3. 输出 promise。
  6. 【call stack 为空】第二轮 EV:
    1. microtask为空。
    2. 执行第一个 task。输出 timeout。
    3. 执行第二个 task。输出 timeout。

第二个例子,来自(zhuanlan.zhihu.com/p/33087629

console.log(1)

setTimeout(() => {
    console.log(2)
    new Promise(resolve => {
        console.log(4)
        resolve()
    }).then(() => {
        console.log(5)
    })
    //process.nextTick(() => {
    //   console.log(3)
    //})
})

new Promise(resolve => {
    console.log(7)
    resolve()
}).then(() => {
    console.log(8)
})

//process.nextTick(() => {
//    console.log(6)
//})

setTimeout(() => {
    console.log(9)
    //process.nextTick(() => {
    //    console.log(10)
    //})
    new Promise(resolve => {
        console.log(11)
        resolve()
    }).then(() => {
        console.log(12)
    })
})
复制代码

有了上面的铺垫,这一题答案应该比较明显了,输出为:1 7 8 2 4 5 9 11 12。

Libuv

Node 中的 Event Loop 是基于 libuv 的,因此它的具体实现是确定的。本节介绍 libuv。

libuv is a multi-platform support library with a focus on asynchronous I/O. It was primarily developed for use by Node.js, but it's also used by LuvitJuliapyuv, and others.

核心代码在deps/uv/src/unix/core.c (代码解析文章

libuv 的实现都是建立在 handle 和 request 的基础上。(uvbook 中文版

handle代表了持久性对象。在异步的操作中,相应的handle上有许多与之关联的request。request是短暂性对象(通常只维持在一个回调函数的时间),通常对映着handle上的一个I/O操作。request用来在初始函数和回调函数之间,传递上下文。

简单理解,handle (句柄)就是一个对象,用来保存持久性对象的状态。常见的句柄,例如 tcp 句柄、udp 句柄、进程句柄、文件流句柄。

image.png

回到 libuv 事件循环上,其分为六个阶段,如图所示。

image.png

timers阶段相当于libuv自己提供的api,在不断的loop中可以注册触发回调。 IO callbacks相当于运行io的callback,也就是stream流式传输的回调,比如我用libuv中提供的流读文件,或者IPC之类的。 而idle是为了越过poll直接执行check的,所以idle必须在前面。 而prepare则类似于poll之前的缓冲,因为poll会阻塞,idle又可以越过poll,所以可以单独在prepare阶段特殊处理,嵌入特定idle。 poll阶段为什么放到后边,因为有uv_stop的存在,你可以用uv_stop来关闭事件循环,而这个开关是放在prepare和poll之间的,也就是如果我在poll之后stop了,libuv还会跑一次循环直到跑完prepare,这样正好保证了IO callbacks的完整退出。 check和close感觉就没必要介绍了。 reference

timers

timers 阶段执行所有超时的计时器回调。

值得注意的是,并不是在任务到期之后,把回调放入所谓的「timer queue」,而是用最小堆管理所有的计时器任务。根据最小堆的性质,每次判断头部节点有没有超时,没有的话说明后面的节点也不会超时,有的话继续往下判断即可。

idle prepare check

代码:github.com/libuv/libuv… 代码解析

这三类 handle 的功能非常类似,只是优先级不同。都是存放用户自定义的回调,并在每次事件循环时触发一次。 idle 和 prepare 最大的区别在于,当存在活跃的 idle 时,poll 的超时时间会被设置为 0。

由于 idle 每次事件循环都会执行一次回调,因此在实现上,回调出队执行完成后,会重新回到对尾。其他阶段的回调都是用完即丢。

setImmediate是利用 idle 这个阶段实现的。

poll

poll 阶段的任务是阻塞等待执行回调。但是这个阻塞不是无止境,而是带有超时时间的。

在 poll 开始之前,会计算超时时间。

  1. 超时时间为距离现在最近的 timer。
  2. 在满足以下条件时,超时时间会被设置为0:
    1. 显式设置为非阻塞模式:uv_run处于UV_RUN_NOWAIT模式下
    2. 显式调用 stop:uv_stop()被调用
    3. 没有活跃的handles和request
    4. 有活跃的idle handles
    5. 有等待关闭的handles
  3. 如果上述都不符合,则会一直阻塞下去。

poll 开始后,会发生以下几种常见:

  1. callback queue 中的回调依次被执行,直到超时时间到,poll 结束。
  2. 如果没有超时,且queue 队列为空,则阻塞,直到下一个 fd 返回。
  3. 如果阻塞过程中,有新的 timer 到期,则跳回 timers 阶段。

close

循环关闭所有的closing handles。

Nodejs Event Loop

简单过了一下 libuv,接下来就可以讲一讲 nodejs 是如何基于 libuv 来做事件循环的。

nodejs 在 libuv 的六阶段里,分别作了这些事情:

  1. timers: 执行 setTimeout 和 setInterval 中到期的回调。
  2. I/O callbacks: 执行上一轮残留的一些回调。
  3. idle:仅内部使用,用来控制 setImmediate 的执行。
  4. prepare:仅内部使用。
  5. poll:除了其他环节的少量回调,其他所有任务都在这里完成。
  6. check:执行 setImmediate 的回调。
  7. close callbacks:循环关闭所有的closing handles,如 socket.on("close", cb)。

node.js v11 之后的每个阶段的一个 callback 执行完成后,都会检查并执行 process.nextTick。

setImmediate 的实现

cnodejs.org/topic/5a9e3… setImmediate 源码解析

另外提一下的就是setImmediate和setTimeout谁先谁后的问题。这个其实是不一定的。从uv_run中我们看到执行定时器的代码比是比uv__run_check先的,但是如果我们在执行完定时器之后,uv__run_check之前,又新增了一个定时器和执行了setImmediate,那么setImmediate的回调就会先执行。

process.nextTick()是node早期版本无setImmediate时的产物,node作者推荐我们尽量使用setImmediate。

zhuanlan.zhihu.com/p/101009546

待补充,只需要知道 nodejs 是通过 idle 阶段设置一个活跃的 idle handle 开关,使得 poll 超时时间为 0,从而跳过 poll 阻塞,进入 check 阶段,并在 check 阶段执行 setImmedate 回调。

实战练习

console.log(1)

setTimeout(() => {
    console.log(2)
    new Promise(resolve => {
        console.log(4)
        resolve()
    }).then(() => {
        console.log(5)
    })
    process.nextTick(() => {
        console.log(3)
    })
})

new Promise(resolve => {
    console.log(7)
    resolve()
}).then(() => {
    console.log(8)
})

process.nextTick(() => {
    console.log(6)
})
复制代码

分析以下流程:

  1. 输出 1
  2. 注册 setTimeout 的回调,超时时间是1ms。
  3. 执行 promise1,输出 7。
  4. 注册 promise1.then。
  5. 注册 nextTick6
  6. 此时 EV 应处于 poll 阻塞阶段。拿到 promise1.then 后马上执行。
    1. 注册 promise2
    2. 检查 nextTick,输出 6
  7. 此时 EV 应处于 poll 阻塞阶段。执行 promise 2。
    1. 输出 8
  8. 此时 EV 应处于 poll 阻塞阶段。直到 setTimeout 到期。
  9. 执行 setTimeout 回调。
    1. 输出 2
    2. 执行promise4,输出 4。
    3. 注册 promise4.then,nextTick3
  10. 又到了 poll 阶段。执行 promise4.then。
    1. 输出 5
    2. 检查 nextTick,输出 3

setImmediate 和 setTimeout 的顺序

没有所谓固定的优先级,其实执行顺序的本质在于,此时 EV 是否已经阻塞在 poll 阶段了。

case1:这种情况下,输出是不固定的,取决于当次的运行状态。

setImmediate(function () {
    console.log('1'); 
});
setTimeout(function () {
    console.log('2'); 
}, 0);
复制代码

case 2:有其他代码执行的情况,EV 在晚于 4ms 之后才到达 timers 阶段,因此先输出2,再输出1。

setImmediate(function () {
    console.log('1'); 
});
setTimeout(function () {
    console.log('2'); 
}, 0);
console.log(3)
复制代码

因此在实际的大多数情况下,setImmediate 都能在 setTimeout 之前运行。

如果要保证 setTimeout 先运行,只需在 setImmedate 前执行同步代码,保证在 timers 前定时器到期。

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

let now = Date.now();
while (Date.now() - now < 10) {}

setImmediate(function () {
    console.log('1'); 
});

console.log(3)
复制代码

感谢:

文章分类
前端
文章标签