Init
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
---
|
||||
tags: [计算机网络, Socket API, listen, accept, backlog, SYN队列]
|
||||
create time: 2026-05-18 04:30
|
||||
---
|
||||
|
||||
# Socket API 与 backlog 深度解析
|
||||
|
||||
## 概述
|
||||
|
||||
Socket 是应用层与传输层之间的抽象接口。理解 Socket API 如何映射到 TCP/IP 协议栈的各层操作,以及 `listen()` 中 backlogged 参数的真实含义——它是两个队列的组合而非一个——是从入门走向高性能网络编程的关键一步。
|
||||
|
||||
## Socket API 到 OSI 层的映射
|
||||
|
||||
| 系统调用 | OSI 层级 | 做了什么? | 关键参数 |
|
||||
|----------|---------|-----------|---------|
|
||||
| `socket()` | N/A | 创建 socket 对象 | domain(AF_INET/AF_UNIX), type(SOCK_STREAM/SOCK_DGRAM) |
|
||||
| `bind()` | L4/L3 | 绑定本地地址+端口 | struct sockaddr_in{family, port, addr} |
|
||||
| `listen()` | L4 (TCP) | 转为被动监听模式 | backlog = 半连接队列 + 全连接队列 |
|
||||
| `accept()` | L4 (TCP) | 从已完成队列取出一个连接 | 返回新 conn fd + peer 地址 |
|
||||
| `connect()` | L4 (TCP) | 发起三次握手 | struct sockaddr_in{remote_port, remote_addr} |
|
||||
| `send()/write()` | L4→L7 | 将数据放入发送缓冲区 | buffer, length, flags(MSG_DONTWAIT...) |
|
||||
| `recv()/read()` | L4←L7 | 从接收缓冲区读取 | buffer, length, flags(MSG_PEEK...) |
|
||||
| `setsockopt()` | 各层 | 修改 socket 选项 | SO_REUSEADDR / TCP_NODELAY / etc. |
|
||||
| `close()` | L4 | 发起四次挥手 | — |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Client["客户端"] -->|"connect() → SYN"| Server
|
||||
subgraph "服务端"
|
||||
Server["服务器"] -->|"listen()"+|"创建两层队列"| TwoQ["半连接队列 + 完成队列"]
|
||||
TwoQ -->|"accept()"+"取出"| App["应用程序"]
|
||||
end
|
||||
|
||||
style App fill:#DDA0DD,color:#000
|
||||
```
|
||||
|
||||
### Go 中的标准启动流程
|
||||
|
||||
```go
|
||||
// Go 中的标准 HTTP 服务器启动流程
|
||||
ln, err := net.Listen("tcp", ":8080")
|
||||
// 等价 C 代码序列:
|
||||
// fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)
|
||||
// setsockopt(fd, SO_REUSEADDR, 1) // 允许地址快速复用
|
||||
// bind(fd, {addr=0.0.0.0, port=8080})
|
||||
// listen(fd, SOMAXCONN) // ⬅️ backlog 在这里起作用
|
||||
// for {
|
||||
// conn_fd, _ = accept(fd, &peer_addr) // 从完成队列取
|
||||
// go handle(conn_fd) // 每个连接一个 goroutine
|
||||
// }
|
||||
```
|
||||
|
||||
## backlog 参数的奥秘 —— 两个队列!
|
||||
|
||||
这是很多开发者容易误解的地方:`listen(fd, backlog)` 中的 backlog **不是一个队列的大小**,而是控制两个不同队列的组合。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Client["客户端<br/>发出 SYN"] -->|"三次握手进行中"| SyncQ["半连接队列<br/>SYN_RECV 状态<br/>容量: tcp_max_syn_backlog"]
|
||||
|
||||
SyncQ -->|"握手完成"| CompQ["已完成连接队列<br/>ESTABLISHED 状态<br/>容量: min(backlog, somaxconn)"]
|
||||
|
||||
CompQ -->|"accept() 取出"| App["应用程序"]
|
||||
|
||||
SyncQ -.->|"队列满时"| Drop["丢弃后续 SYN<br/>客户端超时重传"]
|
||||
CompQ -.->|"队列满时"| Reject["拒绝 accept<br/>对客户端表现为延迟"]
|
||||
|
||||
style SyncQ fill:#FFD700,color:#000
|
||||
style CompQ fill:#98FB98,color:#000
|
||||
style Drop fill:#FF6B6B,color:#fff
|
||||
style Reject fill:#FFA07A,color:#000
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux 相关内核参数(通过 sysctl 查看)
|
||||
$ sysctl net.ipv4.tcp_max_syn_backlog # SYN 半连接队列最大数 (默认 ~1024)
|
||||
$ sysctl net.core.somaxconn # 全局 backlog 上限 (默认 128! 🔴 生产要改)
|
||||
$ sysctl net.ipv4.tcp_abort_on_overflow # 超过限制时的行为: 0=静默丢弃, 1=RST
|
||||
```
|
||||
|
||||
> [!QUESTION] 为什么 backlog 默认只有 128?
|
||||
> 这个保守设计源于通用场景:普通桌面或小型服务器不可能同时有大量连接。但生产级 Web/API 服务器轻松面对上万并发,128 就是瓶颈。
|
||||
|
||||
### 半连接队列 vs 已完成队列的行为差异
|
||||
|
||||
| 维度 | 半连接队列 (SYN Queue) | 已完成队列 (Accept Queue) |
|
||||
|------|----------------------|-------------------------|
|
||||
| 内容 | 正在三次握手中的连接 | 已完成握手的连接 |
|
||||
| 增长方式 | connect() 发 SYN 时加入 | 握手完成后自动移入 |
|
||||
| 减少方式 | 握手完成/超时清理 | accept() 取出时移除 |
|
||||
| 满了的后果 | 丢弃 SYN,客户端重传 | 客户端收到旧数据而非错误 |
|
||||
| 可调参数 | tcp_max_syn_backlog | net.core.somaxconn |
|
||||
|
||||
```go
|
||||
// Go 中如何设置大的 backlog
|
||||
ln, _ := net.Listen("tcp", ":8080")
|
||||
// Go 内部调用 syscall.Listen(fd, SOMAXCONN),实际取:
|
||||
// min(user_specified_backlog, net.core.somaxconn)
|
||||
// 所以即使 Go 写了 65535,内核 still 受限于 somaxconn=128
|
||||
// 必须先 sysctl 调大内核参数!
|
||||
```
|
||||
|
||||
> [!tip] 生产环境 backlog 配置黄金公式
|
||||
> ```ini
|
||||
> # /etc/sysctl.conf
|
||||
> net.core.somaxconn = 65535
|
||||
> net.ipv4.tcp_max_syn_backlog = 8192
|
||||
> # 重启后生效: sudo sysctl -p
|
||||
> ```
|
||||
|
||||
## bind() 的高级选项
|
||||
|
||||
### SO_REUSEADDR — 快速重启的关键
|
||||
|
||||
```go
|
||||
// 在 C 中必须在 bind() 之前设置
|
||||
fd, _ := socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)
|
||||
opt := 1
|
||||
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, size)
|
||||
bind(fd, ...)
|
||||
```
|
||||
|
||||
```go
|
||||
// Go 的 net.Listen 已经内置了 SO_REUSEADDR
|
||||
ln, _ := net.Listen("tcp", ":8080")
|
||||
// 不需要手动设置!Go runtime 已处理
|
||||
```
|
||||
|
||||
`SO_REUSEADDR` 允许一个新的 socket 绑定到一个处于 `TIME_WAIT` 状态的地址上,避免了等待 2MSL 后才能重新绑定的尴尬。
|
||||
|
||||
### SO_REUSEPORT — 多进程负载均衡
|
||||
|
||||
```bash
|
||||
# 多个进程/线程各自 listen 同一个端口
|
||||
# kernel 在收到 SYN 后选择一个进程来 accept
|
||||
# 避免单进程 accept 竞争锁
|
||||
```
|
||||
|
||||
这在 Nginx master-worker 模型、Go 中使用 `GOMAXPROCS` 多线程监听同一端口时有用。
|
||||
|
||||
## connect() — 客户端视角
|
||||
|
||||
```go
|
||||
// Go 标准连接方式(阻塞)
|
||||
conn, err := net.Dial("timeout", "server.example.com:443")
|
||||
// 等价于:
|
||||
// fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)
|
||||
// connect(fd, {server_ip, 443}) // 阻塞直到握手完成或超时
|
||||
```
|
||||
|
||||
```go
|
||||
// 非阻塞 connect + poll 实现带超时的连接
|
||||
func dialWithTimeout(addr string, timeout time.Duration) (net.Conn, error) {
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// 实际 Go net.DialTimeout 内部使用 OS 级超时机制
|
||||
// 底层仍是 non-blocking connect + select/poll 等待就绪
|
||||
```
|
||||
|
||||
> [!warning] DNS 解析耗时
|
||||
> `net.Dial` 会先做 DNS 查询再发起 connect。如果需要独立控制 DNS 和 TCP 连接时间:
|
||||
> ```go
|
||||
> addrs, _ := net.LookupHost("server.example.com") // DNS 阶段
|
||||
> conn, _ := net.DialTimeout("tcp", addrs[0]+":443", timeout) // TCP 阶段
|
||||
> ```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/NETWORK/TCP三次握手与四次挥手]] — connect() 触发的三次握手完整过程
|
||||
- [[hhs/NETWORK/TCP段结构与状态机]] — bind/listen/accept 对应 TCP 的状态转换
|
||||
- [[hhs/NETWORK/HTTPS与TLS握手]] — TLS 握手中的 connect() 阻塞
|
||||
- [[hhs/NETWORK/01-带宽延迟RTT与吞吐量]] — connect() 造成的第一个 RTT 开销
|
||||
@@ -0,0 +1,298 @@
|
||||
---
|
||||
tags: [计算机网络, epoll, select, poll, ET模式, LT模式, I/O多路复用]
|
||||
create time: 2026-05-18 04:35
|
||||
---
|
||||
|
||||
# I/O 多路复用:select → poll → epoll 深度解析
|
||||
|
||||
## 概述
|
||||
|
||||
I/O 多路复用是让一个线程管理成千上万个 Socket 连接的核心技术。它经历了 select → poll → epoll 三阶段进化,每一代的核心理念都在解决上一代的瓶颈。理解它们的区别,是在高并发场景下做出正确选型的前提。
|
||||
|
||||
## I/O 模型全景图
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
BIO["Blocking I/O<br/>阻塞在读/写系统调用<br/>每连接一个线程"] -->|"问题:"| Scalability["扩展性差<br/>万级连接就需要万级线程"]
|
||||
|
||||
AIO["Non-blocking +<br/>I/O 多路复用<br/>单线程管理万级 FD"] -->|"优势:"| HighConc["高并发 ✅"]
|
||||
|
||||
AsyncIO["真正的 Async I/O<br/>aio / io_uring<br/>内核完成通知"] -->|"终极形态:"| ZeroWait["零等待 ✅✅"]
|
||||
|
||||
style BIO fill:#FF6B6B,color:#fff
|
||||
style AIO fill:#98FB98,color:#000
|
||||
style AsyncIO fill:#B0C4DE,color:#000
|
||||
```
|
||||
|
||||
| 模型 | 说明 | 典型框架 |
|
||||
|------|------|---------|
|
||||
| **Blocking (BIO)** | read()/write() 阻塞,每连接一线程 | Python threading, PHP-FPM |
|
||||
| **Non-blocking + 多路复用** | 注册多个 FD,等待就绪事件 | Nginx, Node.js, Go runtime |
|
||||
| **Async I/O (aio/io_uring)** | 内核完成 I/O 后回调通知 | Linux io_uring, Windows IOCP |
|
||||
| **Signal-driven (SIGIO)** | 数据就绪时 SIGIO 信号通知 | 较少使用 |
|
||||
|
||||
> [!NOTE] Go 的独特路线
|
||||
> Go 选择了第三条路:**goroutine + epoll 后台轮询**。每个请求一个 goroutine(看起来像 BIO),但底层由少量 OS 线程通过 epoll 调度。这被称为 **"M:N 调度"** —— M 个 goroutine 映射到 N 个 OS 线程(通常 N=GOMAXPROCS)。
|
||||
|
||||
## select —— 第一代多路复用
|
||||
|
||||
### C API 原型
|
||||
|
||||
```c
|
||||
int select(int nfds, fd_set *readfds, fd_set *writefds,
|
||||
fd_set *exceptfds, struct timeval *timeout);
|
||||
// 返回值:就绪的 fd 总数;-1 = 错误
|
||||
```
|
||||
|
||||
### 核心缺陷
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ select 三大缺陷 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 1. FD_SETSIZE 固定 1024 │
|
||||
│ 必须 recompile 内核才能扩大 │
|
||||
│ │
|
||||
│ 2. O(n) 线性扫描 │
|
||||
│ 每次调用都要遍历全部注册的 fd │
|
||||
│ │
|
||||
│ 3. 用户态 ↔ 内核态拷贝整个 fd_set │
|
||||
│ n=10000 时每次拷贝 12.5KB │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
```go
|
||||
// Go 中几乎不会直接用 select 做多路复用
|
||||
// Go 的 select 关键字是语言级语法糖,底层走的是自己的 netpoller
|
||||
select {
|
||||
case <-ch1:
|
||||
// channel ch1 可读
|
||||
case <-ch2:
|
||||
// channel ch2 可读
|
||||
default:
|
||||
// 都没有就绪
|
||||
}
|
||||
```
|
||||
|
||||
## poll —— 第二代改进
|
||||
|
||||
### C API 原型
|
||||
|
||||
```c
|
||||
struct pollfd {
|
||||
int fd; /* file descriptor */
|
||||
short events; /* 关注的事件: POLLIN/POLLOUT/POLLERR */
|
||||
short revents; /* 返回的实际事件 */
|
||||
};
|
||||
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
|
||||
```
|
||||
|
||||
### 改进了什么?
|
||||
|
||||
```
|
||||
✅ 不再有固定的 FD_SETSIZE 上限(改用动态数组)
|
||||
❌ 仍然是 O(n) 线性扫描
|
||||
❌ 每次仍需将整个 fds 数组拷贝到内核
|
||||
❌ 返回时需要逐个检查 revents 位
|
||||
|
||||
结论:适合中等规模(几百连接),不适合万级
|
||||
```
|
||||
|
||||
### poll 事件类型速查
|
||||
|
||||
| 宏 | 数值 | 含义 |
|
||||
|----|------|------|
|
||||
| `POLLIN` | 0x001 | 有数据可读 |
|
||||
| `POLLRDNORM` | 0x040 | 正常可读(等效 POLLIN)|
|
||||
| `POLLOUT` | 0x002 | 可写 |
|
||||
| `POLLERR` | 0x008 | 错误条件 |
|
||||
| `POLLHUP` | 0x010 | 挂起(对端关闭)|
|
||||
| `POLLNVAL` | 0x020 | 无效请求(fd 未打开)|
|
||||
|
||||
## epoll —— Linux 王者方案
|
||||
|
||||
### 三大系统调用
|
||||
|
||||
```c
|
||||
// 1. 创建 epoll 实例
|
||||
int epfd = epoll_create(1); // size 参数已被忽略,传 1 即可
|
||||
|
||||
// 2. 增删改 fd 的事件注册
|
||||
int epoll_ctl(epfd, EPOLLADD, fd, &event); // 添加
|
||||
int epoll_ctl(epfd, EPOLLMOD, fd, &event); // 修改
|
||||
int epoll_ctl(epfd, EPOLLDEL, fd, NULL); // 删除
|
||||
|
||||
// 3. 等待就绪事件(只返回活跃的 fd!)
|
||||
int ready = epoll_wait(epfd, events, maxevents, timeout);
|
||||
// 返回就绪数量,events 数组被填充
|
||||
```
|
||||
|
||||
### 内核数据结构 —— O(1) 的秘密
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph "内核空间 (Kernel)"
|
||||
RBTree["红黑树 rb_root<br/>管理所有注册的 fd<br/>O(log n) 增删查"]
|
||||
|
||||
CallbackList["就绪链表 ready_list<br/>只放真正活跃的 fd<br/>O(1) 插入"]
|
||||
|
||||
DeviceCallback["中断回调函数<br/>当网卡收到数据包时<br/>自动将 fd 加入 ready_list"]
|
||||
end
|
||||
|
||||
subgraph "用户空间 (User)"
|
||||
App["用户应用<br/>epoll_wait()"] -->|"仅复制就绪项"| EventArray["epoll_event events[]<br/>只包含就绪 fd"]
|
||||
end
|
||||
|
||||
DeviceCallback -.->|"fd 有数据时触发"| CallbackList
|
||||
|
||||
style RBTree fill:#DDA0DD,color:#000
|
||||
style CallbackList fill:#98FB98,color:#000
|
||||
style App fill:#FFD700,color:#000
|
||||
```
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ epoll 三大优势 │
|
||||
├──────────────────────────────────────────┤
|
||||
│ ✅ O(1) 时间复杂度 │
|
||||
│ 就绪事件通过回调链表直接给出 │
|
||||
│ │
|
||||
│ ✅ 内存拷贝极小 │
|
||||
│ 只拷贝就绪事件的个数 × sizeof(event) │
|
||||
│ │
|
||||
│ ✅ fd 无上限 │
|
||||
│ 只受系统可用文件描述符数限制 (~百万级) │
|
||||
│ │
|
||||
│ ❌ Linux 专属 │
|
||||
│ macOS/BSD 用 kqueue, Windows 用 IOCP │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## ET vs LT 模式深度对比
|
||||
|
||||
epoll 支持两种触发模式,这是面试和生产中最常涉及的话题。
|
||||
|
||||
### LT(Level Triggered)—— 默认模式
|
||||
|
||||
```
|
||||
📥 fd 上有数据到达
|
||||
↓
|
||||
epoll_wait() 返回 ✅ 通知一次
|
||||
↓
|
||||
应用只读了部分数据(还剩一半)
|
||||
↓
|
||||
下次 epoll_wait() 仍然返回 ✅ 继续通知
|
||||
↓
|
||||
一直到该 fd 上的数据全部读完毕才停止
|
||||
```
|
||||
|
||||
```c
|
||||
// LT 模式下可以安全地只读一部分
|
||||
n = read(fd, buf, sizeof(buf)); // 可能还有剩余数据
|
||||
if (n > 0) handle(buf, n);
|
||||
// 下次 epoll_wait 还会提醒你有数据没读完
|
||||
```
|
||||
|
||||
> [!tip] LT 类比
|
||||
> LT 就像**消息通知**——只要你没读完,消息就一直亮着未读红点。
|
||||
|
||||
### ET(Edge Triggered)—— 边缘触发
|
||||
|
||||
```
|
||||
📥 fd 状态从「无数据」变为「有数据」 ← 仅在此刻通知!
|
||||
↓
|
||||
epoll_wait() 返回 ✅
|
||||
↓
|
||||
你必须一次性读完所有数据!⚠️
|
||||
↓
|
||||
如果有剩余?下次不通知了!🚨
|
||||
```
|
||||
|
||||
```c
|
||||
// ET 模式的标准 read 循环模板
|
||||
while (1) {
|
||||
n = read(fd, buf, sizeof(buf));
|
||||
if (n < 0) {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
break; // 数据已全部读完,退出循环
|
||||
}
|
||||
perror("read error");
|
||||
close(fd);
|
||||
break;
|
||||
} else if (n == 0) {
|
||||
close(fd); // EOF = 对端关闭
|
||||
break;
|
||||
}
|
||||
// 处理 n 字节数据
|
||||
}
|
||||
```
|
||||
|
||||
> [!warning] ET 模式的常见陷阱
|
||||
>
|
||||
> 1. **必须配合 non-blocking fd** —— blocking 模式下 read 会在无数据时永久阻塞,破坏 ET 语义
|
||||
> 2. **必须用 while 循环读到底** —— `read()` 返回 EAGAIN 前一直读
|
||||
> 3. **write 也要同理** —— 写缓冲区满时要重新注册 POLLOUT
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ ET vs LT 决策表 │
|
||||
├────────────────┬────────────────┬───────────┤
|
||||
│ 特性 │ LT │ ET │
|
||||
├────────────────┼────────────────┼───────────┤
|
||||
│ 默认模式 │ ✅ 是 │ ❌ 否 │
|
||||
│ 编程难度 │ 简单 │ 较复杂 │
|
||||
│ 性能 │ 略低 │ 更高 ⭐ │
|
||||
│ 误通知 │ 无(有数据就通知)│ 无 │
|
||||
│ 遗漏风险 │ 低 │ 高(漏读即丢失)│
|
||||
│ 推荐场景 │ 大多数场景 │ 极致性能追求 │
|
||||
└────────────────┴────────────────┴───────────┘
|
||||
```
|
||||
|
||||
### Go 的 netpoller 使用哪种模式?
|
||||
|
||||
```
|
||||
Linux: epoll 的 ET 模式
|
||||
macOS: kqueue 的边缘触发语义
|
||||
Windows: IOCP 本身就是边缘触发
|
||||
```
|
||||
|
||||
Go 的 net 包在底层做了大量封装,你不需要关心 ET/LT 的区别。但当你在 Go 中写自定义网络框架(如 RPC 框架、游戏服务器)时,就会直面这个问题。
|
||||
|
||||
## 实战:最小可用的 epoll 框架
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"golang.org/x/sys/unix"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. 创建 epoll 实例
|
||||
epfd, _ := unix.EpollCreate1(0)
|
||||
defer unix.Close(epfd)
|
||||
|
||||
// 2. 打开文件并注册 EPOLLIN
|
||||
fd, _ := unix.Open("/tmp/test.txt", unix.O_RDONLY|unix.O_NONBLOCK, 0)
|
||||
ev := unix.EpollEvent{Events: unix.EPOLLIN, Fd: int32(fd)}
|
||||
unix.EpollCtl(epfd, unix.EPOLL_CTL_ADD, fd, &ev)
|
||||
|
||||
// 3. 等待就绪
|
||||
events := make([]unix.EpollEvent, 128)
|
||||
ready, _ := unix.EpollWait(epfd, events, 5000) // 5s timeout
|
||||
for i := 0; i < ready; i++ {
|
||||
fmt.Printf("fd %d is ready!\n", events[i].Fd)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 实际项目建议
|
||||
> 除非有特殊需求,否则直接使用 Go 的 `net`、`bufio`、`http` 包即可。epoll 的细节已被这些成熟库封装好了。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/NETWORK/SocketAPI与backlog详解]] — Socket 创建后的第一步:listen + accept
|
||||
- [[hhs/NETWORK/零拷贝与GoNetpoller]] — epoll 之上的零拷贝技术与 Go runtime 集成
|
||||
- [[hhs/NETWORK/TCP段结构与状态机]] — 何时关注 EPOLLRDHUP 等 TCP 特殊事件
|
||||
@@ -0,0 +1,245 @@
|
||||
---
|
||||
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
|
||||
Reference in New Issue
Block a user