Nodejs事件循环

52次阅读

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

说到 Node.js 的事件循环网上已经有了很多形形色色的文章来讲述其中的原理,说的大概都是一个意思,学习了一段时间,对 Node.js 事件循环有了一定的了解之后写一篇博客总结一下自己的学习成果。

事件循环

在笔者看来事件与循环本身就是两个概念,事件是可以被控件识别的操作,如按下确定按钮,选择某个单选按钮或者复选框。每一种控件有自己可以识别的事件,如窗体的加载、单击、双击等事件,编辑框(文本框)的文本改变事件。

然而循环则是在 GUI 线程中包含有一个循环,然而这个循环对于开发者和用户来讲是看不见的,只有关闭了程序之后该循环才会结束。当用户触发了一个按钮事件之后,就会产生响应的事件,这些时间被加入到一个队列中,用户在前台不断的产生事件,然而后台也在不断的处理这些时间,在处理的时候被加入到一个队列中,由于主循环中循环的存在会挨个处理这些对应的事件。

而对于 JavaScript 来讲的话由于 JavaScript 是单线程的,对于一个比较耗时的操作则是使用异步的方法解决(Ajax…)。对于不同的异步事件来也是由不同的线程各司其职来处理的。

Node.js 中的事件循环

Node.js的事件循环与浏览器的事件循环还是有很大的区别的,当 Node.js 启动后,它会初始化事件轮询;处理已提供的输入脚本(或丢入 REPL,本文不涉及到),它可能会调用一些异步的API 函数调用,安排任务处理事件,或者调用process.nextTick(),然后开始处理事件循环。

有一点是非常明确的,事件循环同样运行在单线程环境下,JavaScript的事件循环是依靠于浏览器来实现的,然而 Node.js 则是依赖于 Libuv 来实现的。

根据 Node.js 官方介绍,每次事件循环都包含了 6 个阶段,对应到 Libuv 源码中的实现,如下图所示,图中显示了事件循环的概述以及执行顺序。

  1. timersj 阶段:这个阶段执行 timer(setTimeout、setInterval)的回调
  2. I/O callbacks:执行一些系统调用错误,比如网络通信的错误回调
  3. idle,prepare:仅 node 内部使用
  4. poll:获取新的 I / O 事件, 适当的条件下 node 将阻塞在这里
  5. check:执行 setImmediate() 的回调
  6. close callbacks:执行 socket 的 close 事件回调

下面是 Node.js 事件循环源代码:

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;
  int r;
  int ran_pending;
  r = uv__loop_alive(loop);
  if (!r)
    uv__update_time(loop);
  while (r != 0 && loop->stop_flag == 0) {uv__update_time(loop);
    // timers 阶段
    uv__run_timers(loop);
    // I/O callbacks 阶段
    ran_pending = uv__run_pending(loop);
    // idle 阶段
    uv__run_idle(loop);
    // prepare 阶段
    uv__run_prepare(loop);
    timeout = 0;
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop);
    // poll 阶段
    uv__io_poll(loop, timeout);
    // check 阶段
    uv__run_check(loop);
    // close callbacks 阶段
    uv__run_closing_handles(loop);
    if (mode == UV_RUN_ONCE) {uv__update_time(loop);
      uv__run_timers(loop);
    }
    r = uv__loop_alive(loop);
    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
      break;
  }
  if (loop->stop_flag != 0)
    loop->stop_flag = 0;
  return r;
}

假设事件循环进入到某一个阶段,及时在这期间其他队列中的事件已经准备就绪,也会先将当前阶段对应队列中所有的回调方法执行完毕之后才会继续向下执行,结合代码也是能够很好的理解的。不难可以得出在事件循环系统中回调的执行顺序是有迹可循的,同样也会造成事件阻塞。

var fs = require("fs");
fs.readFile('input.txt', function (err, data) {if (err){console.log(err.stack);
      return;
   }
   console.log(data.toString());
});
fs.readFile('test.txt', function (err, data) {if (err){console.log(err.stack);
      return;
   }
   console.log(data.toString());
});
console.log("程序执行完毕");

对于整个事件循环有个一个大概的认知之后,接下来针对每个阶段进行详细的说明。

timers

该阶段主要用来处理定时器相关的回调方法,当一个定时器超市后一个事件就会加入到该阶段的队列中,事件循环会跳转至这个阶段执行对应的回调方法。

定时器的回调会在触发后尽可能早的被调用,为什么要说尽可能早的呢?因为实际的触发事件可能要比预先设置的时间要长。Node.js并不能保证 timer 在预设时间到了就会立即执行,因为 Node.jstimer的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。

I/O callbacks

在这个阶段中除了 timers、setImmediate,以及close 操作之外的大多数的回调方法都位于这个阶段执行。例一个 TCP socket 执行出现了一些错误,那么这个回调函数会在 I/O callbacks 阶段来执行。名字会让人误解为执行 I/O 回调处理程序,然而一些常见的回调则会再 poll 阶段进行处理。

I/O callbacks阶段主要经过如下过程:

  1. 检查是否有 pending 的 I / O 回调。如果有,执行回调。如果没有,退出该阶段。
  2. 检查是否有 process.nextTick 任务,如果有,全部执行。
  3. 检查是否有 microtask,如果有,全部执行。
  4. 退出该阶段。

poll

对于 Poll 阶段其主要的功能主要有两点:

  1. 处理 poll 队列的事件
  2. 当有已超时的 timer,执行它的回调函数

当事件循环到达 poll 阶段时,如果这时没有要处理的定时器的回调方法,则会进行如下判断:

  1. 如果 poll 队列不为空,则事件循环会按照顺序便利执行队列中的回调方法,这个过程是同步的。
  2. 如果 poll 队列为空则会再次进行判断

    • 若有预设的 setImmediate(),事件循环将结束poll 阶段进入 check 阶段,并执行 check 阶段的任务队列
    • 若没有预设的 setImmediate(),那么事件循环可能会进入等待状态,并等待新事件的产生,这也是该阶段为什么被命名为poll 的原因。出了这些意外,该阶段还会不断的检查是否有相关的定时器超市,如果有就会跳转到 timers 阶段,然后执行对应的回调方法

check

该阶段执行 setImmediate() 的回调函数。关于 setImmediate 是一个比较特殊的定时器方法,setImmediate的回调则会加入到 check 队列中,从事件循环的阶段图可以知道,check阶段的执行顺序是在 poll 之后的。

一般情况下,事件循环到达 poll 阶段后,就会检查当前代码是否调用了 setImmediate 方法,这个在叙述 poll 阶段的时候已经有提及了,如果一个回调函数是被 setImmediate 方法调用的,事件循环则会跳出 poll 阶段从而进入到 check 阶段。(这一段有点重复 …)

close

close阶段是用来管理关闭事件,用于清理应用程序的状态。如程序中的 socket 关闭等都会加入到 close 队列中,当本轮事件结束后则会进入下一轮循环。

小结

对于事件循环来说每个阶段都有一个任务队列,当事件循环到达某个阶段的时候,讲执行该阶段的任务队列,知道队列清空或执行的对调到达系统上限后,才会转入到下一个阶段。当所有的阶段被执行一次后,事件循环则就完成了一个tick

process.nextTick

这是 Node.js 特有的方法,它不存在于任何浏览器(以及进程对象)中,process.nextTick是一个异步的动作,并且让这个动作在事件循环中当前阶段执行完之后立即执行,也就是上面所说的tick

process.nextTick(() => {console.log("1")   
})
console.log("2")
//  2
//  1

官方对于 process.nextTick 有一段很有意思的解释:从语义角度看,setImmediate(稍后会说到)应该比 process.nextTick 先执行才对,而事实相反,命名是历史原因也很难再变。

然而对于 process.nextTick 来说该方法并不是事件循环中的一部分,但是它的回调方法确是由事件循环调用的,该方法定义的回调方法会被加入到 nextTickQueue 的队列中。相反地,nextTickQueue将会在当前操作完成之后立即被处理,而不管当前处于事件循环的哪个阶段。

Node.jsprocess.nextTick 进行了限制,若递归调用 process.nextTick 当倒带 nextTickQueue 最大限制之后则会抛出一个错误。

function nextTick (i){while(i<9999){process.nextTick(nextTick(i++));
    }
}

//  Maxmum call stack size exceeded
nextTick(0);

既然说 process.nextTick 也是存在于队列中,那么其执行顺序也是根据程序所编写顺序执行的。

process.nextTick(() => {console.log(1)
});
process.nextTick(() => {console.log(2)
});

//  1
//  2

和其它回调函数一样,process.nextTick定义的回调也是由事件循环执行的,如果 process.nextTick 的回调方法中出现了阻塞操作,后面的要执行的回调函数同样会被阻塞。process.nextTick会在各个事件阶段之间执行,一旦执行,要直到 nextTickQueue 被清空,才会进入到下一个事件阶段,所以如果递归调用 process.nextTick,会导致出现I/O starving 的问题,比如下面例子的 readFile 已经完成,但它的回调一直无法执行。

const fs = require('fs')
const starttime = Date.now()
let endtime;
fs.readFile('text.txt', () => {endtime = Date.now()
  console.log('finish reading time:', endtime - starttime)
})
let index = 0
function handler () {if (index++ >= 1000) return
  console.log(`nextTick ${index}`)
  process.nextTick(handler)
}
handler();

//  nextTick 1
//  nextTick 2
//  ......
//  nextTick 999
//  nextTick 1000
//  finish reading time: 170

process.nextTick() vs setImmediate()

seImmediate方法不属于 ECMAScript 标准,而是 Node.js 提出的新方法,它同样将一个回调函数加入到事件队列中,不同于 setTimeoutsetIntervalsetImmediate并不接受一个时间作为参数,setImmediate的事件会在当前事件循环的结尾触发,对应的回调方法会在当前事件循环的末尾(check)执行。虽然它确实存在于某些浏览器中,但并未在所有浏览器中达到一致的行为,因此在浏览器中使用时,您需要非常小心。它类似于 setTimeout(fn,0) 代码,但有时会优先于它。这里的命名也不是最好的。

  1. process.nextTick中的回调在事件循环的当前阶段中被立即执行。
  2. setImmediate中的回调在事件循环的下一次迭代或 tick 中被执行

本质上,它们两个的名字应该互相调换一下。process.nextTick()的执行时机比 setImmediate() 要更及时 (上面有提过)。实施这项改变将导致很多npm 包无法使用。每天都有很多新模块被加入,这意味着每等待一天,就会有更多潜在的破坏发生。虽然他们的名字相互混淆,但将它们调换名字这种事是不会发生的(建议开发者在所有地方使用setImmediate,这样程序更容易让人理解)。

仍然使用上述例子,若把 nextTick 替换成 setImmediate 会怎样呢?

const fs = require('fs')
const starttime = Date.now()
let endtime;
fs.readFile('text.txt', () => {endtime = Date.now()
  console.log('finish reading time:', endtime - starttime)
})
let index = 0
function handler () {if (index++ >= 1000) return
  console.log(`setImmediate ${index}`)
  setImmediate(handler)
}
handler();

// setImmediate 1
// setImmediate 2
// finish reading time: 80
// ......
// setImmediate 999
// setImmediate 1000

这是因为嵌套调用的 setImmediate() 回调,被排到了下一次事件循环才执行,所以不会出现阻塞。

setImmediate vs setTimeout

定时器在 Node.js 和浏览器中的表现形式是相同的。关于定时器的一个重要的事情是,我们提供的延迟不代表在这个时间之后回调就会被执行。它的真正含义是,一旦主线程完成所有操作(包括微任务)并且没有其它具有更高优先级的定时器,Node.js将在此时间之后执行回调。

  1. setImmediate()被设计在 poll 阶段结束后立即执行回调
  2. setTimeout()被设计在指定下限时间到达后执行回调
setTimeout(function timeout () {console.log('timeout');
},0);

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

//  结果一
//  timeout
//  immediate
/**-------- 华丽的分割线 --------**/
//  结果二
//  immediate
//  timeout

why?为什么会有两个结果,笔者在研究这里的时候也是有些不太明白,于是又做了第二个例子:

var fs = require('fs')
fs.readFile(__filename, () => {setTimeout(() => {console.log('timeout')
  }, 0)
  setImmediate(() => {console.log('immediate')
  })
});
//  运行 N 次
//  immediate
//  timeout
  1. 如果两者都在主模块调用,那么执行先后取决于进程性能,即随机。
  2. 如果两者都不在主模块调用,那么 setImmediate 的回调永远先执行。

虽然结论得出来了,但是这又是为啥呢?回想一下文章上半段所叙述的事件循环。首先进入 timer 阶段,如果我们的机器性能一般,那么进入 timer 阶段时,1 毫秒可能已经过去了(setTimeout(fn,0)等价于 setTimeout(fn,1)),那么setTimeout 的回调会首先执行。如果没到一毫秒,那么我们可以知道,在 check 阶段,setImmediate的回调会先执行。为什么 fs.readFile 回调里设置的,setImmediate始终先执行?因为 fs.readFile 的回调执行是在 poll 阶段,所以,接下来的 check 阶段会先执行 setImmediate 的回调。我们可以注意到,UV_RUN_ONCE模式下,事件循环会在开始和结束都去执行timer

练习题

阅读完本文章有什么收获呢?不如看下下面的代码,预测一下输出结果是什么样的。先不要急着看答案额 …

const fs = require('fs');
console.log('beginning of the program');
const promise = new Promise(resolve => {console.log('I am in the promise function!');
    resolve('resolved message');
});
promise.then(() => {console.log('I am in the first resolved promise');
}).then(() => {console.log('I am in the second resolved promise');
});
process.nextTick(() => {console.log('I am in the process next tick now');
});
fs.readFile('index.html', () => {console.log('==================');
    setTimeout(() => {console.log('I am in the callback from setTimeout with 0ms delay');
    }, 0);
    setImmediate(() => {console.log('I am from setImmediate callback');
    });
});
setTimeout(() => {console.log('I am in the callback from setTimeout with 0ms delay');
}, 0);
setImmediate(() => {console.log('I am from setImmediate callback');
});





// 输出结果
// beginning of the program
// I am in the promise function!
// I am in the process next tick now
// I am in the first resolved promise
// I am in the second resolved promise
// I am in the callback from setTimeout with 0ms delay
// I am from setImmediate callback
// ==================
// I am from setImmediate callback
// I am in the callback from setTimeout with 0ms delay

总结

对于本文中一些知识点任然有些模糊,懵懵懂懂,一直都在学习中,通过学习事件循环也看了一些文献,在其中看到了这一句话:除了你的代码,一切都是同步的,我觉得很有道理,对于理解事件循环很有帮助。

  1. Node.js的事件循环分为 6 个阶段
  2. process.nextTick不属于事件循环,但是产生的回调会加入到nextTickQueue
  3. setImmediatesetTimeout 的执行顺序会受到环境所影响

文章略长若文章中有哪些错误,请在评论区指出,我会尽快做出修正。大家可以踊跃发言共同进步,交流。

正文完
 0

Nodejs-事件循环

52次阅读

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

Node.js 免费课程:阿里云大学——开发者课堂
Node.js 是单进程单线程应用程序,但是通过事件和回调支持并发,所以性能非常高。
Node.js 的每一个 API 都是异步的,并作为一个独立线程运行,使用异步函数调用,并处理并发。
Node.js 基本上所有的事件机制都是用设计模式中观察者模式实现。
Node.js 单线程类似进入一个 while(true) 的事件循环,直到没有事件观察者退出,每个异步事件都生成一个事件观察者,如果有事件发生就调用该回调函数.

事件驱动程序
Node.js 使用事件驱动模型,当 web server 接收到请求,就把它关闭然后进行处理,然后去服务下一个 web 请求。
当这个请求完成,它被放回处理队列,当到达队列开头,这个结果被返回给用户。
这个模型非常高效可扩展性非常强,因为 webserver 一直接受请求而不等待任何读写操作。(这也被称之为非阻塞式 IO 或者事件驱动 IO)
在事件驱动模型中,会生成一个主循环来监听事件,当检测到事件时触发回调函数。

整个事件驱动的流程就是这么实现的,非常简洁。有点类似于观察者模式,事件相当于一个主题(Subject),而所有注册到这个事件上的处理函数相当于观察者(Observer)。

Node.js 有多个内置的事件,我们可以通过引入 events 模块,并通过实例化 EventEmitter 类来绑定和监听事件,如下实例:

// 引入 events 模块 var events = require('events');// 创建 eventEmitter 对象 var eventEmitter = new events.EventEmitter();

以下程序绑定事件处理程序:

// 绑定事件及事件的处理程序 eventEmitter.on('eventName', eventHandler);

我们可以通过程序触发事件:

// 触发事件 eventEmitter.emit('eventName');

实例
创建 main.js 文件,代码如下所示:

// 引入 events 模块 var events = require('events');// 创建 eventEmitter 对象 var eventEmitter = new events.EventEmitter();// 创建事件处理程序 var connectHandler = function connected() {console.log('连接成功。');
  
   // 触发 data_received 事件 
   eventEmitter.emit('data_received');}// 绑定 connection 事件处理程序 eventEmitter.on('connection', connectHandler);
 // 使用匿名函数绑定 data_received 事件 eventEmitter.on('data_received', function(){console.log('数据接收成功。');});// 触发 connection 事件 eventEmitter.emit('connection');console.log("程序执行完毕。");

接下来让我们执行以上代码:

$ node main.js 连接成功。数据接收成功。程序执行完毕。

Node 应用程序是如何工作的?
在 Node 应用程序中,执行异步操作的函数将回调函数作为最后一个参数,回调函数接收错误对象作为第一个参数。
接下来让我们来重新看下前面的实例,创建一个 input.txt , 文件内容如下:

阿里云大学地址:edu.aliyun.com

创建 main.js 文件,代码如下:

var fs = require("fs");fs.readFile('input.txt', function (err, data) {if (err){console.log(err.stack);
      return;
   }
   console.log(data.toString());});console.log("程序执行完毕");

以上程序中 fs.readFile() 是异步函数用于读取文件。如果在读取文件过程中发生错误,错误 err 对象就会输出错误信息。
如果没发生错误,readFile 跳过 err 对象的输出,文件内容就通过回调函数输出。
执行以上代码,执行结果如下:

程序执行完毕阿里云大学地址:edu.aliyun.com

接下来我们删除 input.txt 文件,执行结果如下所示:

程序执行完毕 Error: ENOENT, open 'input.txt'

因为文件 input.txt 不存在,所以输出了错误信息。

Node.js 免费课程:阿里云大学——开发者课堂

正文完
 0

Nodejs-事件循环

52次阅读

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

Node.js 事件循环

Node.js 是单进程单线程应用程序,但是因为 V8 引擎提供的异步执行回调接口,通过这些接口可以处理大量的并发,所以性能非常高。
Node.js 几乎每一个 API 都支持回调函数。
Node.js 基本上所有都事件机制都是通过观察者模式实现
Node.js 单线程类似进入一个 while(true) 事件循环,直到没有事件观察者退出,每个异步事件都生成一个事件观察者,
如果有事件发生就调用该回调函数。

事件驱动程序
Node.js 使用事件驱动模型,当 web server 接收到请求,就把它关闭然后进行处理,然后去服务下一个 web 请求。

当这个请求完成,它被放回处理队列,当到达队列开头,这个结果被返回给用户。

这个模型非常高效可扩展性非常强,因为 webserver 一直接受请求而不等待任何读写操作。(这也被称之为非阻塞式 IO 或者事件驱动 IO)

在事件驱动模型中,会生成一个主循环来监听事件,当检测到事件时触发回调函数。

// 引入 events
const events = require('events')
// 创建 eventEmitter 对象
const eventEmitter = new events.EventEmitter();

// 创建时间处理程序 
var connectHandler = function connected (){console.log('连接成功')
    // 出发 data_received 事件
    eventEmitter.emit('data_received')
}
// 绑定 connection 事件处理程序
eventEmitter.on('connection', connectHandler)

// 使用匿名函数绑定 data_received 事件
eventEmitter.on('data_received',() => {console.log('数据接收成功。')
})
// 触发 connection 事件
eventEmitter.emit('connection')

console.log('程序执行完毕')

Node 应用程序是如何工作的??

在 Node 应用程序中,执行异步操作都函数将回调函数作为最后一个参数,回调函数接收错误对象做一个第一个参数。

const fs = require("fs")

fs.readFile('input.txt',(err,data) => {if(err) {console.log(err)
    }else{console.log(data.toString())
    }
})
console.log("程序执行结束!")

这段代码,input.txt 文件我给删除了,所以在执行过程中,发生错误,错误 err 对象就会输出错误信息。

正文完
 0