资讯专栏INFORMATION COLUMN

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

newsning / 1222人阅读

摘要:如果一个即时定时器是被一个正在执行的回调排入队列的,则该定时器直到下一次事件循环迭代才会被触发。参数描述在事件循环的当前回合结束时要调用的函数。事件轮询随后的调用,会在任何事件包括定时器之前运行。

系列文章

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

前言

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

写下来之后可能还是有点懞,以后慢慢补充,也欢迎指正,特别是那篇翻译文章后面已经看不懂了.有人出手科普一下就好了.因为懒得动手做,整篇文章的图片要么来源官网,要么来源百度图片.
补充: 当前Nodejs版本10.3.0
2019/8/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()

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/106704.html

相关文章

  • Nodejs性能原理(上) --- 异步非阻塞事件驱动模型

    摘要:使用了一个事件驱动非阻塞式的模型,使其轻量又高效。的包管理器,是全球最大的开源库生态系统。按照这个定义,之前所述的阻塞,非阻塞,多路复用信号驱动都属于同步。 系列文章 Nodejs高性能原理(上) --- 异步非阻塞事件驱动模型Nodejs高性能原理(下) --- 事件循环详解 前言 终于开始我nodejs的博客生涯了,先从基本的原理讲起.以前写过一篇浏览器执行机制的文章,和nodej...

    yy736044583 评论0 收藏0
  • 优秀文章收藏(慢慢消化)持续更新~

    摘要:整理收藏一些优秀的文章及大佬博客留着慢慢学习原文协作规范中文技术文档协作规范阮一峰编程风格凹凸实验室前端代码规范风格指南这一次,彻底弄懂执行机制一次弄懂彻底解决此类面试问题浏览器与的事件循环有何区别笔试题事件循环机制异步编程理解的异步 better-learning 整理收藏一些优秀的文章及大佬博客留着慢慢学习 原文:https://www.ahwgs.cn/youxiuwenzhan...

    JeOam 评论0 收藏0
  • JS代码在nodejs环境执行机制和事件循环

    摘要:开始执行文件,同步代码执行完毕后,进入事件循环。时间未到的时候,如果有事件返回,就执行该事件注册的回调函数。对于多次执行输出结果不同,需要了解事件循环的基础问题。 1. 说明 nodejs是单线程执行的,同时它又是基于事件驱动的非阻塞IO编程模型。这就使得我们不用等待异步操作结果返回,就可以继续往下执行代码。当异步事件触发之后,就会通知主线程,主线程执行相应事件的回调。 本篇文章讲解n...

    Lowky 评论0 收藏0
  • JavaScript单线程事件循环(Event Loop)那些事

    摘要:概述本篇主要介绍的运行机制单线程事件循环结论先在中利用运行至完成和非阻塞完成单线程下异步任务的处理就是先处理主模块主线程上的同步任务再处理异步任务异步任务使用事件循环机制完成调度涉及的内容有单线程事件循环同步执行异步执行定时器的事件循环开始 1.概述 本篇主要介绍JavaScript的运行机制:单线程事件循环(Event Loop). 结论先: 在JavaScript中, 利用运行至...

    Shisui 评论0 收藏0
  • JS与Node.js中的事件循环

    摘要:的单线程,与它的用途有关。特点的显著特点异步机制事件驱动。队列的读取轮询线程,事件的消费者,的主角。它将不同的任务分配给不同的线程,形成一个事件循环,以异步的方式将任务的执行结果返回给引擎。 这两天跟同事同事讨论遇到的一个问题,js中的event loop,引出了chrome与node中运行具有setTimeout和Promise的程序时候执行结果不一样的问题,从而引出了Nodejs的...

    abson 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<