从一次网络请求出发,彻底搞懂事件循环、I/O 多路复用与响应式编程

当你在浏览器里发出一个请求,到 Spring Cloud Gateway 处理这个请求并返回响应,中间到底发生了什么?这篇文章从最底层的硬件中断开始,一直讲到上层的响应式编程和背压机制,帮你把整条链路彻底串起来。


一、一个请求到达服务器:从电信号到线程被唤醒

当客户端发出一个 HTTP 请求,数据经过网络传输,最终以电信号的形式到达服务器的网卡。从这一刻起,一条精密的中断驱动链路开始运作。

1.1 网卡收到数据

网卡(NIC)是一块独立的硬件,有自己的芯片,能独立工作。它不需要 CPU 参与就能接收电信号并解析成数据帧。

1.2 DMA 搬运数据到内存

网卡收到数据后,通过 DMA(Direct Memory Access) 把数据直接写入内存中预先分配好的一块区域(Ring Buffer)。这一步完全不需要 CPU 参与------网卡和内存之间有独立的数据通道,CPU 此时对这件事一无所知。

1.3 硬件中断:拍 CPU 的肩膀

数据搬完后,网卡向 CPU 发出一个硬件中断信号。这是一个真实的电信号,打到 CPU 的中断引脚上。CPU 的行为是硬件电路层面写死的:

  1. 立刻停下当前正在执行的指令
  2. 保存当前上下文(寄存器等)
  3. 查中断向量表,找到中断号对应的处理函数
  4. 跳转执行该函数

这就像你在专心写代码,有人拍了你一下肩膀。你不需要每隔几秒回头看------是别人主动来打断你的。

需要注意的是,"收到数据"和"拍肩膀"是两件事。网卡收到数据、校验、搬到内存,这些都是网卡自己干的。直到它发出中断信号的那一刻,CPU 才第一次"意识到"有数据来了。就像快递员先把包裹放到你门口,然后才按门铃------按门铃才是"拍肩膀"。

1.4 硬件中断处理(极短)

硬件中断处理必须极快(微秒级),因为此时整个 CPU 核心被"霸占"了。所以它只做最紧急的事:

c 复制代码
void nic_interrupt_handler() {
    ack_interrupt();              // 告诉网卡:我收到了
    disable_nic_interrupt();      // 暂时关闭网卡中断,防止反复打断
    schedule_softirq(NET_RX);     // 标记一个软中断:有网络数据要处理
}

真正的重活交给软中断。

1.5 软中断:协议栈处理

内核在合适的时机处理软中断,逐层解析数据包:

c 复制代码
void net_rx_softirq() {
    while (ring_buffer 中有数据) {
        skb = 从 ring_buffer 取出一个数据包;
        
        解析以太网帧头 → 知道是 IP 协议;
        解析 IP 头     → 知道目标 IP 是本机,协议是 TCP;
        解析 TCP 头    → 知道目标端口是 8080;
        
        // 根据四元组 (源IP, 源端口, 目标IP, 目标端口) 找到对应的 socket
        socket = lookup_socket(src_ip, src_port, dst_ip, dst_port);
        
        // 把数据放入 socket 的接收缓冲区
        socket.recv_buffer.append(skb.data);
        
        // ★ 关键:触发 socket 上的等待者
        socket.wake_up_waiters();
    }
}

最后一行就是通知机制的关键。

1.6 socket 唤醒等待者

每个 socket 内部有一个等待队列(wait queue) 。如果有人通过 epoll_ctl 注册过这个 socket,那等待队列里就挂着 epoll 的回调函数。当 wake_up_waiters() 执行时:

c 复制代码
void epoll_callback() {
    epoll.ready_list.add(this_socket);      // 加入就绪链表
    wake_up_process(epoll.waiting_thread);  // 唤醒阻塞的线程
}

1.7 线程被唤醒

wake_up_process 把线程状态从 SLEEPING 改为 RUNNABLE,放回 CPU 调度队列。调度器下次选中它时,epoll_wait() 返回,线程拿到就绪的 socket 列表,开始处理业务逻辑。

1.8 完整链路总结

perl 复制代码
电信号到达网卡         →  网卡芯片硬件自动接收
网卡写数据到内存       →  DMA 硬件自动搬运,CPU 不知情
网卡发硬件中断         →  电信号打断 CPU("拍肩膀")
硬件中断处理           →  标记软中断
软中断处理协议栈       →  解析数据,找到 socket,放入缓冲区
触发 socket 回调       →  epoll 加入就绪链表
唤醒线程               →  修改线程状态,加入调度队列
线程处理业务逻辑       →  CPU 调度器选中该线程

每一步都是前一步触发的,没有任何环节在轮询。 整个系统就像一排多米诺骨牌,第一张是网卡收到的电信号,最后一张是你的线程被唤醒。


二、一个线程如何同时处理上万个连接:I/O 多路复用

上面我们看到了一个请求如何到达线程。但现实中,一台服务器要同时处理成千上万个连接。如何做到?

2.1 先打破一个误解:连接不需要线程"维持"

很多人以为一个 TCP 连接必须有一个线程"扶着"它才不会断。事实完全不是这样。

一个 TCP 连接在内核中只是一个数据结构,占几 KB 内存,静静地躺在内核里就行。数据到了内核自己往缓冲区里放,连接断了内核自己更新状态。1 万个连接 = 内核里 1 万个结构体 ≈ 几十 MB 内存。

线程唯一需要做的事情是:在数据就绪时,从缓冲区取出数据并处理业务逻辑。 关键问题变成了:怎么知道哪个连接的数据就绪了?

2.2 多线程模型:一人盯一个

最直觉的做法是为每个连接分配一个线程:

bash 复制代码
线程1:     read(conn_1)     → 阻塞等待 → 数据来了 → 处理
线程2:     read(conn_2)     → 阻塞等待 → 数据来了 → 处理
...
线程10000: read(conn_10000) → 阻塞等待 → ...

每个线程调用 read(),没数据就阻塞睡眠。它不耗 CPU,但本身的存在就有成本:

  • 栈内存:默认 1MB/线程,1 万线程 = 10GB
  • 内核对象task_struct、文件描述符表等
  • 调度开销:1 万线程在调度队列里,内核调度器压力巨大
  • 上下文切换:线程唤醒/挂起时要保存/恢复寄存器、刷新 TLB

连接本身不贵,贵的是为每个连接创建的线程。

2.3 I/O 多路复用:一人盯所有

思路完全不同------既然连接只是内核里的数据结构,我用一个线程去问内核"这 1 万个连接里哪些有数据了"不就行了?

css 复制代码
线程: epoll_wait(1万个连接) → 睡眠 → 被唤醒
      → "第 3、587、4201 号有数据"
      → 处理 conn_3
      → 处理 conn_587
      → 处理 conn_4201
      → 回去继续 epoll_wait()

一个线程,一个栈,一次系统调用,同时监听所有连接。

这样做的前提是一个关键洞察:绝大多数连接在绝大多数时间什么都没发生。 1 万个连接中,任意一个时刻真正有数据到达的可能只有几十个。多线程模型为每个连接都养了一个线程,9997 个线程在睡觉,纯粹浪费资源。

2.4 I/O 多路复用 ≠ while true

有人会问:I/O 多路复用是不是就是程序里的 while(true) 循环?

不完全是。while(true)事件循环 的骨架,I/O 多路复用是循环内部调用的操作系统能力

c 复制代码
while (true) {
    // 这一行才是 I/O 多路复用 ↓
    events = epoll_wait(epfd, ...);   // 没事件时线程休眠,不耗 CPU
    
    // 下面是事件循环的调度逻辑
    for (event in events) {
        handleEvent(event);
    }
}

如果没有多路复用,只用 while(true) 也能轮询,但那是 busy polling------CPU 空转,一个一个问"有数据吗?没有。下一个",效率极低。

多路复用的核心价值是:你不用自己一个个问,内核帮你盯着,有数据了才叫醒你。 就像餐厅里装了呼叫铃------你不用挨桌跑,哪桌按铃了你再过去。


三、select vs epoll:同样的思路,不同的效率

select 和 epoll 都是"一个线程监听多个连接"的实现,但效率天差地别。

3.1 select 的做法

c 复制代码
// 每次调用
fd_set fds;
把 1 万个 fd 全部塞进 fds → 拷贝给内核

// 内核
for (i = 0; i < 10000; i++)      // 遍历所有 fd
    if (fd[i] 有数据) 标记就绪;
把整个 fds 拷贝回用户态

// 用户态
for (i = 0; i < 10000; i++)      // 再遍历一次
    if (FD_ISSET(i, &fds)) 处理;

每次调用都要:拷贝 1 万个 fd 到内核 → 内核遍历 1 万个 → 拷贝回来 → 用户态再遍历。而且 select 有 1024 个 fd 的上限。

3.2 epoll 的做法

c 复制代码
// 初始化(只做一次)
epfd = epoll_create();
for (每个连接)
    epoll_ctl(epfd, ADD, conn, ...);  // 注册一次,内核挂上回调

// 运行时(反复调用)
events = epoll_wait(epfd, ...);       // 不遍历!直接返回就绪链表
for (event in events)                 // 只遍历就绪的(比如 3 个)
    处理(event.fd);

epoll 不遍历所有连接,而是通过回调机制,数据到达时自动把 socket 加入就绪链表。epoll_wait 只需要把就绪链表返回给用户态。

环节 select epoll
注册连接 每次调用重新传入全部 fd 只在新增/删除时调用一次
内核检测就绪 遍历所有 fd,O(n) 回调自动加入就绪链表,O(1)
返回结果 拷贝全部 fd 集合 只拷贝就绪的 fd
fd 上限 1024 无实际上限

3.3 历史背景

这两种机制出现的时间跨度很大,差了将近 20 年:

  • select:1983 年在 4.2 BSD Unix 中引入,Linux 最早期就支持。那时一台服务器可能就几十个连接,遍历几十个 fd 完全没有性能问题。
  • poll:1987 年在 SVR3 Unix 中引入,Linux 2.1.23(1997 年)加入支持。去掉了 1024 的限制,但内核仍然遍历所有 fd。
  • epoll:2002 年在 Linux 2.5.44 中引入,Linux 原创。专为互联网高并发场景设计,解决了 C10K 问题。

其他操作系统也各自发展了类似机制:FreeBSD 的 kqueue、Solaris 的 /dev/poll、Windows 的 IOCP。


四、事件循环线程模型

有了 I/O 多路复用作为底层能力,上层就可以构建事件循环线程模型。Node.js、Nginx、Redis 以及 Netty 都采用了这种模型。

程序启动后,主线程进入一个无限循环,每一轮大致做三件事:

  1. 检查事件队列 ------调用 epoll_wait(),查看是否有就绪事件
  2. 取出事件并执行回调------把对应的回调函数在当前线程中同步执行
  3. 回到步骤 1------继续轮询

所有 I/O 操作都以非阻塞方式发起:发出请求后立即返回,结果就绪时由操作系统通知事件循环。

维度 多线程(Thread-per-connection) 事件循环
并发方式 每个连接一个线程 单线程轮询 + 回调
内存开销 每线程独立栈空间,连接多时开销大 极低,一个线程服务成千上万连接
上下文切换 频繁 几乎没有
编程复杂度 需要处理锁、竞态、死锁 无需锁,但要管理异步流程
CPU 密集任务 天然并行 会阻塞事件循环,需卸载到 Worker 线程

五、响应式编程与 Spring WebFlux

理解了事件循环和 I/O 多路复用,就能明白为什么 Spring 要推出 WebFlux。

5.1 响应式编程

响应式编程是一种以数据流和变化传播为核心的编程范式。核心思想:你不主动去"拉"数据,而是声明好"数据到来时该怎么处理",让数据"推"给你。

传统命令式(阻塞):

java 复制代码
// 线程在这里停住,等数据库返回
User user = userRepository.findById(id);   // 阻塞等待
Order order = orderService.getOrder(user);  // 再阻塞等待
return order;

响应式(非阻塞):

java 复制代码
// 不等待,只描述"数据到了之后做什么"
return userRepository.findById(id)          // 返回 Mono<User>
        .flatMap(user -> orderService.getOrder(user))
        .map(order -> toResponse(order));

线程发出请求后立刻释放,去处理别的事情;等数据就绪时,框架自动回调后续逻辑。

核心类型:

  • Mono:代表 0 或 1 个元素的异步序列
  • Flux:代表 0 到 N 个元素的异步流

5.2 Spring WebFlux

WebFlux 是 Spring 5 引入的响应式 Web 框架,和传统 Spring MVC 并列:

维度 Spring MVC Spring WebFlux
编程模型 命令式、同步阻塞 响应式、异步非阻塞
底层服务器 Servlet 容器(Tomcat) Netty(默认)
线程模型 一个请求占一个线程 少量线程(事件循环)处理大量请求
返回类型 普通对象 User Mono<User> / Flux<User>

WebFlux 默认运行在 Netty 上,Netty 本身就是事件循环模型:启动时只创建少量线程(通常等于 CPU 核数),通过事件循环 + 非阻塞 I/O 处理所有连接。

5.3 Spring Cloud Gateway 为什么选 WebFlux

Gateway 作为 API 网关,工作本质是:接收请求 → 转发到下游微服务 → 拿到响应 → 返回给客户端。几乎全是 I/O 等待,几乎没有 CPU 计算。

用传统 Spring MVC,每个请求占一个线程,1000 个并发就需要 1000 个线程"干等"下游响应。用 WebFlux + Netty,少量线程就能同时"挂起"成千上万个请求,哪个下游先返回就处理哪个。

整条链路:

css 复制代码
客户端请求
    ↓
Netty EventLoop 线程接收(非阻塞)
    ↓
Gateway Filter 链处理(Mono/Flux 流水线)
    ↓
用 Netty 非阻塞地转发给下游服务(线程立即释放)
    ↓
下游响应到达 → EventLoop 被唤醒
    ↓
继续执行 Filter 链的后续逻辑
    ↓
返回响应给客户端

全程没有线程阻塞。


六、背压(Backpressure):防止被数据"淹没"

响应式编程还解决了一个重要问题:如果上游产生数据的速度远快于下游消费的速度怎么办?

6.1 没有背压会怎样

数据库每秒查出 10 万条数据,但下游 HTTP 响应每秒只能发送 1 万条:

css 复制代码
数据库 ──10万条/秒──→ [内存缓冲区] ──1万条/秒──→ 客户端
                        ↑
                    越积越多,最终 OOM 崩溃

6.2 Reactive Streams 规范

Java 的 Reactive Streams 定义了背压的标准协议,核心是 Subscription 接口中的 request(long n) 方法------下游告诉上游"我要 n 个":

scss 复制代码
Publisher                          Subscriber
   │                                  │
   │──── onSubscribe(subscription) ──→│  建立连接
   │                                  │
   │←──── request(3) ────────────────│  "我先要3个"
   │                                  │
   │──── onNext(数据1) ─────────────→│
   │──── onNext(数据2) ─────────────→│
   │──── onNext(数据3) ─────────────→│  发够3个,停下等待
   │                                  │
   │         (下游处理中...)          │
   │                                  │
   │←──── request(2) ────────────────│  "再给我2个"
   │                                  │
   │──── onNext(数据4) ─────────────→│
   │──── onNext(数据5) ─────────────→│

上游不能主动推,必须等下游 request(n) 才能发 n 个。

6.3 背压策略

当上下游速度不匹配时,Reactor 提供了几种策略:

策略 行为 适用场景
buffer 放入有界队列,满了报错 数据不能丢,但要限制内存
drop 来不及处理的直接丢 传感器数据,旧数据无价值
latest 只保留最新一个 UI 刷新,只关心最新状态
error 直接抛异常终止 严格场景,不允许积压

6.4 在 WebFlux 中的实际运作

以 Gateway 转发大文件为例,背压可以从应用层一路传递到 TCP 层:

scss 复制代码
1. Netty 读到客户端数据 → 发出 onNext(chunk1)
2. Gateway 转发 chunk1 给下游,等待下游确认
3. 下游还没处理完 → Gateway 不调用 request() → Netty 不从 socket 读新数据
4. TCP 接收缓冲区满了 → TCP 窗口收缩 → 客户端被迫降速
5. 下游处理完了 → request(1) → Netty 继续读 → 客户端恢复发送

背压从应用层一路传递到了 TCP 层,形成端到端的流量控制


七、全景回顾

让我们把所有知识点串成一条线,看看一个请求从发出到返回经历了什么:

css 复制代码
客户端发出 HTTP 请求
    ↓
数据以电信号到达服务器网卡               [物理层]
    ↓
网卡通过 DMA 将数据搬入内存              [硬件层]
    ↓
网卡发出硬件中断,"拍 CPU 肩膀"          [硬件中断]
    ↓
内核协议栈解析数据,找到对应 socket       [内核层]
    ↓
触发 socket 上的 epoll 回调              [I/O 多路复用]
    ↓
Netty EventLoop 线程被唤醒               [事件循环]
    ↓
WebFlux 以 Mono/Flux 流水线处理请求       [响应式编程]
    ↓
Gateway Filter 链非阻塞转发给下游         [Spring Cloud Gateway]
    ↓
下游响应到达,同样的链路再走一遍           [全链路非阻塞]
    ↓
背压机制确保上下游速度匹配                [背压]
    ↓
响应返回给客户端

从最底层的硬件中断,到操作系统的 epoll,到应用层的事件循环和响应式编程------每一层都在做同一件事:用最少的资源,处理最多的并发,永远不让线程空等。

这就是现代高性能网络服务的完整图景。

相关推荐
缪懿5 小时前
网络层和数据链路层中的常见协议解析
网络·网络协议·java-ee
田里的水稻6 小时前
OE_永久配置网络_linux系统终端命令行ip_setting
人工智能·网络协议·机器人·运维开发
辣椒思密达6 小时前
住宅IP与机房IP的区别及技术选型指南
网络·网络协议·tcp/ip
阿文的代码库7 小时前
用于事件驱动系统的WebSocket
网络·websocket·网络协议
不只会拍照的程序猿8 小时前
深入理解AFDX(ARINC 664 Part7):从原理到实现(上篇)
网络协议·航空总线·afdx·arinc 664
AIwenIPgeolocation8 小时前
IP+设备双维监控,让黑产的“秒拨”和“云手机”无所遁形
网络协议·tcp/ip·智能手机
TechWayfarer8 小时前
IP数据接口调用示例:社交软件如何做同城匹配与用户画像分析
python·网络协议·tcp/ip·社交电子
天天进步201510 小时前
Tunnelto 源码解析 #3:客户端启动流程:配置解析、鉴权 Key、本地地址与控制服务器连接
网络协议