NodeJs

大白话 Node.js 异步底层实现原理

前言


Node.js 的异步特性是其高性能的核心优势,其底层实现融合了 JavaScript 事件循环机制、Libuv 库的线程池管理,以及操作系统的异步 I/O 能力。以下通过具体示例和底层原理解析,深入理解其工作机制。

Node.js 的组成和代码架构

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

  • 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 语言实现的代码。

 

Node.js 中的 Libuv

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

  • 1. 介绍 Libuv 的模型和限制
  • 2. 介绍线程池解决的问题和带来的问题
  • 3. 介绍事件循环

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 通常是搭配单线程的,如果在单线程里执行耗时任务,就会导致后面的任务无法执行。

 

线程池解决的问题和带来的问题

针对这个问题,Libuv 提供的解决方案就是使用线程池。下面来看一下引入了线程池之后, 线程池和主线程的关系。

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

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

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

 

一、Node.js 异步的核心组件


Node.js 的异步实现依赖三大核心组件:

  1. V8 引擎:解析和执行 JavaScript 代码。
  2. Libuv 库:跨平台的异步 I/O 引擎,负责事件循环、线程池管理等。
  3. 操作系统内核:提供底层系统调用(如文件读写、网络请求)。

 

二、底层实现示例:文件读取的异步过程


以读取文件为例,看 Node.js 如何实现异步操作:

const fs = require('fs');

console.log('1. 开始执行');

fs.readFile('/path/to/file.txt', 'utf8', (err, data) => {
  console.log('3. 文件内容:', data);
});

console.log('2. 继续执行其他任务');

底层执行流程解析

  1. JavaScript 层的调用
    • 调用 fs.readFile 时,Node.js 不会阻塞主线程,而是将文件读取任务交给底层处理。
    • 回调函数 (err, data) 被暂存,等待文件读取完成后执行。
  2. Libuv 库的任务分发
    • Libuv 的核心模块
      • 事件循环(Event Loop):管理任务执行顺序。
      • 线程池(Thread Pool):处理 CPU 密集型异步任务(如文件读写、加密计算)。
    • 文件读取的底层处理
      • 由于文件读写属于阻塞操作,Libuv 会将任务分配给线程池中的一个工作线程。
      • 主线程继续执行后续代码(输出 2. 继续执行其他任务),不被阻塞。
  3. 操作系统的异步 I/O 机制
    • 工作线程通过系统调用(如 Linux 的 pread)向内核发起文件读取请求。
    • 内核处理 I/O 操作时,工作线程不会阻塞,而是进入等待状态,直到内核完成数据读取并通知线程。
  4. 结果回调的处理
    • 当文件数据读取完成后,内核通知 Libuv 的事件循环。
    • 事件循环将回调函数放入 微任务队列 或 宏任务队列(取决于任务类型),等待主线程空闲时执行(输出 3. 文件内容:)。

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

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

三、Node.js 事件循环的底层结构


Node.js 的事件循环在 Libuv 中分为 6 个阶段,每个阶段处理特定类型的任务:

┌───────────────────────────┐
│           timers          │ (处理 setTimeout/setInterval 回调)
├───────────────────────────┘
└───────────────────────────┐
│       I/O callbacks       │ (处理已完成的 I/O 操作回调)
├───────────────────────────┘
└───────────────────────────┐
│     idle, prepare         │ (内部使用)
├───────────────────────────┘
└───────────────────────────┐
│       poll                │ (等待新的 I/O 事件)
├───────────────────────────┘
└───────────────────────────┐
│       check               │ (处理 setImmediate 回调)
├───────────────────────────┘
└───────────────────────────┐
│       close callbacks     │ (处理 close 事件回调)

关键阶段示例:poll 阶段的工作机制

  • 当主线程无同步任务时,进入 poll 阶段,等待新的 I/O 事件。
  • 若有已完成的 I/O 任务(如文件读取结果),事件循环会执行这些任务的回调。
  • 若 poll 阶段空闲且存在 setImmediate 任务,则进入 check 阶段执行该任务。

 

四、线程池与异步操作类型


Node.js 的线程池(默认大小为 4 个线程)用于处理两类异步任务:

  1. 必须由线程池处理的任务
    • 文件系统操作(如 fs.readFile)、加密计算(crypto 模块)、zlib 压缩等。
    • 示例场景:当调用 fs.readFile 时,Libuv 从线程池取出一个线程,执行文件读取,避免阻塞主线程。
  2. 可由系统直接处理的异步任务
    • 网络请求(net 模块)、DNS 查询等,这些操作依赖操作系统的异步 I/O 能力(如 Linux 的 epoll、Windows 的 IOCP),无需线程池参与。
    • 示例场景http.get 发起网络请求时,Libuv 通过操作系统的异步接口直接监听数据返回,不占用线程池资源。

 

五、异步操作的性能优化示例


当处理大量文件读取时,线程池可能成为瓶颈(默认仅 4 个线程),可通过 worker_threads 模块创建更多工作线程:

const fs = require('fs');
const { Worker } = require('worker_threads');

// 主进程
console.log('主进程开始');

// 创建工作线程处理文件读取
const worker = new Worker('./file-worker.js');

worker.on('message', (data) => {
  console.log('文件内容:', data);
});

console.log('主进程继续执行');

// file-worker.js(工作线程代码)
const fs = require('fs');

// 工作线程中执行文件读取,不占用主线程池资源
const data = fs.readFileSync('/path/to/large-file.txt', 'utf8');
process.send(data);

底层优化原理

  • 工作线程拥有独立的 V8 实例和线程池,可并行处理多个文件读取任务,避免主线程池过载。
  • 主线程与工作线程通过 IPC(进程间通信)机制交换数据,确保主线程不被阻塞。

 

总结:Node.js 异步的三层实现模型

  1. 应用层:JavaScript 代码通过回调函数、Promise、Async/Await 处理异步逻辑。
  2. 引擎层:Libuv 库管理事件循环、线程池,将异步任务分发到系统底层。
  3. 系统层:操作系统通过异步 I/O 接口(如 epoll、IOCP)处理实际的硬件操作。

  

Node.js 异步 = 快递站的「前台 + 仓库」模式


假设你去快递站取包裹:

  • 主线程:像快递站的前台小姐姐,负责接待顾客、查单号、打包,但不负责去仓库找包裹(否则会被堵在仓库,没法接待其他人)。
  • 异步任务:像 “找某一个包裹”,比如你报出单号:“我要取 1024 号包裹!”
  • Libuv 仓库工人:像仓库里的分拣员,专门负责找包裹(处理耗时任务)。
  • 事件循环:像前台的 “待办小本本”,记录哪些包裹找到了需要通知顾客。

 

取包裹的全过程:从报单号到拿到包裹

场景:你报出单号,让前台找包裹(对应代码里的文件读取)

  1. 你对前台说:“我要 1024 号包裹!”
    • 前台小姐姐不会自己冲进仓库翻包裹(否则没人接待后面的顾客),而是对仓库工人喊:“帮我找 1024 号!”,然后继续给下一个顾客查单号(主线程继续执行后续代码)。
  2. 仓库工人找包裹(耗时操作)
    • 仓库有多个工人(线程),其中一个工人接到任务后,钻进仓库找包裹。找包裹需要时间(比如在货架间跑来跑去),但工人不能闲着玩手机,必须一直找直到找到(因为找包裹是 “体力活”,需要亲自翻找)。不过这时前台还在外面正常接待,没人被卡住。
  3. 工人找到包裹,告诉前台
    • 找到包裹后,工人喊:“前台!1024 号包裹找到了!”,前台听到后,把这件事记在 “待办小本本” 上:“等有空了把 1024 号给顾客”。
  4. 前台按小本本顺序叫号(事件循环执行回调)
    • 前台小姐姐手里的 “待办小本本” 有顺序:
      • 先处理 “马上要超时的快递”(比如 setTimeout 像快递滞留 24 小时要提醒);
      • 再处理 “已经找到的包裹”(比如你的 1024 号);
      • 最后看看有没有 “加急件”(比如 setImmediate 像生鲜快递要优先送)。
    • 等前台忙完当前顾客,就会按小本本的顺序喊你:“1024 号顾客,你的包裹好了!”(执行回调函数,把包裹给你)。

如果仓库工人不够用?比如突然来了 100 个取件人

快递站默认只有 4 个仓库工人(Node.js 线程池大小默认 4),如果同时有 100 个人要找包裹,后面的人只能排队等。这时候可以:

  • 多开一个仓库(worker_threads:像快递站开分店,每个分店有独立的工人,同时处理不同的取件任务,前台小姐姐不用等某一个仓库忙完。
  • 让机器人送包裹(系统异步 I/O):比如寄快递(网络请求)不需要工人找包裹,机器人直接扫码出库,不占用工人的时间,前台更高效。

和浏览器的区别:快递站 vs 小区便利店

  • Node.js(快递站):有专业仓库团队(线程池)处理重活(找包裹),前台专注接待(主线程处理业务),适合大量顾客同时来取件(高并发请求)。
  • 浏览器(小区便利店):没有仓库团队,老板一个人既要看店又要找货(单线程),除非喊邻居来帮忙(Web Worker),否则来太多顾客就会手忙脚乱(页面卡顿)。

Node.js 异步就像:前台小姐姐不自己找包裹,把麻烦事丢给仓库工人,自己继续接待顾客,等工人找到包裹后,按小本本顺序叫号。这样快递站就能同时服务很多人,不会因为某个人的包裹难找而排队堵车。