把「零拷贝」理解成「内核 sendfile 那种不经过用户态」会在业务网关里立刻失效。应用层说的零拷贝,绝大多数时候是另一件事:同一块已经在用户态的字节,不要为了换一个所有者再 memcpy 一次。socket 读进一块缓冲,协议头和 payload 用切片指向它,跨任务移动只改引用计数。内核到用户态那一次 copy(或一次 page flip)已经付过了;后面每一层 to_vec()、String::from_utf8、Bytes::copy_from_slice 才是你真正能砍掉的账。
这篇文章只讲用户态缓冲:Bytes/BytesMut 怎么用计数共享一块分配、解析器如何把视图和所有权分开、readv/writev 在减少什么、什么时候拷贝反而是诚实的。读完你应该能独立回答三个问题:这段 to_vec 是在买独立可变副本还是在交税、头部和 body 为什么能指向同一块堆、连接级读缓冲为什么不能直接把引用交给下游任务。
1. 先把「拷贝」画在正确的层
一次 HTTP 请求体从网卡到业务 handler,可能经过这些拷贝:
- 网卡 DMA 到内核 sk_buff(你几乎管不着)。
read/recvmsg从内核拷到用户态缓冲(常规 socket 的默认税;io_uring固定缓冲、mmap、splice能减,但换来的是生命周期和硬件约束)。- 协议栈把缓冲
to_vec成「请求体所有物」。 - JSON 库再拷成
String字段。 - 下游 RPC 再
clone一份发出去。 - 日志再
format!一份。
1 和 2 是操作系统的活。3 到 6 是应用层习惯。把火焰图上的 memcpy / memmove / alloc 当成「解析器不够快」,会去微优化 parser,而真正的乘数在第 3 层:每个请求一份新 Vec,QPS 上万时分配器比状态机更忙。
内核零拷贝(sendfile、splice、MSG_ZEROCOPY)解决的是「文件或用户缓冲如何进网卡队列」。它和 bytes::Bytes 不互相替代。静态文件网关该研究 sendfile;RPC 网关、多路复用代理、需要把同一包切给多个下游的服务,该研究用户态共享切片。两套词共用「零拷贝」这个名字,账不能混。
还有一类「看起来像拷贝、其实是搬所有权」:Vec 的 move、BytesMut::split、Cursor 往前走。它们改的是指针和长度,不碰有效载荷。优化时先问每一次「拷」落在上面哪一类。答错就会出现「我已经用了 Bytes,为什么分配器还在喷」——因为你在 Bytes 之上又 to_vec 了。
2. 所有权在计数上,不在每一层的新 Vec 上
bytes::Bytes 是一个胖指针:指向某段字节,外加一块共享的引用计数头(或静态/Vec 的特化路径)。clone() 只增加计数,不复制载荷。slice(start..end) 再得到一个指向同一分配、更短范围的 Bytes。最后一个 Bytes drop 时计数归零,底层缓冲才释放。
BytesMut 是可变侧:在唯一所有者(计数为 1、且没有只读视图活着)时可以 put / reserve / 改字节。freeze() 把它变成不可变的 Bytes,之后只能切片不能改。split_to(n) 把前 n 字节拆成另一段 BytesMut,剩下的仍可变——这是读缓冲「切走一个完整帧、留下半包」的原语。
use bytes::{BufMut, Bytes, BytesMut};
fn take_frame(buf: &mut BytesMut) -> Option<Bytes> {
if buf.len() < 4 {
return None;
}
let n = u32::from_be_bytes(buf[0..4].try_into().unwrap()) as usize;
if buf.len() < 4 + n {
return None;
}
buf.advance(4);
Some(buf.split_to(n).freeze())
}
take_frame 没有为载荷分配。长度不够就返回 None,调用方继续 read 往同一个 BytesMut 尾部填。够了就把这一帧的所有权切走,半包留在原缓冲。连接上的读循环只拥有一块可变缓冲;飞出去的帧是只读视图。这张图一旦画清,后面的解析器、下游发送、日志,都应该吃 Bytes 而不是再要一份 Vec<u8>。
Bytes::from(vec) 会接管这个 Vec 的堆,不再拷载荷。Bytes::copy_from_slice 才是显式拷贝。代码评审里看到后者,要问:源缓冲的生命周期是否短于我需要的视图?短,才该拷;源是 Bytes 或能 freeze 的 BytesMut,就不该拷。
3. 解析器返回视图,调用方决定要不要拥有
解析的输入应该是 &[u8] 或 Bytes。输出如果只在当前函数用,返回 &[u8] / 结构体里带生命周期。输出如果要跨 await、跨任务、进队列,返回 Bytes 切片。
调用方拿到视图之后,第一件事是决定哪些字段要脱离共享块。连接 id、方法、状态码、路径里那一小段路由键,拷成整数或小 String;真正的 payload 继续用 Bytes。这个决定一旦做错,就会出现「我明明 slice 了 16 字节,为什么这块 64KB 缓冲还不释放」——因为计数钉的是整块分配。解析器文档应当写明:返回的 Bytes 可能钉住多大的底层块,调用方有没有责任在边界上拷小字段。
热路径上不要为了「接口好看」把 Head 改成全 String 字段。好看的代价是每一帧几次分配,QPS 上万时比解析本身还贵。类型设计在这里就是缓冲策略:字段类型决定以后还能不能零拷贝传给下游。
use bytes::Bytes;
struct Head {
ty: u8,
flags: u8,
payload: Bytes,
}
fn parse_head(mut frame: Bytes) -> Option<Head> {
if frame.len() < 2 {
return None;
}
let ty = frame[0];
let flags = frame[1];
let payload = frame.split_off(2);
Some(Head { ty, flags, payload })
}
split_off 之后 frame 持有前缀、返回值持有后缀,底层仍是一块。ty/flags 是拷贝两个字节——小且要脱离缓冲,这是诚实的。不要把整个 payload String::from_utf8(payload.to_vec()) 只为了 println!。日志用 String::from_utf8_lossy(payload.as_ref()) 或直接 log 十六进制前 N 字节。路由、header 名字、方法枚举,能做成 &'static str 或小枚举就不要保有原文切片。
过早 String 还有正确性问题:协议字段未必是 UTF-8。二进制协议里 from_utf8 失败被你写成 unwrap,等于把一个畸形包变成进程崩溃。先当 [u8] 验证,需要文本时再在边界转换,并且失败是错误类型,不是 panic。
JSON / protobuf 反序列化常常是隐藏的全量拷贝:每个 String 字段一份。热路径上的网关如果只做转发,不要解码成领域对象再编码回去——透传 Bytes,只解析你路由需要的那几个字节。必须解码时,看库是否支持借用反序列化(serde 的 &str / Cow、protobuf 的 bytes 字段映射到 Bytes)。默认 String 是库作者图省事,不是物理定律。
4. 连接级缓冲不能把引用借出连接
这是所有权和零拷贝交界上最容易翻的地方。连接任务拥有 BytesMut 读缓冲。一个完整帧切出去之后,这帧的 Bytes 可以发给别的任务;指向缓冲内部的 &[u8] 不行,因为下一轮 read 可能 reserve 导致重分配,旧指针悬空。即便不重分配,连接关闭时缓冲 drop,引用也死。
所以:连接循环和下游之间的契约是 Bytes(或 Vec<u8>),不是 &[u8]。看起来多了一次计数,买到的是「下游的 await 不再绑在连接的栈上」。这和所有权篇里「跨 await 只移动拥有的值」是同一条规则,只是这里的「拥有」碰巧是共享只读的计数切片。
反例:
async fn bad(buf: &BytesMut, tx: tokio::sync::mpsc::Sender<&[u8]>) {
let _ = tx.send(&buf[..]).await; // 生命周期无法越过连接任务
}
正例:tx.send(buf.split_to(n).freeze()).await。有界 channel 满了,发送方 await——背压自然作用在读循环上,读循环不再疯狂 read。这和 Stream 那篇是同一根管子,缓冲策略提供的是管子里流动的物件形状。
写路径对称:多个 Bytes 要写出,用 writev / Buf::chunks_vectored 一次 syscall 写多个不连续片段,而不是先 copy_to_vec 拼成连续再 write。拼连续是为了对齐、为了 C API、为了哈希整包——不是为了 write 本身。
5. 读循环:reserve、fill、切帧、回收
一个能在生产里跑的读循环,状态比「read 到 EOF」多:
buf.reserve(min_needed)。容量不够会重分配;重分配时如果还有活着的Bytes视图指向旧块,BytesMut会先把唯一所有权拿回来(必要时拷贝)。这就是「视图活得太久会逼出一次拷贝」的机制。切走的帧尽快送走、不要在连接上囤积未 freeze 的别名。- 从 socket
read进buf.chunk_mut()或read_buf,再advance_mut。用AsyncReadExt::read_buf比先read(&mut [u8])再put少一次中间拷。 - 循环
take_frame直到半包。一包都切不出来就回到 1。 - 帧大小设上限。长度字段说 2GB 就
reserve(2GB)是攻击面。超过上限切断连接并打点,不要指望分配器拒绝来救你。 - 空闲连接把容量还回去。长期 keep-alive、偶发大包,会把
BytesMut的容量钉在峰值。BytesMut::new()替换或truncate+ 适当shrink,按策略,不要每个连接永占 1MB。
use bytes::BytesMut;
use tokio::io::{AsyncRead, AsyncReadExt};
async fn read_loop<R: AsyncRead + Unpin>(
mut r: R,
tx: tokio::sync::mpsc::Sender<bytes::Bytes>,
) -> std::io::Result<()> {
let mut buf = BytesMut::with_capacity(8 * 1024);
loop {
if tx.capacity() == 0 {
tx.reserve().await.map_err(|_| std::io::ErrorKind::BrokenPipe)?;
}
let n = r.read_buf(&mut buf).await?;
if n == 0 {
return Ok(());
}
while let Some(frame) = take_frame(&mut buf) {
if frame.len() > 1024 * 1024 {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "frame too large"));
}
if tx.send(frame).await.is_err() {
return Ok(());
}
}
if buf.capacity() > 64 * 1024 && buf.len() < 4096 {
buf = BytesMut::from(&buf[..]); // 小半包搬到小分配,旧峰值释放
}
}
}
take_frame 见第 2 节。容量回收那一行是拷贝——半包通常很小,这是用一次小拷贝换回大容量。这就是「诚实的拷贝」。
6. 何时必须拷贝(以及如何只拷该拷的)
必须拷贝的常见原因:
- 要改几个字节,但其它视图还活着。 改
Bytes只能先copy_to_bytes/to_vec成自己的BytesMut。不要为了改一个 flags 位把 2MB payload 一起拷——切开,只拷头部那几个字节,payload 继续共享。 - 要对齐、合并给 C API 或硬件。 DMA、某些加密库、
iovec数量有上限时,不连续片段必须收成连续。先writev试,不够再合并。 - 所有权必须唯一且对端会
free。 FFI 那篇会展开。交给 C 的缓冲如果对方用free,你得用 libc 分配器分配并放弃 Rust 析构。这不是Bytes能直接表达的。 - 加密/压缩输出和输入重叠不安全。 in-place 算法有的允许,有的不允许。允许时才 in-place,默认当需要独立输出缓冲。
- 要持有一份不受上游回收影响的快照。 审计日志、重放、失败重试。这时拷贝是产品需求。限制快照大小,不要把整个流量镜像进内存。
clone() 在 Bytes 上便宜,在 Vec<u8> 上昂贵。代码里看到 clone 先看类型。Arc<Vec<u8>> 也能共享,但切片要自己做偏移;Bytes 把偏移和计数打成一个类型。已经在用 Bytes 就不要再套一层 Arc。
小对象不要零拷贝原教旨:16 字节的 id 直接 Copy 进数组,比共享切片更甜。零拷贝的收益和载荷大小、拷贝次数、缓存行有关。对 20 字节的 header 谈引用计数,计数本身可能更贵。
7. 池化:先去掉明显的 to_vec,再谈对象池
缓冲池(BytesMut 回收、slab、Pool<Vec<u8>>)减少的是分配器压力和缓存不命中。它不减少逻辑错误:你把池里的缓冲发给下游、下游还没还、上游又从池里拿同一块来写,就是 use-after-free 的用户态版。Rust 的所有权在这里仍然有效——池的 acquire 必须把唯一 BytesMut 交出去,release 只接受所有权回来的那块。不要用 unsafe 把池做成共享可变数组然后靠注释保证。
顺序应当是:
- 热路径消灭
to_vec/copy_from_slice/ 无谓String。 - 读缓冲按连接复用一块
BytesMut(上一节的循环已经在做)。 - profiler 仍显示分配热点,再引入跨连接的池。
- 池有容量上限、有单块大小上限、有泄漏检测(借出超时打点)。
过早上池的典型症状:代码充满 unsafe 和生命周期谎言,性能和「每连接一块 BytesMut」差不多,因为瓶颈根本不在分配。jemalloc / mimalloc 对多线程小分配比系统 allocator 友好,有时换分配器比写池更便宜。先测。
with_capacity 是最便宜的池:你知道 99% 的帧小于 8KB,就不要让 BytesMut 从 0 一次次倍增。倍增会留下 7KB 的中间分配垃圾。预分配不是浪费,是把峰值变成稳态。
8. 内核侧:writev、sendfile、以及不要假装
用户态切片共享解决不了「数据还在文件里、我不想读进进程」。静态资源、大文件下载走 sendfile / tokio-uring / 平台对应 API,让内核把页送到 socket。这时进程里甚至没有 Bytes。混用的错误形状是:先 read 整个文件进 Vec,再 write_all——两份拷贝,内存按文件大小涨。
writev 把多段用户缓冲一次交给内核。HTTP 响应「状态行 + 若干 header + body」天然是多段。拼成一个 Vec 再写,是为了代码好看。Vec<IoSlice> 或 Buf 的 vectored 写,能保住 header 小分配和 body 的 Bytes 共享。注意 writev 部分写:返回值可能只写了前几段的一部分,要自己推进 iovec。AsyncWriteExt::write_all_buf 一类 API 帮你做推进,手写 syscall 就要处理短写。
io_uring 固定缓冲、注册文件,是另一套所有权:缓冲被内核借用期间,用户态不能改、不能释放。这和借用检查器想表达的是同一件事,只是发生在 syscall 边界。封装库如果把这块做成 Future,必须在 future 完成前钉住缓冲(Pin + 拥有)。自己用 unsafe 把栈上数组提交给 uring,然后 await 别的东西——这是 use-after-free,编译器救不了你。
9. 取舍:Vec、Bytes、&[u8]、mmap 各买什么
&[u8]:最便宜的视图。不能跨await除非所有者还在栈上且被借用检查器承认。解析、校验、哈希的输入默认它。Vec<u8>:唯一可变所有权,API 无处不在,和 C 交接简单(as_mut_ptr)。切片共享要自己搞。适合构建中的消息、必须独占改的缓冲。Bytes:只读共享切片,跨任务便宜。生态(hyper、h2、tonic、多数代理)把它当硬币。适合已经成形的帧、请求体、RPC 消息。BytesMut:连接读侧、编码器写侧。freeze/split 是和Bytes的边界。mmap:把文件当切片。巨大只读、随机访问、生命周期绑在Mmap对象上。注意 SIGBUS(文件被截断)、和 fork 的相互作用、以及异步运行时里 mmap 缺页会堵 worker——大范围缺页该当阻塞活。Cow<[u8]>:偶尔改、多数不改。不是网络热路径的第一选择。
选类型就是选以后能不能零拷贝传给下游。入口已经 Vec,后面每层再转 Bytes 会有一次接管或拷贝。入口用 Bytes,要唯一可变时再 try_into_mut / 拷贝。公共库的 API 若强制 Vec<u8>,等于强迫所有调用方放弃共享。能收 impl Buf 或 Bytes 就不要收 Vec。
10. 排障:分配器、RSS、错把共享当拷贝
- 火焰图上
malloc/memcpy占比高。 先 grepto_vec、to_owned、Bytes::copy_from_slice、String::from_utf8、clone()在Vec/String/HashMap上。不要先写池。 - RSS 随连接数线性涨。 每条连接的
BytesMut容量钉在峰值;或切出去的Bytes被下游(日志、access log、慢消费者)长期握着,底层整块缓冲无法释放——因为计数切片共享的是整块分配,不是你 slice 出来的那 16 字节。一条 64KB 读缓冲上切出 16 字节 header 送到慢队列,这 64KB 会活到那 16 字节被 drop。修法:小字段拷贝出来(诚实的小拷贝),大 payload 才共享。 - 延迟尖刺伴随分配。 某条消息异常大,
reserve一次翻倍;或 jemalloc 从中心刷 dirty page。对帧长做直方图,不要只看平均。 - 以为用了 Bytes 仍然在拷。
hyper的 body 聚合成to_bytes().await会把所有 chunk 收成一块——这是一次合并拷贝。能流式处理就不要聚合。gRPC 默认有消息大小上限,超大消息会在这一层爆炸。 - 数据错乱。 池或
unsafe复用缓冲、切帧长度算错、advance和split_to混用导致半包错位。先用单测喂粘包/拆包/最大长度,再谈性能。错的零拷贝比慢的Vec贵得多。
度量:每请求分配次数和字节(dhat、heaptrack、ALLOCATOR 计数器)、帧大小直方图、连接上 BytesMut.capacity、channel 里待处理 Bytes 的数量。后一项能直接看到「慢消费者握着多少整块读缓冲」。
11. 设计上为什么是引用计数切片,而不是全局缓冲池加下标
Bytes 把偏移、长度、释放策略(Vec / 静态 / 自定义析构)收进一个值类型。你可以把它送进 channel、放进结构体、跨 crate 传递,接收方不需要知道这块内存来自哪一个池。全局池加下标的模型更「零开销」,但所有权变成了整数,借用检查器帮不上忙,跨组件的释放约定会回到注释里。
这是和 Rust 整体一致的取舍:用一个薄薄的计数,换回类型系统对「谁最后释放」的证明。计数的原子操作在热路径上可测,但通常低于一次 malloc+memcpy。只有在 profiler 指出 Bytes clone 的原子是热点时,才考虑对那一段改回单所有权 Vec 或线程局部池。先共享,测,再收紧——和锁那篇的顺序一样。
再把「半包」说透。TCP 是字节流,不是消息流。一次 read 可能得到半个帧、一个帧、一个半帧、或十个帧粘在一起。take_frame 必须能在这四种输入下都不丢字节、不重分配出错误的切片。测试至少覆盖:正好一帧、两帧粘连、长度字段跨两次 read、长度字段声称的大小超过上限、长度为 0、对端在半包时关闭。关闭时缓冲里若还有半包,那是协议错误,不要把半包当一帧送下去。二进制协议用大端长度很常见,解析时不要 as u32 截断 usize,也不要在 32 位目标上让 u32 长度直接 reserve 到 OOM——上限检查在 reserve 之前。
编码器对称:先写长度占位,再写 payload,再回填长度。BytesMut 在 reserve 后指针稳定(没有并发视图时),可以先 split_to 出头部槽。不要为了回填把已经写好的 payload clone 到新缓冲。HTTP/1 的 chunked 编码每次 chunk 一个长度行,适合小 Bytes 列表 vectored 写出;不要先拼成一个大 String。
压缩和加密是拷贝的高发区。多数 zlib/tls 实现吃 &[u8] 吐新 Vec。输入侧仍可零拷贝喂 Bytes.as_ref();输出侧新分配通常不可避免。有的库支持 write 到 BufMut,让你预留 BytesMut 当输出,少一次中间 Vec。TLS 的记录层还有自己的缓冲,应用层再缓冲一层要知道总深度。终端用户看到的延迟是各层缓冲之和,不是你这一层的 ready_chunks。
调试零拷贝 bug 时,在 Bytes drop 时打日志没有用——共享的是整块,你不知道谁还握着。更好的办法是在切帧处记录 ptr 和 len,在可疑的下游打印同一 ptr。同一指针出现在「连接已关闭」之后,就是有人把视图活过了连接。这不是 Rust 的 use-after-free(计数还在,内存还在),而是逻辑泄漏:大块缓冲被一个 16 字节的 header 视图钉在堆上。把小字段拷走之后,ptr 就不该再出现在下游。
HTTP 和 RPC 框架里的 body 类型是缓冲策略能否贯彻的试金石。hyper 的 Incoming 是一串 chunk 的 Stream;to_bytes().await 把它们收成一块 Bytes,中间可能发生一次合并拷贝。网关若只是把 body 转给下游,应当 forward 这个 Stream,并设置帧大小和总大小上限,而不是先聚合再转发。聚合的正当理由是:下游 API 要求完整消息、要算签名、要反序列化。即便如此,上限必须在聚合之前,否则恶意的无限 chunk 会把内存吃光——这是背压在 HTTP 层的具体化。gRPC 的消息大小上限、HTTP 的 content-length 检查、chunked 的累计字节,都是同一类墙。
编解码器(tokio-util::codec::Decoder)把「切帧」做成 trait:decode(&mut BytesMut) -> Result<Option<Item>>。返回 None 表示半包,Some 表示切出一项。你自己维护的 take_frame 和它是同一个函数。Item 里如果包含 Bytes,就是零拷贝切帧;如果包含 Vec<u8> 或 String,每一帧一份分配。能用 Bytes 就不要在 codec 里 to_vec。长度检查、最大帧、reserve 策略都应该在 decoder 里,而不是在业务 handler 里补。handler 看到的应当已经是合法的、有界的项。
文件上传和下载不要经过「整个 Vec」。上传:把 Stream<Item = Bytes> 直接 write_all_buf 到文件或对象存储 SDK 的分片接口。下载:sendfile 或按块 read 成 BytesMut freeze 后写出。中间若要算哈希,用流式 hasher 喂每一块,不要等全部到齐。内存峰值应接近块大小,而不是文件大小。谁在路径上偷偷 collect,谁就把峰值变回文件大小。代码评审 grep collect、to_bytes、read_to_end 在上传下载路径上应当是红色的。
缓存层常破坏零拷贝:把 Bytes 放进 LRU,条目的寿命变成「缓存策略」,底层那块读缓冲就活到 LRU 驱逐。小 key 拷贝、大 value 才共享,并且 value 最好是已经独立分配的 Bytes(copy_from_slice 或 freeze 时恰好唯一所有者),而不是某个 64KB 读缓冲上的 2KB 切片。缓存条目的真实成本是它钉住的分配大小,不是 slice.len()。度量缓存字节数时按底层分配估,否则你会觉得缓存只有 100MB,RSS 却多了 1GB。
scatter-gather 写(writev)失败时的推进容易写错。内核可能写完第一段和一半第二段就返回。你的 IoSlice 数组要切掉已写前缀,而不是从头再写一遍——重写会把已经到对端的字节再发一次,协议就乱。write_all_buf 一类封装帮你做这件事,手写 syscall 就必须测短写。读侧 readv 同理:半包可能落在第 i 段的中间,切帧逻辑仍应建立在逻辑字节流上,而不是「一段刚好一帧」的幻想。测试里用一次只吐一个字节的 AsyncRead mock,能把这类假设打碎。
还有一类「假装零拷贝」:mmap 后再 Vec::from 整段。mmap 的意义是按页缺页,不把文件一次读进进程;立刻拷进 Vec 就把意义取消了,还加上了 SIGBUS 和生命周期。对文件做解析,能用 &[u8] 从 mmap 切就切,要跨 await 再拷需要的那一段。巨大 JSON 文件用 mmap + 借用反序列化,峰值内存接近文件大小的映射而不是两倍拷贝;但缺页会堵 worker,大文件解析仍该进阻塞池,映射对象的所有权跟着闭包走。
对象存储 SDK、TLS、压缩中间层经常在内部藏缓冲。你在应用层已经零拷贝了,SDK 仍可能把 body collect 再发。选 SDK 时看它吃 Stream<Bytes> 还是吃 Vec<u8>。吃 Vec 的,你的 Bytes 策略在边界上结束,这是诚实的拷贝点,记在文档里,不要在更外层再怪自己「没做好零拷贝」。能换吃流的 API 就换;不能换,就把拷贝算进预算,限制并发上传数,让这份拷贝的内存有界。
解析状态机应把「还差多少字节」告诉读循环,reserve 才能刚好。盲目倍增会把容量钉在 2 的幂峰值上,小半包长期占着它。还差 8 字节时去 read 64KB,是用超前读取换 RSS 乘法器。按帧长分布选一次读的目标大小。
String::from_utf8_unchecked 接在 to_vec 后面是假零拷贝:既拷了,又关掉 UTF-8 检查。要文本且已验证,用 from_utf8 借;要拥有再 to_owned。未验证就 unchecked,畸形包会变成更晚才炸的内存问题。二进制字段默认保持 [u8] 直到出口。
scatter-gather 必须测短写。内核写完一段加半段就返回时,要从 IoSlice 里切掉已写前缀,从头重写会把已到对端的字节再发一遍。用一次只吐一个字节的 mock AsyncRead/AsyncWrite 把「一段刚好一帧」的假设打碎。
SDK 和 TLS 内部常藏 collect。应用层 Bytes 策略在吃 Vec<u8> 的边界上结束,把这次拷贝写进预算并限制并发,比在更外层责怪自己没做好零拷贝诚实。
12. 小结
应用层零拷贝的原理是:共享底层分配 + 切片表达子范围 + 用引用计数而不是 memcpy 转移只读所有权。连接拥有可变读缓冲,切出去的帧是 Bytes,解析器在同步代码里借 &[u8],跨 await 只移动计数切片。小字段、要对齐的 C 边界、需要独立可变的副本,拷贝是诚实的;热路径上的 to_vec 往往不是。
先画字节从 fd 到 handler 经过几次分配,再决定池和内核零拷贝值不值得上。下一篇把另一类「过早拥有」拿掉:错误值的类型。库把错误做成可 match 的枚举,应用边界再决定怎么汇报。