浅谈IO模型 异步IO
其实面试的时候一个问题就可以看清是否对这些理解透彻了, Node.js 事件循环机制如何实现的,
- 关键点是, 是否可以分清 libuv 库, 事件循环, v8 引擎, nodejs 这几个概念的关系, 以及 IO 复用在这里面的作用
异步 IO 和 非阻塞 IO 的区别 又是什么
- 是否理解事件驱动
1. 阻塞IO (Blocking IO)#
阻塞IO是指在进行IO操作(如读写文件或网络通信)时,调用线程会一直等待直到操作完成。阻塞IO通常实现简单,但在高并发场景下效率较低,因为每个连接可能需要一个线程,线程切换和资源占用会成为瓶颈
- 传统的socket编程中 使用
socket.recv()
读取网络数据时,线程会等待直到数据到达 - MySQL客户端连接 传统数据库查询(如SELECT)通常是阻塞的,等待数据库返回结果
早期 Java 服务器为每个连接分配一个线程, 遇到高并发(如 C10K)时性能急剧下降, 促使了NIO(非阻塞IO)和 Netty 的流行
C10K(Concurrency 10K)问题 指的是服务器如何高效地处理 1万个并发连接
- 当并发连接数增加(例如C10K问题,即1万个并发连接),服务器需要创建大量线程(每个连接一个线程)
- 因为线程很多, 操作系统需要处理大量线程上下文切换, 这会消耗大量CPU资源
因此在高并发场景下, BIO模型效率低下, 性能会急剧下降, 这也是为什么 C10K 问题推动了非阻塞 IO(NIO)和异步框架(如Netty)的流行
现在的 Spring MVC 不也是为每个连接分配一个线程吗?
Spring MVC 通常运行在 Servlet 容器(如Tomcat、Jetty)之上, 这些容器的线程模型决定了 Spring MVC 的并发处理方式, 现代Servlet 容器并不为每个客户端连接分配一个专用线程,而是使用线程池和事件驱动机制: 在高并发场景下,线程池的大小远小于并发连接数, 线程可以复用, 极大减少了线程切换和内存开销
不太懂现代 Servlet 容器的 IO 模型, 为什么是事件驱动?
2. 非阻塞IO (Non-blocking IO)#
非阻塞IO允许线程在执行IO操作时立即返回,而不会等待操作完成。如果数据不可用,会返回一个错误或标志(如EAGAIN),调用者需要轮询(polling)来检查状态。
- C语言的socket编程:通过
fcntl
设置socket
为O_NONBLOCK
, 调用recv
时立即返回 - Java NIO(New IO):使用
SocketChannel
配置为非阻塞模式,检查read()
返回值
非阻塞IO虽然避免了线程阻塞,但频繁轮询会消耗CPU资源,因此单独使用非阻塞IO在高并发场景下效率也不高, 非阻塞IO常与IO复用结合使用(如
select
或epoll
),单独使用效率低
2.1. 为什么非阻塞IO需要与IO复用结合?#
先看看非阻塞 I/O 的使用场景 理解了非阻塞 IO 的使用场景, 才能更好的了解为什么和 IO 复用搭配使用更好, 假设你正在开发一个简单的TCP服务器, 需要同时处理多个客户端连接, 但没有使用IO复用机制:
// 简单的非阻塞TCP服务器轮询示例
int main() {
int server_fd, client_fds[MAX_CLIENTS] = {0};
int client_count = 0;
// 创建服务器socket并设置为非阻塞
server_fd = socket(AF_INET, SOCK_STREAM, 0);
fcntl(server_fd, F_SETFL, O_NONBLOCK);
// 绑定和监听代码省略...
while(1) {
// 1. 轮询接受新连接
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int new_fd = accept(server_fd, (struct sockaddr*)&client_addr, &addr_len);
if(new_fd > 0) {
// 新连接成功
printf("新客户端连接: %d\n", new_fd);
fcntl(new_fd, F_SETFL, O_NONBLOCK); // 设置客户端socket为非阻塞
client_fds[client_count++] = new_fd;
} else if(errno != EAGAIN && errno != EWOULDBLOCK) {
// 真正的错误
perror("accept失败");
}
// 2. 轮询检查每个客户端是否有数据可读
for(int i = 0; i < client_count; i++) {
char buffer[1024] = {0};
int ret = recv(client_fds[i], buffer, sizeof(buffer), 0);
if(ret > 0) {
// 成功读取数据
printf("从客户端%d接收: %s\n", client_fds[i], buffer);
// 处理数据并回复
send(client_fds[i], "已收到消息", 12, 0);
} else if(ret == 0) {
// 客户端关闭连接
printf("客户端%d断开连接\n", client_fds[i]);
close(client_fds[i]);
// 移除该客户端
client_fds[i] = client_fds[--client_count];
i--; // 重新检查当前位置
} else if(errno != EAGAIN && errno != EWOULDBLOCK) {
// 真正的错误
perror("recv失败");
close(client_fds[i]);
client_fds[i] = client_fds[--client_count];
i--;
}
// 如果errno是EAGAIN或EWOULDBLOCK,表示没有数据可读,继续轮询下一个
}
// 可选:短暂休眠以减少CPU使用
usleep(1000); // 休眠1毫秒
}
return 0;
}
在资源受限的嵌入式系统中,可能没有复杂的IO复用机制,需要轮询多个传感器:
// 嵌入式系统传感器轮询示例
void main_loop() {
// 初始化传感器
init_sensors();
while(1) {
// 轮询温度传感器
int temp_ready = check_temperature_sensor();
if(temp_ready) {
float temp = read_temperature();
process_temperature_data(temp);
}
// 轮询湿度传感器
...
// 执行其他任务
perform_periodic_tasks();
// 短暂休眠以节省电量
sleep_ms(10);
}
}
轮询的主要问题
- CPU资源浪费:大部分时间在检查没有变化的资源
- 响应延迟:轮询间隔决定了响应延迟
- 扩展性差:随着监控资源数量增加,性能下降
轮询与IO复用的对比
// 纯轮询方式
while(1) {
for(int i = 0; i < 100; i++) {
// 每次循环都要对100个socket调用recv系统调用
ret = recv(sockets[i], buffer, sizeof(buffer), MSG_DONTWAIT);
// 处理结果...
}
usleep(1000);
}
// IO复用方式 (epoll)
int epfd = epoll_create1(0);
// 注册100个socket到epoll...
while(1) {
// 只有当有事件发生时才会返回
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
// 只处理有事件的socket,通常远少于100个
for(int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
ret = recv(fd, buffer, sizeof(buffer), 0);
// 处理结果...
}
}
- 非阻塞IO避免线程等待IO操作完成, 通过IO复用(如select、poll、epoll)高效监控多个IO描述符的状态, 从而减少轮询开销并提升性能
- 除此之外, 主动轮询, 频繁调用
recv()
系统调用, 需要从用户态切换到内核态, 上下文切换也是一个不小的开销
- 这涉及到保存用户态的寄存器状态、切换到内核堆栈、执行内核代码等操作
- IO复用通过一次系统调用(例如
select()
或epoll_wait()
)监控多个socket
的状态,而不是为每个socket
单独调用recv()
3. IO复用 (IO Multiplexing)#
IO复用是指一个线程监控多个IO描述符, 当某个描述符就绪时通知应用程序, 常见的实现包括 select
、poll
和 epoll
- Nginx:使用
epoll
(Linux)或kqueue
(BSD)处理高并发连接 - Redis:基于
epoll/select
的单线程事件循环,高效处理客户端请求 - libevent/libuv:高性能事件循环库,广泛用于 Nginx、Node.js 等
3.1. select, poll, epoll, kqueue#
IO 复用 如何监听的文件描述符? 比如是否可读, 可写等…
IO复用机制通过不同的API和数据结构来监听文件描述符的状态:
机制 | 平台 | 监听方式 | 性能 | 最大连接数 |
---|---|---|---|---|
select | 全平台 | fd_set 位图 |
O(n) | 受限(通常1024) |
poll | 全平台 | pollfd 结构体数组 |
O(n) | 不受限 |
epoll | Linux | epoll_event 结构体 |
O(1) | 不受限 |
kqueue | BSD/macOS | kevent 结构体 |
O(1) | 不受限 |
select epoll 核心区别:通知机制 最本质的区别: select 是主动轮询,而 epoll 是被动通知
既然 select 也是轮询, 和 非阻塞 IO 中的主动轮询有什么区别呢, 为什么要用 select?
在纯非阻塞 IO 轮询中, 应用程序直接轮询每个文件描述符, 假设我们有 100 个连接, 但在某一时刻只有 5 个连接有数据可读,
纯非阻塞 IO 轮询:
// 每次循环需要 100 次系统调用 for (int i = 0; i < 100; i++) { recv(sockets[i], buffer, sizeof(buffer), MSG_DONTWAIT); // 95次调用会立即返回EAGAIN }
select 轮询:
// 设置fd_set (一次系统调用) FD_ZERO(&read_fds); for (int i = 0; i < 100; i++) { FD_SET(sockets[i], &read_fds); } // select调用 (一次系统调用) select(max_fd + 1, &read_fds, NULL, NULL, NULL); // 只对就绪的5个连接调用recv (5次系统调用) for (int i = 0; i < 100; i++) { if (FD_ISSET(sockets[i], &read_fds)) { recv(sockets[i], buffer, sizeof(buffer), 0); } }
3.2. select epoll 区别#
系统调用开销:
-
select:每次调用都需要传递完整的文件描述符集合
-
epoll:通过 epoll_ctl 注册一次,之后无需重复传递
内存拷贝:
-
select:每次调用需要在用户空间和内核空间之间复制 fd_set
-
epoll:通过 mmap 共享内存,减少数据拷贝
就绪通知方式:
-
select:返回后需要遍历所有文件描述符检查状态
-
epoll:只返回就绪的文件描述符列表
3.3. 实际应用中的IO复用#
Node.js使用 libuv
库实现事件循环,根据平台自动选择最优的IO复用机制:
// Node.js服务器示例
const net = require('net');
const server = net.createServer((socket) => {
console.log('客户端连接');
// 监听可读事件
socket.on('data', (data) => {
console.log('收到数据:', data.toString());
// 响应客户端
socket.write('服务器已收到消息');
});
// 监听关闭事件
socket.on('close', () => {
console.log('客户端断开连接');
});
// 监听错误事件
socket.on('error', (err) => {
console.error('连接错误:', err);
});
});
server.listen(8000, () => {
console.log('服务器启动在端口8000');
});
Nginx使用事件驱动架构,根据平台选择最佳的IO复用机制:
// Nginx事件处理伪代码
ngx_event_module_init() {
// 根据平台选择最佳的IO复用机制
if (epoll_supported) {
use_epoll();
} else if (kqueue_supported) {
use_kqueue();
} else if (poll_supported) {
use_poll();
} else {
use_select();
}
}
// 事件循环
ngx_process_events_and_timers() {
// 等待事件
events = io_multiplexing_wait();
// 处理所有事件
for (i = 0; i < events.count; i++) {
event = events[i];
if (event.read) {
event.read_handler(event.connection);
}
if (event.write) {
event.write_handler(event.connection);
}
}
// 处理定时器事件
process_timers();
}
应用总结:
高并发Web服务器:如Nginx,处理数千个并发连接
单线程高性能系统:如Redis,单线程处理大量客户端请求
4. 异步 IO (Asynchronous IO)#
异步IO是指发起IO操作后立即返回, 操作系统在后台完成IO, 完成后通过回调、协程或事件通知应用程序
- Node.js:基于
libuv
的事件循环,异步处理文件、网络IO - Python FastAPI:依赖
asyncio
和uvicorn
,通过async/await
实现异步Web服务 - Java Netty:异步网络框架,基于NIO和事件驱动,广泛用于高性能服务器
4.1. 异步 IO 的本质 ‼️#
思考一个问题 异步 IO (Asynchronous IO) 的本质是什么? 和非阻塞 IO 的区别呢?
我的理解 异步的本质就是通过回调函数来执行, 可是异步好像也像是同步:
const response = await fetch(....);
// 执行其他的任务
异步 IO 的本质是允许程序在 IO 操作进行时继续执行其他任务, 而不是等待 IO 操作完成, 异步 IO 确实常常通过回调函数实现, 但这只是实现机制之一, 而非本质, 异步 IO 的核心在于: IO 操作的发起与结果的获取被分离, 当 IO 操作完成后, 通过某种机制(回调函数、Promise、事件等)通知程序处理结果, 中间的等待时间可以用来做其他事情
上面的例子:
const response = await fetch(...);
// 执行其他的
这里的 await 并不意味着同步。它只是让代码看起来像同步,但实际上:
fetch
是异步操作,调用后立即返回Promise
-
await
暂停当前函数的执行,但不会阻塞 JavaScript 的主线程 - 在 IO 操作进行时,JavaScript 引擎可以执行其他任务(事件循环中的其他回调)
- IO 完成后,事件循环会让暂停的函数继续执行
所以 await
是异步 IO 的语法糖, 让异步代码更易读, 但底层仍然是异步的
虽然异步 IO 和非阻塞 IO 都允许程序在 IO 操作进行时继续执行其他任务
非阻塞 IO:
- 非阻塞 IO 是指在发起 IO 操作时, 设置 IO 操作(如 socket 或文件描述符)为非阻塞模式, 如果 IO 操作无法立即完成, 系统调用会立即返回一个错误(如
EAGAIN
或EWOULDBLOCK
), 而不是等待操作完成- 程序需要**轮询(polling)**或通过其他机制(如
select
、poll
、epoll
)检查 IO 操作是否完成- 非阻塞 IO 的核心是系统调用立即返回, 但后续是否需要程序主动检查状态取决于具体实现
set_socket_nonblocking(socket); // 轮询检查 + 做其他的事情 同时发生 while (true) { if (socket_ready(socket)) { read(socket, buffer); } // 做其他事情 }
异步 IO:
- 异步 IO 是指程序发起 IO 操作后, 操作系统接管整个 IO 过程, 程序无需主动关心操作状态, 当 IO 操作完成时, 操作系统通过回调、事件通知或信号等方式通知程序
- 异步 IO 的核心是完全将 IO 操作交给操作系统, 程序只需在操作完成时处理结果, 中间无需轮询
async_read(socket, buffer, callback); // 直接做其他事情 // 操作系统会在完成后调用 callback
有没有发现 异步IO 有点像 非阻塞IO + IO复用, 只不过是 IO 复用的部分, 自动帮你实现了?
确实很像, 但并不是, 但你可以把 异步IO看作是"非阻塞IO + IO复用 + 自动通知机制" 的组合
模型 组成部分 谁负责轮询/等待 非阻塞IO 仅非阻塞调用 应用程序自己轮询 IO复用 非阻塞IO + 集中式事件监听 应用程序通过select/epoll等等待 异步IO 非阻塞IO + 系统级事件监听 + 回调机制 操作系统/运行时负责 在很多系统中, 异步IO 的实现确实是建立在非阻塞IO 和 IO复用的基础上的:
- Node.js的libuv:在Linux上,libuv使用epoll(一种IO复用机制)实现异步IO
- Windows的IOCP:完整的异步IO实现
- Java的NIO:基于非阻塞IO和Selector(IO复用)
以 Node.js 为例, 其事件循环大致如下:
// Node.js事件循环的简化伪代码 while (true) { // 1. 处理定时器回调 processTimerCallbacks(); // 2. 处理IO回调(使用epoll/kqueue/IOCP等实现) processIOCallbacks(); // 3. 处理其他类型的事件... processOtherEvents(); // 如果没有待处理的事件,可能会退出 if (noMoreCallbacks && noMoreWork) { break; } }
在一些异步 IO 框架(如 Node.js 的 libuv)中,底层甚至可能使用 IO 复用机制(如 epoll 或 kqueue)来实现异步效果, 例如,Node.js 的事件循环会使用 epoll 监控 socket,并在就绪时触发回调,这让人感觉异步 IO 是“非阻塞 IO + IO 复用”的封装
但关键区别在于:
- 异步 IO 更彻底地将 IO 操作的完成交给内核,程序无需主动执行后续的 IO 调用
- 异步 IO 的通知是操作完成(数据已准备好),而 IO 复用通知的是描述符就绪(仍需程序执行 IO)
非阻塞 IO:
常用于高性能服务器开发,如 Nginx、Redis 等,它们通过事件循环和非阻塞 socket 处理大量并发连接
适合需要细粒度控制 IO 行为的场景
异步 IO:
- 常用于需要简化并发处理的场景,如 Node.js 的异步文件操作、网络请求,或者数据库查询
4.2. 事件驱动#
可以看 009-事件驱动-异步编程.md 文件
5. 事件循环 和 IO 复用的关系#
强调 epoll 相较 select 的性能优势(select的O(n) vs epoll的O(1)),并提到Nginx如何利用epoll实现高并发
事件循环是什么? 怎么实现的?
应用总结: 高并发Web服务器:如Node.js、FastAPI,处理大量HTTP请求
5.1. 事件循环 Event Loop#
事件循环 是一种编程架构,用于处理和协调异步操作(主要是 IO 操作,如网络请求、文件读写等),它通过一个循环不断检查是否有事件(如 IO 操作完成、定时器触发)需要处理,并在事件发生时调用相应的回调函数
事件循环的核心思想:
- 1 非阻塞:事件循环允许程序在等待 IO 操作(如网络数据到达、文件读取完成)时不被阻塞,而是继续执行其他任务
- 2 事件驱动:程序通过注册事件(event)和回调函数(callback),当事件发生时,事件循环触发对应的回调来处理结果
事件循环的典型流程:
- 检查事件队列是否有待处理的事件
- 如果有事件,取出事件并执行对应的回调函数
- 执行完回调后,继续循环检查队列,直到程序结束
- 如果没有事件,事件循环可能进入休眠状态(阻塞等待新事件),以避免 CPU 空转
5.2. libuv 库 - 事件循环 + IO 复用#
Node.js 的事件循环实现依赖于 libuv 库, 这是一个跨平台的异步 I/O 库, 用 C 语言编写, libuv 是 Node.js 的底层支柱, Node.js 通过 V8 引擎执行 JavaScript 代码, 并通过 libuv 处理异步事件和 I/O 操作
事件循环是 libuv 的核心机制, 它本质上是一个状态机, 不断地检查是否有事件需要处理, 并按照特定顺序处理这些事件:
libuv 事件循环过程
├── Timers
│ ├── 处理 setTimeout() 和 setInterval() 的回调
│ └── 回调放入 Timers 队列
│
├── I/O 回调
│ ├── 处理一些系统操作的回调(如 TCP 错误)
│ └── 回调放入 I/O 回调队列
│
├── Poll
│ ├── 获取新的 I/O 事件
│ ├── 执行与 I/O 相关的回调
│ ├── 使用 IO 复用技术(如 epoll, kqueue, IOCP)
│ └── 回调放入 Poll 队列
│
├── Check
│ ├── 处理 setImmediate() 的回调
│ └── 回调放入 Check 队列
│
├── Close Callbacks
│ ├── 处理关闭事件的回调(如 socket.on('close'))
│ └── 回调放入 Close Callbacks 队列
IO 复用就是(只能) 用来监视多个文件描述符(网络连接、文件)变得可读或可写, 你猜他为什么叫 IO 复用
const fs = require('fs');
const http = require('http');
console.log('1. 程序开始执行'); // 主线程直接执行,不属于事件循环的任何阶段
// 设置一个定时器,回调将在 Timers 阶段执行
setTimeout(() => {
console.log('2. 定时器回调执行'); // 由 libuv 的 Timers 阶段处理,V8 执行 JavaScript 回调
// 在定时器回调中设置的 immediate 会在下一次事件循环的 Check 阶段执行
setImmediate(() => {
console.log('5. setImmediate 回调执行'); // 由 libuv 的 Check 阶段处理,V8 执行回调
});
// 在定时器回调中发起的文件读取操作
fs.readFile('example.txt', (err, data) => {
// 文件 I/O 完成后,此回调会被放入 Poll 阶段的队列
// 由 libuv 通过操作系统的 IO 复用机制(epoll/kqueue/IOCP)监控文件描述符
console.log('6. 文件读取回调执行'); // 由 libuv 的 Poll 阶段处理,V8 执行回调
// 在文件读取回调中设置的定时器
setTimeout(() => {
console.log('8. 嵌套定时器回调执行'); // 下一次事件循环的 Timers 阶段执行
}, 0);
});
}, 0);
// 创建 HTTP 服务器
const server = http.createServer((req, res) => {
// 网络 I/O 回调,当有 HTTP 请求时,此回调会在 Poll 阶段执行
console.log('7. HTTP 请求回调执行'); // 由 libuv 的 Poll 阶段处理,V8 执行回调
res.end('Hello World');
// 关闭服务器
server.close(() => {
// 关闭回调会在 Close Callbacks 阶段执行
console.log('9. 服务器关闭回调执行'); // 由 libuv 的 Close Callbacks 阶段处理,V8 执行回调
});
});
// 立即设置的 immediate 会在当前事件循环的 Check 阶段执行
setImmediate(() => {
console.log('4. 立即的 setImmediate 回调执行'); // 由 libuv 的 Check 阶段处理,V8 执行回调
});
// 发起一个网络请求
http.get('http://localhost:3000', (res) => {
// 网络 I/O 回调,此回调会在 Poll 阶段执行
// 由 libuv 通过操作系统的 IO 复用机制监控网络套接字
res.on('data', (chunk) => {
console.log('收到数据:', chunk.toString());
});
});
// 启动服务器
server.listen(3000, () => {
console.log('3. 服务器启动回调执行'); // 由 libuv 的 Poll 阶段处理,V8 执行回调
});
console.log('0. 主线程代码结束'); // 主线程直接执行,不属于事件循环的任何阶段
-
libuv 的角色:负责管理事件循环和异步 I/O 操作,使用操作系统的 IO 复用机制(如 epoll、kqueue、IOCP)监控文件描述符和网络套接字
-
V8 的角色:执行 JavaScript 代码,包括回调函数
-
回调队列:每个阶段都有自己的回调队列,回调按照注册顺序执行
主线程执行
├── console.log('1. 程序开始执行')
├── 注册 setTimeout 回调
├── 创建 HTTP 服务器
├── 注册 setImmediate 回调
├── 发起 HTTP GET 请求
├── 启动服务器监听
└── console.log('0. 主线程代码结束')
└── 进入事件循环
│
├── 第一次事件循环迭代
│ ├── Timers 阶段
│ │ └── 执行 setTimeout 回调
│ │ ├── console.log('2. 定时器回调执行')
│ │ ├── 注册 setImmediate 回调
│ │ └── 启动异步文件读取操作
│ │
│ ├── I/O 回调阶段
│ │ └── (无回调执行)
│ │
│ ├── Poll 阶段
│ │ └── 执行服务器启动回调
│ │ └── console.log('3. 服务器启动回调执行')
│ │
│ ├── Check 阶段
│ │ └── 执行 setImmediate 回调
│ │ └── console.log('4. 立即的 setImmediate 回调执行')
│ │
│ └── Close Callbacks 阶段
│ └── (无回调执行)
│
├── 第二次事件循环迭代
│ ├── Timers 阶段
│ │ └── (无回调执行)
│ │
│ ├── I/O 回调阶段
│ │ └── (无回调执行)
│ │
│ ├── Poll 阶段
│ │ └── (等待 I/O 事件)
│ │
│ ├── Check 阶段
│ │ └── 执行第一次循环中注册的 setImmediate 回调
│ │ └── console.log('5. setImmediate 回调执行')
│ │
│ └── Close Callbacks 阶段
│ └── (无回调执行)
│
├── 文件读取完成
│ └── 第三次事件循环迭代
│ ├── Timers 阶段
│ │ └── (无回调执行)
│ │
│ ├── I/O 回调阶段
│ │ └── (无回调执行)
│ │
│ ├── Poll 阶段
│ │ └── 执行文件读取回调
│ │ ├── console.log('6. 文件读取回调执行')
│ │ └── 注册 setTimeout 回调
│ │
│ ├── Check 阶段
│ │ └── (无回调执行)
│ │
│ └── Close Callbacks 阶段
│ └── (无回调执行)
│
├── HTTP 请求到达
│ └── 第四次事件循环迭代
│ ├── Timers 阶段
│ │ └── 执行文件读取回调中注册的 setTimeout 回调
│ │ └── console.log('8. 嵌套定时器回调执行')
│ │
│ ├── I/O 回调阶段
│ │ └── (无回调执行)
│ │
│ ├── Poll 阶段
│ │ └── 执行 HTTP 请求回调
│ │ ├── console.log('7. HTTP 请求回调执行')
│ │ └── 关闭服务器
│ │
│ ├── Check 阶段
│ │ └── (无回调执行)
│ │
│ └── Close Callbacks 阶段
│ └── (无回调执行)
│
└── 服务器关闭
└── 最终事件循环迭代
├── Timers 阶段
│ └── (无回调执行)
│
├── I/O 回调阶段
│ └── (无回调执行)
│
├── Poll 阶段
│ └── (无回调执行)
│
├── Check 阶段
│ └── (无回调执行)
│
└── Close Callbacks 阶段
└── 执行服务器关闭回调
└── console.log('9. 服务器关闭回调执行')