246 lines
8.9 KiB
Markdown
246 lines
8.9 KiB
Markdown
---
|
||
tags: [计算机网络, 零拷贝, sendfile, Go netpoller, goroutine调度]
|
||
create time: 2026-05-18 04:40
|
||
---
|
||
|
||
# 零拷贝技术与 Go netpoller 原理
|
||
|
||
## 概述
|
||
|
||
在前两章掌握了 Socket API 和 epoll 之后,本章聚焦两个工程实践话题:如何通过内核优化减少数据传输的拷贝次数(零拷贝),以及 Go 如何用 goroutine 让高并发变得优雅。
|
||
|
||
## 零拷贝技术演进
|
||
|
||
### 什么是零拷贝?
|
||
|
||
传统文件传输中,数据从磁盘到网络经历多次 CPU 参与的内存拷贝。零拷贝的目标是**让数据绕过用户态**,直接在 DMA 硬件和内核空间之间流转。
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph "传统方式 (4次拷贝, 2次上下文切换)"
|
||
D1["磁盘 Page Cache<br/>Kernel"] -->C1["CPU 拷贝到<br/>User Buffer"]
|
||
C1 -->C2["CPU 拷贝到<br/>Socket Buffer Kernel"]
|
||
C2 -->DMA1["DMA 传到 NIC"]
|
||
end
|
||
|
||
subgraph "sendfile() (2次拷贝, 2次上下文切换)"
|
||
D2["磁盘 Page Cache<br/>Kernel"] -->S1["DMA 直接到<br/>Socket Buffer"]
|
||
S1 -->DMA2["DMA 传到 NIC"]
|
||
end
|
||
|
||
style D1 fill:#DDA0DD,color:#000
|
||
style D2 fill:#DDA0DD,color:#000
|
||
style DMA2 fill:#B0C4DE,color:#000
|
||
```
|
||
|
||
```
|
||
┌──────────────────────────────────────────────┐
|
||
│ 拷贝次数对比 │
|
||
├───────────────┬──────────┬─────────┬──────────┤
|
||
│ 方式 │ 用户态拷贝 │ 内核态拷贝 │ 上下文切换 │
|
||
├───────────────┼──────────┼─────────┼──────────┤
|
||
│ read()+write() │ 2 │ 2 │ 4 │
|
||
│ sendfile() │ 0 │ 2 │ 2 │
|
||
│ sendmmsg() │ 0 │ 1 │ 1 │
|
||
│ io_uring │ 0 │ 1 │ 1 │
|
||
└───────────────┴──────────┴─────────┴──────────┘
|
||
```
|
||
|
||
### 四种零拷贝方案对比
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
SF["sendfile()<br/>内核 ≥ 2.1<br/>最简单"]
|
||
SM["sendmmsg()<br/>内核 ≥ 2.6.34<br/>批量发送"]
|
||
DM["DMA Gather<br/>scatter-gather DMA<br/>零额外拷贝"]
|
||
|
||
SF --> DM
|
||
SM --> DM
|
||
|
||
IR["io_uring<br/>内核 ≥ 5.1<br/>终极异步方案"]
|
||
|
||
style DM fill:#98FB98,color:#000
|
||
style IR fill:#B0C4DE,color:#000
|
||
```
|
||
|
||
#### 1. sendfile() — 最经典的零拷贝
|
||
|
||
```c
|
||
// C 语言原生 API
|
||
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
|
||
// 数据路径: 磁盘 → page cache → socket buffer → NIC (DMA)
|
||
// 用户态完全不参与!
|
||
```
|
||
|
||
```go
|
||
// Go 中的 sendfile — 自动选择最优路径
|
||
f, _ := os.Open("static/logo.png")
|
||
defer f.Close()
|
||
|
||
w, _ := net.FileConn(f.File()) // 获取底层的 OS fd
|
||
io.Copy(w, f) // Go 会检测是否支持 sendfile
|
||
// Linux: 直接调用 sendfile() syscall
|
||
// macOS: 使用 sendfile() BSD 版本
|
||
// 其他: 降级为 buffered copy
|
||
```
|
||
|
||
#### 2. mmap + writev —— 更灵活的零拷贝
|
||
|
||
```c
|
||
// mmap 将文件映射到用户虚拟地址空间
|
||
// 用户态可以直接"看"到文件内容(但不拷贝)
|
||
// writev/scatter-gather 一次性提交多块数据给内核
|
||
void *ptr = mmap(NULL, filesize, PROT_READ, MAP_PRIVATE, fd, 0);
|
||
writev(sockfd, &iov, 1); // scatter-write
|
||
munmap(ptr, filesize);
|
||
```
|
||
|
||
> [!tip] mmap 的特殊之处
|
||
> mmap 不是严格意义上的零拷贝——用户态可以看到数据(页表映射),但如果不去触碰这些数据,就不会发生实际的物理内存拷贝。这是一种 **"按需加载(lazy load)"** 的零拷贝变体。
|
||
|
||
### Go 中的零拷贝最佳实践
|
||
|
||
```go
|
||
func serveFileStatic(w http.ResponseWriter, r *http.Request, path string) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
http.Error(w, "not found", 404)
|
||
return
|
||
}
|
||
defer f.Close()
|
||
|
||
info, _ := f.Stat()
|
||
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
|
||
w.Header().Set("Content-Type", http.DetectContentType(file))
|
||
|
||
// Go 1.13+ 的 http.ServeContent 会自动尝试零拷贝
|
||
http.ServeContent(w, r, info.Name(), info.ModTime(), f)
|
||
// 底层逻辑:
|
||
// 1. 检测 OS 是否支持 sendfile
|
||
// 2. 如果支持,使用 syscall.Sendfile
|
||
// 3. 如果不支持,用 32KB buffer 优化 copy
|
||
}
|
||
```
|
||
|
||
## Go netpoller 原理 —— C10K 问题的优雅解法
|
||
|
||
### Goroutine 与 OS 线程的关系
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
G1["goroutine 1<br/>handle request A"] --> P1["OS Thread<br/>M1"]
|
||
G2["goroutine 2<br/>handle request B"] --> P1
|
||
G3["goroutine 3<br/>blocking on read..."] -.parked.-.-> Empty["M1 空闲出去服务其他 goroutine"]
|
||
|
||
G4["goroutine 4<br/>handle request C"] --> P2["OS Thread<br/>M2"]
|
||
G5["goroutine 5<br/>network read ready"] --> P2
|
||
|
||
style P1 fill:#DDA0DD,color:#000
|
||
style P2 fill:#98FB98,color:#000
|
||
```
|
||
|
||
```
|
||
GOMAXPROCS = N 意味着最多 N 个 OS 线程同时运行 goroutine
|
||
但可能有 M >> N 个 goroutine 在排队等待执行
|
||
这就是 M:N 调度模型
|
||
```
|
||
|
||
### netpoller 的工作流程
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Acceptor as Accept Goroutine
|
||
participant Epoll as epoll ET (后台线程)
|
||
participant RunQueue as runq 待执行队列
|
||
participant Handler as Handle Goroutine
|
||
|
||
Acceptor->>Epoll: conn, _ := ln.Accept()
|
||
Note over Epoll: conn_fd 设为 non-blocking
|
||
Epool->>Epoll: unix.SetNonblock(conn.Fd(), true)
|
||
Epoll->>Epoll: epoll_ctl(EPOLL_CTL_ADD, conn_fd, EPOLLIN)
|
||
Epoll->>RunQueue: goroutine ready (Read 待执行)
|
||
RunQueue->>Handler: goroutine 被唤醒
|
||
|
||
Handler->>Handler: conn.Read(buf)
|
||
alt 数据已到
|
||
Handler->>Handler: 成功读取,继续处理
|
||
else 数据未到 (EAGAIN)
|
||
Handler->>Epoll: goroutine park<br/>移除 netpoller 监控
|
||
Note over Epoll: 等待网卡中断
|
||
Epoll->>Handler: 数据到达 → goroutine ready
|
||
Handler->>Handler: 再次 Read,成功
|
||
end
|
||
```
|
||
|
||
### Go netpoller 的 OS 差异化实现
|
||
|
||
| 平台 | 多路复用机制 | 触发模式 | 源码位置 |
|
||
|------|------------|---------|---------|
|
||
| **Linux** | epoll | ET 模式 | `src/runtime/netpoll_epoll.go` |
|
||
| **macOS / BSD** | kqueue | 边缘触发 | `src/runtime/netpoll_kqueue.go` |
|
||
| **Windows** | IOCP | 边缘触发 | `src/runtime/netpoll_iocp.go` |
|
||
| **Solaris** | event ports | 边缘触发 | `src/runtime/netpoll Solaris.go` |
|
||
|
||
```go
|
||
// Go 1.14 引入的 io_uring 实验性支持
|
||
// src/runtime/netpoll_io_uring.go (GOEXPERIMENT=io_uring)
|
||
// 预期优势:
|
||
// 1. 统一的异步接口(读写+文件 I/O)
|
||
// 2. 减少系统调用次数(submit + wait 合一)
|
||
// 3. 完全的用户态环缓冲区,无拷贝
|
||
|
||
// 未来 Go 的 netpoller 可能统一走 io_uring 路径
|
||
```
|
||
|
||
> [!tip] Go 协程的优势总结
|
||
>
|
||
> Go 不需要像 C/C++ 那样手动维护 epoll + callback 地狱。每个连接挂起一个 goroutine(栈 2KB 起步),CPU 和内存效率极高。这被称为 **"C10K problem solved by goroutines"**。
|
||
>
|
||
> 但这并不意味着可以无视底层细节——了解 netpoller 原理有助于:
|
||
> - 理解 goroutine leak(永远在等待的 conn.Read 不会释放)
|
||
> - 正确设置超时(context.WithTimeout 最终驱动 epoll_wait timeout)
|
||
> - 调试 "stuck" goroutine 的问题
|
||
|
||
## Go http.Server 核心配置回顾
|
||
|
||
```go
|
||
srv := &http.Server{
|
||
Addr: ":443",
|
||
Handler: myHandler,
|
||
|
||
// === 超时控制(防慢连接攻击 Slowloris)===
|
||
ReadTimeout: 10 * time.Second, // 读完整请求体的时间
|
||
WriteTimeout: 30 * time.Second, // 写出响应的时间
|
||
IdleTimeout: 120 * time.Second, // Keep-Alive 空闲多久断开
|
||
MaxHeaderBytes: 1 << 20, // Header 最大 1MB
|
||
|
||
// TLS 要求
|
||
TLSConfig: &tls.Config{
|
||
MinVersion: tls.VersionTLS13,
|
||
NextProtos: []string{"h2", "http/1.1"},
|
||
},
|
||
}
|
||
|
||
// ⚠️ 注意:没有 Context 超时!
|
||
// 如果用 handler 里接 context.Context,那是业务层面的超时
|
||
// 与 srv.ReadTimeout 是两个维度的控制
|
||
```
|
||
|
||
```go
|
||
// http.Server 的 ListenAndServe 内部流程
|
||
ln, _ := net.Listen("tcp", ":443")
|
||
for {
|
||
conn, _ := ln.Accept() // OS accept() → 拿 conn fd
|
||
go func() { // 启动 goroutine 处理
|
||
srv.handleConn(conn) // 内部使用 readLoop + writeLoop
|
||
}()
|
||
}
|
||
// handleConn 中会用到 netpoller 来高效读取数据
|
||
```
|
||
|
||
## 关联笔记
|
||
|
||
- [[hhs/NETWORK/epoll深度解析ETvsLT]] — epoll 的 ET/LT 模式是 netpoller 的基础
|
||
- [[hhs/NETWORK/SocketAPI与backlog详解]] — accept() 拿到 conn 后的一切始于 backlog
|
||
- [[hhs/NETWORK/01-带宽延迟RTT与吞吐量]] — BDP 决定需要多大的 send/receive buffer
|