第24章 C:epoll 裸写与零拷贝,及语言选型对比
学习目标
- 用 C + epoll 写出事件驱动代理的核心循环,理解 Nginx/HAProxy 的底层模型
- 用
splice做内核零拷贝转发(第12章 的代码落地) - 理解裸写 C 的代价:手动状态机、缓冲、fd 管理、EAGAIN 处理
- 对全篇四种语言做选型总结:数据面 vs 控制面该选谁
前置知识
原理
为什么(以及为什么不)裸写 C
C 是最接近内核的语言,能榨干每一点性能、精确控制内存与系统调用。Nginx、HAProxy 是 C,Envoy 是 C++——高性能代理的基石。但代价是:前三章里 Go/Rust/Python 帮你做的事(协程调度、缓冲管理、错误传播),C 全要手动。所以现实中很少有人从零裸写 C 代理,而是理解它以读懂 Nginx 这类代码。
epoll:事件驱动的核心
C 写高并发代理靠 epoll(Linux 的 IO 多路复用,网络手册 · 多核网络优化)。模型是:所有 socket 设为非阻塞,注册到一个 epoll 实例,单线程在 epoll_wait 上等待,哪个就绪处理哪个——一个线程就能管十万连接(C10K/C10M)。
epoll_create1() → 建 epoll 实例
epoll_ctl(ADD) → 注册监听 socket 和每个连接的 fd
loop:
n = epoll_wait() ← 阻塞等待,返回就绪的 fd 列表
for 每个就绪 fd:
是监听 fd? → accept 新连接,连后端,注册两端
是连接 fd? → splice 转发数据
- ET(边缘触发)vs LT(水平触发):ET 性能更高但必须一次读到
EAGAIN(否则丢事件),是裸写最易错的点 - 每个连接是一个状态机:因为非阻塞,
connect/read/write都可能"还没好",要记录状态等下次事件
splice 零拷贝(第12章的落地)
第12章 讲过 socket→socket 零拷贝要借一个内核管道。C 里直接用 splice:
socket_in ──splice──▶ [pipe] ──splice──▶ socket_out
数据全程在内核,不进用户态
代码:epoll + splice TCP 代理核心
下面是事件驱动代理的核心骨架(为聚焦主线,简化了部分错误处理与连接清理;生产级需完整状态机):
// proxy.c —— gcc -O2 proxy.c -o proxy && ./proxy
// 监听 :8088,把每条连接 splice 转发到 BACKEND
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define BACKEND_IP "93.184.216.34"
#define BACKEND_PORT 80
#define MAX_EVENTS 1024
// 一条代理连接:客户端 fd ↔ 后端 fd,各配一个管道做 splice
typedef struct { int peer; int pipe_r, pipe_w; } conn_t;
static conn_t conns[65536]; // 按 fd 索引(演示用静态表)
static void set_nonblock(int fd) {
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);
}
// 把 from 的数据经管道 splice 到它的 peer,全程零拷贝
static void forward(int from) {
conn_t *c = &conns[from];
for (;;) {
// ① socket → pipe
ssize_t n = splice(from, NULL, c->pipe_w, NULL, 65536,
SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
if (n <= 0) break; // EAGAIN 或对端关闭
// ② pipe → peer socket
while (n > 0) {
ssize_t m = splice(c->pipe_r, NULL, c->peer, NULL, n,
SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
if (m <= 0) break;
n -= m;
}
}
}
static int dial_backend(void) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in a = {0};
a.sin_family = AF_INET;
a.sin_port = htons(BACKEND_PORT);
inet_pton(AF_INET, BACKEND_IP, &a.sin_addr);
connect(fd, (struct sockaddr*)&a, sizeof(a)); // 简化:阻塞 connect
set_nonblock(fd);
return fd;
}
int main(void) {
int lfd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in srv = {0};
srv.sin_family = AF_INET; srv.sin_addr.s_addr = INADDR_ANY; srv.sin_port = htons(8088);
bind(lfd, (struct sockaddr*)&srv, sizeof(srv));
listen(lfd, 512);
set_nonblock(lfd);
int ep = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = lfd };
epoll_ctl(ep, EPOLL_CTL_ADD, lfd, &ev);
printf("C epoll 代理监听 :8088 → %s:%d\n", BACKEND_IP, BACKEND_PORT);
struct epoll_event events[MAX_EVENTS];
for (;;) {
int n = epoll_wait(ep, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == lfd) {
// 新连接:accept + 连后端 + 互相登记 + 各建管道
int cfd = accept(lfd, NULL, NULL);
if (cfd < 0) continue;
set_nonblock(cfd);
int bfd = dial_backend();
int p1[2], p2[2]; pipe(p1); pipe(p2);
conns[cfd] = (conn_t){ .peer = bfd, .pipe_r = p1[0], .pipe_w = p1[1] };
conns[bfd] = (conn_t){ .peer = cfd, .pipe_r = p2[0], .pipe_w = p2[1] };
struct epoll_event e1 = { .events = EPOLLIN, .data.fd = cfd };
struct epoll_event e2 = { .events = EPOLLIN, .data.fd = bfd };
epoll_ctl(ep, EPOLL_CTL_ADD, cfd, &e1);
epoll_ctl(ep, EPOLL_CTL_ADD, bfd, &e2);
} else {
// 已有连接就绪:零拷贝转发到对端
forward(fd);
}
}
}
}
gcc -O2 proxy.c -o proxy && ./proxy &
curl -s -H "Host: example.com" http://127.0.0.1:8088/ -o /dev/null -w "%{http_code}\n" # 200
# strace 验证零拷贝(第12章):
strace -f -e trace=splice -p $(pgrep proxy) 2>&1 | head
对比前三章:Go/Rust/Python 用一行
io.Copy/copy_bidirectional/gather(pipe)就完成的事,C 里要手动 epoll 注册、管道、状态登记、EAGAIN 循环。这就是"控制力"的代价。 而这段代码恰恰让你看懂了 Nginx 事件循环的本质。
全篇语言选型总对比
四种语言写完,给出选型决策(呼应 第02章 的"能力 vs 性能"权衡):
| 维度 | Go | Rust | Python | C/C++ |
|---|---|---|---|---|
| 吞吐/延迟 | 高 | 最高 | 低 | 最高 |
| 内存安全 | GC 保证 | 编译期保证 | 运行时 | ❌ 全手动 |
| 开发速度 | 快 | 慢 | 最快 | 最慢 |
| 并发模型 | goroutine(有栈) | async(无栈) | asyncio | 手写 epoll |
| 零拷贝 | io.Copy 自动 splice | 需库 | 难 | 直接 splice |
| 心智负担 | 低 | 高 | 低 | 高(手动一切) |
| 代表作 | Traefik、frp、Clash、v2ray | pingora(Cloudflare) | mitmproxy | Nginx、HAProxy、Envoy(C++) |
数据面 vs 控制面:黄金分工
云原生代理普遍遵循一条分工(第15章 Envoy 数据面 + 控制面即此):
数据面(Data Plane):要快、要稳、要省 → C/C++、Rust、Go
每个请求都经过它,性能 = 钱,尾延迟 = 体验
例:Envoy(C++)、pingora(Rust)、Nginx(C)
控制面(Control Plane):要开发快、逻辑复杂、不在数据热路径 → Go、Python
配置管理、服务发现、下发策略,QPS 低、迭代快
例:Istiod(Go)、各类控制器(Go)、运维脚本(Python)
一句话选型:
- 高吞吐核心数据面 → Rust / C++ / C(pingora / Envoy / Nginx)
- 又要性能又要开发快、单体代理 → Go(Traefik / frp / Clash)
- 调试工具 / 控制面 / 原型 / 中低流量 → Python(mitmproxy)
排错
| 现象 | 根因 | 解决 |
|---|---|---|
| ET 模式偶尔"卡住"漏数据 | 没循环读到 EAGAIN | ET 必须把就绪 fd 读尽 |
fd 耗尽 Too many open files | 连接关闭没回收 fd/管道 | 完整 close cfd/bfd/pipe |
splice 返回 EINVAL | 一端不是管道 | socket↔socket 必须经管道中转 |
| 惊群(thundering herd) | 多进程争 accept | SO_REUSEPORT 或 EPOLLEXCLUSIVE |
| 内存越界/崩溃 | 手动管理出错 | 这正是 Rust 用编译期消灭的问题 |
本章小结
- C + epoll 是高性能代理的底层模型(Nginx/HAProxy 即此):非阻塞 socket + epoll 事件循环 + 每连接状态机。
splice在 C 里直接可用,实现 socket↔socket 内核零拷贝(第12章)。- 裸写 C 的代价是手动管理一切——这让你读懂了高级语言一行
io.Copy背后的全部工作。 - 选型黄金律:数据面要快(C++/Rust/Go),控制面要开发快(Go/Python)。
多语言手写篇至此完结(21 Go / 22 Rust / 23 Python / 24 C)。下一篇 第六篇·容器与 Kubernetes 是你最关心的落地重头戏:从 Docker 的 HTTP_PROXY,到 Istio 的流量劫持、Ingress 南北向、Egress 出网治理、Service Mesh 数据面。