土法炼钢兴趣小组的算法知识备份

用 Rust 重写一个 C 网络服务器,编译器拦了我五次

源码下载

本文相关源码已整理,共 3 个文件。

打开下载目录 →

目录

仓库里有一个现成的 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 次:

  1. cqe->res < 0 错误路径 – close(fd) + free
  2. accept 完成 – free(旧 accept ctx 用完即弃)
  3. read 返回 EOF – close(fd) + free
  4. read 成功转 write – free(旧 read ctx 扔掉,新建 write ctx)
  5. write 完成 – free

3 个入口,5 个出口。这是异步状态机的经典特征:分配在提交端,释放散落在完成端的多条分支里。 少一个 free 就泄漏,多一个 free 就 double free。173 行代码管得住,1730 行代码呢?

还有一个更隐蔽的问题:user_data 的类型是 __u64。你往里塞 struct conn_info *,取出来也当 struct conn_info * 用——但这个约定完全靠人脑维持。编译器看到的只是一个整数来回搬运,既不知道你塞的是什么类型,也不关心那块内存还活不活着。

C 与 Rust 的连接上下文所有权流转

二、第一拦:栈对象不能活过函数返回

直译的第一版,我想像 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_infofd 作为 int 随便复制:

memcpy(conn->buf, buf, len);  // 复制一份,两边各用各的

注释甚至写了 in real app, avoid copy or manage lifetime——翻译成人话就是”我知道这样不够好,但我不想现在处理生命周期问题”。

Rust 编译器逼你现在就处理。最终实现里,fdbuf 的所有权在状态之间做单向传递:

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_requestmalloc 新的 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 分支,你敢保证每个分支都写全了?新加一个分支的时候不会忘?closefree 的顺序反了也不会出问题?C 编译器一概不管。

Rust 版本里,Op 持有 OwnedFdVec<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::AcceptOp::Read { fd, buf }Op::Write { fd, buf, len }。类型明确,所有权清晰,drop 自动执行。

unsafe 被关在提交和完成两个口子里,审计面积从”整个事件循环”缩小到”六行指针操作”。剩下的 185 行全是 safe Rust,编译器全程看着你。

把 unsafe 压在最薄的 FFI 边界

七、量化对比

不吹”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 程序员能力的质疑——是对人脑工作记忆容量的客观评估。

Echo 状态机与最容易出错的所有权节点

结语

这篇文章不是说”C 不好用”。那个 C echo server 能跑,跑得稳,我自己也写了很多年 C。

这篇文章想说的是:当你把 C 代码往 Rust 翻的时候,编译器拦下来的那些地方,恰好就是你在 C 里靠经验硬扛的地方。 不是巧合——Rust 的类型系统就是为了把这些”经验”编码成”约束”。

经验可以传授,但不能保证传达。你交接给新人的时候,约定会丢失,注释会被忽略,“大家都知道这里要注意”会变成”我不知道还有这个坑”。约束不会。编译器不会忘记你的规矩。

这篇是《Rust 所有权:C++ RAII 本来想成为的样子》的工程落地版——那篇讲规则,这篇讲规则在一个真实异步状态机里怎么咬人。下一步是 unsafe Rust(计划中):当你不得不穿过编译器的边界时,怎么活着回来。


完整代码

延伸阅读


By .