从 Event Loop 谈 JavaScript 的执行机制

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

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

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

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

  • 任务计算量过大,CPU 处理不过来;
  • 执行任务需要的东西没有准备好(如 Ajax 获取到数据才能往下执行),所以无法继续执行,只好等待 IO 设备(输入输出设备),而 CPU 却是闲着的。

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

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

  • 同步任务:在主线程上排队执行的任务。只有上一个任务执行完,才能执行下一个任务;
  • 异步任务:不进入主线程、而进入任务队列(task queue)的任务。只有任务队列通知主线程某个异步任务可以执行了,该任务才会进入主线程执行。

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以后,才会去执行任务队列中的回调函数。

注意:

  • setTimeout(fn, 0)的含义是:指定某个任务在主线程最早可得的空闲时间执行。

  • 虽然代码的本意是 0 毫秒后就将事件放入任务队列,但是 W3C 在 HTML 标准中规定,setTimeout() 的延迟时间不能低于 4 毫秒。

  • setTimeout() 只是将事件插入了任务队列,必须要等到执行栈执行完毕,主线程才会去执行它指定的回调函数。如果当前代码耗时很长,那这个事件就得一直等待,所以并没有办法保证回调函数一定会在setTimeout() 指定的时间执行。

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(宏任务):可以理解为每次执行栈执行的代码就是一个宏任务,包括每次从任务队列中获取一个事件并将其对应的回调放入到执行栈中执行。宏任务需要多次事件循环才能执行完,任务队列中的每一个事件都是一个宏任务。每次事件循环都会调入一个宏任务,浏览器为了能够使 JS 内部宏任务与 DOM 任务有序的执行,会在一个宏任务结束后,下一个宏任务开始前,对页面进行重新渲染。

  • micro-task(微任务):可以理解为在当前宏任务执行结束后立即执行的任务。微任务是一次性执行完的,在一个宏任务执行完毕后,就会将它执行期间产生的所有微任务都执行完毕。如果在微任务执行期间微任务队列加入了新的微任务,会将新的微任务放到队列尾部,之后会依次执行。

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

  • macro-task:script(整体代码),setTimeout,setInterval,setImmediate,I/O,UI 渲染等
  • micro-task:process.nextTick,Promise(这里指浏览器实现的原生 Promise),Object.observe,MutationObserver

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


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

  • 整个 script 代码,放在了macro-task 队列中,取出来放入执行栈开始执行;
  • 遇到 setTimeout,加入到 macro-task 队列;
  • 遇到 Promise.then,放入到另一个队列 micro-task 队列;
  • 等执行栈执行完后,下一步该取出 micro-task 队列中的任务了,在这里也就是 Promise.then;
  • 等到 micro-task 队列都执行完后,然后再去取出 macro-task 队列中的setTimeout。

Node.js 中的 Event Loop

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

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


  • Node.js 是 使用 V8 引擎作为 JS 解释器,V8 引擎将 JS 代码解析后去调用Node API;
  • 这些 API 由 libuv 引擎驱动,执行对应的任务。libuv 引擎将不同的任务分配给不同的线程,形成一个事件循环(event loop),以异步的方式将任务的执行结果返回给 V8 引擎;
  • V8 引擎再将结果返回给用户。

事件循环模型

下面是一个 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) --> 轮询阶段...

各阶段的功能大致如下:

  • timers 阶段:这个阶段执行 setTimeout() 和 setInterval() 的回调;
  • I/O callbacks 阶段:这个阶段执行除了close 事件、定时器和 setImmediate() 的回调之外的其它回调;
  • idle,prepare 阶段:仅 Node 内部使用,可以不用理会;
  • poll 阶段:获取新的 I/O 事件,在一些特殊情况下 Node 会阻塞在这里;
  • check 阶段:执行 setImmediate() 的回调;
  • close callbacks 阶段:比如 socket.on('close', callback) 的回调会在这个阶段执行。

每个阶段都有一个装有 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 阶段的执行逻辑如下:

  • 先查看 poll queue 中是否有事件,如果有,就按顺序依次执行 callbacks。
  • 当 poll queue 为空时,
    • 会检查是否有 setImmediate() 的 callback,如果有就进入 check 阶段执行这些 callback。
    • 同时也会检查是否有到期的 timer,如果有,就把这些到期的 timer 的 callback 按照调用顺序放到 timer queue 中,之后循环会进入 timer 阶段执行 timer queue 中的 callback。
      这两者的顺序是不固定的,受到代码运行环境的影响。如果两者的 queue 都是空的,那么 event loop 会停留在 poll 阶段,直到有一个 I/O 事件返回,循环会进入 I/O callback 阶段,并立即执行这个事件的 callback。

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

参考资料

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

推荐阅读更多精彩内容