作者介绍:陈跃标,ByteDance Web Infra 团队成员,目前主要负责 Node.js 基础架构方向的工作
本文内容主要分为两大部分,第一部分是 Node.js 的基础和架构,第二部分是 Node.js 核心模块的实现。
Node.js 主要由 V8、Libuv 和第三方库组成:
上图是 Node.js 的代码架构,Node.js的代码主要分为 JS、C++、C 三种:
了解了 Node.js 的组成和代码架构后,我们看看 Node.js 启动的过程都做了什么。
首先 Node.js 会调用 registerBuiltinModules 函数注册 C++ 模块,这个函数会调用一系列 registerxxx 的函数,我们发现在 Node.js 源码里找不到这些函数,因为这些函数是在各个 C++ 模块中,通过宏定义实现的,宏展开后就是上图黄色框的内容,每个 registerxxx 函数的作用就是往 C++ 模块的链表了插入一个节点,最后会形成一个链表。
那么 Node.js 里是如何访问这些 C++ 模块的呢?在 Node.js 中,是通过 internalBinding 访问 C++ 模块的,internalBinding 的逻辑很简单,就是根据模块名从模块队列中找到对应模块。但是这个函数只能在 Node.js 内部使用,不能在用户 JS 模块使用,用户可以通过 process.binding 访问 C++ 模块。
注册完 C++ 模块后就开始创建 Environment 对象,Environment 是 Node.js 执行时的环境对象,类似一个全局变量的作用,他记录了 Node.js 在运行时的一些公共数据。创建完 Environment 后,Node.js 会把该对象绑定到 V8 的 Context 中,为什么要这样做呢?主要是为了在 V8 的执行上下文里拿到 env 对象,因为 V8 中只有 Isolate、Context 这些对象,如果我们想在 V8 的执行环境中获取 Environment 对象的内容,就可以通过 Context 获取 Environment 对象。
假设用户 JS 如下:
分别加载了一个用户模块和原生 JS 模块,我们看看加载过程,执行 require 的时候:
接着 Node.js 就会执行用户的 JS,通常用户的 JS 会给事件循环生产任务,然后就进入了事件循环系统,比如我们 listen 一个服务器的时候,就会在事件循环中新建一个 TCP handle。Node.js 就会在这个事件循环中一直运行。
net.createServer(() => {}).listen(80)
下面我们看一下事件循环的实现。事件循环主要分为 7 个阶段,timer 阶段主要是处理定时器相关的任务,pending 阶段主要是处理 Poll IO 阶段回调里产生的回调,check、prepare、idle 阶段是自定义的阶段,这三个阶段的任务每次事件序循环都会被执行,Poll IO 阶段主要是处理网络 IO、信号、线程池等等任务,closing 阶段主要是处理关闭的 handle,比如关闭服务器。
定时器的底层数据结构是二叉堆,最快到期的节点在最上面。在定时器阶段的时候,就会逐个节点遍历,如果节点超时了,那么就执行他的回调,如果没有超时,那么后面的节点也不用判断了,因为当前节点是最快过期的,如果他都没有过期,说明其他节点也没有过期。节点的回调被执行后,就会被删除,为了支持 setInterval 的场景,如果设置 repeat 标记,那么这个节点会被重新插入到二叉堆。
我们看到底层的实现稍微简单,但是 Node.js 的定时器模块实现就稍微复杂。
当我们调用 setTimeout 的时候,首先根据 setTimeout 的入参,从 map 中找到二叉堆节点,然后插入链表的尾部,必要的时候,Node.js 会根据 js 二叉堆的最快超时时间来更新底层节点的超时时间。当事件循环处理定时器阶段的时候,Node.js 会遍历 JS 二叉堆,然后拿到过期的节点,再遍历过期节点中的链表,逐个判断是否需要执行回调,必要的时候调整 JS 二叉堆和底层的超时时间。
check、idle、prepare 阶段相对比较简单,每个阶段维护一个队列,然后在处理对应阶段的时候,执行队列中每个节点的回调,不过这三个阶段比较特殊的是,队列中的节点被执行后不会被删除,而是一直在队列里,除非显式删除。
pending 阶段:在 Poll IO 回调里产生的回调。closing 阶段:执行关闭 handle 的回调。pending 和closing 阶段也是维护了一个队列,然后在对应阶段的时候执行每个节点的回调,最后删除对应的节点。
Poll IO 阶段是最重要和复杂的一个阶段,下面我们看一下实现。首先我们看一下 Poll IO 阶段核心的数据结构:IO 观察者,IO 观察者是对文件描述符,感兴趣事件和回调的封装,主要是用在 epoll 中。
当我们有一个文件描述符需要被 epoll 监听的时候
另外我们看到,Poll IO 阶段会可能会阻塞,是否阻塞和阻塞多久取决于事件循环系统当前的状态。当发生阻塞的时候,为了保证定时器阶段按时执行,epoll 阻塞的时间需要设置为等于最快到期定时器节点的时间。
Node.js 中的进程是使用 fork+exec 模式创建的,fork 就是复制主进程的数据,exec 是加载新的程序执行。Node.js 提供了异步和同步创建进程两种模式。
同步创建子进程会导致主进程阻塞,具体的实现是
接下来我们看一下父子进程间怎么通信呢?在操作系统中,进程间的虚拟地址是独立的,所以没有办法基于进程内存直接通信,这时候需要借助内核提供的内存。进程间通信的方式有很多种,管道、信号、共享内存等等。
Node.js 选取的进程间通信方式是 Unix 域,Node.js 为什么会选取 Unix 域呢?因为只有 Unix 域支持文件描述符传递,文件描述符传递是一个非常重要的能力。
首先我们看一下文件系统和进程的关系,在操作系统中,当进程打开一个文件的时候,他就是形成一个fd->file->inode 这样的关系,这种关系在 fork 子进程的时候会被继承。
但是如果主进程在 fork 子进程之后,打开了一个文件,他想告诉子进程,那怎么办呢?如果仅仅是把文件描述符对应的数字传给子进程,子进程是没有办法知道这个数字对应的文件的。如果通过 Unix 域发送的话,系统会把文件描述符和文件的关系也复制到子进程中。
具体实现
Node.js 是单线程的,为了方便用户处理耗时的操作,Node.js 在支持多进程之后,又支持了多线程。Node.js 中多线程的架构如下图所示,每个子线程本质上是一个独立的事件循环,但是所有的线程会共享底层的 Libuv 线程池。
接下来我们看看创建线程的过程。
当我们调用 new Worker 创建线程的时候
那么 Node.js 中的线程是如何通信的呢?线程和进程不一样,进程的地址空间是独立的,不能直接通信,但是线程的地址是共享的,所以可以基于进程的内存直接进行通信。
下面我们看看 Node.js 是如何实现线程间通信的。了解 Node.js 线程间通信之前,我们先看一下一些核心数据结构。
我们看到两个 port 是互相关联的,当需要给对端发送消息的时候,只需要往对端的消息队列插入一个节点就行。我们来看看通信的具体过程
我们知道 Node.js 是单进程架构的,不能很好地利用多核,Cluster 模块使得 Node.js 支持多进程的服务器架构。Node.s 支持轮询(主进程 accept )和共享(子进程 accept )两种模式,可以通过环境变量进行设置。多进程的服务器架构通常有两种模式,第一种是主进程处理连接,然后分发给子进程处理,第二种是子进程共享 socket,通过竞争的方式获取连接进行处理。
我们看一下 Cluster 模块是如何使用的。
这个是 Cluster 模块的使用例子
我们先看一下主进程 accept 这种模式。
我们再看一下子进程 accept 这种模式。
为什么需要使用线程池?文件 IO、DNS、CPU 密集型不适合在 Node.js 主线程处理,需要把这些任务放到子线程处理。
了解线程池实现之前我们先看看 Libuv 的异步通信机制,异步通信指的是 Libuv 主线程和其他子线程之间的通信机制。比如 Libuv 主线程正在执行回调,子线程同时完成了一个任务,那么如何通知主线程,这就需要用到异步通信机制。
下面我们来看一下线程池的实现。
上图是操作系统中信号的表示,操作系统使用一个 long 类型表示进程收到的信息,并且用一个数组来标记对应的处理函数。我们看一下信号模块在 Libuv 中是如何实现的。
Node.js 中,是通过监听 newListener 事件来实现信号的监听的,newListener 是一种 hooks 的机制。每次监听事件的时候,如果监听了 newListener 事件,那就会触发 newListener 事件。所以当执行 process.on(’SIGINT’) 时,就会调用 startListeningIfSignal (newListener事件的处理器)注册一个红黑树节点。并在 events 模块保存了订阅关系,信号触发时,执行 process.emit(‘SIGINT’) 通知订阅者。
Node.js 中文件操作分为同步和异步模式,同步模式就是在主进程中直接调用文件系统的 API,这种方式可能会引起进程的阻塞,异步方式是借助了 Libuv 线程池,把阻塞操作放到子线程中去处理,主线程可以继续处理其他操作。
Node.js 中文件监听提供了基于轮询和订阅发布两种模式。我们先看一下轮询模式的实现,轮询模式比较简单,他是使用定时器实现的,Node.js 会定时执行回调,在回调中比较当前文件的元数据和上一次获取的是否不一样,如果是则说明文件改变了。
第二种监听模式是更高效的 inotify 机制,inotify 是基于订阅发布模式的,避免了无效的轮询。我们首先看一下操作系统的 inotify 机制,inotify 和 epoll 的使用是类似的:
接下来我们看看 Node.js 中是如何基于 inotify 机制 实现文件监听的。
我们通常会调用 http.createServer(cb).listen(port) 启动一个服务器,那么这个过程到底做了什么呢?listen 函数其实是对网络 API 的封装:
那么 Node.js 是如何处理连接的呢?当建立了一个 TCP 连接后,Node.js 会在 Poll IO 阶段执行对应的回调:
这就是 Node.js 处理一个连接的过程,处理完一个连接后,Node.js 会判断是否设置了 single_accept 标记,如果有则睡眠一段时间,给其他进程处理剩下的连接,一定程度上避免负责不均衡,如果没有设置该标记,Node.js 会继续尝试处理下一个连接。这就是 Node.js 处理连接的整个过程。
因为 UDP 是非连接、不可靠的协议,在实现和使用上相对比较简单,这里讲一下发送 UDP 数据的过程,当我们发送一个 UDP 数据包的时候,Libuv 会把数据先插入等待发送队列,接着在 epoll 中注册等待可写事件,当可写事件触发的时候,Libuv 会遍历等待发送队列,逐个节点发送,成功发送后,Libuv 会把节点移到发送成功队列,并往 pending 阶段插入一个节点,在 pending 阶段,Libuv 就会执行发送完成队列里每个节点的会调通知调用方发送结束。
因为通过域名查找 IP 或通过 IP 查找域名的 API 是阻塞式的,所以这两个功能是借助了 Libuv 的线程池实现的。发起一个查找操作的时候,Node.js 会往线程池提及一个任务,然后就继续处理其他事情,同时,线程池的子线程会调用底层函数做 DNS 查询,查询结束后,子线程会把结果交给主线程。这就是整个查找过程。
其他的 DNS 操作是通过 cares 实现的,cares 是一个异步 DNS 库,我们知道 DNS 是一个应用层协议,cares 就是实现了这个协议。我们看一下 Node.js 是怎么使用 cares 实现 DNS 操作的。