仓库里有一个现成的 io_uring echo
server,C 写的,173
行,能跑。accept -> read -> write
循环,状态靠 user_data 指针传递,内存靠
malloc/free 手动管。
我试着把它一行一行翻成 Rust。预期半天搞定——逻辑已经想清楚了,只是换个语法。
结果编译器拦了我五次。不是语法错误,是它逼我承认:这份 C 代码里有五个”靠经验硬扛”的地方,我一直假装它们不存在。
一、C 基线:3 个 malloc,5 个 free
先把 C 基线摊开看。核心结构体:
struct conn_info {
int fd;
int type; // EVENT_ACCEPT / EVENT_READ / EVENT_WRITE
char buf[BUF_SIZE];
struct iovec iov;
};每次提交 I/O 请求,malloc 一个
conn_info,把指针塞进
io_uring_sqe_set_data()。CQE 回来时用
io_uring_cqe_get_data()
取回指针,dispatch,最后 free。
malloc 出现 3 次(accept / read / write
各一次)。free 出现 5 次:
cqe->res < 0错误路径 –close(fd) + free- accept 完成 –
free(旧 accept ctx 用完即弃) - read 返回 EOF –
close(fd) + free - read 成功转 write –
free(旧 read ctx 扔掉,新建 write ctx) - write 完成 –
free
3 个入口,5 个出口。这是异步状态机的经典特征:分配在提交端,释放散落在完成端的多条分支里。 少一个 free 就泄漏,多一个 free 就 double free。173 行代码管得住,1730 行代码呢?
还有一个更隐蔽的问题:user_data 的类型是
__u64。你往里塞
struct conn_info *,取出来也当
struct conn_info *
用——但这个约定完全靠人脑维持。编译器看到的只是一个整数来回搬运,既不知道你塞的是什么类型,也不关心那块内存还活不活着。
二、第一拦:栈对象不能活过函数返回
直译的第一版,我想像 C 那样在栈上建个上下文然后把地址塞给 SQE:
fn add_read_request(ring: &mut IoUring, fd: RawFd) {
let mut ctx = ReadCtx { fd, buf: [0u8; BUF_SIZE] };
let entry = opcode::Read::new(types::Fd(fd), ctx.buf.as_mut_ptr(), BUF_SIZE as u32)
.build()
.user_data(&ctx as *const _ as u64); // 栈对象地址塞给内核
unsafe { ring.submission().push(&entry).unwrap() };
}
// 函数返回,ctx 析构,内核拿着的指针悬空C 版本碰巧没有这个 bug——因为它用了
malloc,对象在堆上,函数返回后还活着。但如果哪天有人把
malloc 改成栈变量”优化”一下,就炸了。C
编译器不会告诉你。
Rust 编译器直接拦住:ctx
的生命周期不够长,不能把它的引用传出函数。 不是
warning,是 error。
修法:用 Box 把对象放到堆上,用
Box::into_raw() 显式交出所有权:
fn push_read(ring: &mut IoUring, fd: OwnedFd) {
let op = Op::Read { fd, buf: vec![0u8; BUF_SIZE] };
let op_ptr = Box::into_raw(Box::new(op)); // 所有权离开 Rust 类型系统
// ...用 op_ptr 构建 SQE 并提交
}Box::into_raw()
的语义很精确:从这一刻起,这块内存不再被 Rust 追踪。你必须在
CQE 回来时手动 Box::from_raw()
接回来,否则永远泄漏。
这比 C 的 malloc/free
好在哪里?“交出去”和”接回来”是两个显式的、对称的操作,而且中间那段”不归
Rust 管”的区间被 unsafe 标记圈住了。C
里面全程都是”不归谁管”。
三、第二拦:move 之后不能再碰
read 完成后,我想把 buffer 交给 write op,然后立刻给同一个连接再提交一次 read:
Op::Read { fd, buf } => {
push_write(ring, fd, buf, n); // buf 的所有权转给 write
push_read(ring, fd); // 再用 fd 提交 read?
}error[E0382]: use of moved value: `fd`
fd 在 push_write 调用时已经被 move
走了。同一个 fd 不能既交给 write 又交给
read——两边都想拥有同一个文件描述符,编译器不答应。
C 版本怎么处理这个问题?它在
add_write_request() 里 memcpy
了一份 buffer,然后两边各持有各的
conn_info,fd 作为
int 随便复制:
memcpy(conn->buf, buf, len); // 复制一份,两边各用各的注释甚至写了
in real app, avoid copy or manage lifetime——翻译成人话就是”我知道这样不够好,但我不想现在处理生命周期问题”。
Rust
编译器逼你现在就处理。最终实现里,fd
和 buf 的所有权在状态之间做单向传递:
Op::Read { fd, buf } => {
if res > 0 {
push_write(ring, fd, buf, res as usize); // fd+buf 整体转给 write
}
// EOF 或 error: fd 和 buf 在这里 drop,自动 close + 释放
}
Op::Write { fd, buf, len } => {
if res > 0 && (res as usize) >= len {
push_read(ring, fd); // fd 转给下一轮 read,buf 可以丢了
}
}每个状态转换,所有权做一次交接。不会出现两个分支同时持有同一个资源——编译器保证这一点。C
版本也在做类似的事(每次 add_read_request 都
malloc 新的
conn_info),只不过它没有语言层面的约束来保证你不会手滑复用旧指针。
四、第三拦:借用和修改不能同时
想在 read 完成后打个日志再转交 buffer:
Op::Read { fd, ref buf } => {
let preview = &buf[..std::cmp::min(n, 32)]; // 借用 buf 前 32 字节看一眼
push_write(ring, fd, buf.to_vec(), n); // 这里要拿走 buf 做拷贝
println!("echoing {} bytes: {:?}", n, preview); // 还想用 preview?
}如果直接 move buf 进 write op(不做
to_vec() 拷贝),然后还想用
preview——它引用了一块已经被 move
走的内存——编译器当场拦住:
error[E0505]: cannot move out of `buf` because it is borrowed
对应的 C 风险:你在 C 里用一个局部指针指向
conn->buf,然后 free(conn)
了——那个局部指针就悬空了。C 编译器不说一个字,你得等
Valgrind 或者线上 core dump 告诉你。
Rust 的解法很朴素:先用完再交,不要交了还想用。
Op::Read { fd, buf } => {
let n = res as usize;
println!("echoing {} bytes: {:?}", n, &buf[..std::cmp::min(n, 32)]);
push_write(ring, fd, buf, n); // 日志打完了,现在 move
}代码顺序变了,但语义更清晰。这不是 Rust 在刁难你,这是正确的资源管理顺序——只不过以前没人逼你遵守。
五、第四拦:错误路径不能忘记清理
C 版本的错误处理,每个分支都要手动 close + free:
if (cqe->res < 0) {
close(user_data->fd);
free(user_data);
}有五个 free
分支,你敢保证每个分支都写全了?新加一个分支的时候不会忘?close
和 free 的顺序反了也不会出问题?C
编译器一概不管。
Rust 版本里,Op 持有 OwnedFd 和
Vec<u8>。OwnedFd drop 时自动
close,Vec drop 时自动释放。所以 CQE
回来后,不管走哪条分支,只要 Box<Op>
的所有权回到 Rust,资源清理就是自动的:
let op = unsafe { reclaim_op(user_data) };
match *op {
Op::Read { fd, buf } => {
if res > 0 {
push_write(ring, fd, buf, res as usize); // 所有权转移,不释放
}
// else: 走到这里 op 被 drop -> fd 自动 close, buf 自动释放
}
// ...
}不需要写 close(fd)。不需要写
free(buf)。你甚至不需要写 else
分支——走不到 push_write 那一行的时候,Rust
自动帮你做完所有清理。
这就是 RAII 真正该做到的事。在上一篇里我说过,C++
的 RAII 有五个逃生舱门。Rust 把它们焊死了——Drop
保证析构一定执行,move 语义保证不会有僵尸对象,borrow
checker 保证不会有悬垂引用。
在异步状态机里,这个保证的价值是倍增的:超时、对端断连、内核返回异常
errno、ring
满了提交失败——每一条都是一个潜在的资源泄漏点。五个
free
分支很快变成五十个。靠人脑逐条检查,和靠类型系统兜底,是完全不同等级的可靠性。
六、第五拦:unsafe
的面积必须被控制
最后一个问题不是编译错误,而是设计选择。
跟 io_uring 打交道,unsafe 跑不掉。内核不懂
Rust 的生命周期,C ABI 也不懂。你总得在某个地方把
Box<Op> 变成裸指针塞给
user_data,在另一个地方把它接回来。
问题是:这些 unsafe 放在哪里?
错误做法是把 unsafe
散到业务逻辑每个角落——提交的地方来一坨,完成的地方又来一坨,中间还穿插着状态机的
match 分支。这等于白用 Rust:unsafe
扩散到哪里,编译器的保证就失效到哪里。
正确做法是把裸指针操作压进两个函数:
/// 把 Op 交给内核。从此刻起,Rust 不再追踪这块内存。
fn submit_op(op: Op) -> u64 {
Box::into_raw(Box::new(op)) as u64
}
/// 从内核接回 Op。从此刻起,Rust 重新接管这块内存。
unsafe fn reclaim_op(user_data: u64) -> Box<Op> {
Box::from_raw(user_data as *mut Op)
}所有跟 submission().push()
打交道的函数,内部的 unsafe 只做一件事:把 SQE 推进
ring。上层状态机看到的永远是类型化的 Op
枚举——Op::Accept、Op::Read { fd, buf }、Op::Write { fd, buf, len }。类型明确,所有权清晰,drop
自动执行。
unsafe
被关在提交和完成两个口子里,审计面积从”整个事件循环”缩小到”六行指针操作”。剩下的
185 行全是 safe Rust,编译器全程看着你。
七、量化对比
不吹”Rust 更快”——echo server 这种 I/O bound 的东西,瓶颈在内核和网卡,不在用户态语法。真正的差异在风险面:
| 维度 | C (173 行) | Rust (191 行) |
|---|---|---|
| 上下文类型 | void *
传送带,强转回来靠人脑 |
enum Op,编译期区分 |
| 分配点 | 3 个
malloc,散布在三个函数里 |
Box::new 统一在
submit 时 |
| 释放点 | 5 个
free,散布在五条分支里 |
reclaim_op 在
complete 时 + 自动 drop |
| buffer 生命周期 | 注释约定(in real app, manage lifetime) |
move / borrow 编译期检查 |
| 错误路径清理 | 每个分支手写
close + free |
Drop trait
自动执行 |
| unsafe 面积 | 整个程序(C 没有 safe/unsafe 区分) | 6 行裸指针操作 +
push 调用 |
重点不是哪个数字大或小。重点是:C 版本的正确性依赖”程序员记住了所有分支”,Rust 版本的正确性依赖”编译器检查了所有分支”。
173 行的 echo server,两种方式都管得住。但项目长到 1 万行、10 万行的时候,“程序员记住一切”这个假设就会开始破裂。这不是对 C 程序员能力的质疑——是对人脑工作记忆容量的客观评估。
结语
这篇文章不是说”C 不好用”。那个 C echo server 能跑,跑得稳,我自己也写了很多年 C。
这篇文章想说的是:当你把 C 代码往 Rust 翻的时候,编译器拦下来的那些地方,恰好就是你在 C 里靠经验硬扛的地方。 不是巧合——Rust 的类型系统就是为了把这些”经验”编码成”约束”。
经验可以传授,但不能保证传达。你交接给新人的时候,约定会丢失,注释会被忽略,“大家都知道这里要注意”会变成”我不知道还有这个坑”。约束不会。编译器不会忘记你的规矩。
这篇是《Rust 所有权:C++ RAII 本来想成为的样子》的工程落地版——那篇讲规则,这篇讲规则在一个真实异步状态机里怎么咬人。下一步是 unsafe Rust(计划中):当你不得不穿过编译器的边界时,怎么活着回来。
完整代码
- C 基线: 02-echo-server.c (173 行)
- Rust 版本: echo_server.rs (191 行) + Cargo.toml
- 编译:
cargo build --release,测试:nc localhost 8080
延伸阅读
- 实战:基于 io_uring 的 TCP Echo Server – 本文 C 基线的详细解析
- io_uring 多线程编程模式 – 为什么本文只讲单线程
- Linux 内核的内存屏障:一个让我调了三天的 bug – “经验正确不等于有保证”在硬件层面的体现
- 大多数”无锁”代码其实不是无锁的 – “自称安全的代码到底安不安全”
- 零拷贝的肮脏真相 – 零拷贝和 buffer 生命周期的矛盾
- unsafe Rust:当编译器不再替你扛枪 – 本文 submit_op/reclaim_op 的 unsafe 深入解剖