Rust in Everything
基于 bytes 1.12.1

取舍汇总

主线位置:两条主线的总账。前两条决定共享主线的形状(Bytes 背后能放什么、引用计数什么时候才分配),第三条决定构建主线的形状(BytesMut 怎么在不分配的前提下记住自己是什么),第四条是两条线交汇处的代价,后两条是两条线各自的游标接口。

flowchart TB
  d1[手写 vtable] --> c1[全是 unsafe fn]
  c1 --> f1["miri、loom、TSAN"]
  d2[可提升存储] --> c2[第一次 clone 抢 CAS]
  d3[元数据塞进 data] --> c3[原始容量会丢]
  c3 --> f3["with_capacity 给足"]
  d4[切片共享整块] --> c4[小切片留住大块]
  c4 --> f4[copy_from_slice]
  d5[游标与存储分开] --> c5[chunk 只给一段]
  c5 --> f5["get_* 与 chunks_vectored"]
  d6[UninitSlice] --> c6[不能下标读]
  c6 --> f6["put_* 默认实现"]

每一节的格式和 tracing 卷、hyper 卷 的同名页相同:做了什么决定,换来了什么,代价是什么,代价由谁来补。机制都在别的页讲过,这里只给结论和链接;引到的数字都来自那些页的实验,平台都是 M2 Max、rustc 1.99.0,bytes = "=1.12.1"。

手写 vtable:一个类型,五种存储

决定:Bytes 是 ptr、len、data(AtomicPtr<()>)和一个 &'static Vtable;Vtable 是五个 unsafe fn 指针(clone、into_vec、into_mut、is_unique、drop),pub(crate),crate 外写不出新的表。静态切片、可提升(奇偶两张)、Shared、from_owner 的 Owned<T>、BytesMut 冻结来的共享块,一共六处表定义(结构与 Vtable、五种存储)。

换来的:

  • 静态数据零分配。 Bytes::from_static 的 data 是空指针,构造和 clone 都是 0 次分配;用「视图 + Arc<dyn AsRef<[u8]>>」写的 DynBytes 包同一段 &'static [u8] 也要分配 1 次。Bytes::new() 是 const fn,靠的就是这条路(结构与 Vtable)。
  • data 可以不是一个对象的地址。 它可以是空指针、缓冲地址加 1、缓冲地址本身,只有配套的那张表知道怎么读;它还能被一次 CAS 原地换掉,可提升存储(下一节)就靠这一点。trait object 的数据指针做不到这几件事(结构与 Vtable)。
  • 用的一方只认一个类型。 hyper 交出的 body 块、axum 的 Bytes 提取器都是 Bytes,不管背后是哪种存储(bytes 解决什么问题)。

这一条换来的不是大小。Bytes 是 32 字节,DynBytes(ptr、len 加一个两个字的 Arc<dyn ...>)也是 32 字节,Option<Bytes> 和 (Arc<[u8]>, Range<usize>) 同样是 32(结构与 Vtable、bytes 解决什么问题)。

代价:

  • 正确性全靠每张表自己。 表里的函数都是 unsafe fn,Send / Sync 是两行 unsafe impl,注释只有一句「Vtable must enforce this behavior」(结构与 Vtable)。编译器帮不上的地方出过真 bug:owned_to_vec 拷贝之后没释放 owner(#773,1.10.1,起因之一是 to_ 这个名字,1.11.0 改成 into_*,结构与 Vtable);冻结来的 Bytes 的 is_unique 填了另一个模块的函数,把 bytes_mut::Shared 当成 bytes::Shared 读(#718,1.6.1,clone 与提升)。
  • 同一个类型,行为随存储变。 调用方看不到背后是哪张表,is_unique 和 try_into_mut 的结果却取决于它:静态和 from_owner 的 is_unique 永远是 false,try_into_mut 永远是 Err,哪怕只有一个句柄(五种存储、clone 与提升);clear 在可提升存储上当场释放缓冲,在别的存储上只把 len 置 0(切片)。
  • 每次 clone、drop 都是一次间接调用。 Drop 和 Clone 都是读出表里的函数指针再调用,编译器看不进去。本卷没有量它的耗时;量过的是它对代码生成的影响:1.12.0 之前 drop 拿的是 &mut data,LLVM 只能认为含 Bytes 的值的地址被这次间接调用捕获,按值传一个 232 字节的结构体时多出一次 alloca 加 memcpy;#826 改成按值传 data 之后这两行没了(结构与 Vtable)。
  • STATIC_VTABLE 是 const(因为 from_static 是 const fn),每处使用各有一份,没有唯一地址:同一个程序里实测出两张地址不同的静态表(五种存储)。

谁来补:bytes 自己的测试和 CI。tests/test_bytes_odd_alloc.rs、test_bytes_vec_alloc.rs 各带一个全局分配器,一个逼出奇数地址,一个核对释放时的大小;CI 在 miri(strict provenance、大端)、ASAN、TSAN、loom 下跑(源码地图)。loom 只模型检查了两个线程并发 clone,is_unique、try_into_mut、into_vec 没有 loom 测试(clone 与提升)。用的一方要记住:try_into_mut 返回 Err 不一定是「还有别人」(常见坑:try_into_mut 返回 Err)。

可提升存储:引用计数等到第一次 clone 再分配

决定:From<Vec<u8>> 在 len == cap 时转成 Box<[u8]>,挂 PROMOTABLE_EVEN_VTABLE 或 ODD,data 是缓冲地址(最低位为 1);第一次 clone 进 shallow_clone_vec,先分配一个 ref_cnt = 2 的 Shared,再 CAS 进 data。len < cap 的 Vec 当场分配 Shared,直接挂 SHARED_VTABLE(五种存储、clone 与提升)。

换来的:

  • Vec 转 Bytes 本身 0 次分配。从没被 clone 过的 Bytes(转过去只读一遍、或者整个交出去)一辈子不分配计数块,drop 时直接按算出来的容量释放缓冲(五种存储)。
  • 真被共享时也只付一次:64 KiB 的 Vec 转成 Bytes 再 split_to 成 100 帧,总共 1 次分配、24 字节,0 字节拷贝;容量等于长度时那 24 字节发生在第一次 split_to,多 1 字节容量时发生在 Bytes::from,总数一样(bytes 解决什么问题)。
  • 唯一时转回去不拷贝:try_into_mut 原地复用缓冲(clone 与提升)。

代价:

  • 第一次 clone 要抢。 分配在 CAS 之前,所以并发的第一次 clone 会多分配:8 个线程同时 clone 一个刚建好的 Bytes,跑 10 万轮,约 97% 的轮次(三次运行分别 97293、98047、97542 轮)8 个线程每个都分配了 Shared,抢输的 7 个再释放掉;每轮只留下一个,没有泄漏(clone 与提升)。
  • 提升之后原句柄换不了表。 原句柄的 vtable 仍是 PROMOTABLE_*,之后每次 clone / drop 都先查 data 的最低位;可提升表读 data 要用 Acquire,SHARED_VTABLE 用 Relaxed 就够(clone 与提升)。
  • 不记容量,靠一条不变式算。 cap = (ptr - buf) + len 只在「可见段末尾就是缓冲末尾」时成立,所以会缩短末尾的操作必须先提升:truncate 在可提升存储上走 split_off,实测分配 1 次 Shared(切片)。这条不变式被打破过:0.5.4 之前 truncate 只改 len,drop 时用错的大小 dealloc(#360)。
  • 最低位标记要两张表。 u8 缓冲的地址可以是奇数,最低位本来就是 1 的地址原样存放,「还原时要不要清最低位」只能记在挂哪张表上;只有一张表的时候这是个 bug(#343,0.5.4 修,五种存储)。
  • 只覆盖 len == cap。 带空闲容量的 Vec 当场分配 24 字节的 Shared(#592,1.4.0 起;之前是 into_boxed_slice 缩容、重新分配并拷贝)。所以 KIND_VEC、len < cap 的 freeze 有 1 次分配,freeze 文档里「The conversion is zero cost」不总成立(五种存储)。

谁来补:竞争的代价只是几次 malloc / free,换的是单线程下完全不分配;要避开它,在交给多个线程之前先在一个线程里 clone 一次或 slice(..)(clone 与提升)。提升竞争由 loom 的 bytes_cloning_vec 穷举两个线程的交错来检查,奇数地址由 test_bytes_odd_alloc.rs 的分配器逼出来(源码地图)。

BytesMut 把元数据塞进 data 的位

决定:BytesMut 是 ptr、len、cap、data。data 的最低位 1 是 KIND_VEC(独占一个 Vec,data 是一组位:第 2–4 位是原始容量的 repr,第 5 位往上是前面切掉的 pos),0 是 KIND_ARC(data 是 Shared 的地址,靠 Shared 的偶数对齐天然为 0,有编译期断言)(data 字段的编码)。

换来的:

  • 第一次 split 之前不分配引用计数。 照 hyper 读一个请求,读缓冲 with_capacity(0)、reserve、写入、split_to、freeze、slice、drop、再 reserve,只分配了 8192 字节的缓冲和第一次 split_to 时 40 字节的 Shared(一块缓冲的一生)。advance 只改 pos,不分配(data 字段的编码)。
  • 一个字记下两种形态。 KIND_VEC 时释放、freeze、reserve 用 pos 拼回原来的 Vec;KIND_ARC 时同一个字就是指针,不需要另一个字段区分(data 字段的编码)。BytesMut 和 Bytes 一样是 32 字节。

代价:

  • 原始容量只有 3 位。 repr 记的是容量去掉低 10 位之后的位宽,还原成区间下界:不到 1024 记 0,3000 还原成 2048,65535 还原成 32768,1 MiB 只还原成 64 KiB。它只在 from_vec 时算一次:从 with_capacity(0) 起步、靠 reserve 长大的缓冲(hyper 的读缓冲就是)原始容量永远是 0(data 字段的编码、reserve)。
  • pos 有上限。 64 位上是 2^59 - 1,碰不到;32 位上是 134217727(128 MiB),超过时 promote_to_shared(1),wasm32 上实测分配 20 字节的 Shared(data 字段的编码)。
  • split 一律提升。 split_to 不看 at,KIND_VEC 就提升:split_to(0) 也分配 40 字节,空的 BytesMut::new() split 也一样,这和 Bytes 切出空段时不 clone 不同(data 字段的编码、split 与 freeze)。
  • 回不去也是一种代价。 KIND_ARC 回到 KIND_VEC 只有 reserve 另分配这一条路,之后再 split 又要分配一个 Shared(reserve)。第 1 位从 0.5.0 起就空着,是 kind 曾经占两位留下的(data 字段的编码)。

谁来补:位不够时库自己换形态(提升成 Shared),不报错也不截断。原始容量丢了的影响由用的一方决定:hyper 每次 reserve 至少 8192,reserve(8192) 的读循环两种缓冲的分配完全相同;每次只 reserve(4096)、偶尔留帧时,原始容量为 0 的缓冲多出 2318 次 realloc,给足 with_capacity(8192) 就是 0(reserve、常见坑:reserve 的意外)。

切片共享整块分配

决定:slice、split_to、split_off 是「检查范围、clone 一次、改 ptr / len」;BytesMut::split_to 切出的两段共用一个 Shared。引用计数只记「有几个句柄」,不记每个句柄看多大一段(切片、split 与 freeze)。

换来的:零拷贝。64 KiB 切成 100 帧交给 100 个所有者:

做法分配拷贝
Vec<u8> 每帧 to_vec100 次,65536 字节64 KiB
整块 Arc<[u8]> 加下标1 次,65552 字节64 KiB
Bytes::from(Vec) 再 split_to1 次,24 字节0
BytesMut 的 split_to + freeze1 次,40 字节0

Arc<[u8]> 的计数和数据在同一块分配里,从 Vec 转过去只能整块搬;Bytes 的计数单独一小块,数据留在原地(bytes 解决什么问题)。切出空段时连 clone 都省掉(切片)。

代价:

  • 一个小切片留住整块缓冲。 从 1 MiB 的 Bytes 里 slice(100..110),drop(big) 之后存活字节仍是 1048600,直到 drop(small) 才一起释放(切片)。
  • 在 hyper 里,它让读缓冲没法复用。 上层还拿着 body 的一块 Bytes 时,下一次 reserve(8192) 只能另分配一块新的 8 KiB,旧块只为那 11 个字节活着(一块缓冲的一生)。类 hyper 的读循环里,新缓冲的个数和被留下的帧数相同:1/16 的帧被留 32 轮,10 000 轮里是 2560 个新缓冲(reserve)。
  • 读缓冲自己也算一份。 只要读缓冲还在,从它切出去的 Bytes 都不是唯一的,try_into_mut 返回 Err(一块缓冲的一生)。
  • 原始容量这个启发式会押错。 它让被迫另起的新缓冲至少和当初一样大,押的是「很快会被收回」;每一帧都被留两轮时押错了,with_capacity(8192) 的缓冲总共分配 81.6 MB,原始容量为 0 的只要 46.3 MB(reserve)。

谁来补:要长期保存的一小段用 Bytes::copy_from_slice 拷出来:多一次 10 字节的分配,drop(big) 立刻还回 1 MiB(切片、常见坑:小切片留住整块缓冲)。库这一侧没有补:计数块里没有地方记每个句柄看了多少。

Buf 与 BufMut:游标和存储分开

决定:Buf 只要求 remaining、chunk、advance,BufMut 只要求 remaining_mut、chunk_mut、advance_mut;get_*、put_*、copy_to_bytes 都是默认实现,具体类型可以覆盖。chunk 只需要给出当前这一段(Buf、BufMut 与 UninitSlice)。

换来的:

  • 解析代码写一遍,对 &[u8]、Bytes、BytesMut、Chain、VecDeque<u8> 都能用。存储可以不连续:两段用 chain 拼起来就是一个 Buf(Buf、适配器)。
  • 具体类型把拷贝换成引用计数:同一个长度前缀帧的 parse,源是 &[u8] 时分配 2 次、拷贝 8 字节,源是 Bytes 时 0 次,因为 Bytes 把 copy_to_bytes 覆盖成了 split_to(bytes 解决什么问题)。
  • 操作不会失败(文档原话「Operations on Buf and BufMut are infallible」),get_u16 返回 u16 而不是 Result(bytes 解决什么问题)。

代价:

  • 跨段要走慢路径。 同样读一个 get_u32:连续的 &[u8] 约 0.5 ns,Chain 里 4 字节都在第一段约 2.35 ns,2 + 2 跨段约 6.33 ns(criterion,三次相差不超过 5%,Buf)。
  • chunk 只给一段,容易被当成全部。 Chain 的 chunk 只返回第一段非空数据:在 Chain 上找 \r\n 的解析器得到 None,从 chunk()[..4] 切长度前缀的 panic 成 range end index 4 out of range for slice of length 2(适配器)。chunks_vectored 的默认实现也只交一段,1.10.0 起文档不再保证交全;Chain 把一个只交一部分的 Buf 和别的拼起来时,交出的 IoSlice 会跳过数据(Buf)。
  • 「不会失败」就是「会 panic」。 数据不够时 get_* panic,信息是 advance out of bounds: the len is N but advancing by M(Buf)。
  • 覆盖要一层层转发。 &mut T、Box<T> 转发了所有方法;自己写的包装类型不转发 copy_to_bytes,Bytes 的不拷贝就丢了(Buf)。

谁来补:定长字段用 get_* / try_get_* / copy_to_slice,它们本来就按段处理;找分隔符先保证数据连续(hyper 的做法是读进同一块 BytesMut),或逐段处理 chunks_vectored(适配器、常见坑:Chain 的 chunk 只有第一段)。不想 panic 用 1.10.0 加的 try_get_*,它返回 TryGetError、失败时游标不动;但 1.12.1 的 try_get_int 不做符号扩展,修复还没发版(Buf、常见坑:advance out of bounds、常见坑:try_get_int 的符号)。

UninitSlice:写未初始化的内存

决定:chunk_mut 返回 &mut UninitSlice,它是 #[repr(transparent)] 包着的 [MaybeUninit<u8>],两条不变式:不许读,也不许写入未初始化的值。advance_mut 是 unsafe fn,BufMut 本身是 unsafe trait(BufMut 与 UninitSlice)。

换来的:

  • with_capacity 之后的空位不用先清零就能交出去写。hyper 和 tokio 的 read_buf 都是 chunk_mut、as_uninit_slice_mut、ReadBuf::uninit 三步,socket 直接写进读缓冲的尾部(BufMut 与 UninitSlice)。
  • 只用 safe 代码不会出 UB。第二条不变式是为 issue #328 加的:0.5 的 &mut [MaybeUninit<u8>] 让 safe 代码能往一个已初始化的 &mut [u8] 里写 MaybeUninit::uninit(),0.6.0(#433)换成了 UninitSlice(BufMut 与 UninitSlice)。

代价:

  • API 不如 &mut [u8] 好用。 没有 Index<usize>,buf.chunk_mut()[0] 编译不过(E0277:「the type UninitSlice cannot be indexed by {integer}」);写只能用 write_byte、长度必须正好相等的 copy_from_slice,或者拿裸指针(BufMut 与 UninitSlice)。
  • 「写了多少」只能由调用方担保。 advance_mut 能检查越界,检查不了这 cnt 个字节是不是真的写过,所以是 unsafe;as_uninit_slice_mut 也是 unsafe,调用方承诺不读、不写未初始化的值(BufMut 与 UninitSlice)。
  • 返回类型改过两次(0.5.0、0.6.0),每次都是破坏性变更(BufMut 与 UninitSlice)。

谁来补:大多数代码碰不到它:put_slice、put_u32 这些默认实现替你做「chunk_mut、写、advance_mut」,Vec 和 BytesMut 还把 put_slice 覆盖成了 extend_from_slice(BufMut 与 UninitSlice)。要接系统调用的,用 tokio 的 ReadBuf::uninit,hyper 在调用处写了 SAFETY 注释说明它为什么满足约定。

小结

  • 手写 vtable 让一个 Bytes 容纳五种存储,静态数据零分配,data 可以是带标记的地址并能被 CAS 换掉;它换来的不是大小(和 Arc<dyn ...> 写法一样 32 字节),代价是所有表都是 unsafe fn、行为随存储变,靠 miri、loom、TSAN 和专用分配器的测试补。
  • 可提升存储让 Vec 转 Bytes 不分配,计数等到第一次 clone;代价是并发的第一次 clone 多数时候每个线程都分配一次再扔掉、truncate 要先提升、奇偶地址要两张表,len < cap 时不适用。
  • BytesMut 把形态、原始容量、偏移塞进一个字,第一次 split 之前不分配计数;代价是原始容量只有 3 位而且只在出生时记一次,split_to(0) 也提升,32 位上偏移有上限。
  • 切片共享整块分配,100 帧只要一次 24 或 40 字节的分配、0 拷贝;代价是 10 字节的切片能留住 1 MiB,hyper 的读缓冲因此另起新块;copy_from_slice 补。
  • Buf / BufMut 让解析代码对所有存储通用,Bytes 的 copy_to_bytes 不拷贝;代价是跨段读慢几纳秒、chunk 只给一段,get_* 会 panic,try_get_* 补。
  • UninitSlice 让未初始化的空位能安全地交出去写;代价是不能下标读、advance_mut 要调用方担保,put_* 的默认实现替大多数代码处理掉。

💬 关联讨论

发起讨论
加载中...