首页 » 网站推广 » nodejsphptcp技巧_聊聊 Nodejs 的底层事理

nodejsphptcp技巧_聊聊 Nodejs 的底层事理

访客 2024-12-12 0

扫一扫用手机浏览

文章目录 [+]

之前分享了 Node.js 的底层事理,紧张是大略先容了 Node.js 的一些根本事理和一些核心模块的实现,本文从 Node.js 整体方面先容 Node.js 的底层事理。

内容紧张包括五个部分。
第一部分是首先先容一下 Node.js 的组成和代码架构。
然后先容一下 Node.js 中的 Libuv, 还有 V8 和模块加载器。
末了先容一下 Node.js 的做事器架构。

nodejsphptcp技巧_聊聊 Nodejs 的底层事理

1 Node.js 的组成和代码架构

下面先来看一下Node.js 的组成。
Node.js 紧张是由 V8、Libuv 和一些第三方库组成。

nodejsphptcp技巧_聊聊 Nodejs 的底层事理
(图片来自网络侵删)

1). V8 我们都比较熟习,它是一个 JS 引擎。
但是它不仅实现了 JS 解析和实行,它还是自定义拓展。
比如说我们可以通过 V8 供应一些 C++ API 去定义一些全局变量,这样话我们在 JS 里面去利用这个变量了。
正是由于 V8 支持这个自定义的拓展,以是才有了 Node.js 等 JS 运行时。

2). Libuv 是一个跨平台的异步 IO 库。
它紧张的功能是它封装了各个操作系统的一些 API, 供应网络还有文件进程的这些功能。
我们知道在 JS 里面是没有网络文件这些功能的,在前端时,是由浏览器供应的,而在 Node.js 里,这些功能是由 Libuv 供应的。

3). 其余 Node.js 里面还引用了很多第三方库,比如 DNS 解析库,还有 HTTP 解析器等等。

接下来看一下 Node.js 代码整体的架构。

Node.js 代码紧张是分为三个部分,分别是C、C++ 和 JS。

1. JS 代码便是我们平时在利用的那些 JS 的模块,比方说像 http 和 fs 这些模块。

2. C++ 代码紧张分为三个部分,第一部分紧张是封装 Libuv 和第三方库的 C++ 代码,比如net 和 fs 这些模块都会对应一个 C++ 模块,它紧张是对底层的一些封装。
第二部分是不依赖 Libuv 和第三方库的 C++ 代码,比方像 Buffer 模块的实现。
第三部分 C++ 代码是 V8 本身的代码。

3. C 措辞代码紧张是包括 Libuv 和第三方库的代码,它们都是纯 C 措辞实现的代码。

理解了 Nodejs 的组成和代码架构之后,再来看一下 Node.js 中各个紧张部分的实现。

2 Node.js 中的 Libuv

首先来看一下 Node.js 中的 Libuv,下面从三个方面先容 Libuv。

1). 先容 Libuv 的模型和限定

2). 先容线程池办理的问题和带来的问题

3). 先容事宜循环

2.1 Libuv 的模型和限定

Libuv 实质上是一个生产者消费者的模型。

从上面这个图中,我们可以看到在 Libuv 中有很多种生产任务的办法,比如说在一个回调里,在 Node.js 初始化的时候,或者在线程池完成一些操作的时候,这些办法都可以生产任务。
然后 Libuv 会不断的去消费这些任务,从而驱动着全体进程的运行,这便是我们一贯说的事宜循环。

但是生产者的消费者模型存在一个问题,便是消费者和生产者之间,怎么去同步?比如说在没有任务消费的时候,这个消费者他该当在干嘛?第一种办法是消费者可以就寝一段韶光,睡醒之后,他会去判断有没有任务须要消费,如果有的话就连续消费,如果没有的话他就连续就寝。
很显然这种办法实在是比较低效的。
第二种办法是消费者会把自己挂起,也便是说这个消费所在的进程会被挂起,然后等到有任务的时候,操作系统就会唤醒它,相对来说,这种办法是更高效的,Libuv 里也正是利用这种办法。

这个逻辑紧张是由事宜驱动模块实现的,下面看一下事宜驱动的大致的流程。

运用层代码可以通过事宜驱动模块订阅 fd 的事宜,如果这个事宜还没有准备好的话,那么这个进程就会被挂起。
然后等到这个 fd 所对应的事宜触发了之后,就会通过事宜驱动模块回调运用层的代码。

下面以 Linux 的 事宜驱动模块 epoll 为例,来看一下利用流程。

1. 首先通过 epoll_create 去创建一个epoll 实例。

2. 然后通过 epoll_ctl 这个函数订阅、修正或者取消订阅一个 fd 的一些事宜。

3. 末了通过 epoll_wait 去判断当前订阅的事宜有没有发生,如果有事情要发生的话,那么就直接实行上层回调,如果没有事宜发生的话,这种时候可以选择不壅塞,定时壅塞或者一贯壅塞,直到有事宜发生。
要不要壅塞或者说壅塞多久,是根据当前系统的情形。
比如 Node.js 里面如果有定时器的节点的话,那么 Node.js 就会定时壅塞,这样就可以担保定时器可以按时实行。

接下来再深入一点去看一下 epoll 的大致的实现。

当运用层代码调用事宜驱动模块订阅 fd 的事宜时,比如说这里是订阅一个可读事宜。
那么事宜驱动模块它就会往这个 fd 的行列步队里面注册一个回调,如果当前这个事宜还没有触发,这个进程它就会被壅塞。
等到有一块数据写入了这个 fd 时,也便是说这个 fd 有可读事宜了,操作系统就会实行事宜驱动模块的回调,事宜驱动模块就会相应的实行用层代码的回调。

但是 epoll 存在一些限定。
首先第一个是不支持文件操作的,比方说文件读写这些,由于操作系统没有实现。
第二个是不适宜实行耗时操作,比如大量 CPU 打算、引起进程壅塞的任务,由于 epoll 常日是搭配单线程的,如果在单线程里实行耗时任务,就会导致后面的任务无法实行。

2.2 线程池办理的问题和带来的问题

针对这个问题,Libuv 供应的办理方案便是利用线程池。
下面来看一下引入了线程池之后, 线程池和主线程的关系。

从这个图中我们可以看到,当运用层提交任务时,比方说像 CPU 打算还有文件操作,这种时候不是交给主线程去处理的,而是直接交给线程池处理的。
线程池处理完之后它会关照主线程。

但是引入了多线程后会带来一个问题,便是怎么去担保上层代码跑在单个线程里面。
由于我们知道 JS 它是单线程的,如果线程池处理完一个任务之后,直接实行上层回调,那么上层代码就会完备乱了。
这种时候就须要一个异步关照的机制,也便是说当一个线程它处理完任务的时候,它不是直接去实行上程回调的,而是通过异步机制去关照主线程来实行这个回调。

Libuv 中详细通过 fd 的办法去实现的。
当线程池完成任务时,它会以原子的办法去修正这个 fd 为可读的,然后在主线程事宜循环的 Poll IO 阶段时,它就会实行这个可读事宜的回调,从而实行上层的回调。
可以看到,Node.js 虽然是跑在多线程上面的,但是所有的 JS 代码都是跑在单个线程里的,这也是我们常常谈论的 Node.js 是单线程还是多线程的,从不同的角度去看就会得到不同的答案。

下面的图便是异步任务处理的一个大致过程。

比如我们想读一个文件的时候,这时候主线程会把这个任务直接提交到线程池里面去处理,然后主线程就可以连续去做自己的事情了。
当在线程池里面的线程完成这个任务之后,它就会往这个主线程的行列步队里面插入一个节点,然后主线程在 Poll IO 阶段时,它就会去实行这个节点里面的回调。

2.3 事宜循环

理解 Libuv 的一些核心实现之后,下面我们再看一下 Libuv 中一个著名的事宜循环。
事宜循环紧张分为七个阶段,

1. 第一是 timer 阶段,timer 阶段是处理定时器干系的一些任务,比如 Node.js 中的 setTimeout和 setInterval。

2. 第二是 pending 的阶段, pending 阶段紧张处理 Poll IO 阶段实行回调时产生的回调。

3. 第三是 check、prepare 和 idle 三个阶段,这三个阶段紧张处理一些自定义的任务。
setImmediate 属于 check 阶段。

4. 第四是 Poll IO 阶段,Poll IO 阶段紧张要处理跟文件描述符干系的一些事宜。
5. 第五是 close 阶段, 它紧张是处理,调用了 uv_close 时传入的回调。
比如关闭一个 TCP 连接时传入的回调,它就会在这个阶段被实行。

下面这个图是各个阶段在事宜循环的顺序图。

下面我们来看一下每个阶段的实现。

1. 定时器

Libuv 在底层里面掩护了一个最小堆,每个定时节点便是堆里面的一个节点(Node.js 只用了 Libuv 的一个定时器节点),越早超时的节点就在越上面。
然后等到定时期阶段的时候, Libuv 就会从上往下去遍历这个最小堆判断当前节点有没有超时,如果没有到期的话,那么后面节点也不须要去判断了,由于最早到期的节点都没到期,那么它后面节点也显然不会到期。
如果当前节点到期了,那么就会实行它的回调,并且把它移出这个最小堆。
但是为了支持类似 setInterval 这种场景。
如果这个节点设置了repeat 标记,那么这个节点它会被重新插入到最小堆中,等待下一次的超时。

2. check、idle、prepare 阶段和 pending、close 阶段。

这五个阶段的实现实在类似的,它们都对应自己的一个任务行列步队。
当产生任务的时候,它就会往这个行列步队里面插入一个节点,等到相应的阶段时,它就会去遍历这个行列步队里面的每个节点,并且实行它的回调。
但是 check idle 还有 prepare 阶段有一个比较特殊的地方,便是当这些阶段的节点回调被实行之后,它还会重新插入行列步队里面,也是说这三个阶段它对应的任务在每一轮的事宜循环都会被实行。

3. Poll IO 阶段 Poll IO 实质上是对前面讲的事宜驱动模块的封装。
下面来看一下整体的流程。

当我们订阅一个 fd 的事宜时,Libuv 就会通过 epoll 去注册这个 fd 对应的事宜。
如果这时候事宜没有就绪,那么进程就会壅塞在 epoll_wait 中。
等到这事宜触发的时候,进程就会被唤醒,唤醒之后,它就遍历 epoll 返回了事宜列表,并实行上层回调。

现在有一个底层能力,那么这个底层能力是怎么暴露给上层的 JS 去利用呢?这种时候就须要用到 JS 引擎 V8了。

3. Node.js 中的 V8

下面从三个方面先容 V8。

1. 先容 V8 在 Node.js 的浸染和 V8 的一些根本观点

2. 先容如何通过 V8 实行 JS 和拓展 JS

3. 先容如何通过 V8 实现 JS 和 C++ 通信

3.1 V8 在 Node.js 的浸染和根本观点

V8 在 Node.js 里面紧张是有两个浸染,第一个是卖力解析和实行 JS。
第二个是支持拓展 JS 能力,作为这个 JS 和 C++ 的桥梁。
下面我们先来看一下 V8 里面那些主要的观点。

1. Isolate:首先第一个是 Isolate 它是代表一个 V8 的实例,它相称于这一个容器。
常日一个线程里面会有一个这样的实例。
比如说在 Node.js主线程里面,它就会有一个 Isolate 实例。

2. Context:Context 是代表我们实行代码的一个高下文,它紧张是保存像 Object,Function 这些我们平时常常会用到的内置的类型。
如果我们想拓展 JS 功能,就可以通过这个工具实现。

3. ObjectTemplate:ObjectTemplate 是用于定义工具的模板,然后我们就可以基于这个模板去创建工具。

4. FunctionTemplate:FunctionTemplate 和 ObjectTemplate 是类似的,它紧张是用于定义一个函数的模板,然后就可以基于这个函数模板去创建一个函数。

5. FunctionCallbackInfo:用于实现 JS 和 C++ 通信的工具。

6. Handle:Handle 是用管理在 V8 堆里面那些工具,由于像我们平时定义的工具和数组,它是存在 V8 堆内存里面的。
Handle 便是用于管理这些工具。

7. HandleScope:HandleScope 是一个 Handle 容器,HandleScope 里面可以定义很多 Handle,它紧张是利用自己的生命周期管理多个 Handle。

下面我们通过一个代码来看一下 HandleScope 和 Handle 它们之间的关系。

首先第一步新建一个 HandleScope,就会在一个栈里面定义一个 HandleScope 工具。
然后第二步新建了一个 Handle 并且把它指向一个堆工具。
这时候就会在栈里面分配一个叫 Local 工具,然后在堆里面分配一块 slot 所代表的内存和一个 Object 工具,并且建立关联关系。
当实行完这个函数的时候,这个栈就会被清空,相应的这个 slot 代表的内存也会被开释,但是 Object 所代表这个工具,它是不会立马被开释的,它会等待 GC 的回收。

3.2 通过 V8 实行 JS 和拓展 JS

理解了 V8 的根本观点之后,来看一下怎么通过 V8 实行一段 JS 的代码。

首先第一步新建一个 Isolate,它这表示一个隔离的实例。
第二步定义一个 HandleScope 工具,由于我们下面须要定义 Handle。
第三步定义一个 Context,这是代码实行所在的高下文。
第四步定义一些须要被实行的 JS 代码。
第五步通过 Script 工具的 Compile 函数编译 JS 代码。
编译完之后,我们会得到一个 Script 工具,然后实行这个工具的 Run 函数就可以完成代码的实行。

接下来再看一下怎么去拓展 JS 原有的一些能力。

首先第一步是通过 Context 高下文工具拿到一个全局的工具,类似于在前端里面的 window 工具。
第二步通过 ObjectTemplate 新建一个工具的模板,然后接着会给这个工具模板设置一个 test 属性, 值是函数。
接着通过这个工具模板新建一个工具,并且把这个工具设置到一个全局变量里面去。
这样我们就可以在 JS 层去访问这个全局工具。

下面我们通过利用刚才定义那个全局工具来看一下 JS 和 C++ 是怎么通信的。

3.3 通过 V8 实现 JS 和 C++ 层通信

当在 JS 层调用刚才定义 test 函数时,就会相应的实行 C++ 层的 test 函数。
这个函数有一个入参是 FunctionCallbackInfo,在 C++ 中可以通过这个工具拿到 JS 传来一些参数,这样就完成了 JS 层到 C++ 层通信。
经由一系列处理之后,还是可以通过这个工具给 JS 层设置须要返回给 JS 的内容,这样可以完成了 C++ 层到 JS 层的通信。

现在有了底层能力,有了这一层的接口,但是我们是怎么去加载后实行 JS 代码呢?这时候就须要模块加载器。

4 Node.js 中的模块加载器

Node.js 中有五种模块加载器。

1. JSON 模块加载器

2. 用户 JS 模块加载器

3. 原生 JS 模块加载器

4. 内置 C++ 模块加载器

5. Addon 模块加载器

现在来看下每种模块加载器。

4.1 JSON 模块加载器

JSON 模块加载器实现比较大略,Node.js 从硬盘里面把 JSON 文件读到内存里面去,然后通过 JSON.parse 函数进行解析,就可以拿到里面的数据。

4.2 用户 JS 模块

用户 JS 模块便是我们平时写的一些 JS 代码。
当通过 require 函数加载一个用户 JS 模块时,Node.js 就会从硬盘读取这个模块的内容到内存中,然后通过 V8 供应了一个函数叫 CompileFunctionInContext 把读取的代码封装成一个函数,接着新建立一个 Module 工具。
这个工具里面有两个属性叫 exports 和 require 函数,这两个工具便是我们平时在代码里面所利用的变量,接着会把这个工具作为函数的参数,并且实行这个函数,实行完这个函数的时候,就可以通过 module.exports 拿到这个函数(模块)里面导出的内容。
这里须要把稳的是这里的 require 函数是可以加载原生 JS 模块和用户模块的,以是我们平时在我们代码里面,可以通过require 加载我们自己写的模块,或者 Node.js 本身供应的 JS 模块。

4.3 原生 JS 模块

接下来看下原生 JS 模块加载器。
原生JS 模块是 Node.js 本身供应了一些 JS 模块,比如常常利用的 http 和 fs。
当通过 require 函数加载 http 这个模块的时候,Node.js 就会从内存里读取这个模块所对应内容。
由于原生 JS 模块默认是打包进内存里面的,以是直接从内存里面读就可以了,不须要从硬盘里面去读。
然后还是通过 V8 供应的 CompileFunctionInContext 这个函数把读取的代码封装成一个函数,接着新建一个 NativeModule 工具,同样这个工具里面也是有个 exports 属性,接着它会把这个工具传到这个函数里面去实行,实行完这函数之后,就可以通过 module.exports 拿到这个函数里面导出的内容。
须要把稳是这里传入的 require 函数是一个叫 NativeModuleRequire 函数,这个函数它就只能加载原生 JS 模块。
其余这里还传了其余一个 internalBinding 函数,这个函数是用于加载 C++ 模块的,以是在原生 JS 模块里面,是可以加载 C++ 模块的。

4.4 C++ 模块

Node.js 在初始化的时候会注册 C++ 模块,并且形成一个 C++ 模块链表。
当加载 C++ 模块时,Node.js 就通过模块名,从这个链表里面找到对应的节点,然后去实行它里面的钩子函数,实行完之后就可以拿到 C++ 模块导出的内容。

4.5 Addon 模块

接着再来看一下 Addon 模块, Addon 模块实质上是一个动态链接库。
当通过 require 加载Addon 模块的时候,Node.js 会通过 dlopen 这个函数去加载这个动态链接库。
下图是我们定义一个 Addon 模块时的一个标准格式。

它里面有一些 C措辞宏,宏展开之后里面内容像下图所示。

里面紧张定义了一个构造体和一个函数,这个函数会把这个构造体赋值给 Node.js 的一个全局变量,然后 Nodejs 它就可以通过全局变量拿到这个构造体,并且实行它里面的一个钩子函数,实行完之后就可以拿到它里面要导出的一些内容。

现在有了底层的能力,也有了这一次层的接口,也有了代码加载器。
末了我们来看一下 Node.js 作为一个做事器的时候,它的架构是怎么样的?

5 Node.js 的做事器架构

下面从两个方面先容 Node.js 的做事器架构

1. 先容做事器处理 TCP 连接的模型

2. 先容 Node.js 中的实现和存在的问题

5.1 处理 TCP 连接的模型

首先来看一下网络编程中怎么去创建一个 TCP 做事器。

int fd = socket(…); bind(fd, 监听地址); listen(fd);

首先建一个 socket, 然后把须要监听的地址绑定到这个 socket 中,末了通过 listen 函数启动做事器。
启动做事器之后,那么怎么去处理 TCP 连接呢?

1). 串行处理(accept 和 handle 都会引起进程壅塞)

第一种处理办法是串行处理,串行办法便是在一个 while 循环里面,通过 accept 函数不断地摘取 TCP 连接,然后处理它。
这种办法的缺陷便是它每次只能处理一个连接,处理完一个连接之后,才能连续处理下一个连接。

2). 多进程/多线程

第二种办法是多进程或者多线程的办法。
这种办法紧张是利用多个进程或者线程同时处理多个连接。
但这种模式它的缺陷便是当流量非常大的时候,进程数或者线程数它会成为这种架构下面的一个瓶颈,由于我们不能无限的创建进程或者线程,像 Apache 还有 PHP 便是这种架构的。

3). 单进程单线程 + 事宜驱动( Reactor & Proactor ) 第三种便是单线程 + 事宜驱动的模式。
这种模式下有两种类型,第一种叫 Reactor, 第二种叫 Proactor。
Reactor 模式便是运用程序可以通过事宜驱动模块注册 fd 的读写事宜,然后事宜触发的时候,它就会通过事宜驱动模块回调上层的代码。

Proactor 模式便是运用程序可以通过事宜驱动模块注册 fd 的读写完成事宜,然后这个读写完成事宜后就会通过事宜驱动模块回调上层代码。

我们看到这两种模式的差异是,数据读写是由内核完成的,还是由运用程序完成的。
很显然,通过内核去完成是更高效的,但是由于 Proactor 这种模式它兼容性还不是很好,以是目前用的还不算太多,紧张目前主流的一些做事器,它用的都是 Reactor 模式。
比方说像 Node.js、Redis 和 Nginx 这些做事器用的都是这种模式。

刚才提到 Node.js 是单进程单线程加事宜驱动的架构。
那么单线程的架构它怎么去利用多核呢?这种时候就须要用到多进程的这种模式了,每一个进程里面会包含一个Reactor 模式。
但是引入多进程之后,它会带来一个问题,便是多进程之间它怎么去监听同一个端口。

5.2 Node.js 的实现和问题

下面来看下针对多进程监听同一个端口的一些办理办法。

1. 主进程监听端口并吸收要求,轮询分发(轮询模式)

2. 子进程竞争吸收要求(共享模式)

3. 子进程负载均衡处理连接(SO_REUSEPORT 模式)

第一种办法便是主进程去监听这个端口,并且吸收连接。
它吸收连接之后,通过一定的算法(比如轮询)分发给各个子进程。
这种模式。
它的一个缺陷便是当流量非常大的时候,这个主进程就会成为瓶颈,由于它可能都来不及吸收或者分发这个连接给子进程去处理。

第二种便是主进程创建监听 socket, 然后子进程通过 fork 的办法继续这个监听的 socket, 当有一个连接到来的时候,操作系统就唤醒所有的子进程,所有子进程会以竞争的办法吸收连接。
这种模式,它的缺陷紧张是有两个,第一个便是负载均衡的问题,由于操作系统唤醒了所有的进程,可能会导致某一个进程一贯在处理连接,其他其它进程都没机会处理连接。
然后其余一个问题便是惊群的问题,由于操作系统唤起了所有的进程,但是只有一个进程它会处理这个连接,然后剩下进程就会被无效地唤醒。
这种办法会造成一定的性能的丢失。

第三种通过 SO_REUSEPORT 这个标记来办理刚才提到的两个问题。
在这种模式下,每个子进程都会有一个独立的监听 socket 和连接行列步队。
当有一个连接到来的时候,操作系统会把这个连接分发给某一个子进程并且唤醒它。
这样就可以办理惊群的问题,由于它只会唤醒一个子进程。
又由于操作系统分发这个连接的时候,内部是有一个负载均衡的算法。
以是这样的话又可以办理负载均衡的问题。

接下来我们看一下 Node.js 中的实现。

1). 轮询模式。
在这种模式下,主进程会 fork 多个子进程,然后每个子进程里面都会调用 listen 函数。
但是 listen 函数不会监听一个端口,它会要求主进程监听这个端口,当有连接到来的时候,这个主进程就会吸收这个连接,然后通过文件描述符的办法传给各个子进程去处理。

2). 共享模式 共享模式下,主进程同样还是会 fork 多个子进程,然后每个子进程里面还是会实行 listen 函数,但同样的这个 listen 函数不会监听一个端口,它会要求主进程创建一个 socket 并绑定到一个须要监听的地址,接着主进程会把这个 socket 通过文件描述符通报的办法传给多个子进程,这样就可以达到多个子进程同时监听同一个端口的效果。

通过刚才先容,我们可以知道 Node.js 的做事器架构存在的问题。
如果我们利用轮询模式,当流量比较大的时候,那么这个主进程就会成为系统瓶颈。
如果我们利用共享模式,就会存在惊群和负载均衡的问题。
不过在 Libuv 里面,可以通过设置 UV_TCP_SINGLE_ACCEPT 环境变量来一定程度缓解这个问题。
当我们设置了这个环境变量。
Libuv 在吸收完一个连接的时候,它就会休眠一会,让其它进程也有吸收连接的机会。

末了来总结一下,本文的内容。
Node.js 里面通过 Libuv 办理了操作系统干系的问题。
通过 V8 办理了实行 JS 和拓展 JS 功能的问题。
通过模块加载器办理了代码加载还有组织的问题。
通过多进程的做事器架构,使得 Node.js 可以利用多核,并且办理了多个进程监听同一个端口的问题。

下面是一些资料,有兴趣的同学也可以看一下。

1. 基于 epoll + V8 的JS 运行时 Just:

https://github.com/theanarkh/read-just-0.1.4-code

2. 基于 io_uring+ V8 的 JS 运行时 No.js:

https://github.com/theanarkh/No.js

3. 理解 Node.js 事理:

https://github.com/theanarkh/understand-nodejs

标签:

相关文章

phpcmf手册技巧_SHANKECMF内容治理框架

系统采取大略的模板标签,只要懂HTML就可快速开拓企业网站。官方将致力于为广大开拓者和企业供应最佳的网站开拓培植办理方案。一个上让...

网站推广 2024-12-14 阅读0 评论0