Skip to content

Node模块

Node模块分类

nodejs模块可以分为下面三类:

  • 核心模块(native模块):包含在 Node.js 源码中,被编译进 Node.js 可执行二进制文件 JavaScript 模块,其实也就是lib和deps目录下的js文件,比如常用的http,fs等等。
  • 内建模块(built-in模块):一般我们不直接调用,而是在 native 模块中调用,然后我们再require。
  • 第三方模块:非 Node.js 源码自带的模块都可以统称第三方模块,比如 express,webpack 等等。
    • JavaScript 模块,这是最常见的,我们开发的时候一般都写的是 JavaScript 模块
    • JSON 模块,这个很简单,就是一个 JSON 文件
    • C/C++ 扩展模块,使用 C/C++ 编写,编译之后后缀名为 .node

比如 Node 源码lib目录下的 fs.js 就是 native 模块,而fs.js调用的 src 目录下的 node_fs.cc 就是内建模块。

libuv

Libuv是一个高性能的,事件驱动的异步I/O库,它本身是由C语言编写的,具有很高的可移植性。libuv封装了不同平台底层对于异步IO模型的实现,libuv 的 API 包含有时间,非阻塞的网络,异步文件操作,子进程等等,所以它还本身具备着Windows, Linux都可使用的跨平台能力。

An image

IOCP

概念:输入输出完成端口(Input/Output Completion Port,IOCP), 是支持多个同时发生的异步I/O操作的应用程序编程接口,在Windows NT的3.5版本以后,或AIX5版以后或Solaris第十版以后,开始支持。

我直接这么说概念你可能也不太懂。可以暂时知道 Windows 下注意通过 IOCP 来向系统内核发送 I/O 调用和从内核获取已完成的 I/O 操作,配以事件循环,完成异步I/O的过程。在 linux 下通过 epoll 实现这个过程,也就是由 libuv 自行实现。

IOCP 的另一个应用场景在之前Node.js进程与线程那篇文章也有写过。Mater 和 app worker 进程通信使用到。

线程池

线程池,是一种线程的使用模式,它为了降低线程使用中频繁的创建和销毁所带来的资源消耗与代价。 通过创建一定数量的线程,让他们时刻准备就绪等待新任务的到达,而任务执行结束之后再重新回来继续待命。

这就是线程池最核心的设计思路,「复用线程,平摊线程的创建与销毁的开销代价」。

本文使用到线程池的地方:在 Node 中,无论是 *nix 还是 Window 平台。内部完成 I/O 任务的都有用到线程池。

libuv 目前使用了一个全局的线程池,所有的循环都可以往其中加入任务。目前有三种操作会在这个线程池中执行:

  • 文件系统操作

  • DNS 函数(getaddrinfo 和 getnameinfo)

  • 通过 uv_queue_work() 添加的用户代码

Node与异步I/O调用流程

对比图中两段经典api代码(server.listenfs.open,选择两种api的原因:网络 I/O 代表和文件 I/O 代表)和之前 libuv 图片,我们来一起理解异步I/O调用流程

An image

上图展示了libuv细节的流程,图中代码很简单,包括2个部分:

  1. server.listen() 是用来创建 TCP server 时,通常放在最后一步执行的代码。主要指定服务器工作的端口以及回调函数。

  2. fs.open() 是用异步的方式打开一个文件。

选择两个示例很简单,因为 libuv 架构图可视:libuv 对 Network I/O和 File I/O 采用不同的机制。

上图右半部分,主要分成两个部分:

  1. 主线程:主线程也是 node 启动时执行的现成。node 启动时,会完成一系列的初始化动作,启动 V8 engine,进入下一个循环。

  2. 线程池:线程池的数量可以通过环境变量 UV_THREADPOOL_SIZE 配置,最大不超过 128 个,默认为 4 个。

在Node.js 中经典的代码调用方式:都是从 JavaScript 调用 Node 核心模块,核心模块调用 C++ 内建模块,内建模块通过 libuv 进行系统调用。请记住这段话

事件循环

不管是server.listen还是fs.open,他们在开启一个 node 服务(进程)的时候,Node会创建一个while(true)的循环,这个循环就是事件循环。每执行一次循环体的过程,我们称之为Tick。每个Tick的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行。然后进入下一个循环,如果不再有事件处理,退出进程。

An image

这里我们知道事件循环已经创建了,上面加粗字体查看是否有事件待处理,去哪里查看?事件怎么进入事件循环的?什么情况会产生事件继续往下看。

底层调用与事件产生

An image

继续看这张图,讲解一下事件产生基本流程,(注意网络I/O和文件I/O会有一些不同)这里对c++代码调用简单提一下,有兴趣的小伙伴可以继续深入研究。

File I/O

(这里就用到了文初提到的模块分类知识)先是 javascript 代码,然后调用 lib/fs.js 核心模块代码 fs.open ,核心模块调用 C++ 内建模块 src/node_file.cc,内建模块c++代码会有一个平台判断,然后通过 libuv 进行系统调用。

从前面到达 libuv ,会有一个参数,请求对象,也就是open函数前面整个流程传递进来的请求对象,它保存了所有状态,包括送入线程池等待执行以及I/O操作完毕后的回调处理。

请求对象组装完成后,送入 libuv 中创建的 I/O 线程池,线程池中的 I/O 操作完毕后,会将获取的结果存储到 req->result 属性上,然后通知某函数通知 IOCP ,告知当前对象操作已经完成。

在这整个过程中,进程初期创建的事件循环中有一个 I/O 观察者,每次 Tick 的执行中,它会调用 IOCP 相关的方法检查线程池中是否有执行完成的请求,如果存在,会讲请求对象和之前绑定的 result 属性,加入到 I/O 观察者的队列中,然后将其当作事件处理。

看到这里,前面提到的是否有事件待处理,去哪里查看?事件怎么进入事件循环的?这两个问题是不是搞懂了。

文字配上图。更清晰!

An image

Network I/O

V8 engine 执行从 server.listen() 开始,调用 builtin module Tcp_wrap 的过程。

在创建TCP链接的过程中,libuv直接参与Tcp_wrap.cc函数中的 TCPWrap::listen() 调用uv_listen()开始到执行uv_io_start()结束。看起来很短暂的过程,其实是类似linux kernel的中断处理机制。

uv_io_start()负载将 handle 插入到处理的water queue中。这样的好处是请求能够立即得到处理。中断处理机制里面的下半部分与数据处理操作相似,交由主线程去完成处理。

An image

重要:虽然 libuv 的异步文件 I/O 操作是通过线程池实现的,但是网络 I/O 总是在单线程中执行的,注意最后还是会把完成的内容作为事件加入事件循环,事件循环就和文件I/O相同了。

Node高性能

传统的服务器模型

  • 同步式: 同步的服务,一次只能处理一个请求,并且其余请求都处于等待状态。
  • 每进程/每请求: 为每个请求启动一个进程,这样可以处理多个请求,但是不具有扩展性,系统资源有限,开启太多进程不太合适
  • 每线程/每请求: 为每个请求启动一个线程来处理。尽管线程比进程轻量,但是每个线程也都会占用一定内存,当大并发请求的时候,也会占用很大内存,导致服务器缓慢。

Node就不一样了!

看了文章前面的内容,Node 通过事件驱动的方式处理请求,无需为每个请求创建额外的对应线程,可以省掉创建线程和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换的代价很低。这也是 Node.js 高性能之一

Nginx 目前也采用了和 Node 相同的事件驱动方式,有兴趣的也去了解下,不过 Nginx 采用 c 语言编写。