Node.js design pattern : Reactor (Event Loop)

Nodejs是非阻塞的,源于它是基于事件循环的设计模式,该模式也称为Reactor模式。javascript

Nodejs同时也是单线程的,这里的单线程指的是开发人员编写的代码运行在单线程上,而Nodejs的内部一些实现代码倒是多线程的,如对于I/O 的处理(读取文件、网络请求等)。关于Event Loop另外一篇文章中有粗略提到,本文将详细阐述。java

但对于I/O请求不也是开发人员编写的代码吗,不是说咱们本身写的代码都是运行在单线程上的,怎么这里又可能变成多线程了? 这里就要讲到reactor模式了。在此以前,先简单了解下Blocking I/ONon-blocking I/Onode

Blocking I/O vs Non-blocking I/O

Blocking I/Oreact

Blocking I/O是程序会等待I/O请求直到结果返回,至关于控制权一直在等待I/O这边,在等待的这段时间里程序不会去干其余事,就这么一直干等着。例子如:web

data = socket.read();
	// wait until the data fetch back
	print(data)
复制代码

对于web server来讲,是必需要处理多个请求的。对于Blocking I/O状况,是没法处理多个请求,每一个请求都会在上一个请求处理完才能处理。解决的方法是启用多线程处理,该处理场景以下图:设计模式

blocking IO

开启多个线程处理的代价有点高(内存占用,上下文切换),并且从图中看到每一个线程都有不少空余时间在干等着,没法充分利用时间。网络

Non-blocking I/O数据结构

对于Non-blocking I/O, 通常是请求后直接返回,不用等待请求结果返回。若是没有数据能够返回的话,是直接返回一个预设好的常量标识当前还没数据能够返回。多线程

这里首先举例一个最基本的实现方式,不断循环这些资源直到能读取到数据。并发

// 资源集合
	resources = [socketA, socketB, pipeA];
	// 只要还有资源没获取到数据,就一直循环操做
	while(!resources.isEmpty()) {
		for(i = 0; i < resources.length; i++) {
			resource = resources[i];

			// 直接返回non-blocking
			// 若无数据则直接返回预设常量
			let data = resource.read();

			if(data === NO_DATA_AVAILABLE)
			// 该资源还在等待中未准备好
				continue;

			if(data === RESOURCE_CLOSED)
			// 该资源已经读取完毕,从集合中删除
				resources.remove(i);
			else
			// 数据已经获取,处理数据
				consumeData(data);
			}
	}
复制代码

这样就能够作到单个线程中处理并发处理多个请求资源了。这种作法被称为busy-wait,该作法虽然使得单个线程能够处理多个并发请求,但CPU会一直消耗在轮询中,没法抽身去干其余事情。所以non-blocking I/O通常经过synchronous event demultiplexer来实现。

关于什么是 synchronous event demultiplexer,这里引用wikipedia中的一段话。

Uses an event loop to block on all resources. The demultiplexer sends the resource to the dispatcher when it is possible to start a synchronous operation on a resource without blocking

(Example: a synchronous call to read() will block if there is no data to read. The demultiplexer uses select() on the resource, which blocks until the resource is available for reading. In this case, a synchronous call to read() won't block, and the demultiplexer can send the resource to the dispatcher.)

简单来讲就是,对于事件循环中的资源会经过该多路分发器(demultiplexer)下发给对应的程序去处理,处理好了则把对应事件保存到event queue中等待事件循环轮询运行。

如上述例子说的调用read()以后立刻能够运行接下来的代码而不会产生阻塞,阻塞的事情交给了分发器去作了,具体怎么作每一个系统有不一样的实现,这就是更底层的事了。

简单例子如:

socketA, pipeB;
	// 注册事件
	watchedList.add(socketA, FOR_READ); 
	watchedList.add(pipeB, FOR_READ);
	
	// demultiplexer blocking 等待事件完成(成功取回数据)
 	// events保存成功的事件
	while(events = demultiplexer.watch(watchedList)) { 
		...
	}

复制代码

Reactor Pattern

Nodejs中的事件循环正是基于event demultiplexerevent queue,而这两块正是Reactor Pattern的核心。对于Nodejs的事件循环,首选要明确的一点是:

只有一个主线程执行JS代码,咱们写的代码就是在该线程执行的,该线程也同是event loop运行的线程。(并非主线程运行JS代码,而后又有一个线程在同时运行event loop)。

该模式执行过程大体以下图所示:

event loop

  1. event demultiplexer接收到I/O请求而后下发给对应的底层去处理。

  2. 一旦I/O获取到了数据,event demultiplexer会把注册的回调函数添加到event queue中等待event loop去执行。

  3. event queue中的回调函数依次被event loop执行,直到event queue为空。

  4. event queue中没数据了或者event demultiplexer没有再接受到请求,程序即event loop就会结束,意味着该应用就退出了,不然回到第一步。

Event Demultiplexer

以前已经初略讲过了Event Demultiplexer是什么了,这里详细讲下nodejs中的event demultiplexer

event demultiplexer其实是一个抽象的概念,不一样的系统有不一样的实现方式,如Linux的epoll,MacOS中的kqueue,Windows中的IOCP。nodejs则经过libuv屏蔽了对不一样系统的实现支持跨平台,提供了针对多种不一样I/O请求的具体处理方式的API(如File I/O,Network I/O,DNS处理等)。

能够认为libuv把这一堆复杂的东西都结合在一块儿造成了nodejs中的event demultiplexer。libuv结构以下图所示:

libuv

libuv中,对于一些I/O操做是直接利用系统层级I/O中的non-blockingasynchronous特性(如提到的epoll等),但对于一些类型的I/O,因为复杂性的问题libuv则经过thread pool来处理。

因此就如同一开始说的,用户开发层面的代码是单线程的,但在I/O处理中是有可能出现多线程,但不会涉及到开发人员写的JS代码,由于thread pool是在libuv库里面的。

Event Queue

上面说到了event queue,是用来存储回调函数等待被event loop处理的。但实际上,不止一个event queue队列,事件循环要处理的主要有4个类型的队列。

  • Timers and Intervals Queue: 保存setTimeoutsetInterval中的回调函数(实际上不是队列,数据结构是最小堆实现,这里就统一都叫队列了)
  • IO Event Queue: 保存已经完成的I/O回调函数。
  • Immediates Queue: 保存setImmediate中的回调函数。
  • Close Handlers Queue: 其余全部close事件的回调,如socket.on('close', ...)

除了上述四个主要队列外,还有两个比较特殊的队列:

  • Next Ticks Queue:保存process.nextTick中的回调函数。
  • Other Microtasks Queue:保存Promise等microtask中的回调函数。

这里又再插一句,macrotask和microtask的区别

那么这些队列是怎么被事件循环处理的呢?直接看图。

event queue

事件循环会依次处理timers and intervals queueIO event queueimmediates queueclose handlers queue这四个队列,若是处理完close hanlers queue后,timers and intervals没有数据再进来,就退出事件循环。

处理其中一个队列的过程称为一个phase。一次事件循环就是处理这四个phase的过程。那另外两个特殊的队列是在何时运行的呢? 答案就是在每一个 phase运行完后立刻就检查这两个队列有无数据,有的话就立刻执行这两个队列中的数据直至队列为空。当这两个队列都为空时,event loop 就会接着执行下一个phase

这两个队列相比,Next Ticks Queue的权限要比Other Microtasks Queue的权限要高,所以Next Ticks Queue会先执行。

此外要注意的是,若是process.nextTick中出现递归调用没有中止条件的话,Next Ticks Queue将一直有数据进来一直都不会为空,则会阻塞event loop的执行。为了防止该状况,process.maxTickDepth定义了迭代的最大值,不过从NodeJS v0.12版本开始已经移除了。

参考

1.Event Loop and the Big Picture

2.What you should know to really understand the Node.js Event Loop

3.Node.js Design Patterns

4.what is the eventloop