Node.js 不是单线程的
Node.js 以其革命性的单线程架构而闻名,被誉为速度极快的服务器平台,能够更高效地利用服务器资源。但是,真的有可能仅使用一个线程就实现如此惊人的性能吗?答案或许会让你大吃一惊。
本文将以非常简单的方式揭示Node.js背后的所有秘密和魔力。
进程 vs 线程 ⚙️
在开始之前,我们必须了解什么是过程和线程,并发现它们之间的异同。
进程是程序当前正在执行的一个实例。每个进程独立于其他进程运行。进程拥有多种重要资源:
- 执行代码;
- 数据段 - 包含需要从程序任何部分访问的全局变量和静态变量;
- 堆 - 动态内存分配;
- 栈 ——局部变量、函数参数和函数调用;
- 寄存器 ——CPU 内部的小型、快速存储位置,用于在程序执行期间临时保存数据(如程序指针和堆栈指针)。
线程是进程内的一个独立执行单元。一个进程内可能存在多个线程,它们同时执行不同的操作。进程与线程共享执行代码、数据和堆,但栈和寄存器是为每个线程单独分配的。
JavaScript 不是多线程的❗️
为了避免对术语产生误解,需要指出的是,JavaScript 本身既不是单线程的,也不是多线程的。这门语言与线程无关,它只是一组供执行平台处理的指令。平台会以自己的方式处理这些指令——无论是单线程还是多线程。
I/O 操作🧮
(或输入/输出操作)通常被认为比其他计算机操作速度慢。以下是一些示例:
- 将数据写入磁盘;
- 从磁盘读取数据;
- 等待用户输入(例如鼠标点击);
- 发送HTTP请求;
- 执行数据库操作。
I/O 速度慢🐢
你可能想知道为什么从磁盘读取数据速度慢?答案在于硬件组件的物理实现方式。
访问 RAM 的时间以纳秒为单位,而访问磁盘或网络上的数据的时间以毫秒为单位。
带宽也是如此。内存的传输速率稳定在GB/s级别,而磁盘或网络的传输速率则从MB/s到 GB/s 不等(乐观估计)。
除此之外,我们还必须考虑人为因素。在很多情况下,应用程序的输入来自真人(例如,按键)。因此,I/O 的速度和频率不仅仅取决于技术方面。
I/O 操作会阻塞线程🚧
I/O 操作会显著降低程序运行速度。线程会被阻塞,在 I/O 操作完成之前,不会执行任何其他操作。
多开几个帖子!🤪
好吧,为什么不在程序内部创建更多线程,然后分别处理每个请求呢?嗯,这似乎是个好主意。现在,每个客户端请求都有自己的线程,服务器就可以同时处理多个请求了。
该程序需要为每个线程分配额外的内存和 CPU 资源。这听起来合情合理。然而,当线程执行 I/O 操作时,就会出现一个严重的问题——它们会进入空闲状态,大部分时间都在等待操作完成,资源利用率降至 0%。线程越多,资源利用率越低。
此外,线程管理本身就是一项极具挑战性的任务,容易引发诸如竞态条件、死锁和活锁等问题。操作系统需要在线程间进行切换,这会增加系统开销,并降低多线程带来的效率提升。
解决方案是什么?🤔
幸运的是,人类已经发明了智能机制来高效地执行这类操作。
欢迎使用事件解复用器。它涉及一种称为复用的过程 ——一种将多个信号组合成一个信号并通过共享资源传输的方法。其目的是共享稀缺资源(在本例中为 CPU 和 RAM)。例如,在电信领域,可以使用一根线路传输多个电话呼叫。
事件解复用器的职责分为以下步骤:
- 识别事件来源。每个来源都可以生成事件;
- 注册事件源。注册过程包括指定要针对每个来源监控哪些事件;
- 等待事件发生;
- 发送事件通知。
重要提示!事件解复用器并非现实世界中存在的组件或设备。它更像是一种理论模型,用于解释如何高效地处理大量同时发生的事件。
为了理解这个复杂的过程,让我们回顾一下过去。想象一下老式的电话交换机:它识别并记录事件源(电话),并等待新的事件(来电)。一旦有新的事件(来电)发生,交换机就会发出通知(点亮指示灯)。然后,交换机操作员会根据通知检查目标电话号码,并将呼叫转接到正确的目的地。
对于计算机而言,原理相同。然而,数据源的角色由文件描述符、网络套接字、定时器或用户输入设备等扮演。每个数据源都可以生成事件,例如可读取的数据、可写入的空间或连接请求。
每个操作系统都已经实现了事件解复用器机制:epoll(Linux)、kqueue(macOS)、事件端口(Solaris)、IOCP(Windows)。
但是 Node.js 是跨平台的。为了在支持跨平台 I/O 的同时管理整个过程,它有一个抽象层,封装了这些平台间和平台内的复杂性,并为 Node 的上层公开了一个通用的 API。
利布夫国王🏆
欢迎使用libuv—— 一个跨平台库(用 C 语言编写),最初是为 Node.js 开发的,旨在为各种操作系统上的非阻塞 I/O 提供一致的接口。libuv 不仅与系统的事件解复用器交互,还集成了两个重要组件:事件队列和事件循环。这些组件协同工作,高效地处理并发的非阻塞资源。
事件队列是一种数据结构,事件解复用器会将所有事件放入其中,以便事件循环按顺序进行排队和处理,直到队列为空为止。
事件循环是一个持续运行的进程,它等待事件队列中的消息,然后将它们分发给相应的处理程序。
问题解决了吗?🥳
这就是调用 I/O 操作时发生的情况:
- Libuv 根据操作系统初始化相应的事件解复用器;
- Node.js 解释器会扫描代码,并将每个操作放入调用堆栈中;
- Node.js 会按顺序执行调用栈中的操作。但是,对于 I/O 操作,Node.js 会以非阻塞的方式将其发送到事件解复用器。这种方式确保 I/O 操作不会阻塞线程,从而允许其他操作并发执行。
- 事件解复用器识别 I/O 操作的来源,并使用操作系统的功能注册该操作;
- 事件解复用器持续监视事件源(例如,网络套接字)的事件(例如,何时有数据可供读取);当事件发生时(例如,数据变得可供读取),事件解复用器发出信号并将事件及其关联的回调添加到事件队列中;
- 事件循环不断检查事件队列并处理事件回调。
Node.js 的工作原理是,即使一个请求正在等待处理,它也可以处理另一个请求。Node.js 不会等待一个请求完成就去处理所有其他请求。默认情况下,Node.js 中发出的所有请求都是并发的——它们不会等待其他请求完成就执行。
太好了!问题似乎解决了。Node.js 可以在单线程上高效运行,因为操作系统开发者已经解决了大部分阻塞式 I/O 操作的复杂性。谢谢!
问题尚未解决🫠
但如果我们仔细观察libuv的结构,会发现一个有趣的方面:
等等,线程池?什么?是的,现在我们已经深入研究到足以回答主要问题了——为什么 Node.js 不是(完全)单线程的?
揭开秘密🤫
好的,我们拥有强大的工具和操作系统实用程序,可以让我们在一个线程中运行异步代码。
但事件解复用器存在一个问题。由于每个操作系统对事件解复用器的实现方式不同,某些 I/O 操作的异步性无法得到完全支持。要支持所有操作系统平台上的所有 I/O 类型非常困难。这些问题尤其与文件 I/O 的实现有关。这也会对Node.js 的一些 DNS 功能产生影响。
不仅如此,还有其他类型的 I/O 操作无法以异步方式完成,例如:
- DNS 操作可能
dns.lookup会被阻塞,因为它们可能需要查询远程服务器; - CPU密集型任务,例如密码学;
- ZIP压缩。
对于这类情况,线程池用于在单独的线程中执行 I/O 操作(通常默认情况下有 4 个线程)。因此,完整的 Node.js 架构图如下所示:
是的,Node.js 本身是单线程的,但它内部使用的库,例如 libuv 及其用于某些I/O 操作的线程池,则不是单线程的。
线程池与任务队列配合使用,用于处理阻塞式 I/O 操作。默认情况下,线程池包含 4 个线程,但可以通过提供额外的环境变量来修改此行为:
UV_THREADPOOL_SIZE=8 node my_script.js
当 I/O 操作无法异步执行时,就会发生这种情况,但主要区别在于:
- 当事件解复用器识别出 I/O 操作的来源时,它会将该操作注册到任务队列中;
- 线程池持续监控任务队列,查看是否有新任务;
- 当一个新任务被放入任务队列时,线程池会通过一个预定义的线程异步地处理该任务;
- 操作完成后,线程池会发出信号,并将事件及其关联的回调函数添加到事件队列中。
这里没有什么魔法。I/O 操作实际上无法做到完全无阻塞,而且目前也无法实现(至少现在如此)。数据传输速度也无法超越物理定律的限制。世上没有完美的事物,因此,在我们找到硬件层面提升数据传输速度的方法之前,我们只能使用一系列优化算法,以尽可能高效的方式执行异步操作。
感谢阅读,祝您度过美好的一天 :)
文章来源:https://dev.to/evgenytk/nodejs-is-not-single-threaded-29o1








