Nodejs高性能原理下-事件循环详解

3次阅读

共计 12162 个字符,预计需要花费 31 分钟才能阅读完成。

系列文章

Nodejs 高性能原理(上) — 异步非阻塞事件驱动模型
Nodejs 高性能原理(下) — 事件循环详解

前言

终于开始我 nodejs 的博客生涯了, 先从基本的原理讲起. 以前写过一篇浏览器执行机制的文章, 和 nodejs 的相似之处还是挺多的, 不熟悉可以去看看先.
Javascript 执行机制 – 单线程,同异步任务,事件循环

写下来之后可能还是有点懞, 以后慢慢补充, 也欢迎指正, 特别是那篇翻译文章后面已经看不懂了. 有人出手科普一下就好了.
补充: 当前 Nodejs 版本 10.3.0
2018/06/13 补充些信息, 新增输出例子

Nodejs 事件循环详解

基本来自 The Node.js Event Loop, Timers, and process.nextTick(), 可以说这部分我就是翻译功能, 部分翻译太绕口会和谐一下, 基本忠于原文.

当 nodejs 开始运行的时候会初始化事件循环, 处理所提供的输入脚本或者放置进REPL(Read Eval Print Loop: 交互式解释器类似 Window 系统的终端或 Unix/Linux shell), 可能会进行异步 API 调用. 定时器调度, 或者process.nextTick(), 然后开始处理事件循环的流程.

下面来自官网的炫酷流程代码示意图 (官网直接用符号拼凑出来, 这里因为编辑器问题衹能截图)

注意: 每个框都被称为事件循环的一个流程阶段.

每个阶段都有一个 FIFO(先进先出) 执行回调函数的队列, 然而每个阶段都有其独特之处. 通常当事件循环进入到给定阶段会执行特定于该阶段的所有操作. 然后执行该阶段队列的回调事件直到队列耗尽或者超过最大执行限度为止, 然后事件循环就会走向下一阶段, 以此类推.

因为这些操作可能会调度更多的操作并且在 poll 阶段中新的处理事件会加入到内核的队列, 即处理轮询事件时候又加入新的轮询事件, 因此, 长时间运行回调事件会让 poll 阶段运行时间超过定时器的阈值.

阶段综述:

  • timers(定时器): 这阶段执行 setTimeoutsetInterval调度的回调;
  • pending callbacks(等待回调): 推迟到下一次循环迭代执行 I / O 回调;
  • idle,prepare(闲置, 准备): 只能内部使用;
  • poll(轮询): 检索新的 I / O 事件; 执行 I / O 相关回调(除了 close callbacks 以外, 大多数是定时器调度, 和setImmediate()), 当运行时候适当条件下 nodejs 会占用阻塞;
  • check(检测): setImmediate()回调就在这执行;
  • close callbacks(关闭回调): 一些关闭回调, 例如 socket.on(‘close’, …),

在事件循环的每次运行过程中,nodejs 会检测是否有任何待处理的异步 I / O 或者定时器, 没有的话就彻底清除关闭.

Timers(定时器)

在定时器设定了一个阈值之后, 被提供的回调函数实际执行时间可能不是开发者想要它被执行的时间, 定时器回调会在指定阈值过去后尽可能早的运行, 然而操作系统调度或者其他回调运行都可能会导致延迟.
注意: 为了防止轮询阶段持续时间太长,libuv 会根据操作系统的不同设置一个轮询的上限。(这就是为什么上面会说执行该阶段队列的回调事件直到队列耗尽或者超过最大执行限度为止)
(下面会单独详细讲解定时器的东西)

pending callbacks(等待回调)

这阶段会执行一些系统操作回调像 TCP 错误类型, 例如当一个 TCP socket 想要连接的时候接收到 ECONNREFUSED, 一些 *nix 系统会等待错误报文, 这会被排在 pending callbacks 阶段执行.

poll(轮询)

这阶段有两个主要功能:

  • 计算它应该阻塞多长的时间和进行轮询 I / O 操作;
    (原文: Calculating how long it should block and poll for I/O, then, 我看到有些人会翻译成当 timers 的定时器到期后,执行定时器(setTimeout 和 setInterval)的 callback。不知道版本不对还是我翻译不对味)
  • 处理 poll 队列事件;

当事件循环进入 poll 阶段, 并且没有 timers 调度, 会发生其中一种情况:

  • 如果 poll 队列不为空, 事件循环会迭代回调队列同步执行它们直到队列耗尽或者到达系统限制;
  • 如果 poll 队列为空, 一件或者多件情况会发生:

    • 如果 setImmediate()脚本已经被调度, 事件循环的 poll 阶段完成然后继续到 check 阶段去执行那里的调度脚本;
    • 如果 setImmediate()脚本还没被调度, 事件循环会等待回调被添加到队列, 然后立即执行.

一旦 poll 队列清空了事件循环会检测有没有定时器阈值是否到达, 如果一个或多个定时器已经准备好, 事件循环会绕回到 timers 阶段去执行它们的定时器回调函数.

check(检测)

这阶段允许开发者在 poll 阶段完成之后立即执行回调函数, 如果 poll 阶段在闲置中并且脚本已经被 setImmediate() 加入队列, 事件循环会跳到 check 阶段而不是等待.

setImmediate()实际上是一个特殊的定时器, 它会在事件循环的单独阶段运行. 通过libuv API 在 poll 阶段完成之后调度回调去执行.

一般来说, 当代码执行完, 事件循环最终会到达 poll 阶段去等待即将到来的连接, 请求等等. 然而, 如果一个回调函数被 setImmediate()调度并且 poll 阶段是闲置状态, 它会结束并且跳到 check 阶段而不是在等待轮询事件.

close callbacks(关闭回调)

如果一个 sockethandle突然被关闭 (例如 socket.destroy()),’close’ 事件会在这阶段被触发, 否则会通过process.nextTick() 被触发.

非异步 API(强势插楼)

事件循环阶段部分已经讲完了, 剩下的是定时器之间区别部分, 在那之前我想在这里补充一下定时器知识!

Node.js 中的计时器函数实现使用了一个与浏览器类似但不同的内部实现,它是基于 Node.js 事件循环构建的。

浏览器定时器

setTimeout(callback,delay,lang):

在指定的毫秒数后调用函数或计算表达式, 返回一个用于 clearTimeout() 的 Timeout 或窗口被关闭。

参数 描述
callback 必需。要调用的函数后要执行的 JavaScript 代码串。
delay 必需。在执行代码前需等待的毫秒数, W3C 标准规定时间间隔低于 4ms 被算为 4ms,具体看浏览器
lang 可选。脚本语言可以是:JScript VBScript JavaScript

setInterval(callback,delay,lang):

按照指定的周期(以毫秒计)来调用函数或计算表达式。方法会不停地调用函数,返回一个用于 clearInterval() 的 Timeout 或窗口被关闭。
参数请看上面 setTimeout.

nodejs 定时器

setTimeout(callback, delay[, …args])

在指定的毫秒数后调用函数或计算表达式, 返回一个用于 clearTimeout() 的 Timeout 或窗口被关闭。

参数 描述
callback 必需。要调用的函数后要执行的 JavaScript 代码串。
delay 必需。在执行代码前需等待的毫秒数。当 delay 大于 2147483647 或小于 1 时,delay 会被设为 1。
…args 可选, 当调用 callback 时要传入的可选参数。

此外还增加一些方法 timeout.ref(),timeout.unref()等, 请自行查看.Timeout 类

setInterval(callback, delay[, …args])

按照指定的周期(以毫秒计)来调用函数或计算表达式。方法会不停地调用函数,返回一个用于 clearInterval() 的 Timeout 或窗口被关闭。
参数请看上面 setTimeout.

setImmediate(callback[, …args])

预定立即执行的 callback,它是在 I/O 事件的回调之后被触发。返回一个用于 clearImmediate() 的 Immediate。
当多次调用 setImmediate() 时,callback 函数会按照它们被创建的顺序依次执行。每次事件循环迭代都会处理整个回调队列。如果一个即时定时器是被一个正在执行的回调排入队列的,则该定时器直到下一次事件循环迭代才会被触发。

参数 描述
callback 在 Node.js 事件循环的当前回合结束时要调用的函数。
…args 可选, 当调用 callback 时要传入的可选参数。

对应的清除方法 clearImmediate(), 此外还增加一些方法 setImmediate.ref(),setImmediate.unref()等, 请自行查看.Immediate 类

promise 写法(题外话)

可用 util.promisify()提供的 promises 常用变体

const util = require('util');
const setTimeoutPromise = util.promisify(setTimeout),
  setImmediatePromise = util.promisify(setImmediate);

setTimeoutPromise(40, 'foobar').then(value => {// value === 'foobar' (passing values is optional)
  // This is executed after about 40 milliseconds.
});

setImmediatePromise('foobar').then(value => {// value === 'foobar' (passing values is optional)
  // This is executed after all I/O callbacks.
});

// or with async function
async function timerExample() {console.log('Before I/O callbacks');
  await setImmediatePromise();
  console.log('After I/O callbacks');
}
timerExample();

process.nextTick(callback[, …args])

将 callback 添加到 next tick 队列。一旦当前事件轮询队列的任务全部完成,在 next tick 队列中的所有 callbacks 会被依次调用。但是不同于上面的定时器. 在内部的处理机制不同,nextTick 拥有比延时更多的特性.
注意:这不是定时器, 而且 递归调用 nextTick callbacks 会阻塞任何 I / O 操作 ,就像一个 while(true) 循环一样

参数 描述
callback 一旦当前事件轮询队列的任务全部完成,在 next tick 队列中要调用的函数
…args 可选, 当调用 callback 时要传入的可选参数。

事件轮询随后的 ticks 调用,会在任何 I / O 事件(包括定时器)之前运行。

console.log('start');
process.nextTick(() => {console.log('nextTick callback');
});
console.log('scheduled');
// Output:
// start
// scheduled
// nextTick callback

在对象构造好但还没有任何 I / O 发生之前,想给用户机会来指定某些事件处理器。

function MyThing(options) {this.setupOptions(options);

  process.nextTick(() => {this.startDoingStuff();
  });
}

const thing = new MyThing();
thing.getReadyForStuff();

// thing.startDoingStuff() gets called now, not before.

每次事件轮询后,在额外的 I / O 执行前,next tick 队列都会优先执行。

// 使用场景
const maybeTrue = Math.random() > 0.5;

maybeSync(maybeTrue, () => {foo();
});

bar();

// 危险写法, 因为不清楚 foo() 或 bar() 哪个会被先调用
function maybeSync(arg, cb) {if (arg) {cb();
    return;
  }

  fs.stat('file', cb);
}

// 优化写法, 每次事件轮询后,在额外的 I / O 执行前,next tick 队列都会优先执行
function definitelyAsync(arg, cb) {if (arg) {process.nextTick(cb);
    return;
  }

  fs.stat('file', cb);
}

继续回到文章

setImmediate() vs setTimeout()

setImmediate() vs setTimeout()很相似, 但是行为方式的不同取决于他们调用时机.

  • setImmediate()被设计为在当前 poll 阶段完成之后执行脚本.
  • setTimeout()会在消耗一段时间阈值之后调度一段脚本去运行.

定时器被执行时候的顺序变化取决于它们被调用时候的上下文, 如果都是在主模块内部被调用会受到进程性能的约束(可能被本机其他应用运行影响);

例如, 如果我们不在 I / O 循环运行下面的脚本(也就是在主模块中), 两个定时器的执行顺序是不确定的, 因为它们受到进程性能的约束.

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

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

$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

可是如果你把两个代码放进 I / O 循环内部,immediate()回调函数总是先执行;

// 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

使用 setImmediate()而不是 setTimeout()的主要优势是 如果在 I / O 循环内部调用,setImmediate()总会在所有定时器之前执行, 与你定义多少个定时器无关.

理解 process.nextTick()

你可能已经注意到 process.nextTick()并没有出现在图表, 虽然它是异步 API 的一部分, 那是因为 process.nextTick()技术上不是事件循环部分. 相反,process.nextTick()会在当前操作完成之后被处理, 不管事件循环的当前阶段如何.

回顾我们的图表, 在给定阶段的任何时候你调用 process.nextTick(), 传递给 process.nextTick()的回调函数都会在事件循环继续之前被解决, 这会造成一些糟糕情况因为它允许你通过执行递归 process.nextTick()调用去 ’ 饿死 '(starve)你的 I /O, 从而阻止事件循环到达 poll 阶段.

为什么会被允许?

为什么一些像这样的内容会被包含在 Nodejs? 这部分是因为它是一种设计哲学,API 应该总是异步即使它并不需要, 看这段代码片段例子

function apiCall(arg, callback) {if (typeof arg !== 'string')
    return process.nextTick(callback, new TypeError('argument should be string'));
}

这片段会检查入参, 如果不正确会传递错误到回调函数, 最近更新的 API 允许传递入参到 process.nextTick(), 允许他在回调后取传递的任何参数作为回调的入参, 这样就不必嵌套函数了.

这句又长又绕口, 附上部分原文:
The API updated fairly recently to allow passing arguments to process.nextTick() allowing it to take any arguments passed after the callback to be propagated as the arguments to the callback so you don’t have to nest functions.

我们要做的是传递一个错误给开发者但仅仅是我们已经允许开发者其余的代码执行之后. 通过使用 process.nextTick()我们保证 apiCall()总会在开发者其余代码执行之后事件循环允许执行之前运行它的回调函数, 为了实现这一步,JS 调用堆栈允许展开立即执行所提供的回调函数, 允许开发者执行递归调用 process.nextTick()而不会达到引用错误: Maximum call stack size exceeded from v8.

这句又长又绕口, 附上原文:
What we’re doing is passing an error back to the user but only after we have allowed the rest of the user’s code to execute. By using process.nextTick() we guarantee that apiCall() always runs its callback after the rest of the user’s code and before the event loop is allowed to proceed. To achieve this, the JS call stack is allowed to unwind then immediately execute the provided callback which allows a person to make recursive calls to process.nextTick() without reaching a RangeError: Maximum call stack size exceeded from v8.

这种哲学会导致一些潜在的有问题的情况, 看看这段片段例子

let bar;

// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback) {callback(); }

// the callback is called before `someAsyncApiCall` completes.
someAsyncApiCall(() => {
  // since someAsyncApiCall has completed, bar hasn't been assigned any value
  console.log('bar', bar); // undefined
});

bar = 1;

开发者定义 someAsyncApiCall()有一个异步签名 (signature??), 实际上却是同步操作, 当它调用时候提供给 someAsyncApiCall() 的回调函数会在事件循环的相同阶段被调用因为 someAsyncApiCall()实际上并没有做任何异步事情, 结果回调函数试著去引用 bar 即使它可能还没在作用域里, 因为代码不可能运行完成.

但是如果把它放进 process.nextTick(), 代码依旧有能力跑完, 允许所有变量, 函数等等在回调函数被调用之前优先初始化完, 它具有不让事件循环继续的优点, 在允许事件循环继续之前,提醒用户注意错误可能是有用的。

这句又长又绕口, 附上原文:
The user defines someAsyncApiCall() to have an asynchronous signature, but it actually operates synchronously. When it is called, the callback provided to someAsyncApiCall() is called in the same phase of the event loop because someAsyncApiCall() doesn’t actually do anything asynchronously. As a result, the callback tries to reference bar even though it may not have that variable in scope yet, because the script has not been able to run to completion.

By placing the callback in a process.nextTick(), the script still has the ability to run to completion, allowing all the variables, functions, etc., to be initialized prior to the callback being called. It also has the advantage of not allowing the event loop to continue. It may be useful for the user to be alerted to an error before the event loop is allowed to continue. Here is the previous example using process.nextTick():

这是上面使用 process.nextTick()的例子

let bar;

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

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

bar = 1;

这是另一个现实世界的例子:

const server = net.createServer(() => {}).listen(8080);

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

(这句又长又绕口, 不想翻了:)
When only a port is passed, the port is bound immediately. So, the ‘listening’ callback could be called immediately. The problem is that the .on(‘listening’) callback will not have been set by that time.

想要避开这问题,’listening’ 事件会加入 nextTick()队列以容许脚本运行完, 这允许开发者设置任何他们想要的任何事件处理器.

process.nextTick() vs setImmediate()

就用户而言,我们有两个类似的调用, 不过他们的名字令人困惑.
process.nextTick() 在同一阶段立刻触发(原文 fires: 点燃;解雇;开除;使发光;烧制;激动;放枪???)
setImmediate() 在事件循环的后续迭代或“tick”中触发(原文 fires)

本质上, 名字应该调换,process.nextTick()比 setImmediate()更加容易触发, 但这是一种不可变得的过去的产物, 这种转换会在 npm 中破坏大量的包, 每天都有很多新包被添加, 意味着我们每等待一天就有更多潜在的破坏发生, 即使它们多困惑也不能更改它们的名字.

我们建议开发者们在任何情况使用 setImmediate()因为它容易推出(reason about??)(它会让代码兼容更广泛的环境变量, 像 browser JS)

为什么使用 process.nextTick()?(翻译文章最后内容)

两个原因:
1, 允许开发者们处理错误, 清除任何不需要的资源, 或者尝试在事件循环继续之前再次发起请求.
2, 在需要的时候允许调用栈释放 (unwound??) 之后但事件循环继续之前运行一个回调函数.

一个符合开发者们期望的简单例子

const server = net.createServer();
server.on('connection', (conn) => {});

server.listen(8080);
server.on('listening', () => {});

假设 listen()在事件循环开始的时候运行,但是监听回调被放置在 setImmediate()。除非传递主机名立即绑定端口,想让事件循环继续进行必须进入 poll 阶段,意味着有机会(a non-zero chance??)已经接收到一个连接,允许在监听事件之前触发连接事件。

(有段名词不懂怎么翻译:)
which means there is a non-zero chance that a connection could have been received allowing the connection event to be fired before the listening event

另一个例子是运行构造函数,从 EventEmitter 继承并且想要在构造函数内部调用一个事件。

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', () => {console.log('an event occurred!');
});

我们不能在构造函数立刻发出事件是因为脚本可能还没处理到开发者设置触发事件回调函数的位置,所以在构造函数内部本身你能使用 process.nextTick()设置触发事件回调函数以在构造函数已经完成之后提供期望结果。

const EventEmitter = require('events');
const util = require('util');

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

  // use nextTick to emit the event once a handler is assigned
  process.nextTick(() => {this.emit('event');
  });
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {console.log('an event occurred!');
});

输出例子

你们试试看这个输出顺序符不符合你们预期

const fs = require('fs');

console.log('start');
setTimeout(function timeout() {console.log('模块外部 timeout');
}, 1000);

setImmediate(function immediate() {console.log('模块外部 immediate');
});

process.nextTick(() => {console.log('模块外部 nextTick callback');
});

fs.readFile(__filename, () => {setTimeout(function timeout() {console.log('I/ O 内部 timeout');
  }, 0);

  setImmediate(function immediate() {console.log('I/ O 内部 immediate');
  });

  process.nextTick(() => {console.log('I/ O 内部 nextTick callback');
  });
});

console.log('end');
// start
// end
// 模块外部 nextTick callback
// 模块外部 immediate
// I/ O 内部 nextTick callback
// I/ O 内部 immediate
// I/ O 内部 timeout
// 模块外部 timeout

Nodejs 劣势

总的来说单线程的锅.
1, 异常抛出终止
我们都知道 Javascript 是一门单线程语言, 在发生各种错误之后,JavaScript 引擎通常会停止,并抛出一个错误.
Nodejs 具体错误直接看 Error (错误).
暂时还没研究到, 但是肯定可以通过一些方法解决的, 后补.

2, 不适合 CPU 密集型
尽管我们上面已经提出了事件驱动异步 IO 非阻塞模型的各种优点, 但是里面有个关键词叫 ”I/O”, 如果是非 I / O 的处理例如 CPU 计算还是没改进的, 如果有长时间运行的计算,将会导致 CPU 时间片不能释放,使得后续 I / O 无法发起.
可以通过把密集运算拆分成多个小任务, 减轻 CPU 压力.

3, 不能用到 CPU 的多核
现在的服务器操作系统基本都是支持多 CPU/ 核了, 单线程言语注定只能占用一个资源, 不能充分利用.

解决单线程痛点方案
可以新开进程去玩, 还没研究到不说.
process – 进程

参考资源

Node.js 中文网 API
The Node.js Event Loop, Timers, and process.nextTick()

正文完
 0