内容简介:虽然js是单线程的,但是事件循环会尽可能地将卸载操作(offloading operations)托付给系统内核,让node能够执行非阻塞的I/O操作由于大多数现代内核都是多线程的,因此它们可以处理在后台执行的多个操作。当其中任意一个任务完成后,内核都会通知Node.js,以保证将相对应的回调函数推入poll队列中最终执行。稍后我们将在本文中详细解释这一点。当Node.js服务启动时,它就会初始化事件循环。每当处理到脚本(或者是放置到REPL执行的代码,本文咱不提及)中异步的API, 定时器,或者调用
虽然js是单线程的,但是事件循环会尽可能地将卸载操作(offloading operations)托付给系统内核,让node能够执行非阻塞的I/O操作
由于大多数现代内核都是多线程的,因此它们可以处理在后台执行的多个操作。当其中任意一个任务完成后,内核都会通知Node.js,以保证将相对应的回调函数推入poll队列中最终执行。稍后我们将在本文中详细解释这一点。
事件循环的定义
当Node.js服务启动时,它就会初始化事件循环。每当处理到脚本(或者是放置到REPL执行的代码,本文咱不提及)中异步的API, 定时器,或者调用 process.nextTick()
都会触发事件循环,
下图简单描述了事件循环的执行顺序
┌───────────────────────────┐ ┌─>│ timers │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ pending callbacks │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ idle, prepare │ │ └─────────────┬─────────────┘ ┌───────────────┐ │ ┌─────────────┴─────────────┐ │ incoming: │ │ │ poll │<─────┤ connections, │ │ └─────────────┬─────────────┘ │ data, etc. │ │ ┌─────────────┴─────────────┐ └───────────────┘ │ │ check │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ close callbacks │ └───────────────────────────┘ 复制代码
注: 每个方框都是事件循环的一个阶段
每个阶段都有一个待执行回调函数的FIFO队列, 虽然每个阶段都不尽相同,总体上说,当事件循环到当前阶段时,它将执行特定于该阶段的操作,然后就会执行被压入当前队列中的回调函数, 直到队列被清空或者达到最大的调用上限。 当队列被清空或者达到最大的调用上限时,事件循环就会进入到下一阶段,如此反复。
因为任意阶段的操作都有可能调用更多的任务和触发新的事件,这些事件都最终会由内核推入poll阶段,poll事件可以在执行事件的时候插入队列。所以调用栈很深的回调允许poll阶段运行时间比定时器的阀值更久,详细部分请查看定时器和poll部分的内容。
注:Windows和Unix/Linux实现之间存在细微的差异,但这对于本文来说并不重要,最重要的部分在文中会一一指出。 实际上事件循环一共有七到八个步骤, 但是我们只需要关注Node.js中实际运用到的,也就是上文所诉的内容
阶段概览
-
timers: 这个阶段将会执行
setTimeout()
和setInterval()
的回调函数 - pending callbacks: 执行延迟到下一个循环迭代的I/O回调
- idle, prepare: 只会在内核中调用
-
poll: 检索新的I/O事件,执行I/O相关的回调(除了结束回调之外,几乎所有的回调都是由计时器和
setimmediation()
触发的); node将会在合适的时候阻塞在这里 -
check:
setImmediate()
的回调将会在这里触发 -
close callbacks: 一些关闭事件的回调, 比如
socket.on("close", ...)
在任意两个阶段之间,Node.js都会检查是否还有在等待中的异步I/O事件或者定时器,如果没有就会干净得关掉它。
阶段的细节
timers
定时器将会在一个特定的时间之后执行相应的回调,而不是在一个通过开发者设置预期的时间执行。定时器将会在超过设定时间后尽早地执行,然而操作系统的调度或者运行的其他回调将会将之滞后。
注: 从技术上讲,poll阶段会控制定时器什么时候执行
比如说,你设定了一个100ms过后执行的定时器,但是你的脚本在刚开始时异步读取文件耗费了95ms:
const fs = require('fs'); function someAsyncOperation(callback) { // Assume this takes 95ms to complete fs.readFile('/path/to/file', callback); } const timeoutScheduled = Date.now(); setTimeout(() => { const delay = Date.now() - timeoutScheduled; console.log(`${delay}ms have passed since I was scheduled`); }, 100); // do someAsyncOperation which takes 95 ms to complete someAsyncOperation(() => { const startCallback = Date.now(); // do something that will take 10ms... while (Date.now() - startCallback < 10) { // do nothing } }); 复制代码
当事件循环进入到poll阶段,它将会声明一个空的队列(fs.readFile()还暂时没有完成),所以它将会等待一段时间来尽早到达定时器的阀值。当等待了95ms过后, fs.readFile()
结束读取文件的任务并且再花费10ms的时间去完成被推入poll队列中的回调,当回调结束,此时在队列中没有其他回调,这个时候事件循环将会看到定时器的阀值已经过了,并且是可以尽快执行的时机,这个时候回到timers阶段去执行定时器的回调。这样来说,你将会看到定时器从开始调度到被执行间隔105ms。
注: 为了保证poll阶段不出现轮训饥饿,libuv(一个 c语言 库,由他来实现Node.js的事件循环和所有平台的异步操作)会提供一个触发最大值(取决于系统),在达到最大值过后会停止触发更多事件。
pending callbacks
这个阶段将会执行操作系统的一些回调如同TCP的错误捕获一样。比如如果一个TCP 套接字接收到了 ECONNREFUSED
在尝试建立链接的时候,一些*nix系统就会上报当前错误,这个上报的回调就会被推入pending callback的执行队列中去。
poll
poll阶段有两个主要的功能:
- 计算什么时候阻塞或者轮询更多的I/O
- 执行在poll队列中的回调
当事件循环进入到poll阶段并且没有定时器在被调度中的时候,下面两种情况中的一种会发生:
- 当poll队列不为空,事件循环将会遍历它的队列并且同步执行他们,直到队列被清空或者达到系统执行回调的上限
-
如果poll队列为空,将要发生的另外两件事之一:
-
如果系统调度过
setImmediate()
,那么事件循环将会结束poll阶段然后继续到check阶段去执行setImmediate()
的回调 -
如果系统没有调度过
setImmediate()
, 那么事件循环将等待回调被推入队列,然后立即执行它
-
如果系统调度过
一旦poll阶段队列为空事件循环将会检查是否到达定时器的阀值,如果有定时器准备好了,那么事件循环将会回到timers阶段去执行定时器的回调
check
这个阶段允许开发者在poll阶段执行完成后立即执行回调函数。 如果poll阶段变为空闲状态并且还有 setImmediate()
回调,那么事件循环将会直接来到check阶段而不是继续在poll阶段等待
setImmediate()
实际上是运行在事件循环各个分离阶段的特殊定时器,它直接使用libuv的API去安排回调在poll阶段完成后执行
通常上来说,在执行代码时,事件循环最终会进入轮询阶段,等待传入连接、请求等。但是,如果还有 setImmediate()
回调,并且轮询阶段变为空闲状态,则它将结束并继续到check阶段而不是等待poll事件。
close callbacks
如果一个socket连接突然关闭(比如socket.destroy()),‘close’事件将会被推入这个阶段的队列中,否则它将通过process.nextTick()触发。
setImmediate()和setTimeout()有什么不同
setImmediate
和 setTimeout
相似,但是他们在被调用的时机上是不同的。
setImmediate setTimeout
定时器执行的时机依赖于它们被调用时的上下文环境, 如果他们在主模块中同时被调用,那么他们的执行顺序会被程序(被运行在同一台机子上的应用所影响)的性能所约束
举个例子,如果我们在非I/O循环(比如说主模块)中运行以下脚本,它们的执行顺序就是不确定的,也就是说会被程序的性能所约束。
// 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循环中去, 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()
的主要优点是 setImmediate()
将始终在任何定时器之前执行(如果在I / O周期内调度),与存在多少定时器无关。
process.nextTick()
什么是 process.nextTick()
你可能注意到了 process.nextTick()
不在上面展示的图示里,甚至它不是一个异步调用API,从技术上说, process.nextTick()
并不属于事件循环。 相反的, nextTickQueue
会在当前的操作执行完成后运行,而不必在乎是在某一个特定的阶段
回到我的图示,每次你在一个阶段中调用 process.nextTick()
的时候,所有的回调都会在事件循环进入到下一个阶段的时候被处理完毕。但是这会造成一个非常坏的情况,那就是饥饿轮训,即递归调用你的 process.nextTick()
,这样就会阻止事件循环进入到poll阶段
为什么这种情况会被允许
为什么这样的事情会包含在 Node.js 中?设计它的初衷是这个API 应该始终是异步的,即使它不必是。以此代码段为例:
function apiCall(arg, callback) { if (typeof arg !== 'string') return process.nextTick(callback, new TypeError('argument should be string')); } 复制代码
上诉代码段进行参数检查。如果不正确,则会将错误传递给回调函数。最近对 API 进行了更新,允许将参数传递给 process.nextTick(),允许它在回调后传递任何参数作为回调的参数传播,这样您就不必嵌套函数了。
上述函数做的是将错误传递给用户,而且是在用户其他代码执行完毕过后。通过使用process.nextTick(),apiCall() 可以始终在用户代码的其余部分之后 运行其回调函数,并在允许事件循环之前继续进行。为了实现这一点,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() 函数,但实际上它是同步运行的。当调用它时,提供给 someAsyncApiCall() 的回调在同一阶段调用事件循环,因为 someAsyncApiCall() 实际上并没有异步执行任何事情。因此,回调尝试引用 bar,即使它在范围内可能还没有该变量,因为脚本无法按照预料中完成。
将回调用 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', () => {}); 复制代码
只有端口空闲时,端口才会立即被绑定,可以调用 'listening' 回调。问题是 .on('listening')
回调将不会在那个时候执行。
为了解决这个问题,'listening' 事件在 nextTick() 中排队,以允许脚本运行到完成阶段。这允许用户设置所需的任何事件处理程序。
process.nextTick()
对比 setImmediate()
就用户而言我们有两个类似的调用,但它们的名称令人费解。
- process.nextTick() 在同一个阶段立即执行。
- setImmediate() 在接下来的迭代中或是事件循环上的"tick" 上触发。
实质上,应该交换名称。process.nextTick() 比 setImmediate() 触发得更直接,但这是过去遗留的,所以不太可能改变。进行此操作将会破坏 npm 上的大部分软件包。每天都有新的模块在不断增长,如果这样做了,这意味着我们每天都会有的潜在破损在增长。 虽然他们很迷惑,但名字本身不会改变。
我们建议开发人员在所有情况下都使用 setImmediate()
,因为它更让人理解(并且它导致代码与更广泛的环境,如浏览器 JS 所兼容。)
为什么使用 process.nextTick()
主要有两个原因:
-
允许用户处理错误,清理任何不需要的资源,或者在事件循环继续之前重试请求。
-
有时在调用堆栈已解除但在事件循环继续之前,必须允许回调运行。
下面就是一个符合用户预期的例子:
const server = net.createServer(); server.on('connection', (conn) => { }); server.listen(8080); server.on('listening', () => { }); 复制代码
假设 listen() 在事件循环开始时运行,但回调被放置在 setImmediate()中。除非通过主机名,否则将立即绑定到端口。事件循环进行时,会命中轮询阶段,这意味着可能会收到连接请求,从而允许在回调事件之前激发连接事件。
另一个示例运行的函数继承于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!'); }); 复制代码
这里并不能立即从构造函数中触发 event
事件。因为在此之前用户并没有给 event
事件添加回调。但是,在构造函数本身中可以使用 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!'); }); 复制代码
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- NodeJS定时器与事件循环
- Node.js 事件循环、定时器和process.nextTick()
- 各种定时器--最全的定时器使用
- java定时器无法自动注入的问题解析(原来Spring定时器可以这样注入service)
- Golang定时器陷阱
- jmeter(七)定时器
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。