阅读 2755

《大前端进阶 Node.js》系列 多进程模型底层实现(字节跳动被问)

前言

Coding 应当是一生的事业,而不仅仅是 30 岁的青春饭
本文已收录 Github https://github.com/ponkans/F2E,欢迎 Star,持续更新

字节跳动面试官问:Node.js 多进程模型,以及多进程监听同一端口的底层原理是如何实现滴?

好朋友被字节跳动面试官这道题吊打了, 周末怪怪加班,写下这篇深入探究 Node.js 多进程架构的底层实现~ 纯干货,分享给大家!!!

很多小伙伴对一些基础,特别是底层不是很了解,顺带也可以好好补一下底层原理的基础哈 ~


每篇文章都希望你能收获到东西,这篇由浅入深讲 Node.js 多进程模型(后面会有些底层,小伙伴们做好心理准备哦~),希望看完能够有这些收获:

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

操作系统的进程与线程

之前的《吊打面试官》系列 Node.js 双十一秒杀系统中提了一下 Node 的多进程模型,本文将详细的讲解 Node 进程的各个细节

进程和线程,可以说是老僧长谈的话题了。

只要是从事计算机相关的小伙伴,提起这个大都思如泉涌,多线程~高并发~ 但各种零散的概念和认知或许难以汇成一个成体系的知识结构。我们先来罗列一下这两个概念简洁的官方解释。

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

看到上面两个定义,很多小伙伴小眉头可能会皱一下,啥@#¥%玩意。。怪怪给小伙伴们准备了下图帮助理解哈~。

感受进程

进程其实遍布在我们电脑的每个角落,刚刚被对面团灭的英雄联盟,浏览器上正在播放的小电影等等,都是一个个运行中的进程。

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

那我们总结下就是:运行中的代码+他占有的资源 = 进程

感受线程

讲完进程后,有些小伙伴可能懵了。

进程=运行的代码段+资源,那我们的线程存在的意义在哪?为什么不直接让进程去运行。

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

举个不完全恰当的栗子:我们把一家商场比做一台计算机,里面一个一个的店家就是进程,他们是商场资源的最小单位了,他们既有对应的资源,也在进行着商业活动,就如同一个有资源和在运行中的进程。

每个商铺里面的店员就是一个个线程,他们在自己的资源里各司其职,有人拉客,有人站台,有人把风。这些人才是真正调度的最小单位。

我们试想,如果资源的分配和调度是 1:1 的关系,那意味着一个商店里在活动的人同一时间只能有一个,当你在拉客的时候,其他人不可以在店里,你在站台的时候,其他人也只能在一边候着,但其实你们都是用的同一家店铺的资源。

这显然不 OK,所以进程同理,在进程中使用多线程就是让共享同一批资源的操作一起进行

这样可以极大的减少进程资源切换的开销。当我们在进行多个操作的时候,他们相互之间在切换时自然是越轻量越好。

就像玩手机的时候,你在刷微博,这时你忽然又想玩游戏,当你在这两个操作之间切换的时候,自然是越轻越好,你无需把手关机再重启,然后再打开游戏吧,不然这手机也太弱鸡了吧~~

进程与线程切换的代价

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

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

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

可以理解为线程上下文是进程上下文的子集

线程之下,协程

程序的编写总是追求最极致的性能优化,线程的出现让共享同一批资源的程序在切换时更轻量,那有没有比线程还要轻的呢?

协程的出现让这个变成了可能,线程和进程是操作系统的支持带来的优化,而协程本质上是一种应用层面的优化了。

这就如同线程和进程是天生的游戏奇才,超神玩家,协程是这位奇才觉得自己超神不够还想超鬼,是自己又做了后天努力。

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


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

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


协程一般来自语言的支持,如 Python,下面随意贴一段协程的 py 代码。里面做的事情也很简单,yield 是 python 当中的语法。

当函数执行到 yield 关键字时,会暂停在那一行(并非阻塞,只是应用层面的暂停),等到主线程调用 send 方法发送了数据,协程才会接到数据继续执行,个人感觉跟回调比较像。(Python yield 这个语法比较老旧,新语法使用 async/await)

下面是运行结果。

Linux 之线程进程

Linux 的设计总让人有种化繁为简的感觉,除了大家熟悉的一切皆文件,他对进程线程的设计也是类似的感觉,严格来说在 Linux 上并没有线程的概念,此话怎么说?

因为无论是进程还是线程,都要有存在的证明,你说你在世界上存在,你怎么证明呢?

进程的存在证明就是进程控制块,Linux 每一个进程都有其对应的控制块,里面包含了进程 id,需要的硬件资源,执行的代码段等等。线程亦如是,在 windows 中有明确的线程控制块,由操作系统来做线程调度。

Linux 视线程和进程是一样的,都用进程控制块进行管控,但这并不等于 Linux 不支持线程,只是不同操作系统对概念的抽象不同,Linux 提供 pthread 库来 fork 微进程,多个微进程可以共享资源,和线程本质上并无区别,只是没有提供专门的线程管控,有兴趣的同学可以详细了解下。

哦豁,是不是感觉怪怪有点东西了?别着急,接续往下看↓~

多 CPU,影分身~

要成为 nb 的业界大手,你要会哪些技能?

面试扛千亿并发,入职调按钮样式,哈哈哈。

这里有个概念是并发,与之烂兄烂弟的概念就是并行,让人意乱神迷傻傻分不清。

现在我们经常会听到各种名词,什么多核机器,多 cpu 什么的。多个 cpu 意味着什么呢?

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

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

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

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

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

关于 cpu 调度进程的策略,cpu 执行代码的细节,如果有兴趣可以留言,后续有时间可以安排,这里就不展开了

Node 之线程

Node 单线程

学习 Node 的第一天就看到过 Node 是个单进程单线程模型,他线程安全。嗯确实是线程安全。。但在后端同学看来就如同一个单身狗在说我是不会迷失在爱情里的,废话因为你本来就没有。。

如我们上面所讲,单线程再怎么秀,也只能在一个 cpu 上花里胡哨,对于我们要对标全栈的 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 上尽量编写非阻塞的代码

Node 多进程通信

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

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

此图来自网络
此图来自网络

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

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

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

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

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

我们来分析一下两个进程是如何建立起来通信的。

之前提到了进程会有自己的文件描述符表,我们在 fork 进程的时候父进程也会把自己的文件描述符拷贝给子进程。我们来看一段比较拙劣的 C 代码。(还记得大学刚开始学 C 时,指针带给你的困扰嘛)

我们分析一下上面代码,小伙伴们不必在意 C 的语法哈~,只需关注管道的建立过程

我们一开始调用 pipe(fd),传人的是一个 size 是 2 的空数组,如果创建成功,这个数组的 fd[0]就是读所用的文件描述符,fd[1]就是写所用的文件描述符。

这个时候,我们在当前进程调用 vfork(),create 出一个子进程,父子进程都持有这个 fd[]。

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

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

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

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

如果上面的解释还没看懂,请结合下面的图,再去理解一下,或者加群@接水怪,为你提供一对一私人服务!!!

此图来自网络
此图来自网络

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

极致的优化 — Node 句柄传递

要真正理解服务端为何能承受高并发,理解当前服务架构的核心,需要从网络到操作系统的每一个细节进行理解。

上面聊了一系列比较晦涩的装逼话题,接下来我们聊点相对实际的。我们写出来服务端是为了什么?

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

我们的请求本质就是去访问小电影服务器,服务器对应的端口收到了请求然后做相应处理并且返回结果。看小电影最不能接受的就是卡顿,比如说看建党伟业的时候,在下因为在听xxx宣言的时候卡住了捶胸顿足了好久,hhhh~~。

那服务端如何能不卡?上面我们的多进程如何用起来?

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

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

这个时候有小伙伴会问,为什么不直接让每个进程都去监听 80,干嘛还要转一次。这个思路很 OK。

But,最终会发现直接的监听最后只会有一个进程抢占端口成功,其他进程会抛出端口被占用的异常。为了解决这个问题,Node 用了另外一种架构模式。如下图。

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

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

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

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

这里的关键在于两个点。

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

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

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

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

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

总结

本文已收录 Github https://github.com/ponkans/F2E,欢迎 Star,持续更新💧

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

对进程、线程、cpu 有认知是最基本的,这样写项目才能对自己的每一行代码了然于心。

本文仅算是入门贴,真正的 Node 内核有待大家一一深入学习,如果对某一块有特别的兴趣可以在下面留言,直接加群来讨论,怪怪我等你!~

近期会针对 Node.js 写一个系列,同系列传送门:


喜欢的小伙伴加个关注,点个赞哦,感恩💕😊

联系我 / 公众号

微信搜索【接水怪】或扫描下面二维码回复”加群“,我会拉你进技术交流群。讲真的,在这个群,哪怕您不说话,光看聊天记录也是一种成长。(阿里技术专家、敖丙作者、Java3y、蘑菇街资深前端、蚂蚁金服安全专家、各路大牛都在)。

接水怪也会定期原创,定期跟小伙伴进行经验交流或帮忙看简历。加关注,不迷路,有机会一起跑个步🏃 ↓↓↓

关注下面的标签,发现更多相似文章
评论