译者注:

  1. 为什么要翻译?其实在翻译这篇文章前,笔者有Google了一下中文翻译,看的不是很明白,所以才有自己翻译的打算,当然能力有限,文中或有错漏,欢迎指正。
  2. 文末会有几个小问题,大家不妨一起思考一下
  3. 欢迎关注微信公众号:前端情报局-NodeJs系列

什么是Event loop

尽管JavaScript是单线程的,通过Event Loop使得NodeJs能够尽可能的通过卸载I/O操作到系统内核,来实现非阻塞I/O的功能。

由于大部分现代系统内核都是多线程的,因此他们可以在后台执行多个操作。当这些操作中的某一个完成后,内核便会通知NodeJs,这样(这个操作)指定的回调就会添加到poll队列以便最终执行。关于这个我们会在随后的章节中进一步说明。

Event Loop解析

当NodeJs启动时,event loop 随即会被初始化,而后会执行对应的输入脚本(直接把脚本放入REPL执行不在本文讨论范围内),这个过程中(脚本的执行)可能会存在对异步API的调用,产生定时器或者调用process.nextTick(),接着开始event loop。

译者注:这段话的意思是NodeJs优先执行同步代码,在同步代码的执行过程中可能会调用到异步API,当同步代码和process.nextTick()回调执行完成后,就会开始event loop

下图简要的概述了event loop的操作顺序:


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

注:每一个框代表event loop中的一个阶段

每个阶段都有一个FIFO(先进先出)的回调队列等待执行。虽然每个阶段都有其独特之处,但总体而言,当event loop进入到指定阶段后,它会执行该阶段的任何操作,并执行对应的回调直到队列中没有可执行回调或者达到回调执行上限,而后event loop会进入下一阶段。

由于任何这些阶段的操作可能产生更多操作,内核也会将新的事件推入到poll阶段的队列中,所以新的poll事件被允许在处理poll事件时继续加入队,这也意味着长时间运行的回调可以允许poll阶段运行的时间比计时器的阈值要长

注意:Windows和Unix/Linux在实现上有些差别,但这对本文并不重要。事实上存在7到8个步骤,但以上列举的是Node.js中实际使用的。

阶段概览

  • timers:执行的是setTimeout()setInterval()的回调
  • I/O callbacks:执行除了 close callbacks、定时器回调和setImmediate()设定的回调之外的几乎所有回调
  • idle, prepare:仅内部使用
  • poll:接收新的I/O事件,适当时node会阻塞在这里(==什么情况下是适当的?==)
  • checksetImmediate回调在这里触发
  • close callbacks:比如socket.on('close', ...)

在每次执行完event loop后,Node.js都会检查是否还有需要等待的I/O或者定时器没有处理,如果没有那么进程退出。

阶段细节

timers

一个定时器会指定阀值,并在达到阀值之后执行给定的回调,但通常来说这个阀值会超过我们预期的时间。定时器回调会尽可能早的执行,不过操作系统的调度和其他回调的执行时间会造成一定的延时。

注:严格意义上说,定时器什么时候执行取决于poll阶段

举个例子,假定一个定时器给定的阀值是100ms,异步读取文件需要95ms的时间


const fs = require('fs'); function someAsyncOperation(callback) {
// 假定这里花费了95ms
fs.readFile('/path/to/file', callback);
} const timeoutScheduled = Date.now(); setTimeout(function() { const delay = Date.now() - timeoutScheduled; console.log(delay + 'ms have passed since I was scheduled');
}, 100); // 95ms后异步操作才完成
someAsyncOperation(function() { const startCallback = Date.now(); // 这里花费了10ms
while (Date.now() - startCallback < 10) {
// do nothing
}
});

就本例而言,当event loop到达poll阶段,它的队列是空的(fs.readFile()还未完成),因此它会停留在这里直到达到最早的定时器阀值。fs.readFile()
花费了95ms读取文件,之后它的回调被推入poll队列并执行(执行花了10ms)。回调执行完毕后,队列中已经没有其他回调需要执行了,那么event loop就会去检查是否有定时器的回调可以执行,如果有就跳回到timer阶段执行相应回调。在本例中,你可以看到从定时器被调用到其回调被执行一共耗时105ms。

注:为了防止event loop一直阻塞在poll阶段,libuv(http://libuv.org/ 这是用c语言实现了Node.js event loop以及各个平台的异步行为的库)会指定一个硬性的最大值以阻止更多的事件被推入poll。

I/O callbacks阶段

这个阶段用于执行一些系统操作的回调,比如TCP错误。举个例子,当一个TCP socket 在尝试连接时接收到ECONNREFUSED的错误,一些*nix系统会想要得到这些错误的报告,而这都会被推到 I/O callbacks中执行。

poll阶段

poll阶段有两个功能:

  1. 执行已经达到阀值的定时器脚本
  2. 处理在poll队列中的事件

当event loop进入到poll阶段且此代码中为设定定时器,将会发生下面情况:

  1. 如果poll队列非空,event loop会遍历执行队列中的回调函数直到队列为空或达到系统上限
  2. 如果poll队列是空的,将会发生下面情况:

    • 如果脚本中存在对setImmediate()的调用,event loop将会结束poll阶段进入check阶段并执行这些已被调度的代码
    • 如果脚本中不存在对setImmediate()的调用,那么event loop将阻塞在这里直到有回调被添加进来,新加的回调将会被立即执行

一旦poll队列为空,event loop就会检查是否有定时器达到阀值,如果有1个或多个定时器符合要求,event loop将将会回到timers阶段并执行改阶段的回调.

check阶段

一旦poll阶段完成,本阶段的回调将被立即执行。如果poll阶段处于空闲状态并且脚本中有执行了setImmediate(),那么event loop会跳过poll阶段的等待进入本阶段。

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

通常而言,随着代码的执行,event loop最终会进入poll阶段并在这里等待新事件的到来(例如新的连接和请求等等)。但是,如果存在setImmediate()的回调并且poll阶段是空闲的,那么event loop就会停止在poll阶段漫无目的的等等直接进入check阶段。

close callbacks阶段

如果一个socket或者handle突然关闭(比如:socket.destory()),close事件就会被提交到这个阶段。否则它将会通过process.nextTick()触发

setImmediate() 和 setTimeout()

setImmediatesetTimeout()看起来是比较相似,但它们有不同的行为,这取决于它们什么时候被调用。

  • 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循环中,setImmediate回调会被优先执行:


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

你可能已经注意到process.nextTick()不在上面的图表中,即使它也是异步api。这是因为严格意义上来说process.nextTick()不属于event loop中的一部分,它会忽略event loop当前正在执行的阶段,而直接处理nextTickQueue中的内容。

回过头看一下图表,你在任何给定阶段调用process.nextTick(),在继续event loop之前,所有传入process.nextTick()的回调都会被执行。这可能会导致一些不好的情况,因为它允许你递归调用process.nextTick()从而使得event loop无法进入poll阶段,导致无法接收到新的 I/O事件

为什么这会被允许?

那为什么像这样的东西会被囊括在Node.js?部分由于Node.js的设计理念:API应该始终是异步的即使有些地方是没必要的。举个例子:


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

这是一段用于参数校验的代码,如果参数不正确就会把错误信息传递到回调。最近process.nextTick()有进行一些更新,使得我们可以传递多个参数到回调中而不用嵌套多个函数。

我们(在这个例子)所做的是在保证了其余(同步)代码的执行完成后把错误传递给用户。通过使用process.nextTick()我们可以确保apiCall()的回调总是在其他(同步)代码运行完成后event loop开始前调用的。为了实现这一点,JS调用栈被展开(==什么是栈展开?==)然后立即执行提供的回调,那我们就可以对process.nextTick进行递归(==怎么做到的?==)调用而不会触发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()(函数名可以看出),但实际上操作是同步的。当它被调用时,其回调也在event loop中的同一阶段被调用了,因为someAsyncApiCall()实际上并没有任何异步动作。结果,在(同步)代码还没有全部执行的时候,回调就尝试去访问变量bar

通过把回调置于process.nextTick(),脚本就能完整运行(同步代码全部执行完毕),这就使得变量、函数等可以先于回调执行。同时它也有阻止event loop继续执行的好处。有时候我们可能希望在event loop继续执行前抛出一个错误,这种情况下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', () => {});

当只有一个端口作为参数传入,端口会被立即绑定。所以监听回调可能被立即调用。问题是:on('listening') 回调在那时还没被注册。

为了解决这个问题,把listening事件加入到nextTick() 队列中以允许脚本先执行完(同步代码)。这允许用户(在同步代码中)设置任何他们需要的事件处理函数。

process.nextTick() 和 setImmediate()

对于用户而言,这两种叫法是很相似的但它们的名字又让人琢磨不透。

  • process.nextTick() 会在同一个阶段执行
  • setImmediate() 会在随后的迭代中执行

本质上,这两个的名字应该互换一下,process.nextTick()setImmediate()更接近于立即,但是由于历史原因这不太可能去改变。名字互换可能影响大部分的npm包,每天都有大量的包在提交,这意味这越到后面,互换造成的破坏越大。所以即使它们的名字让人困惑也不可能被改变。

我们建议开发者在所有情况中使用setImmediate(),因为这可以让你的代码兼容更多的环境比如浏览器。

为什么要使用process.nextTick()?

这里又两个主要的原因:

  1. 让开发者处理错误、清除无用的资源或者在event loop继续之前再次尝试重新请求资源
  2. 有时需要允许回调在调用栈展开之后但在事件循环继续之前运行

下面这个例子会满足我们的期望:


const server = net.createServer();
server.on('connection', function(conn) { }); server.listen(8080);
server.on('listening', function() { });

假设listen()是在event loop开始前运行,但是监听回调是包裹在setImmediate中,除非指定hostname参数否则端口将被立即绑定(listening回调被触发),event loop必须要执行到poll阶段才会去处理,这意味着存在一种可能:在listening事件的回调执行前就收到了一个连接,也就是相当于先于listening 触发了connection事件。

另一个例子是运行一个继承至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', function() {
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(function() {
this.emit('event');
}.bind(this));
}
util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter();
myEmitter.on('event', function() {
console.log('an event occurred!');
});

译者注(Q&A)

翻译完本文,笔者给自己提了几个问题?

  1. poll阶段什么时候会被阻塞?
  2. 为什么在非I/O循环中,setTimeoutsetImmediate的执行顺序是不一定的?
  3. JS调用栈展开是什么意思?
  4. 为什么process.nextTick()可以被递归调用?

笔者将在之后的文章[《Q&A之理解NodeJs中的Event Loop、Timers以及process.nextTick()》]()探讨这些问题,有兴趣的同学可以关注笔者的公众号: 前端情报局-NodeJs系列获取最新情报

原文地址: https://github.com/nodejs/nod...

来源:https://segmentfault.com/a/1190000017920493

[NodeJs系列][译]理解NodeJs中的Event Loop、Timers以及process.nextTick()的更多相关文章

  1. The Node.js Event Loop, Timers, and process.nextTick() Node.js事件循环,定时器和process.nextTick()

    个人翻译 原文:https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/ The Node.js Event Loop, Ti ...

  2. The Node.js Event Loop, Timers, and process.nextTick()

    The Node.js Event Loop, Timers, and process.nextTick() | Node.js https://nodejs.org/uk/docs/guides/e ...

  3. Node.js Event Loop 的理解 Timers,process.nextTick()

    写这篇文章的目的是将自己对该文章的理解做一个记录,官方文档链接The Node.js Event Loop, Timers, and process.nextTick() 文章内容可能有错误理解的地方 ...

  4. 不要在nodejs中阻塞event loop

    目录 简介 event loop和worker pool event loop和worker pool中的queue 阻塞event loop event loop的时间复杂度 Event Loop中 ...

  5. node.js中对Event Loop事件循环的理解

    javascript是单线程的,所以任务的执行都需要排队,任务分为两种,一种是同步任务,一种是异步任务. 同步任务是进入主线程上排队执行的任务,上一个任务执行完了,下一个任务才会执行. 异步任务是不进 ...

  6. 详解JavaScript中的Event Loop(事件循环)机制

    前言 我们都知道,javascript从诞生之日起就是一门单线程的非阻塞的脚本语言.这是由其最初的用途来决定的:与浏览器交互. 单线程意味着,javascript代码在执行的任何时候,都只有一个主线程 ...

  7. 深入理解Javascript单线程谈Event Loop

    假如面试回答js的运行机制时,你可能说出这么一段话:"Javascript的事件分同步任务和异步任务,遇到同步任务就放在执行栈中执行,而碰到异步任务就放到任务队列之中,等到执行栈执行完毕之后 ...

  8. 为什么JS是单线程?JS中的Event Loop(事件循环)?JS如何实现异步?setimeout?

    https://segmentfault.com/a/1190000012806637 https://www.jianshu.com/p/93d756db8c81 首先,请牢记2点: (1) JS是 ...

  9. 浏览器和Node 中的Event Loop

    前言 js与生俱来的就是单线程无阻塞的脚本语言. 作为单线程语言,js代码执行时都只有一个主线程执行任务. 无阻塞的实现依赖于我们要谈的事件循环.eventloop的规范是真的苦涩难懂,仅仅要理解的话 ...

随机推荐

  1. ajax异步请求/同源策略/跨域传值

    基本概念 Ajax 全称是异步的 JavaScript 和 XML . 通过在后台与服务器进行少量数据交换,AJAX 可以使网页实现异步更新.这意味着可以在不重新加载整个网页的情况下,对网页的某部分进 ...

  2. Linux服务器性能评估与优化(一)

    网络内容总结(感谢原创) 1.前言简介 一.影响Linux服务器性能的因素   1. 操作系统级         性能调优是找出系统瓶颈并消除这些瓶颈的过程. 很多系统管理员认为性能调优仅仅是调整一下 ...

  3. Android 从 Web 唤起 APP

    前言 知乎在手机浏览器打开,会有个 App 内打开的按钮,点击直接打开且跳转到该详情页,是不是有点神奇,是如何做到的呢? 效果预览 Uri Scheme 配置 intent-filter Androi ...

  4. Linux文件压缩和解压缩命令

    Linux文件压缩和解压缩命令: tar 命令(打包并压缩的话,原文件也会默认存在) -c 建立打包档案 -x 解包 -t 查看包里的类容 -r 向包里追加文件 -v 显示打包过程 -f 文件 比如: ...

  5. 深入理解linux源码安装三板斧

    概述: 根据源码包中 Makefile.in 文件的指示,configure 脚本检查当前的系统环境和配置选项,在当前目录中生成 Makefile 文件(还有其它本文无需关心的文件),然后 make ...

  6. webpack前言:前端模块系统的演进

    前端开发和其他开发工作的主要区别,首先是前端是基于多语言.多层次的编码和组织工作,其次前端产品的交付是基于浏览器,这些资源是通过增量加载的方式运行到浏览器端,如何在开发环境组织好这些碎片化的代码和资源 ...

  7. BZOJ4538:[HNOI2016]网络(树链剖分,堆)

    Description 一个简单的网络系统可以被描述成一棵无根树.每个节点为一个服务器.连接服务器与服务器的数据线则看做 一条树边.两个服务器进行数据的交互时,数据会经过连接这两个服务器的路径上的所有 ...

  8. HDU 1757 A Simple Math Problem 【矩阵经典7 构造矩阵递推式】

    任意门:http://acm.hdu.edu.cn/showproblem.php?pid=1757 A Simple Math Problem Time Limit: 3000/1000 MS (J ...

  9. nginx 图片,js,css等文件允许跨域

    location ~ .*\.(gif|jpg|jpeg|png|bmp|swf|flv|mp4|ico)$ { #允许跨域请求 add_header Access-Control-Allow-Ori ...

  10. P1666 前缀单词

    P1666 前缀单词 tire树上跑dp 首先将trie树建出来,然后对于每个节点.考虑他的子节点. 子节点的方案数都互不干扰,所以子节点与其他子节点的的方案数可以利用乘法原理算出来. 然后如果这个节 ...