前言

谈起NodeJs的都会谈起它的单线程,进而聊起它的event loop模型,那么NodeJs背后的事件循环有什么神秘之处?它又是如何处理高并发的呢?今天,我将简单地分析这块知识。

1、JS的堆栈、队列

任何一种语言的运行环境都少不了堆栈、队列,JS也不例外。作为基础知识我们都应该知道,JS的临时变量以及调用时的形参等等数据都是存储在栈中,而堆则是存储实际的对象,对象的引用变量名也是在栈中。而队列则是JS在实时运行环境中创建的消息队列或者事件队列。而它就是我们今天要讨论的对象。因为是单线程,所以队列的实现让JS的异步处理有了可能性。

Tips:

一个web worker或者一个跨域的iframe有它自己的堆栈的消息队列。两个分开的实时环境只能通过postMessage方法来互相通信。该方法添加一条消息到另外一个实时运行环境中,如果后者有监听消息队列的话。

2、经典的例子

我们先来看看一个经典的例子:

function add() {
   console.log(''exec the add function")
}

add()
setTimeout(add, 1000)

上面的例子都是最后会打印出exec the add function,只是时间的区别而已,而这种情况也就是大家熟悉的同步调用和异步调用。在大家的平时使用中可能一般都去关注如何使用setTimeout的使用方法,很少会去了解这个函数调用之后会在底层内部做些什么操作,为什么setTimeout可以实现定时执行?而今天我们将会深入内部,搞懂这一切。

2.1、事件循环

Javascript运行时会包含一个消息(事件)队列,它存储了需要处理的消息的列表和相关的回调函数,这些消息为了响应外部事件(如鼠标单击或收到HTTP请求的响应)而进入队列并提供对应的回调,如果一个事件没有回调,那么这个事件对应的消息是不会进入队列的。

JS会不断轮询消息(事件)队列(每一次轮询都是一次tick),当一条消息执行时,对应的回调就被执行。

JS的每一条消息都会在别的消息处理之前完全处理完,也就是说无论何时某个函数一旦开始运行,那么它就不可能被别的代码打断运行,这个和C语言不同,因为C语言的某一个函数运行在一个线程上,它是可以被其他线程的代码随时打断的。

事件循环的一个好处就是让JS从来不阻塞,我们一般通过事件和回调去操作,所以当应用在等待一个IndexedDB查询返回或者一个XHR请求返回的时候,主线程仍然可以处理其他事情比如用户输入

2.2、setTimeout

大致了解了事件循环之后,我们来细致地讲一下setTimeout的异步操作时如何实现的。

当我们调用setTimeout的时候:

  1. JS会到操作系统中注册一个超时时间,然后给操作系统指定一个钩子(hook),告知操作系统如果时间到了记得通知我。接着JS继续执行别的代码去了
  2. 超时时间到了,操作系统按照钩子的指定操作,通知了JS。
  3. JS收到操作系统的通知,将一条消息加入到消息(事件)队列中,同时该消息会关联对应的回调函数(add())
  4. 如果在队列中没有别的消息,那么该消息就会立刻被处理;但是如果消息(事件)队列里面还有别的消息并且排在该消息前面,那么该消息就得等待其他消息被处理完成之后才能被处理。

因此对于setTimeout函数给定的第二个参数其实是回调函数被执行的最小保证时间,或者可以说是消息进入队列的时间,而不是回调函数被处理的时间点。

因此下面的这个例子相信你可以很快得到答案:

console.clear();
console.log("a");
setTimeout(function(){console.log("b");},1000);
console.log("c");
setTimeout(function(){console.log("d");},0);

它们的打印顺序是什么呢?相信童鞋们能够很快答出来,我就不多说了。其他异步操作比如AJAX、鼠标点击事件都是如此的思路,不再赘述。

接下去我们谈谈nodejs的事件循环。

3、Nodejs的事件循环

首先来看看Nodejs架构的两张图片:

在第一张图片中我们需要清楚nodejs的整体框架以及框架中哪些模块是用的哪些软件包,当然我们更关心的是图中标注事件循环的实现模块libev。而第二张图片会更加详细地描述了nodejs系统。

在内部,nodejs依赖于libev提供的事件循环,该机制由libeio提供,使用轮询的线程去提供异步IO。你可以参考libev的文档获取更多的信息,事件循环在官网的示意图是这张:

后来我搜了一下谷歌,感觉下面这两张张图片能够更加清楚地示意事件循环的机制:

看完这么张图片再结合第二节讲到事件循环,应该能够大致明白一些(关于线程的简单解释可以参考在linux下查看某个进程下有哪些线程的方法),也就是事件循环线程不断地轮询事件队列中的新事件。当它发现事件队列中有事件的时候它把事件拿出来并尝试执行对应的回调函数,如果回调函数有请求IO阻塞操作或者是长等待的任务,那么将会从线程池中找到一个可用的线程去处理。处理完毕之后再将结果返回给event loop。

接下去我们趁热打铁再提出几个问题:

  1. 我们一直在说Nodejs是一个单线程的服务器,那么当JS引擎在处理更多请求的时候谁去处理setTimeout或者其他异步事件?
  2. nodejs是如何判断你的操作是异步的还是同步的?或者说是怎么知道你的操作会耗费很多时间?
  3. 除了那些异步调用由nodejs主动发送事件到事件队列之外,能否是我们主动来发送事件呢?(删除该问题,更新于2017.05.02)
  4. 如果按照之前说的思路,那么假如我们调用了fs.readFile这个函数,并且假定其回调函数没有涉及任何异步或者长等待的任务,那么nodejs是在读完文件之后再将事件添加到事件队列里面呢?还是马上添加一条事件到事件队列里面然后Event loop再分配一个线程去读取文件之后再把结果返回后去执行回调函数?(知道该问题的答案童鞋们可以留言哦)

3.1、第一个问题

首先我们回答第一个问题:

我们说的单线程指的是执行你的JS代码的线程只有一个,但是对于node本身,事实上是还有其他线程来处理事件循环机制,包括了IO线程池以及事件轮询线程。我们验证这点可以通过查看Node进程下的线程个数:(关于如何查看进程下有哪些线程可以参考这篇文章在linux下查看某个进程下有哪些线程的方法)

可以看到node进程(PID为23337)下启动了10个线程,其中还包括了V8引擎。所以从而解释了第一个问题。

3.2、第二个问题

那么Nodejs是通过什么机制去知道哪些操作时需要异步的呢?我个人还没有去看Nodejs的源码,但按照我的猜想,Nodejs应该是内部会有一个“字典”,该字典包含了所有会是异步的操作,比如调用setTimeout, http.get 或者 fs.readFile, Node.js根据自己的字典知道这是一个异步操作,然后就把这些操作发送到一个不同的线程,这样就可以允许V8继续执行我们的其他代码。然后当超时计数结束或者IO/http操作返回结果的时候再继续执行我们的回调。

但是,因为我们只有一个主线程和一个调用栈,所以当主线程还有别的请求在处理的时候,它的回调就需要等待请求处理完并且栈变为空的时候才能被继续执行。

3.3、第三个问题

答案是肯定的,为了简化与事件循环的交互,nodejs有了EventEmitter这个API。它是一个通用的封装器,用来让开发者更加容易去创建基于事件的API。

在@anxsec童鞋的纠错下,查阅对应的文档发现EventEmitter确实是与事件循环是没有任何关系的,所以该问题本身就是错误的,不过还是保留一下EventEmitter的使用例子。同时感谢@anxsec童鞋的纠错。

可以在node的REPL下直接运行下面的代码:

// Import events module
var events = require('events');

// Create an eventEmitter object
var eventEmitter = new events.EventEmitter();

// Create an event handler as follows
var connectHandler = function connected() {
   console.log('connection succesful.');

   // Fire the data_received event 
   eventEmitter.emit('data_received');
}

// Bind the connection event with the handler
eventEmitter.on('connection', connectHandler);

// Bind the data_received event with the anonymous function
eventEmitter.on('data_received', function(){
   console.log('data received succesfully.');
});

// Fire the connection event 
eventEmitter.emit('connection');

console.log("Program Ended.");

上面的代码便是演示了nodejs是如何让开发者主动去发送事件,如何主动的监听事件的。关于Event Emitter的不再细讲了,因为我本身也还没怎么实践过这一块的内容。

3.4、第四个问题

有哪个童鞋可以回答此问题呢? 更新于5月7号: 回答这个问题我们可以使用下面这个demo来说话:

const fs = require('fs')

fs.readFile('test.pdf', function(err, data) {
  console.log('read the larger file(3M) ok......., it will print later even though it is invoked firstly')
})

fs.readFile('test.json', function(err, data) {
  console.log('read the smaller(30K) file ok......, it will print firstly even though it is invoked lastly')
})

上面的打印解释了代码的打印顺序,也就是说第二个回调是在第一个回调之前进入事件队列,因此第二个打印会提前输出。因此Nodejs中是先使用系统调用去底层告诉操作系统去读取某个文件,然后同时给其钩子告诉操作系统读完文件记得发个事件到我的队列中,于是才有了上面的结果。这样解释,不知大家明白了吗?如果在你的回调中又有读文件的操作,又会怎样呢?大家可以仿造这个demo去实践验证自己的想法哦!

4、Web Workers

说到事件循环就不得不再提提web workers。使用Web Workers可以让你把那些复杂的操作放到一个独立的线程,让主线程去执行别的东西。workers包含了一个独立的消息队列,时间循环以及独立的内存空间。worker和主线程的通信全部依靠消息的传递,类似于以前学习Linux的进程间通信。其大致的实现原理如下:

学习了web worker,你就可以开始你的PWA之旅了

参考

  1. https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop
  2. //www.aaronstannard.com/intro-to-nodejs-for-net-developers/
  3. //blog.carbonfive.com/2013/10/27/the-javascript-event-loop-explained/
  4. //stackoverflow.com/questions/21607692/understanding-the-event-loop
  5. //altitudelabs.com/blog/what-is-the-javascript-event-loop/