Node.js 多进程底层原理

  • 彻底搞懂进程、线程、协程之间的关系
  • 彻底搞懂 Node.js 的多进程模型
  • 如何用有限的计算机资源,搭建更高性能的服务端

先来罗列一下这两个概念简洁的官方解释

对操作系统来说,线程是最小的执行单元,进程是最小的资源管理单元。

  • 进程:处于执行期的代码,正在运行的程序,它不仅包括目标代码,还有数据、资源、状态和虚拟的计算机。
  • 线程:在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”。

image-20200323230147832

进程

运行中的代码+他占有的资源 = 进程

进程其实是处于执行期的程序和相关资源的总称,里面包含了要执行的代码段,需要用到的文件,端口,硬件资源,很常见的一种说法是进程是资源分配的最小单位,这句话更直白的说就是,要运行某个可执行的代码段会需要某些资源,当这个代码段运行起来的时候,这些资源也必须被分配给他。

线程

上面我们提到了,进程是资源分配的最小单位,意味着进程和资源是1:1,与之对应的一句话就是,线程是调度的最小单位,进程和线程是一个1:n的关系。

进程和线程存在的问题

1.涉及到同步锁。

2.涉及到线程阻塞状态和可运行状态之间的切换。

3.涉及到线程上下文的切换。

既然说到了进程的切换,那我们可以细探一下进程切换的开销。一个进程会独占一批资源,比如使用寄存器,内存,文件等。

当切换的时候,首先会保存现场,将一系列执行的中间结果保存起来,存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开的文件描述符的集合,这个状态叫做上下文

然后在他恢复回来的时候又需要将上述资源切换回去。显而易见,切换的时候需要保存的资源越少,系统性能就会越好,线程存在的意义就在于此。线程有自己的上下文,包括唯一的整数线程 ID,栈、栈指针、程序计数器、通用目的寄存器和条件码。

协程

线程和进程是操作系统的支持带来的优化,而协程本质上是一种应用层面的优化了。

协程可以理解为特殊的函数,这个函数可以在某个地方挂起,并且可以重新在挂起处外继续运行,简单来说,一个线程内可以由多个这样的特殊函数在运行,但是有一点必须明确的是,一个线程的多个协程的运行是串行的。

小结

如果是多核 CPU,多个进程或一个进程内的多个线程是可以并行运行的,但是一个线程内协程却绝对是串行的,无论 CPU 有多少个核。

毕竟协程虽然是一个特殊的函数,但仍然是一个函数。一个线程内可以运行多个函数,但这些函数都是串行运行的。当一个协程运行时,其它协程必须挂起。

并发和并行

首先要搞清楚 cpu 到底是干嘛的。cpu 的作用用两个字来讲就是:计算

我们的各种花里胡哨的代码,最终编译完真正执行的时候也无非这两个字:计算。上面提到了进程一定是在运行的代码,那代码的运行必然就是在 CPU 上。

我们有几个 cpu 意味着我们可以有几个程序同时在计算,这就是并行,就如同小时候会想有鸣人的影分身,就可以让他们一个来写数学,一个来写语文,一个来写英语。

与多核对应的就是苦逼的单核今计算机了,就像没有影分身的我,这个时候也有多个作业要做,咋整?半个小时写语文,半个小时写数学,再半个小时写语文,再来半小时写数学。。(强行时间片轮转了)这是语文数学英语也都同时写了,但实际上只有我苦逼的一个人,这就是分时并发,但非并行。

总结下就是并行一定并发,并发未必并行

Node 中的线程

Node 设计成是个单进程单线程模型。

Node 多进程模型

既然一个 Node 进程只能有一个线程,那想通过单进程多线程的姿势来压榨 cpu(类似于 Java)应该是黄了,但 Node 支持多进程模型。

Node 提供了 child_process 模块,通过 child_process.fork()函数来进行进程的复制。

如下图,master 调用 child_process.fork 进程,被 fork 出的进程为 worker。

child_process 模块给予 Node 创建子进程的能力,父进程与子进程之间是一种 master/worker 的工作模式。

这种模式在分布式系统中随处可见,但高手总是能撒豆成兵,Node 在单机上对父子进程采用了这种管理模式,这种模式很像经典的 reactor 模式(只是 reactor 是主线程),利用父进程来做主进程,并且将任务 dispatch 到 worker 进程。

通常会阻塞的操作分发给 worker 来执行(查 db,读文件,进程耗时的计算等等),master 上尽量编写非阻塞的代码

image-20200324145141803

Node 多进程通信

既然提到了主从进程,那避免不了的一个问题就是他们之间的通信。

进程通信的姿势很多,例如基于 socket,基于管道,基于 mmap 内存映射等等,这里我们主要讨论Node 的通信,这里和大家先简单的讲解两个概念:文件描述符管道

image-20200324145219781

文件描述符

文件描述符是操作系统用来做文件管理的一个概念,如上图所示,每个进程会有一个自己的文件描述符表,里面包含了文件描述符标志和文件指针,每个进程自己的表都是从 0 开始,然后由文件指针来指向同一个系统级的打开文件表,打开文件表里面会记录文件偏移量(这个文件被读写到了哪个位置)、inode 指针。

再由 inode 指针来指向系统级的 inode 表,inode 表就是真正维护操作系统文件本身的一个实体了,里面包含了文件类型,大小,create time 等等~

其实系统中的文件描述符不一定是指向一个磁盘文件,也可以能是指向一个网络的 socket 这种,站在Linux的角度上来说,操作系统把一切都抽象为文件,网络数据,磁盘数据等等,都是用文件描述符来做维护。

讲了文件描述符,我们可以大致感知到进程要读东西,一定需要一个媒介,那我们父子进程之间的通信也一定需要一个介质来通信。

接下来我们抛出管道的概念,如同其名字,管道一定是用来连通两个东西的,就像家里的水管,一个入口,一个出口。

管道

进程会有自己的文件描述符表,我们在 fork 进程的时候父进程也会把自己的文件描述符拷贝给子进程。

如果我们判断是子进程,就关闭他的读文件描述符,如果是父进程,就关闭他的写文件描述符

这时,如下图所示,我们会实现一个单向通信,操作系统调用 pipe(创建管道)的时候,会新建一片内存空间,这片内存专用与两个进程通信,这应证了我们上面所说的,系统会把很多东西抽象成文件,比如这里就是把那一片共用内存抽象了起来,之后子进程通过 fd[1],往那片内存区域写入数据,父进程通过 fd[0]来读,这里就实现了一个单工通信

或许上面讲的有点晦涩,我们来举一个不完全恰当的栗子,你住长江头,妹子住长江尾,河流就像你们之间的管道,你想跟她之间有所交流咋整?只需写一封信,顺着江流流下去(write),她在那边接收就行(read)。你们之间就是一个单向的管道通信。

但单向肯定是不行的,如何实现一个双工通信呢,很简单,用两个管道就 OK 了。

image-20200324153328522

接下来我们回到最初的起点,Node 之间的进程如何通信,其实也不过如此。Node 自己抽象了一个 libuv 的概念,根据不同操作系统有不同的底层实现,我们上面讲到的双工管道通信就是其中一种。

image-20200324153346587

Node 句柄传递

想想我们平时调用服务的方式,最简单的就是我们的 http,用浏览器发起小电影请求,小电影服务端接收到并返回结果,然后开始一个个不眠的夜晚。

我们的请求本质就是去访问小电影服务器,服务器对应的端口收到了请求然后做相应处理并且返回结果。看小电影最不能接受的就是卡顿。

image-20200324153646514

上图是一种可以实现的架构,由 master 监听默认的 80 端口,用户的请求都打在 80 上,其他子进程监听一个别的端口,当父进程收到后往子进程监听的端口写数据,子进程来做处理。

这里看似可以实现,实则浪费了太多文件描述符,上面讲到了每个进程都有文件描述符表,而每个 socket 的读写也是基于文件描述符,操作系统的文件描述符是有限的,这样的设计显然不够优雅,拓展性不强。

为什么不直接让每个进程都去监听 80,干嘛还要转一次?But,最终会发现直接的监听最后只会有一个进程抢占端口成功,其他进程会抛出端口被占用的异常

为了解决这个问题,Node 用了另外一种架构模式。

image-20200324153825732

一开始依然是 master 进程监听 80,当收到用户请求之后,master 并不是直接把这些数据扔给 worker,而是在 80 端口接收到数据后,生成对应的 socket,再把该 socket 对应的文件描述符通过管道传给 worker,一个 socket 意味着服务端和客户端的一个数据通道,也就意味着 master 把跟客户端的数据通道传给了 worker。

如下图,在之后 master 停止监听 80port,因为已经把文件描述符给了 worker,之后 worker 直接监听这个套接字即可。

于是就有了下面那种模式,多个 worker 直接监听同一个 port。

image-20200324153851556

这个时候小伙伴们可能很疑惑,为啥这个时候不会端口冲突??

这里的关键在于两个点。

第一个是,Node 对每个端口监听设置了SO_REUSEADRR,表示可以允许这个端口被多个进程监听。

第二个点是,用这个的前提是每个监听这个端口的进程,监听的文件描述符要相同。

之前讲文件描述符的时候提到过,文件描述符表是每个进程私有的,相互之间不可见,那对这个端口他们也会有各自的文件描述符,这样就无法利用 SO_REUSEADRR 的特性。

那为什么通过 master 传给 worker 就可以了呢?

因为 master 在与 worker 通信的时候,每个子进程收到的文件描述符都是一样的(通过 master 传入,不理解的参见上面双工通信的讲解),这个时候就是所有子进程监听相同的 socket 文件描述符,就可以实现多个进程监听同一个端口的目标啦~😝😝😝。

总结

所以,Node 利用 master/worker 模式来利用多核资源,利用 SO_REUSEADRR 与句柄(文件描述符)传递来使多个进程同时监听同一个端口,提高吞吐量。