深入理解nodejs的异步IO与事件模块机制

  • node为什么要使用异步I/O
  • 异步I/O的技术方案:轮询技术
  • node的异步I/O
  • nodejs事件环

 一、node为什么要使用异步I/O

异步最先诞生于操作系统的底层,在底层系统中,异步通过信号量、消息等方式有广泛的应用。但在大多数高级编程语言中,异步并不多见,这是因为编写异步的程序不符合人习惯的思维逻辑。

比如在PHP中它对调用层不仅屏蔽异步,甚至连多线程都不提供,从头到尾的同步阻塞方式执行非常有利于程序员按照顺序编写代码。但它的缺点在小规模建站中基本不存在,在复杂的网络应用中,阻塞就会导致它并发不友好。

1.1异步为什么在node中如此重要

在其他编程语言中,尽管可能存在异步API,但程序员还是习惯同步方式编写应用。在众多高级编程语言中,将异步作为主要编程方式和设计理念,Node是首个。Ryan Dahl基于异步I/O、事件驱动、单线程设计因素,期望设计出一个高性能的web服务器,后来演变为可以基于它构建各种高速、可伸缩网络应用的平台。与node异步I/O、事件驱动设计理念类似的产品Nginx采用纯C编写,性能表现的非常优秀。Nginx具备面向客户端管理链接的强大能力,但它背后依然受限于各种同步方式的编程语言。但Node是全方位的,既能作为服务端处理客户端大量的并发,也能作为客户端向网络中的各个应用进行并发请求。

关于异步I/O为什么在Node里如此重要,这与Node面向的网络设计息息相关。web应用已经不再是单台服务就能胜任的时代,再夸网络的结构下,并发已经是现代编程中的标准配备,具体到实处就是用户体验和资源分配两方面的问题:

用户体验:

由于浏览器执行UI和响应是处于停滞状态,如果脚本执行的时间超过100毫秒,用户就会感到卡顿,以为网页停止响应。

资源分配:

排除用户体验因素,从资源分配层面分析异步I/O的必要性。计算机在发展过程中将组件进行了抽象,分为I/O设备和计算设备。假设业务场景有一组互不相关的任务需要完成,现行的主流方法有以下两种情况:

1.单线程串行依次执行
2.多线程并行完成

单线程的闭端:同步执行时一个略慢的任务会导致后续执行代码被阻塞,通常I/O与CPU计算之间可以并行进行。但同步的编程模块导致I/O的进行会让后续任务等待,造成计算资源不能更好的被利用。

多线程的闭端:创建线程和执行期线程上下文切换的开销较大,在复杂的业务中,多线程经常面临锁、状态同步等问题,但多线程在多核CUP上能有效的提升CPU的利用效率。

虽然操作系统会将CPU的时间片段分配给其余进程,通过启动多个工作进程来提供服务,但对于一组任务而言它不会分发任务到多个进程上,所以依然无法高效的利用资源。

综合以上的问题,nodejs在给出的方案是:利用单线程远离多线程死锁和状态同步等问题;利用异步I/O,让单线程远离阻塞,更好的利用CPU。为了弥补单线程无法利用多核CPU的缺点,Node提供了类似前端浏览器中的web Workers的子进程,通过工作进程进程高效的利用CPU和I/O。(子进程在后面的Node进程管理相关博客中会有详细的解析

 二、异步I/O的技术方案:轮询技术

当我们谈及nodejs时,往往会说异步、非阻塞、回调、事件这些词语,其中异步与非阻塞听起来似乎是同一件事,实际上同步异步和阻塞/非阻塞是两回事。

同步异步是指代码的执行顺序,同步是按照代码的编写顺序串行执行,异步则反之。虽然这样的表达并不完全准确,但这也基本能解答同步异步是什么的问题。

阻塞与非阻塞是指在操作系统中,内核对I/O的两种方式:

在调用阻塞I/O时,应用程序需要等待I/O完成才返回结果,并且后面的程序也需要等待这个结果返回以后才会继续执行,简单的说就是这个I/O任务会阻塞后面的程序执行;

在调用非阻塞I/O在应用程序中不会等待I/O完全返回结果,操作系统对计算机进行了抽象,将所有输入输出设备抽象为文件。内核进行I/O操作时,通过文件描述符进行管理,I/O不会阻塞后面的程序的执行,CPU的时间片段会用来处理其他事务。这时候就有一个问题,I/O什么时候完成操作是不确定的,程序就要重复调用I/O操作来确定是否完成,这种重复的判断操作是否完成的技术叫做轮询,关于轮询的实现技术有很多种,各自也都采用不同的策略。

2.1read

通过重复调用来检查I/O的状态来确认数据是否完全读取,这种主动询问的方式最原始、性能最低,这是因为需要消耗大量的资源来重复进行状态检查。

2.2select

它与read一样,依然采用重复调用检查I/O的状态来确认事件状态,不同之处是select采用一个1024个长度的数组存储文件状态,所以它一次最多可以同时检查1024个文件描述符,相比read的一次检查一个文件描述符一种改进方案。

2.3poll

该方法较select有所改进,采用链表的方式替换数组,避免数组的长度限制,其次能避免不需要的检查。但当文件描述较多时,它的性能会十分低下。

2.4epoll

该方案是Linux下效率最高的I/O事件通知机制,在进入轮询时没有检查到I/O事件,将会休眠,直到事件将它唤醒。它是真正利用了事件通知、执行回调的方式,而不是遍历查询,所以不会浪费CPU,执行效率高。

2.5kqueue:

该方案的实现方式与epoll类似,不过它仅在FreeBSD系统下存在。

2.6合理的非阻塞异步I/O与个平台的最终实现

需要注意的是,尽管epoll、kqueue实现了非阻塞I/O确保获取完整的数据,但对于引用程序而言这依然是同步,因为应用程序依然需要等待I/O完全返回。等待期间要么用于遍历文件描述符的状态,要么用于休眠等待事件发生。

也就是合理的异步非阻塞I/O应该是由应用程发起非阻塞调用,无需通过遍历或者事件唤醒等待轮询的方式,而是可以直接处理下一个任务,只需要在I/O完成后通过信号或回调将数据传递给应用程序即可。

在Linux下实现的AIO就是通过信号或回调来传递数据的,但它存在还有缺陷就是AIO仅支持内核的I/O中的O_DIRECT方式读取,导致无法利用系统缓存。

在windows下实现的IOCP具备调用异步方法、I/O完成通知、执行回调,甚至轮询都由系统内核的线程池接手管理,这在一定程度上提供了理想的异步I/O。

在Nodejs中通过libuv作为系统的I/O抽象层,使得所有平台的兼容性都在这一层完成。为了解决Linux的系统缓存nodejs基于异步I/O库libeio,在这个基础上实现了自定义线程池。

需要注意的是,I/O不仅仅只限于磁盘读写,*nix将磁盘、硬件、套字节等几乎所有计算资源都被抽象为了文件,因此这里描述的阻塞和非阻塞同样适应于套字节等。

 三、node的异步I/O

在nodejs中的js层面事件核心模块是Events,在这个模块中有一个非常重要的类EventEmitter类,nodejs通过EventEmitter类实现事件的统一管理。但实际业务开发中单独引入这个模块的场景并不多,因为nodejs本身就是基于事件驱动实现的异步非阻塞I/O,从js层面来看他就是Events模块。

而其他核心模块需要进行异步操作的API就是继承这个模块的EventEmitter类实现的(例如:fs、net、http等),这些异步操作本身就具备了Events模块定义相关的事件机制和功能,所以也就不需要单独的引入和使用了,我们只需要知道nodejs是基于事件驱动的异步操作架构,內置模块是Events模块。

3.1Events模块

在Events模块上有四个基本的API:on、emit、once、off。

//on:添加当事件被触发时调用的回调函数
//emit:触发事件,按注册的顺序同步调用每个事件监听器
//once:添加当事件在注册之后首次被触发时调用的回调函数,调用之后该回调就会被删除
//off:移除特定的监听器

这四个API的应用非常的简单,就不过多的赘述它们如何应用了,直接上一段测试代码:

 1 const EventEmitter = require('events'); //导入事件模块
 2 const ev = new EventEmitter();          //创建一个事件对象
 3 ev.on('事件1',()=>{                     //向事件对象的监听器添加事件回调
 4     console.log('事件1执行了');
 5 });
 6 function fun(){
 7     console.log("事件1执行了----fun");
 8 }
 9 ev.on('事件1',fun);
10 ev.once('事件1',()=>{
11     console.log("事件1执行了----once回调任务");
12 });
13 ev.emit('事件1');                       //触发事件对象的监听器(注意这里是同步触发),所以只能触发前面三个回调任务,并且会把once注册的回调任务在触发后删除
14 ev.on('事件1',()=>{                     //这个事件回调不会被前面的emit触发
15     console.log("事件1执行了----4");
16 });
17 ev.emit('事件1');                       //这个触发的监听器会调用到“事件1执行了----4”,但前面once注册的任务不会触发了。
18 ev.off("事件1",fun);                    //删除ev事件对象上“事件1”注册的fun回调
19 ev.emit('事件1');                       //这里能触发的除once和off删除之外的回调

通过上面这段示例代码可以看到需要注意的点,就是在示例代码中的emit()的触发是同步的,最直观的就是第13行代码它不会触发“事件1执行了—-4”这个回调任务。

这是因为调用触发事件对象监听器的ev.emit()是在当前主线程上,也就是说它是由主线程同步触发的。而在nodejs中基于Events实现的fs、net、http这些模块的异步操作(这些模块也有同步操作)是由其他I/O线程以异步的方式调用触发emit的,所以如果你是异步触发emit的化,那“事件1执行了—-4”就会被执行,比如下面这段代码:

const EventEmitter = require('events'); //导入事件模块
const ev = new EventEmitter();          //创建一个事件对象
ev.on('ev1',()=>{
    console.log(1);
});
setTimeout(()=>{                        //使用定时器实现异步触发ev.emit
    ev.emit('ev1');
});
ev.on('ev1',()=>{
    console.log(2);
});
//测试结果
1
2

nodejs中给事件回调任务传参:

const EventEmitter = require('events'); //导入事件模块
const ev = new EventEmitter();          //创建一个事件对象
ev.on('ev1',(a,b,c)=>{
    console.log(a);
    console.log(b);
    console.log(c);
});
ev.on('ev1',(...arg)=>{
    console.log(arg);
});
ev.emit('ev1',1,2,3);
//打印结果
1
2
3
[ 1, 2, 3 ]

关于nodejs中的事件回调任务传参,其与浏览器有一些差别,在浏览器事件中会有事件源对象和一些其他固定的参数,不能直接给回调任务传参。

nodejs中的事件回调任务this指向:

 1 console.log(this);      //指向一个空对象{}
 2 ev.on('ev1',()=>{
 3     console.log(this);  //指向一个空对象{}
 4 });
 5 function fun(){
 6     console.log(this);  //指向事件对象本身
 7 }
 8 ev.on('ev1',fun);
 9 let obj = {
10     f:function(){
11         console.log(this);  //指向事件对象本身
12     }
13 };
14 ev.on('ev1',obj.f);
15 let obj2 = {
16     f:()=>{
17         console.log(this);  //指向一个空对象
18     }
19 };
20 ev.on('ev1',obj2.f);
21 ev.emit('ev1');

在nodejs中函数表达式指向事件对象本身这与DOM上的事件回调函数this指向DOM本身有一些类似,但也还是有区别的。箭头函数指向与浏览器中的规则一致,都是指向箭头函数所在包裹它的作用域的this,在前面的示例中这个表现的不明显,上面的箭头函数都是指向包裹它的作用的this(即全局作用域,而nodejs的全局作用this指向就是一个空对象)。

 1 const EventEmitter = require('events'); //导入事件模块
 2 const ev = new EventEmitter();          //创建一个事件对象
 3 let obj = {
 4     f:function(){
 5         ev.on('ev1',()=>{
 6             console.log(this);    //这个this指向包裹箭头函数的作用域f的this,而f的this指向obj
 7         });
 8     }
 9 };
10 
11 obj.f();
12 ev.emit('ev1');

从nodejs的Evets模块的设计模式角度来看是发布订阅者模式,但这仅仅是Events模块的事件注册与触发的角度来看待。而在nodejs的异步事件总体设计角度来看,它的核心还是在异步I/O上,而底层的异步I/O是观察者模式。从总体的nodejs异步I/O设计角度就是基于发布订阅+观察者设计模式实现的,这是两个部分组成从的一个系统性设计,为了更好的理解整体的nodejs的异步I/O,接下来先从nodejs的底层异步I/O角度来分析,然后再在这个基础上来分析Events模块机制。

关于观察者模式、发布订阅模式可以参考这篇博客:https://www.cnblogs.com/onepixel/p/10806891.html

3.2事件循环与观察者模式

在进程启动时,node便会创建一个类似while(true)的循环,每执行一次循环体的过程体通常被称为Tick。每个Tick的过程就是查看是否有事件待处理,如果有就会取出事件及其相关回调函数执行,然后进入下一个循环,这种判断是否有事件需要处理的设计模式就是观察者模式。

 

在整个事件循环过程中,单个异步I/O的具体执行过程:

1.Js层Events模块调用底层I/O的异步任务接口,这个异步任务接口由libuv模块提供
2.libuv创建一个任务对象,向下开启一个异步I/O的核心操作,向上将任务对象交给事件循环池中管理
3.底层的I/O线程处理I/O任务,JS主线程继续往下执行
4.当底层I/O线程处理完任务后,通过消息的方式通知事件循环池,并将数据交给任务对象
5.事件循环Tick观察到有需要处理的事件消息,将数据和任务对象中的回调任务交给主线程处理

组成一个完整的nodejs异步I/O模型有四个基本要素:事件循环、观察者、请求对象(任务对象)、I/O线程池。而在Nodejs除了fs、net、http这些I/O异步还包含一些非I/O异步,定时器、工作线程异步事件,这些异步任务都统一交给事件进程来管理,关于进程管理内容后面会有详细的解析博客,这里先不做解析。这里要关注的是nodejs异步事件驱动模式有哪些优势,通常所说的nodejs高性能服务器又是如何体现出来的。

3.3事件驱动与高性能

上面是基于Nodejs构建的web服务器的流程图,下面先来回顾以下其他几种经典的服务器模型,然后来对比它们的优缺点:

同步方式:一次只能处理一个请求,并且其余请求都处于等待状态。
每进程/每请求:为每个请求启动一个进程,这样可以处理多个请求,但是它不具备扩展性,因为系统资源只有那么多。
每线程/每请求:为每个请求启动一个线程来处理,尽管线程比进程要轻量,但由于每个线程都占用一定内存,当大并发请求到来时,内存将会很快用光,导致服务器缓慢。

每线程/每请求的方式目前Apache所采用,相比nodejs通过事件驱动的方式处理请求无需为每个请求创建额外的对应线程,可以省掉创建线程和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换的代价也很低。这使得node服务即使在大链接的情况下,也不受线程上下文切换开销的影响,这是Node高性能的原因。

事件驱动带来的高效已经逐渐开始为业界所重视,知名服务器Nginx也采用了事件驱动。如今Nginx大有取代Apache之势。Node与Nginx都是事件驱动,但由于Nginx采用纯C编写,性能较高,但它仅适合做web服务器,用于反向代理或负载均衡等服务,在处理具体业务方面较为欠缺。Nodejs则是一套高性能平台,可以利用它构建与Nginx相同的功能,也可以处理各种具体的业务,而且与背后的网络保持异步通畅。两者相比:

Nodejs:应用场景适应性更大,自身性能也不错。

Nginx:作为服务非常专业。

除了nodejs基于事件驱动构建的平台以外,还有基于Ruby构建的Event Machine平台、基于Perl构建的AnyEvent平台、基于Python构建的Twisted。

3.3发布订阅模式与模拟实现Events模块

关于发布订阅模式可以参考这篇博客:javaScript设计模式:发布订阅模式

关于这一部分也没有太多需要解析的,如果你了解发布订阅模式就明白nodejs在Events模块的JS实现,所以这里我直接粘贴模块代码:

 1 //模拟实现Events
 2 function MyEvents(){
 3     //准备一个数据结构用于缓存订阅者信息
 4     this._events = Object.create(null);
 5 }
 6 MyEvents.prototype.on = function(type, callback){   //on相当于订阅者
 7     //判断当前次的事件是否已经存在,然后再决定如何做缓存
 8     if(this._events[type]){
 9         this._events[type].push(callback);
10     }else{
11         this._events[type] = [callback];
12     }
13 };
14 MyEvents.prototype.emit = function(type, ...arg){   //emit相当于是发布者
15     if(this._events && this._events[type].length){
16         this._events[type].forEach(callback =>{
17             callback.call(this, ...arg);
18         });
19     }
20 };
21 
22 MyEvents.prototype.off = function(type,callback){   //实现取消事件监听任务
23     //判断当前type事件监听是否存在,如果存在则取消指定的监听
24     if(this._events && this._events[type]){
25         this._events[type] = this._events[type].filter(item=>{
26             return item !== callback && item !== callback.link;
27         });
28     }
29 };
30 MyEvents.prototype.once = function(type, callback){   //实现添加只触发一次的监听任务
31     let foo = function(...args){
32         callback.call(this, ...arg);
33         this.off(type,foo);
34     };
35     foo.link = callback;
36     this.on(type,foo);
37 };

 四、nodejs事件环

在了解这部分内容之前,建议先了解浏览器UI多线程与JavaScript单线程的原理机制,可以参考这篇博客: 浏览器UI多线程及JavaScript单线程运行机制的理解

4.1浏览器中的事件环

在浏览器中谈到事件环一般首先谈到的就是UI多线程,在ES3之前JavaScript自身没有发起异步请求的能力,所以在此之前的所有关于UI多线程涉及的异步都是宏任务,这些异步宏任务都统一要等待JavaScript主线程执行完以后才会开始按照UI队列的先后顺序被触发,包括DOM事件、定时器。

当JavaScript发展到ES5中引入了Promise,HTML5标准引入了worker、MutaionObserver,JavaScript自身就具备了发起异步任务的能力,虽然同为异步任务,但它们却有执行先后的区别,而不再是统一由UI队列的现后顺序执行那么简单,为了区分这些异步任务的差异,就引入了宏任务和微任务的概念。

浏览器中的宏任务:DOM事件(UI事件)、定时器、worker相关的事件。

浏览器中的微任务:Promise的异步任务、MutaionObserver。

 下面简单的描述一下浏览器中的JS主线与与事件环的执行过程,但需要注意这里并不涉及解析UI渲染线程与JS引擎主线程的互斥问题,这是两个问题不能混淆,这里解析的是JS主线程与异步任务的事件环之间的执行关系。

1.JS主线程执行同步任务
2.同步执行过程中遇到宏任务与微任务添加至相应的队列
3.同步代码执行完以后,如果事件环中的微任务队列中有相应的异步执行结果,传递给JS主线程并在JS主线程上执行相关联的回调任务
4.如果事件环中没有微任务或者微任务执行完了,再执行宏任务(如果有宏任务)
5.如果宏任务执行完了,再立即检查微任务队列是否又有新的微任务,如果有立即执行
6.循环事件环操作

结合上面的解析来看两个示例:

 1 let ev = console.log('start')
 2 setTimeout(() => {
 3   console.log('setTimeout')
 4 }, 0)
 5 new Promise((resolve) => {
 6   console.log('promise')
 7   resolve()
 8 })
 9   .then(() => {
10     console.log('then1')
11   })
12   .then(() => {
13     console.log('then2')
14   })
15 console.log('end')
16 //执行结果:start 、promise 、end、then1、then2、setTimeout


 1 //示例二
 2 setTimeout(()=>{
 3     console.log('s1');
 4     Promise.resolve().then(()=>{
 5         console.log('p1');
 6     });
 7     Promise.resolve().then(()=>{
 8         console.log('p2');
 9     });
10 });
11 setTimeout(()=>{
12     console.log('s2');
13     Promise.resolve().then(()=>{
14         console.log('p3');
15     });
16     Promise.resolve().then(()=>{
17         console.log('p4');
18     });
19 });
20 //执行结果:s1、p1、p1、s2、p3、p4

示例二

4.2Nodejs中的事件环

在nodejs中与浏览器有类似的事件环机制,也同样有宏任务和微任务的概念,但在具体表现上有一些差异:

–nodejs中微任务队列中有两个种不同的优先级:

process.nextTick的回调任务
promise相关异步任务

–nodejs中宏任务队不像浏览器中的宏任务只有一个队列,而是有六个:

timers:setTimout与setInterval的回调任务
pending callbacks:执行系统操作的回调,例如tcp、udp
idle,prepare:只在系统内部使用(也就是说这两个个队列的任务不是传递给JS主线程的,而是传递给系统处理的回调)
poll:执行与I/O相关的回调
check:setImmediate的回调任务
close callbacks:执行close事件的回调

根浏览器的事件环机制一样,nodejs中的事件环机制也是先执行微任务,然后执行宏任务,宏任务执行完以后在检查微任务队列这样的一个循环机制,这是总体的事件环执行机制。然后微任务中按照优先级依次执行,宏任务中的六个任务队列也一样依次执行,具体顺序参考下面的示图(前面的列举顺序其实就是它们的优先级和执行顺序):

关于nodejs微任务的优先级,这在nodejs全局对象简析中的2.9中有详细的说明,这里在做简单介绍,process.nextTick优先于promise的异步任务,但要注意还有一个queueMicrotask()方法是在主线程的末尾处添加一个堆栈,而不是异步任务,但从某种角度上来说它有些类似异步回调,但从它并没有被添加到事件队列中。

最后需要注意的问题是,由于setTimoutsetInterval、setImmediate是基于延时异步回调,但即便传入指定的执行时间或者不传时间都不能保证其精度,所以当不传入时间时从某种意义上来说它们是一种随机状态,比如你将它们在同步相邻的线程上定义了,它们的执行现后顺序是不确定的,比如你可以通过多次测试下面这个代码,就有很大的机率出现打印结果的顺序不一致:

setTimeout(()=>{
    console.log("s1");
});
setImmediate(()=>{
    console.log("s2");
});

发生这种问题的原因就是因为它们载添加到事件队列中之前都会底层模块进行一个延时处理,即便没有设置延时它们也都必须执行这个过程,这个过程就会导致他不能像代码在同步堆栈上定义的那样,而是都会经过底层的异步操作过后再被添加到各自的事件队列中,而底层的异步操作这个过程你是无法预测它们的执行时间,也正是因为这个事件导致它们添加到任务队列中的时机不确定,而且事件环还在循环执行各个任务队列的位置也是不确定的,所以它们这种情况就可以看作是不确定的随机触发。