霜天部落 | 关注LAMP高性能、高并发架构的设计与研究

nodejs真的是单线程吗?

问题由来

事件驱动、异步、单线程、非阻塞I/O,这是我们听得最多的关于nodejs的介绍,连nodejs官网都是这么写的:

Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient, perfect for data-intensive real-time applications that run across distributed devices.

过去很长时间里,我都愿意接受这些“耳熟能详”的观点,直到最近,遇到过很多性能问题之后,我才开始思考,nodejs的内部机制到底是怎样的,nodejs的性能瓶颈在哪里?

#问题

  • nodejs既然是单线程,如何实现异步I/O?
  • nodejs如何实现非阻塞I/O的?
  • nodejs事件驱动是如何实现的?
  • nodejs全是异步调用和非阻塞I/O,就真的不用管并发数了吗?
  • nodejs如何靠js和操作系统打交道的?

 

概念

探讨上面问题之前,我们先看下这些概念是什么意思:

  • 事件驱动:
    所谓的事件驱动是对一些操作的抽象,比如 鼠标点击抽象成一个事件,收到请求抽象成一个事件,事件是对异步的一种实现。
  • 同步/异步
    所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回。
    当一个异步过程调用发出后,调用者不会立刻得到结果。实际处理这个调用的部件是在调用发出后,通过状态、通知来通知调用者,或通过回调函数处理这个调用。
  • 阻塞/非阻塞
    阻塞调用是指调用结果返回之前,当前线程会被挂起。函数只有在得到结果之后才会返回。
    非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。

注意: 很多人弄混了 同步/异步和 阻塞/非阻塞 的关系,实际上他们并不是对等的,同步不一定会阻塞,只是方法没有返回不代表线程被挂起了,实际上你也可以去做别的工作。异步也并不代表一定是非阻塞,它可以立即返回函数,但是在获取回调的时候采用了不断轮训的方式挂起了线程。

nodejs内部揭秘

要弄清楚上面的问题,首先要弄清楚nodejs是怎么工作的。

nodejs真的是单线程吗?

这张图就是nodejs的内部构造。最上面一层就是我们常用的nodejs API,都是通过js封装好的,node-bings是指对底层c/c++代码的封装后和js打交道的部分,属于交界区域,这部分大都是原生API源码调用c++的情况,用户是不需要直接使用c++模块的。
然后就是底层首先是V8引擎,这个我们非常熟悉,他就是 js 的解析引擎,它的作用就是“翻译”js给计算机看,然而我们今天关注的重点并不是V8.在这里我们也看出来node是v8的关系,v8是js解释引擎,node是js的runtime,相当于浏览器是js的runtime一样,我们接下来解释的东西大都发生在runtime上面。
libuv,早期是libev和libeio组成,后来被抽象成libuv,它就是node和操作系统打交道的部分,由它来负责文件系统、网络等等底层工作。也是我们今天重点关注对象。剩下那些这次按住不表。

libuv简介

一张图揭示了libuv在node中的作用

nodejs真的是单线程吗?

可以看出,几乎所有和操作系统打交道的部分都离不开 libuv的支持。libuv也是node实现跨操作系统的核心所在。

现在我们可以回答js是如何同底层操作系统打交道的了?
就是通过libuv,一张简化的图如下(以fs为例):

上面提到过异步和非阻塞IO的特点,那么我们看 nodejs既然是单线程,如何实现异步I/O ?
聪明的你可能马上想到了,js执行线程是单线程,把需要做的I/O交给libuv,自己马上返回做别的事情,然后libuv在指定的时刻回调就行了。其实简化的流程就是酱紫的!细化一点,nodejs会先从js代码通过node-bings调用到C/C++代码,然后通过C/C++代码封装一个叫 请求对象 的东西交给libuv,这个请求对象里面无非就是需要执行的功能+回调之类的东西,给libuv执行以及执行完实现回调。

nodejs异步模型

顺便回答了问题 nodejs真的是单线程吗?,只有js执行是单线程,I/O显然是其它线程,比如我们看到libuv起码要一个线程接受nodejs的异步请求并执行,当然远不止这样,我们后面再说。

libuv何时执行回调?

我们上面提到了libuv接过了js传递过来的 I/O请求,那么何时来处理回调呢?
有人说这还不简单,I/O完了我就回调行不行。这是极度不安全的做法,我们知道js执行是单线程的,如果两个回调同时回来,或者js线程正在工作状态,将会出现回调竞争的情况,这在一个单线程的模式下面是不应该出现的问题,所以,libuv有一个事件循环(event loop)的机制,来接受和管理回调函数的执行。

event loop是libuv的核心所在,上面我们提到 js 会把回调和任务交给libuv,libuv何时来调用回调就是 event loop 来控制的。event loop 首先会在内部维持多个事件队列(或者叫做观察者 watcher),比如 时间队列、网络队列等等,使用者可以在watcher中注册回调,当事件发生时事件转入pending状态,再下一次循环的时候按顺序取出来执行,而libuv会执行一个相当于 while true的无限循环,不断的检查各个watcher上面是否有需要处理的pending状态事件,如果有则按顺序去触发队列里面保存的事件,同时由于libuv的事件循环每次只会执行一个回调,从而避免了 竞争的发生。libuv官方的event loop执行图:

nodejs真的是单线程吗?
哪天有时间了详细讲一下这个循环的过程,也很有意思

文件I/O

上面有副图提到了libuv在nodejs中的作用,右半部分 文件I/O ,DNS 和用户的代码对应的是线程池的机制,它的执行过程大概就是:
1 js层面调用如fs.open等指令通过node-bindings转成c/c++代码。
2 把回调函数等封装成一个请求对象,如果线程池有空闲线程,交给一个线程去执行。
3 执行完成在libuv的事件循环中的文件观察中注入一个回调事件,这个事件中会向上转换成js的回调并执行。

在这里我们就看到了线程池的概念,发现nodejs并不是单线程的,而且还有并行事件发生。同时,线程池默认大小是 4 ,也就是说,同时能有4个线程去做文件i/o的工作,剩下的请求会被挂起等待直到线程池有空闲。 nodejs全是异步调用和非阻塞I/O,就真的不用管并发数了吗?得到了回答。

线程池的大小可以通过 UV_THREADPOOL_SIZE 这个环境变量来改变 或者在nodejs代码中通过 process.env.UV_THREADPOOL_SIZE来重新设置。大概的工作流程可参考下面的流程图:

nodejs真的是单线程吗?

还有深入浅出中的图也很有代表性:

网络I/O

libuv的网络I/O采用了纯事件机制,其实现是使用的操作系统底层方法,在不同的操作系统中选择了不同的解决方案,比如linux下面使用的是 epoll,在windows下面使用的是IOCP等。
以linux为例,epoll是linux下面非常高效的一种异步I/O解决方案,nginx便是采用的这种方案,通过epoll(见下图)可以实现事件通知机制,网络内核在接收到任何绑定了的事件之后都会通知绑定者,然后执行相应的代码。

nodejs真的是单线程吗?

所以我们可以理解为, js绑定事件-> libuv绑定事件 -> 网络内核监听事件. 内核事件触发 -> libuv -> js回调的过程。 所以网络I/O 并没有并发数的限制,因为它也没有线程池的概念,在一个线程中飞快的处理各种回调。

网络I/O的执行流程和文件I/O不同的地方就在于它并没有线程池,而是通过事件机制交给了操作系统去做,操作系统响应或者处理了请求就会触发libuv的callback,进而传递到nodejs执行相应的业务代码。