取舍汇总
主线位置:两条主线的总账。前两条决定共享主线的形状(
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_vec | 100 次,65536 字节 | 64 KiB |
整块 Arc<[u8]> 加下标 | 1 次,65552 字节 | 64 KiB |
Bytes::from(Vec) 再 split_to | 1 次,24 字节 | 0 |
BytesMut 的 split_to + freeze | 1 次,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
BufandBufMutare 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 typeUninitSlicecannot 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_*的默认实现替大多数代码处理掉。