Coding 应当是一辈子的事业,而不只仅是 30 岁的青春饭
本文已收录 Github https://github.com/ponkans/F2E,欢迎 Star,持续更新前端
字节跳动面试官问:Node.js 多进程模型,以及多进程监听同一端口的底层原理是如何实现滴?node
好朋友被字节跳动面试官这道题吊打了, 周末怪怪加班,写下这篇深刻探究 Node.js 多进程架构的底层实现~ 纯干货,分享给你们!!!python
不少小伙伴对一些基础,特别是底层不是很了解,顺带也能够好好补一下底层原理的基础哈 ~react
每篇文章都但愿你能收获到东西,这篇由浅入深讲 Node.js 多进程模型(后面会有些底层,小伙伴们作好心理准备哦~),但愿看完可以有这些收获:git
以前的《吊打面试官》系列 Node.js 双十一秒杀系统中提了一下 Node 的多进程模型,本文将详细的讲解 Node 进程的各个细节github
进程和线程,能够说是老僧长谈的话题了。面试
只要是从事计算机相关的小伙伴,提起这个大都思如泉涌,多线程~高并发~ 但各类零散的概念和认知或许难以汇成一个成体系的知识结构。咱们先来罗列一下这两个概念简洁的官方解释。windows
看到上面两个定义,不少小伙伴小眉头可能会皱一下,啥@#¥%玩意。。怪怪给小伙伴们准备了下图帮助理解哈~。
后端
进程其实遍及在咱们电脑的每一个角落,刚刚被对面团灭的英雄联盟,浏览器上正在播放的小电影等等,都是一个个运行中的进程。数组
进程实际上是处于执行期的程序和相关资源的总称,里面包含了要执行的代码段,须要用到的文件,端口,硬件资源,很常见的一种说法是进程是资源分配的最小单位,这句话更直白的说就是,要运行某个可执行的代码段会须要某些资源,当这个代码段运行起来的时候,这些资源也必须被分配给他。
那咱们总结下就是:运行中的代码+他占有的资源 = 进程。
讲完进程后,有些小伙伴可能懵了。
进程=运行的代码段+资源,那咱们的线程存在的意义在哪?为何不直接让进程去运行。
上面咱们提到了,进程是资源分配的最小单位,意味着进程和资源是1:1,与之对应的一句话就是,线程是调度的最小单位,进程和线程是一个1:n的关系。
举个不彻底恰当的栗子:咱们把一家商场比作一台计算机,里面一个一个的店家就是进程,他们是商场资源的最小单位了,他们既有对应的资源,也在进行着商业活动,就如同一个有资源和在运行中的进程。
每一个商铺里面的店员就是一个个线程,他们在本身的资源里各司其职,有人拉客,有人站台,有人把风。这些人才是真正调度的最小单位。
咱们试想,若是资源的分配和调度是 1:1 的关系,那意味着一个商店里在活动的人同一时间只能有一个,当你在拉客的时候,其余人不能够在店里,你在站台的时候,其余人也只能在一边候着,但其实大家都是用的同一家店铺的资源。
这显然不 OK,因此进程同理,在进程中使用多线程就是让共享同一批资源的操做一块儿进行。
这样能够极大的减小进程资源切换的开销。当咱们在进行多个操做的时候,他们相互之间在切换时天然是越轻量越好。
就像玩手机的时候,你在刷微博,这时你突然又想玩游戏,当你在这两个操做之间切换的时候,天然是越轻越好,你无需把手关机再重启,而后再打开游戏吧,否则这手机也太弱鸡了吧~~
既然说到了进程的切换,那咱们能够细探一下进程切换的开销。一个进程会独占一批资源,好比使用寄存器,内存,文件等。
当切换的时候,首先会保存现场,将一系列执行的中间结果保存起来,存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开的文件描述符的集合,这个状态叫作上下文。
而后在他恢复回来的时候又须要将上述资源切换回去。显而易见,切换的时候须要保存的资源越少,系统性能就会越好,线程存在的意义就在于此。线程有本身的上下文,包括惟一的整数线程 ID,栈、栈指针、程序计数器、通用目的寄存器和条件码。
能够理解为线程上下文是进程上下文的子集。
程序的编写老是追求最极致的性能优化,线程的出现让共享同一批资源的程序在切换时更轻量,那有没有比线程还要轻的呢?
协程的出现让这个变成了可能,线程和进程是操做系统的支持带来的优化,而协程本质上是一种应用层面的优化了。
这就如同线程和进程是天生的游戏奇才,超神玩家,协程是这位奇才以为本身超神不够还想超鬼,是本身又作了后天努力。
协程能够理解为特殊的函数,这个函数能够在某个地方挂起,而且能够从新在挂起处外继续运行,简单来讲,一个线程内能够由多个这样的特殊函数在运行,可是有一点必须明确的是,一个线程的多个协程的运行是串行的。
(圈重点啦)若是是多核 CPU,多个进程或一个进程内的多个线程是能够并行运行的,可是一个线程内协程却绝对是串行的,不管 CPU 有多少个核。
毕竟协程虽然是一个特殊的函数,但仍然是一个函数。一个线程内能够运行多个函数,但这些函数都是串行运行的。当一个协程运行时,其它协程必须挂起。
协程通常来自语言的支持,如 Python,下面随意贴一段协程的 py 代码。里面作的事情也很简单,yield 是 python 当中的语法。
当函数执行到 yield 关键字时,会暂停在那一行(并不是阻塞,只是应用层面的暂停),等到主线程调用 send 方法发送了数据,协程才会接到数据继续执行,我的感受跟回调比较像。(Python yield 这个语法比较老旧,新语法使用 async/await)
下面是运行结果。
Linux 的设计总让人有种化繁为简的感受,除了你们熟悉的一切皆文件,他对进程线程的设计也是相似的感受,严格来讲在 Linux 上并无线程的概念,此话怎么说?
由于不管是进程仍是线程,都要有存在的证实,你说你在世界上存在,你怎么证实呢?
进程的存在证实就是进程控制块,Linux 每个进程都有其对应的控制块,里面包含了进程 id,须要的硬件资源,执行的代码段等等。线程亦如是,在 windows 中有明确的线程控制块,由操做系统来作线程调度。
Linux 视线程和进程是同样的,都用进程控制块进行管控,但这并不等于 Linux 不支持线程,只是不一样操做系统对概念的抽象不一样,Linux 提供 pthread 库来 fork 微进程,多个微进程能够共享资源,和线程本质上并没有区别,只是没有提供专门的线程管控,有兴趣的同窗能够详细了解下。
哦豁,是否是感受怪怪有点东西了?别着急,接续往下看↓~
要成为 nb 的业界大手,你要会哪些技能?
面试扛千亿并发,入职调按钮样式,哈哈哈。
这里有个概念是并发,与之烂兄烂弟的概念就是并行,让人意乱神迷傻傻分不清。
如今咱们常常会听到各类名词,什么多核机器,多 cpu 什么的。多个 cpu 意味着什么呢?
首先要搞清楚 cpu 究竟是干吗的。cpu 的做用用两个字来说就是:计算。
咱们的各类花里胡哨的代码,最终编译完真正执行的时候也无非这两个字:计算。上面提到了进程必定是在运行的代码,那代码的运行必然就是在 CPU 上。
咱们有几个 cpu 意味着咱们能够有几个程序同时在计算,这就是并行,就如同小时候会想有鸣人的影分身,就可让他们一个来写数学,一个来写语文,一个来写英语。
与多核对应的就是苦逼的单核今计算机了,就像没有影分身的我,这个时候也有多个做业要作,咋整?半个小时写语文,半个小时写数学,再半个小时写语文,再来半小时写数学。。(强行时间片轮转了)这是语文数学英语也都同时写了,但实际上只有我苦逼的一我的,这就是分时并发,但非并行。
总结下就是并行必定并发,并发未必并行。
关于 cpu 调度进程的策略,cpu 执行代码的细节,若是有兴趣能够留言,后续有时间能够安排,这里就不展开了
学习 Node 的第一天就看到过 Node 是个单进程单线程模型,他线程安全。嗯确实是线程安全。。但在后端同窗看来就如同一个单身狗在说我是不会迷失在爱情里的,废话由于你原本就没有。。
如咱们上面所讲,单线程再怎么秀,也只能在一个 cpu 上花里胡哨,对于咱们要对标全栈的 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 上尽可能编写非阻塞的代码。
既然提到了主从进程,那避免不了的一个问题就是他们之间的通讯。
进程通讯的姿式不少,例如基于 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 的概念,根据不一样操做系统有不一样的底层实现,咱们上面讲到的双工管道通讯就是其中一种。
要真正理解服务端为什么能承受高并发,理解当前服务架构的核心,须要从网络到操做系统的每个细节进行理解。
上面聊了一系列比较晦涩的装逼话题,接下来咱们聊点相对实际的。咱们写出来服务端是为了什么?
目的天然是让别人来调用,想一想咱们平时调用服务的方式,最简单的就是咱们的 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、蘑菇街资深前端、蚂蚁金服安全专家、各路大牛都在)。
接水怪也会按期原创,按期跟小伙伴进行经验交流或帮忙看简历。加关注,不迷路,有机会一块儿跑个步🏃 ↓↓↓