JS

从 Event Loop 谈 JavaScript 的执行机制

2018-07-22  本文已影响0人  zouyang0921

在上一篇文章 从进程和线程了解浏览器的工作原理 中,我们已经了解了浏览器的渲染流程,浏览器初次渲染完成后,接下来就是 JS 逻辑处理了。这篇文章我们结合 event loop 来了解一下 JavaScript 代码是如何执行的。

浏览器环境下 JS 引擎的事件循环机制

上一篇文章 中我们已经知道了 JavaScript 是单线程的,这意味着 JavaScript 只有一个主线程来处理所有的任务。所以,所有任务都需要排队执行,上一个任务结束,才会执行下一个。如果上一个任务耗时很长,那么下一个任务也要一直等着。

排队通常由两种原因造成:

JavaScript 的设计者意识到,这时主线程完全可以不管 IO 设备,挂起处于等待中的任务,先运行排在后面的任务,等到 IO 设备返回了结果,再把挂起的任务继续执行下去。

于是,任务可以分为两种:

JavaScript 执行的过程如下:

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  2. 主线程之外还存在一个任务队列。当遇到一个异步任务时,并不会一直等待其返回结果,而是会将这个异步任务挂起,继续执行执行栈中的其他任务。当一个异步任务返回结果后,就会在任务队列中放置一个事件。
  3. 被放入任务队列的事件不会立刻执行其回调,而是等待执行栈中的所有同步任务都执行完毕,主线程处于闲置状态时,主线程就会读取任务队列,看里面是否有事件。如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,开始执行。

只要执行栈空了,就会去读取任务队列,主线程从任务队列中读取事件的过程是循环不断的,这种执行机制称为事件循环(event loop)。

这里引用 Philip Roberts的演讲《Help, I’m stuck in an event-loop》中的一张图来协助理解:

图中的 stack 表示我们所说的执行栈,WebAPIs代表一些异步任务,callback queue 则是任务队列。

定时器

任务队列除了放置异步任务的事件,还可以放置定时事件,即指定某些代码在多长时间后执行。

定时器功能主要有 setTimeout() 和 setInterval() 这两个函数来完成,它们的内部运行机制完全一样,区别在于前者指定的代码只执行一次,后者为反复执行。这里我们主要讨论 setTimeout() 。

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

上面这段代码,3000 毫秒后会将该定时事件放入任务队列中,等待主线程执行。

如果将延迟时间设为 0,就表示当前代码执行完(执行栈清空)以后,立刻执行指定的回调函数。

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

上面代码的执行结果总是:

2
1

因为只有在执行完第二个console.log以后,才会去执行任务队列中的回调函数。

注意:

macro task 与 micro task

前面我们已经将 JavaScript 事件循环机制梳理了一遍,在 ES5 中是够用了,但是在 ES6 中仍然会遇到一些问题,比如下面这段代码:

setTimeout(function() {
    console.log('setTimeout');
}, 0);
new Promise(function(resolve) {
    console.log('Promise1');
    for (var i=0; i < 10000; i++) {
        i == 9999 && resolve();
    }
    console.log('Promise2');
}).then(function() {
    console.log('then');
});
console.log('end');

它的结果是:

Promise1
Promise2
end
then
setTimeout

为什么呢?这里就需要解释一个新的概念:macro-taskmicro-task

除了广义的同步任务和异步任务的划分,对任务还有更精细的定义:

形成 macro-task 或 micro-task 的场景:

宏任务和微任务执行的顺序如下:


现在我们再来看看上面那段代码是怎么执行的:

Node.js 中的 Event Loop

在 Node.js 中,事件循环表现出的状态与浏览器中大致相同。不同的是Node.js 中有一套自己的模型,它是通过 libuv 引擎来实现事件循环的。

下面我们来看看 Node.js 是如何执行的?


事件循环模型

下面是一个 libuv 引擎中的事件循环的模型:

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

注:模型中的每一个方块代表事件循环的一个阶段。

(这块引用 Node 官网上的一篇文章 https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/,有兴趣的朋友可以看看原文)

事件循环各阶段详解

从上面这个模型中,我们大致可以分析出 Node.js 中事件循环的顺序:

外部输入数据 --> 轮询阶段(poll) --> 检查阶段(check) --> 关闭事件回调阶段(close callback) --> 定时器检测阶段(timers) --> I/O 事件回调阶段(I/O callback) --> 闲置阶段(idle, prepare) --> 轮询阶段...

各阶段的功能大致如下:

每个阶段都有一个装有 callbacks 的 queue(队列),当 event loop 执行到一个指定阶段时,Node 将按先进先出的顺序执行该阶段的队列,当队列的 callback 执行完或者执行 callbacks 数量超过该阶段的上限时,event loop 会进入下一个阶段。

下面我们来详细说说各个阶段:

poll 阶段

poll 阶段是衔接整个 event loop 各个阶段比较重要的阶段。在 Node.js 里,任何异步方法(除 timer, close, setImmediate 之外)完成时,都会将 callback 加到 poll queue 里,并立即执行。

当 V8 引擎将 JS 代码解析并传入 libuv 引擎后,循环首先进入 poll 阶段。poll 阶段的执行逻辑如下:

check 阶段

check 阶段专门用来执行 setImmediate() 方法的 callback,当 poll 阶段进入空闲状态,并且 setImmediate queue 中有 callback 时,事件循环进入这个阶段。

close 阶段

当一个 socket 连接或者一个 handle 被突然关闭时(例如,调用了 socket.destroy() 方法),close 事件会被发送到这个阶段执行回调;否则事件会用 process.nextTick() 方法发送出去。

timers 阶段

这个阶段执行所有到期的 timer 加入到 timer queue 中 callback。timer callback 指通过 setTimeout()setInterval() 设定的 callback。

I/O callback 阶段

这个阶段主要执行大部分 I/O 事件的 callback,包括一些为操作系统执行的 callback,例如:一个 TCP 连接发生错误时,系统需要执行 callback 来获得这个错误的报告。

process.nextTick() 与 setImmediate()

Node.js 中有三个常用的用来推迟任务执行的方法,分别是:process.nextTick()setTimeout()(setInterval() 与之相同)和 setImmediate()

process.nextTick()

process.nextTick() 不在 event loop 的任何阶段内执行,而是在各个阶段切换的中间执行,即一个阶段执行完毕准备进入到下一个阶段前执行。

下面我们来看一段代码:

const fs = require('fs);

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('setTimeout);
    }, 0);
    setImmediate(() => {
        console.log('setImmediate');
        process.nextTick(() => {
            console.log('nextTick3');
        });
    });
    process.nextTick(() => {
        console.log('nextTick1');
    });
    process.nextTick(() => {
        console.log('nextTick2');
    });
});

结果为:

nextTick1
nextTick2
setImmediate
nextTick3
setTimeout

从 poll --> check 阶段,先执行process.nextTick,输出 nextTick1,nextTick2;
然后进入 check 阶段,执行setImmediate,输出 setImmediate;
执行完 setImmediate 后,出 check,进入 close callback 前,输出 nextTick3;
最后进入 timer 阶段,执行 setTimeout,输出 setTimeout。

setImmediate()

在三个方法中,setImmediate() 和 setTimeout() 这两个方法很容易被弄混,然而实际上这两个方法的意义确大为不同。

setTimeout()是定义一个回调,并且希望这个回调在指定的时间间隔后第一时间去执行。注意这个“第一时间执行”,意味着,受到操作系统和当前执行任务的诸多影响,该回调并不会在我们预期的时间间隔后精准地执行。

setImmediate() 从意义上是立即执行的意思,但实际上是在一个固定的阶段(poll 阶段之后)才会执行回调。这个名字的意义和上面提到的 process.nextTick() 才是最匹配的。

setImmediate()setTimeout(fn, 0) 表现上非常相似。猜猜下面这段代码的结果是什么?

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

setImmediate(() => {
    console.log('setImmediate');
});

答案是不确定。这取决于这段代码的运行环境,运行环境中各种复杂情况会导致在同步队列里两个方法的顺序随机决定。但是,在一种情况下可以准确判断两个方法回调的执行顺序,那就是在一个 I/O 事件的回调中。下面这段代码的顺序永远是固定的:

const fs = require('fs');

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

答案永远是:

setImmediate
setTimeout

在 I/O 事件的回调中,setImmediate() 方法的回调永远在 setTimeout() 的回调前执行。

从上面 process.nextTick() 的示例代码我们可以看出:多个 process.nextTick() 总是在一次 event loop 执行完;多个 setImmediate() 可能需要多次 event loop 才能执行完。这正是 Node.js 10.0 版添加 setImmediate() 方法的原因,否则像下面这样递归调用 process.nextTick() 时,将会导致 Node 进入死循环,主线程根本不会去读取事件队列。

process.nextTick(function foo() {
    process.nextTick(foo);
});

小结

JavaScript 的事件循环是这门语言中非常重要且基础的概念,清楚的了解事件循环的执行顺序和各阶段的特点,可以使我们对一段异步代码的执行顺序有一个清晰的认知,从而减少代码执行的不确定性。

参考资料

上一篇 下一篇

猜你喜欢

热点阅读