事件循环

事件循环

Event Loop 是一种运行机制。

宏任务macrotask和微任务microtask两个概念,这表示任务的两种分类。

在挂起任务时,JS 引擎会将所有任务按照类别分到这两个队列中,首先在 macrotask 的队列(这个队列也被叫做 task queue)中取出第一个任务,执行完毕后取出 microtask 队列中的所有任务顺序执行;

之后再取 macrotask 任务,周而复始,直至两个队列的任务都取完。

V8引擎

V8引擎基本概念关系图 (根据 Google V8 官方文档)

handle

handle 是指向对象的指针,在 V8 中,所有的对象都通过 handle 来引用,handle 主要用于 V8的垃圾回收机制。

上下文(context)

context 是一个执行器环境,使用 context 可以将相互分离的 JavaScript 脚本在同一个 V8 实例中运行,而互不干涉。在运行 JavaScript 脚本是,需要显式的指定 context 对象。

运行时概念

可视化描述

image

函数调用形成了一个栈帧。

image

1
2
3
4
5
6
7
8
9
10
11
function foo(b) {
var a = 10;
return a + b + 11;
}

function bar(x) {
var y = 3;
return foo(x * y);
}

console.log(bar(7));

当调用bar时,创建了第一个帧 ,帧中包含了bar的参数和局部变量。

bar调用foo时,第二个帧就被创建,并被压到第一个帧之上,帧中包含了foo的参数和局部变量。当foo返回时,最上层的帧就被弹出栈(剩下bar函数的调用帧 )。

bar返回的时候,栈就空了。

对象被分配在一个堆中,即用以表示一个大部分非结构化的内存区域。

队列

一个 JavaScript 运行时包含了一个待处理的消息队列。每一个消息都有一个为了处理这个消息相关联的函数。

在事件循环期间的某个时刻,运行时总是从最先进入队列的一个消息开始处理队列中的消息。正因如此,这个消息就会被移出队列,并将其作为输入参数调用与之关联的函数。为了使用这个函数,调用一个函数总是会为其创造一个新的栈帧,一如既往。

函数的处理会一直进行直到执行栈再次为空;然后事件循环将会处理队列中的下一个消息(如果还有的话)。

浏览器事件循环

事件循环(WHATWG规范)

要协调事件(event),用户交互(user interaction),脚本(script),渲染(rendering),网络(networking)等,用户代理(user agent)就需要使用事件循环(event loops)

有两种事件循环:

  1. 用于浏览器上下(browsing context)文的事件循环
  2. 用于workers的事件循环

事件循环机制

  • 一个事件循环有一个或多个任务队列(task queues),一个任务队列是有序列表的任务,这些算法负责以下工作:
    事件(Events),解析(Parsing) ,回调(Callbacks),使用资源(Using a resource),对DOM操作做出反应(Reacting to DOM manipulation)

  • 每个任务都定义为来自特定任务源task source。必须始终添加来自一个特定任务源并发往特定事件循环的所有任务(例如,由Document的计时器生成的回调,针对该Document的鼠标移动而触发的事件,排队等待该Document的解析器的任务)到同一任务队列,但来自不同任务源的任务可以放在不同的任务队列中。

  • 例如,用户代理可以为鼠标和键事件(用户交互任务源)创建一个任务队列,为其他所有事件设置另一个任务队列。然后,用户代理可以在四分之三的时间内为其他任务提供键盘和鼠标事件首选项,保持界面响应但不会使其他任务队列处于饥饿状态,并且永远不会无序地处理来自任何一个任务源的事件。

  • 每个事件循环都有一个当前运行的任务。最初,这是null。它用于处理重入。每个事件循环还具有执行微任务检查点标志(microtask checkpoint flag),该标志最初必须为假(false)。它用于防止执行微任务检查点算法的重入调用。

事件循环处理模型

一个事件循环存在,将连续执行以下步骤:

  1. 选择最先进入事件循环任务队列的一个任务(oldestTask), 如果队列中没有任务,则直接跳到第6步的microtask
  2. 将事件循环的当前运行任务设置为上一步所选择的任务(oldestTask)
  3. 运行所选任务(oldestTask)
  4. 将事件循环的当前运行任务设置为null
  5. 从其任务队列中移除oldestTask
  6. 微任务(microtask):执行微任务检查点
  7. 更新渲染(update the rendering)
  8. 如果这是一个worker事件循环(即一个为WorkerGlobalScope运行的循环)
  9. 跳到第一步

微任务(microtask)

每个事件循环都有一个微任务队列。微任务是最初要在微任务队列上排队的任务,而不是任务队列上排队的任务

当算法需要对微任务进行排队时,必须将其附加到相关事件循环的微任务队列中;这种微任务的任务源是微任务任务源

当用户代理要 执行微任务检查点 时,如果执行微任务检查点标志为false,则用户代理必须运行以下步骤:

  1. 将执行微任务检查点标志(flag)设置为true
  2. 而事件循环的微任务队列不为空:
    1. oldestMicrotask成为事件循环的微任务队列中最老的微任务(oldest microtask )
    2. 将事件循环的当前运行任务设置为oldestMicrotask
    3. 运行oldestMicrotask
    4. 将事件循环的当前运行任务设置为null
    5. 从微任务队列中删除oldestMicrotask
  3. 对于其负责事件循环是此事件循环的每个环境设置对象,请通知该环境设置对象上被拒绝的承诺。
  4. 清理索引数据库事务。
  5. 将执行微任务检查点标志设置为false

微任务的实现:

  • process.nextTick:事件循环的下一次循环中调用 callback 回调函数
  • Promises:Promise 对象用于表示一个异步操作的最终状态(完成或失败),以及其返回的值。
  • Object.observe:Object.observe() 方法用于异步地监视一个对象的修改
  • MutationObserver:(Mutation Observer API 用来监视 DOM 变动)

宏任务(macrotask)

宏任务的实现:

  1. script(整体代码)
  2. setTimeout
  3. setInterval
  4. setImmediate:该方法用来把一些需要长时间运行的操作放在一个回调函数里,在浏览器完成后面的其他语句后,就立刻执行这个回调函数,
  5. I/O
  6. UI rendering

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
console.log("script start");

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

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

console.log("script end");

chrome执行顺序:

script startscript endpromise1promise2setTimeout

nodejs事件循环

nodejsevent是基于libuv

事件循环允许Node.js执行非阻塞I/O操作

尽管JavaScript是单线程的,通过尽可能将操作卸载到系统内核。

由于大多数现代内核都是多线程的,因此它们可以处理在后台执行的多个操作。 当其中一个操作完成时,内核会告诉Node.js,以便可以将相应的回调添加到轮询队列中以最终执行。

事件循环

Node.js启动时,它初始化事件循环,处理提供的输入脚本(或放入交互式解释器(REPL)),这可能会进行异步API调用,调度计时器或调用process.nextTick, 然后开始处理事件循环。

事件循环操作顺序:

注意: 以下每个框都将被称为事件循环的“阶段”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘

每个阶段都有一个要执行的回调先入先出(First Input First Output)队列。虽然每个阶段都有自己的特殊之处,但通常,当事件循环进入给定阶段时,它将执行特定于该阶段的任何操作,然后在该阶段的队列中执行回调,直到队列耗尽或执行的回调的最大数量为止。当队列耗尽或达到回调限制时,事件循环将移至下一阶段,依此类推。

由于这些操作中的任何一个可以调度更多操作,并且在轮询阶段中处理的新事件由内核排队,因此轮询事件可以在处理轮询事件时排队。因此,长时间运行的回调可以允许轮询阶段运行的时间比计时器的阈值长得多

注意WindowsUnix/Linux实现之间存在轻微差异,但这对于此演示并不重要。最重要的部分在这里。实际上有七到八个步骤,而我们关心的是 - Node.js实际使用的那些 - 是上面那些。

阶段概述

nodejs的事件循环分为6个阶段

  1. 计时器(timers):此阶段执行setTimeout()setInterval()调度的回调。
  2. 等待回调(pending callbacks):执行延迟到下一个循环迭代的I/O回调
  3. 空闲,准备(idle, prepare):仅在内部使用
  4. 轮询(poll):检索新的I/O事件;执行与I/O相关的回调(几乎所有回调都是关闭回调,定时器和setImmediate()调度的回调);node将在适当的时候阻止
  5. check:setImmediate()在这里调用回调
  6. 关闭回调(close callbacks):一些关闭回调,例如socket.on(’close’,…)

在事件循环的每次运行之间,Node.js检查它是否在等待任何异步的I/O或定时器,如果没有,则关闭。

代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
setTimeout(() => console.log("setTimeout",1))

setImmediate(() => console.log("setImmediate",2))

process.nextTick(() => console.log("process.nextTick",3))

Promise.resolve().then(() => console.log("Promise",4))

;(() => console.log(5))()

//执行结果
$ node index.js
5
process.nextTick 3
Promise 4
setTimeout 1
setImmediate 2

循环阶段详情

计时器(timers)

计时器指定阈值,在该阈值之后可以执行提供的回调而不是人们希望它执行的确切时间。 定时器回调将在指定的时间过去后尽早安排; 但是,操作系统调度或其他回调的运行可能会延迟它们。

注意:从技术上讲,轮询阶段控制何时执行定时器。

例如,假设您计划在100毫秒阈值后执行超时,那么您的脚本将异步读取一个耗时95毫秒的文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const fs = require('fs');

//一些异步操作
function someAsyncOperation(callback) {
//假设这需要95ms才能完成
fs.readFile('d:/all.txt', callback);
}

const timeoutScheduled = Date.now();
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`已经过去了${delay}ms`);
}, 100);


// 做一些需要95毫秒完成的异步操作
someAsyncOperation((err) => {
if(err){
throw err
}
const startCallback = Date.now();

console.log("while开始");
//做一些需要10ms的事情....
while (Date.now() - startCallback < 10) {
//todo
}
console.log("while结束");
});

当事件循环进入轮询阶段时,它有一个空队列(fs.readFile()尚未完成),因此它将等待剩余的ms数,直到达到最快的计时器阈值。 当它等待95毫秒传递时,fs.readFile()完成读取文件,并且其完成需要10毫秒的回调被添加到轮询队列并执行。 当回调结束时,队列中不再有回调,因此事件循环将看到已达到最快定时器的阈值,然后回绕到定时器阶段以执行定时器的回调。 在此示例中,您将看到正在调度的计时器与正在执行的回调之间的总延迟将为105毫秒。

注意:为了防止轮询阶段使事件循环挨饿,libuv(实现Node.js事件循环的C库和平台的所有异步行为)在停止轮询更多的事件之前具有硬性最大值(取决于系统)。

等待回调(pending callbacks)

此阶段执行某些系统操作(例如TCP错误类型)的回调。 例如,如果TCP套接字在尝试连接时收到拒绝ECONNREFUSED,则某些*nix系统希望等待报告错误。 这将排队等待在挂起的回调阶段执行。

轮询(poll)

轮询阶段有两个主要功能:

  1. 计算它应该阻止和轮询I/O的时间,然后
  2. 处理轮询队列中的事件。

当事件循环进入轮询阶段并且没有计划定时器时,将发生以下两种情况之一:

  1. 如果轮询队列不为空,则事件循环将遍历其同步执行它们的回调队列,直到队列已用尽或者达到系统相关的硬性限制。
  2. 如果轮询队列为空,则会发生以下两种情况之一:
    • 如果setImmediate()已调度脚本,则事件循环将结束轮询阶段并继续执行检查阶段以执行这些调度脚本。
    • 如果setImmediate()尚未调度脚本,则事件循环将等待将回调添加到队列,然后立即执行它们。

轮询队列为空后,事件循环将检查已达到时间阈值的计时器。 如果一个或多个计时器准备就绪,事件循环将回绕到计时器阶段以执行那些计时器的回调。

check

此阶段允许人员在轮询阶段完成后立即执行回调。 如果轮询阶段变为空闲并且脚本已使用setImmediate()排队,则事件循环可以继续到检查阶段而不是等待。

setImmediate()实际上是一个特殊的计时器,它在事件循环的一个单独阶段运行。 它使用libuv API来调度,在轮询阶段完成后执行的回调。

通常,在执行代码时,事件循环最终会到达轮询阶段,它将等待传入连接,请求等。但是,如果已使用setImmediate()调度回调并且轮询阶段变为空闲,则将结束并继续检查阶段,而不是等待轮询事件。

关闭回调close callbacks

如果套接字或句柄突然关闭(例如socket.destroy()),则在此阶段将发出close事件。 否则它将通过process.nextTick()发出。

setImmediate()setTimeout()

setImmediatesetTimeout()类似,但根据它们的调用时间以不同的方式运行。

  1. setImmediate()用于在当前轮询阶段完成后执行脚本,即check阶段。
  2. setTimeout()计划在经过最小阈值(以ms为单位)后运行的脚本。

执行定时器的顺序将根据调用它们的上下文而有所不同。 如果从主模块中调用两者,则时间将受到进程性能的限制(可能受到计算机上运行的其他应用程序的影响)。

例如,如果我们运行不在I/O周期内的以下脚本(即主模块),则执行两个定时器的顺序是不确定的,因为它受进程性能的约束:

1
2
3
4
5
6
7
8
// timeout_vs_immediate.js
setTimeout(function timeout() {
console.log('timeout');
}, 0);

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

输出的顺序不确定:

1
2
3
4
5
6
7
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

但是,如果在I/O周期内移动两个调用,则始终首先执行立即回调:

1
2
3
4
5
6
7
8
9
10
const fs = require('fs');

fs.readFile("d:/all.txt", () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});

始终首先执行立即回调immediate,再执行setTimeout

1
2
3
4
5
6
7
$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout

使用setImmediate()而不是setTimeout()的主要优点是setImmediate()将始终在任何定时器之前执行(如果在I/O周期内调度),与存在多少定时器无关。

process.nextTick()

了解process.nextTick()

您可能已经注意到process.nextTick()没有显示在图中,即使它是异步API的一部分。 这是因为process.nextTick在技术上不是事件循环的一部分。 相反,nextTickQueue将在当前操作完成后处理,而不管事件循环的当前阶段如何。

回顾一下上面的图表,无论何时在给定阶段调用process.nextTick(),传递给process.nextTick()的所有回调都将在事件循环继续之前得到解决。 这可能会产生一些不好的情况,因为它允许您通过进行递归的process.nextTick()调用来饿死(starve)您的I/O,这会阻止事件循环到达轮询阶段。

为什么会被允许

为什么这样的东西会被包含在Node.js中? 其中一部分是一种设计理念,其中API应该始终是异步的,即使它不是必须的。 以下面代码段为例:

1
2
3
4
5
6
function apiCall(arg, callback) {
if (typeof arg !== 'string'){
return process.nextTick(callback,new TypeError('参数应该是字符串')
);
}
}

代码片段进行参数检查,如果不正确,它会将错误传递给回调。这个API最近更新了,允许将参数传递给process.nextTick(),允许它将回调后传递的任何参数作为参数传播到回调,因此您不必嵌套函数。

我们正在做的是将错误传回给用户,但只有在我们允许其余的用户代码执行之后。 通过使用process.nextTick,我们保证apiCall()始终在用户代码的其余部分之后和允许事件循环继续之前运行其回调。 为了实现这一点,允许JS调用堆栈展开然后立即执行提供的回调,这允许一个人对process.nextTick()进行递归调用而不会达到RangeError:超出v8的最大调用堆栈大小。

这种理念可能会导致一些潜在的问题,以以下片段为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let bar;

// 这有一个异步签名,但同步调用回调
function someAsyncApiCall(callback) {
callback();
}

// 在`someAsyncApiCall`完成之前调用回调。
someAsyncApiCall(() => {

// 由于someAsyncApiCall已完成,因此bar未分配任何值
console.log('bar', bar); // undefined
});

bar = 1;

用户将someAsyncApiCall()定义为具有异步签名,但它实际上是同步操作的。 调用它时,在事件循环的同一阶段调用提供给someAsyncApiCall()的回调,因为someAsyncApiCall()实际上不会异步执行任何操作。 因此,回调尝试引用bar,即使它在范围内可能没有该变量,因为该脚本无法运行完成。

通过将回调放在process.nextTick()中,脚本仍然能够运行完成,允许在调用回调之前初始化所有变量,函数等。 它还具有不允许事件循环继续的优点。 在允许事件循环继续之前,向用户警告错误可能是有用的。

以下是使用process.nextTick()的前一个示例:

1
2
3
4
5
6
7
8
9
10
11
let bar;

function someAsyncApiCall(callback) {
process.nextTick(callback);
}

someAsyncApiCall(() => {
console.log('bar', bar); // 1
});

bar = 1;

这是另一个例子:

1
2
3
4
5
6
7
const net = require("net")
const server = net.createServer(() => {
}).listen(8080);

server.on('listening', () => {
console.log(server.address());
});

仅传递端口时,端口立即绑定。 因此可以立即调用listen回调。 问题是那时候不会设置.on('listen')

为了解决这个问题,listening事件在nextTick中排队,以允许脚本运行完成。 这允许用户设置他们想要的任何事件处理程序。

process.nextTick() vs setImmediate()

就用户而言,我们有两个类似的调用,但它们的名称令人困惑。

  • process.nextTick()在同一阶段立即触发
  • setImmediate()触发事件循环的后续迭代或tick

本质上,这些名字应该被交换。与setimmediation()相比,process.nextTick()更快速地触发,但这是过去的产物,不太可能改变。这样做会破坏npm上的大部分包。每天都有更多的新模块被添加,这意味着我们每天都在等待,更多的潜在故障发生。虽然它们令人困惑,但名称本身不会改变。

建议开发人员在所有情况下都使用setimmediation(),因为这样做更容易理解(而且会导致代码与更广泛的环境兼容,比如浏览器JS)。

为什么使用process.nextTick()

主要有两个原因:

  1. 允许用户处理错误,清除任何不需要的资源,或者在事件循环继续之前再次尝试请求。
  2. 有时需要允许回调在调用堆栈展开之后但在事件循环继续之前运行。

一个例子简单的例子:

1
2
3
4
5
6
7
8
const net = require("net")
const server = net.createServer();
server.on('connection', function(conn) {});

server.listen(8080);
server.on('listening', function() {
console.log(server.address());
});

假设listen()在事件循环的开头运行,但是侦听回调被放置在setimmediation()中。现在,除非将主机名传递给端口,否则绑定将立即发生。现在,为了让事件循环继续进行,它必须到达轮询阶段,这意味着有一个非零的机会,连接可能已经收到,允许连接事件在侦听事件之前被触发。

另一个例子是运行一个函数构造函数,比如继承自EventEmitter,它想在构造函数中调用一个事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
EventEmitter.call(this);
this.emit('event');
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', function() {
console.log('发生了一件事!');
});

您无法立即从构造函数中发出事件,因为脚本将不会处理到用户为该事件分配回调的位置。 因此,在构造函数本身中,您可以使用process.nextTick()来设置回调以在构造函数完成后发出事件,从而提供预期的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
EventEmitter.call(this);

//分配处理程序后,使用nextTick发出事件
process.nextTick(function() {
this.emit('event');
}.bind(this));
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', function() {
console.log('发生了一件事!');
});

libuv

libuv是最初为NodeJS编写的跨平台支持库。 它是围绕事件驱动的异步I/O模型设计的。

I/O(或事件)循环

I/O(或事件)循环是libuv的中心部分。它为所有I/O操作建立内容,并且它被绑定到一个线程。只要在不同的线程中运行,就可以运行多个事件循环。libuv事件循环(或任何其他涉及循环或句柄的API)不是线程安全的,除非另有说明。

为了更好地理解事件循环的运行方式,下图说明了循环迭代的所有阶段:

image

重要: 虽然 libuv 的异步文件 I/O操作是通过线程池实现的,但是网络 I/O 总是在单线程中执行的。

相关链接

  1. 事件循环Event loop
  2. WHATWG规范对Event loop
  3. 并发模型与事件循环
  4. 任务,微任务,队列和日程安排
  5. The Node.js Event Loop, Timers, and process.nextTick()
  6. 不要混淆nodejs和浏览器中的event loop
  7. 跨平台异步I/O. libuv
  8. Event loop in JavaScript
  9. 使用 Google V8 引擎开发可定制的应用程序
  10. 深入理解Nodejs核心思想与源码分析基于node v6.0.0
  11. node源码粗读系列文章
  12. process.nextTick(callback[, …args])
  13. libuv 设计概述
  14. 在运行时可视化javascript运行时