Event Loop

简介

我们都知道 JS 是一门单线程执行语言,单线程意味着每次只能处理一件事,意味着阻塞。JS 提供了很多异步代码,Event Loop(事件环,又称事件轮询)就是 JS 处理异步的一种机制。现在我们用一种比较形象的方式去解释这种机制

先备知识

在了解 Event Loop 之前,我们先要了解一些知识:

  1. 什么是(JS)异步?

在表现上来说,异步表现为代码的执行顺序和你书写的顺序不一样了。具体来说,是与同步相对,异步处理不用阻塞当前线程来等待处理完成,而是允许后续操作,直至其它线程将处理完成,并回调通知此线程。

  1. 什么是回调函数?

异步编程往往需要回调函数辅助(但有回调函数并不一定是异步编程)。结合上面提到的异步,举个例子,你去商店买东西,店员说没货,于是你登记自己的电话号码要求店员有货的时候打电话,然后你就可以继续做自己的事,随后进货后店员打电话通知你取货。在这个例子里,你的电话号码就叫回调函数,你把电话留给店员就叫登记回调函数,店里后来有货了叫做触发了回调关联的事件,店员给你打电话叫做调用回调函数,你到店里去取货叫做响应回调事件

// 下面这个并非异步
function call(cb) {
    cb();
}
call(() => {});
// 异步
fetch('http://example.com/movies.json')
.then(function(response) {
    return response.json();
})
setTimeout(param => {
    console.log(param)
}, 1000, 'param')
  1. 进程和线程?

这两个概念都会在下面用到

进程: 进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础,在现代系统中,进程基本都是线程的容器。(百度百科)

线程: 是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位,一条线程指的是进程中一个单一顺序的控制流(由此可见线程同一时间基本只能做一件事情)。(百度百科)

说白了,如果把我们的身体比喻成一个进程,那么线程就是我们身体的各个部件,因为线程同一时间基本只能做一件事情,所以我们脚走路的时候不能踢毽子,踢毽子的时候不能走路。

浏览器每打开一个 Tab 页,即为一个进程,其中又包括许多线程,比如渲染线程、JS 引擎线程、HTTP 请求线程、计时器等等。我们可以在 Chrome > 更多工具 > 任务管理器 中看到我们浏览器中的所有进程。

浏览器进程
  1. 栈和队列?

栈(stack)和队列(queue)都是一个运算受限的线向表。

栈遵循的是“后进先出”原则,栈的模式就像我们挤地铁,最后进去的人(最外面的人,即栈顶)出去之后,先进去的人(最里面的人,即栈底)才能出来。

队列遵循的是“先进先出”原则,队列很语义化,和我们排队一样,前面的人先办业务,后面的人才能继续。

浏览器中的 Event Loop

首先我们引入执行栈的概念,JS 代码在执行时,每次遇到一个函数,便会在执行栈中压入这个函数,函数执行完会被移出执行栈,直到执行栈为空。

当遇到异步代码时(HTTP 请求),回调函数(会被挂起,由其他线程处理(比如网络线程),当处理完毕会把回调函数加入到 Task 队列中。一旦执行栈为空,Event Loop 就会从队列中取出一个函数放入执行栈中执行。所以本质上来说 JS 中的异步还是同步行为。

JS 中的 Task 队列分别两种,不同的任务源会被分配到不同的队列中。任务源可以分为微任务(microtask)宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task。看下图:

  • 微任务包括 process.nextTick ,promise ,MutationObserver,其中 process.nextTick 为 Node 独有。
  • 宏任务包括 script , setTimeout ,setInterval ,setImmediate ,I/O ,UI rendering。

[图片上传失败...(image-1e0115-1555772297164)]

Event Loop 的执行顺序如下:

  • 执行最旧的 macrotask(一次);
  • 检查是否存在 microtask,然后不停执行,直到清空队列(多次);
  • 执行render;
  • 然后开始下一轮 Event Loop;

这里有一个关键点,对于 macrotask,每次只会拉出一个来执行;对于 microtask,是把整体队列拉出来执行空。从图中我们也可以看到这一点

我们来看一下例子

console.log(1)

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

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

setTimeout(() => {
    console.log(9)
    new Promise(resolve => {
        console.log(11)
        resolve()
    }).then(() => {
        console.log(12)
    }).then(() => {
        console.log(12.1)
    })
})
  1. 执行同步代码(macrotask),打印出 1 7;
  2. 执行完所有微任务,打印出 8 8.1;
  3. 执行一次宏任务,打印出 2 4;
  4. 执行完所有微任务,打印出 5 5.1;
  5. 执行一次宏任务,打印出 9 11;
  6. 执行完所有微任务,打印出 12 12.1;

Node Event Loop

Node 中的 Event Loop 与浏览器大有不同,这是因为 Node 中的异步操作更多更复杂,除了网络,定时器外,还有文件读写,数据库操作等等... 所以 Node 中 Event Loop 分为 6 个阶段,每个阶段都有一个回调队列等待执行,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

6个阶段

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

Node 会尽可能地保障定时器任务的准时可靠,所以有好几个阶段都是在处理定时器。我们分别来解释一下这 6 个阶段:

1. timers

这个阶段执行 setTimeout 和 setInterval 设定的回调。我们的定时器都会设定一个时间阈值,但这个值并不意味着回调会精确在这个时刻执行,而是时间超过阈值之后,回调会被尽早的执行。因为操作系统对其他回调的调度可能会延迟他们的执行。

2. pending callbacks

处理一些上一轮循环中的少数未执行的 I/O 回调。

3. idle,prepare

只在内部使用。这个阶段为一些系统操作执行回调,例如 TCP 错误。例如,如果一个 TCP socket 在尝试连接时收到 ECONNREFUSED,一些 *nix 系统会等待要报告这个错误。这会被放入 pending callbacks 阶段的队列中。

4. poll

poll 是一个至关重要的阶段,这个阶段会判断定时器的存在而更改 Event Loop 流程。

poll 阶段有两个主要功能:

  1. 计算需要阻塞多久然后轮询 I/O,然后
  2. 处理 poll 队列中的事件

当事件循环进入 poll 阶段,并且没有需要执行的定时器,会进入下面两种状况之一:

  • 如果 poll 队列不是空的,事件循环会在队列上同步迭代直到完成全部队列的回调,或者系统依赖的限制被达到。
  • 如果 poll 队列是空的,那么会进入下面两种状况之一:
    • 如果有代码被 setImmediate() 设定,事件循环会结束轮询阶段,并且进入 check 阶段去执行设定的回调。
    • 如果代码没有被 setImmediate() 设定,事件循环会等待回调被加入队列,然后立即执行它们。

一旦 poll 队列是空的,事件循环会检查超过阈值的定时器。如果一个或者多个定时器已经准备好,事件循环会绕回定时器阶段去执行定时器回调。

注意:为了防止 poll 阶段耗尽事件循环,libuv(实现了 Node.js 事件循环和这个平台全部异步行为的 C 语言库)也有对轮询事件有一个最大限制。

5. check

这个阶段允许用户在 poll 阶段完成之后立即执行回调。如果 poll 阶段变的空闲并且代码被 setImmediate() 放入队列,事件循环就可能进入 check 阶段而不是继续等待。

事实上 setImmediate() 是在事件循环一个单独的阶段运行的特殊计时器。它使用 libuv 的 API 让设定的回调在 poll 阶段完成之后进行。

通常,随着代码被执行,事件循环会最终达到 poll 阶段,在 poll 阶段会等待可能到来的连接、请求等等。但是,如果一个回调被 setImmediate() 设定并且 poll 阶段是空闲的,那么 poll 阶段就会结束,然后进入 check 阶段而不是继续等待轮询事件。

6. close callbacks

如果一个 socket 或者句柄被突然关闭(例如 socket.destroy()),close 事件会在这个阶段被发送,否则它就会被通过 process.nextTick() 发送。

细节详解

setImmediate() vs setTimeout()

setImmediate() 和 setTimeout() 很相似,但是他们的行为却根据被调用的时机有差别。

  • setImmediate() 是在当前 poll 阶段结束时立即执行。
  • setTimeout() 是一段代码在时间阈值被超过之后尽早执行。

定时器被执行的顺序根据他们被调用的上下文会有区别。如果它们都在主模块被调用,时机会和进程的性能有关(可能会被机器的其他进程影响)。

例如,如果我们运行并不在一个I/O循环中的脚本(比如在主模块中),这两个定时器执行的顺序是不确定的,因为它被进程的性能限制。

  • 首先 setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的
  • 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调
  • 那么如果准备时间花费小于 1ms,那么就是 setImmediate 回调先执行了
// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

但是,如果你把两个回调放入一个 I/O 循环中,那么 setImmediate() 的回调总是会被最先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。

// timeout_vs_immediate.js
const fs = require('fs');

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

$ node timeout_vs_immediate.js
immediate
timeout

相比于使用 setTimeout(),使用 setImmediate() 的主要优势是,如果在一个 I/O 循环中,setImmediate 总会比其他定时器先执行,无论其他定时器有多少。

microtask

上面介绍的都是 macrotask 的执行情况,对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列,这里和浏览器类似,microtask 都会在在 macrotask 前面处理

process.nextTick()

你可能注意到 process.nextTick() 在示意图中没有展示,尽管它是异步 API 的一部分。这是因为 process.nextTick() 严格来说并不属于事件循环的一部分。实际上,nextTickQueue 会在每一个正在进行的操作完成后之后执行。它有一个自己的队列,当每个阶段完成后,如果存在 nextTickQueue,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

参考

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

推荐阅读更多精彩内容