当你在浏览器里发出一个请求,到 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 的行为是硬件电路层面写死的:
- 立刻停下当前正在执行的指令
- 保存当前上下文(寄存器等)
- 查中断向量表,找到中断号对应的处理函数
- 跳转执行该函数
这就像你在专心写代码,有人拍了你一下肩膀。你不需要每隔几秒回头看------是别人主动来打断你的。
需要注意的是,"收到数据"和"拍肩膀"是两件事。网卡收到数据、校验、搬到内存,这些都是网卡自己干的。直到它发出中断信号的那一刻,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 都采用了这种模型。
程序启动后,主线程进入一个无限循环,每一轮大致做三件事:
- 检查事件队列 ------调用
epoll_wait(),查看是否有就绪事件 - 取出事件并执行回调------把对应的回调函数在当前线程中同步执行
- 回到步骤 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,到应用层的事件循环和响应式编程------每一层都在做同一件事:用最少的资源,处理最多的并发,永远不让线程空等。
这就是现代高性能网络服务的完整图景。