结构与 Vtable
主线位置:共享主线的地基。
Bytes上的 clone、drop、is_unique、转回Vec/BytesMut全部是「读出vtable,调用其中一个函数,把data、ptr、len交给它」。切片只改ptr和len,不碰vtable。这一页讲这套结构本身;每张表里的函数具体做什么在 五种存储。
flowchart TB bytes["Bytes 四个字"] --> ptrlen["ptr 和 len:可见的那一段"] bytes --> data["data:AtomicPtr"] bytes --> vt["vtable:静态表"] vt --> fclone["clone 借用 data"] vt --> funiq["is_unique 借用 data"] vt --> fvec["into_vec 按值"] vt --> fmut["into_mut 按值"] vt --> fdrop["drop 按值"]
Bytes 的文档开头就说了它的性质:「Bytes does not have a single implementation. It is an interface, whose exact behavior is implemented through dynamic dispatch in several underlying implementations of Bytes.」一个具体类型,内部却是一个接口。这一页看这个接口长什么样、为什么不直接用 Rust 的 trait object 写。
四个字段
pub struct Bytes {
ptr: *const u8,
len: usize,
// inlined "trait object"
data: AtomicPtr<()>,
vtable: &'static Vtable,
}
| 字段 | 含义 | 谁会改它 |
|---|---|---|
ptr | 这个句柄能看到的第一个字节 | slice、split_to、advance 等,只改自己这一份 |
len | 能看到多少字节 | 同上,还有 truncate |
data | 交给 vtable 解释的一个字:可能是空指针、Shared 的地址、带最低位标记的缓冲地址 | 只有可提升存储第一次 clone 时,用 CAS 原地换掉 |
vtable | 指向一张 'static 的函数表 | 构造之后不变 |
ptr / len 和 data 是分开的:前两个描述「我看哪一段」,data 描述「整块存储归谁管」。所以 Deref 只需要 slice::from_raw_parts(self.ptr, self.len),不经过 vtable;多个 Bytes 可以共享同一个 data 而各看各的一段。
注释里的「inlined "trait object"」说的就是后两个字段:一个数据指针加一个虚表指针,正是 trait object 胖指针的两半,只是拆开放进了结构体,而且数据那一半换成了 AtomicPtr<()>。
PR #298(「Refactor Bytes to use an internal vtable」,0.5.0)的说明是:「The exact strategy for managing the multiple references is no longer hard-coded, but instead backing by a customizable vtable.」同一个 PR 还删掉了两样东西:Bytes 改写底层内存的能力(「Removed ability to mutate the underlying memory from the Bytes type」),以及小数据内联存储(「Removed the "inline" (SBO) mechanism in Bytes」),理由是「improves performance when accessing the slice of bytes, since a branch is no longer needed to check if the data is inline」。
这就是本页「Deref 不经过 vtable」的来历:之前读一个字节切片要先判断数据是不是内联在结构体里;之后 ptr 和 len 总是直接指向数据,可变的那一半交给了 BytesMut。CHANGELOG 0.5.0 记为:「Bytes is now a "trait object", allowing for custom allocation strategies」。那时的 Vtable 只有 clone 和 drop 两个字段,其余三个是后来加的(见下一节的表)。
Vtable:五个函数
// `data` is passed by value (`*mut ()` instead of `&mut AtomicPtr<()>`)
// when `&mut self` or `self` is consumed.
// This allows the optimizer to see that the address of the `Bytes` is not
// captured by the indirect call, enabling further optimizations.
pub(crate) struct Vtable {
/// fn(data, ptr, len)
pub clone: unsafe fn(&AtomicPtr<()>, *const u8, usize) -> Bytes,
/// fn(data, ptr, len)
///
/// `into_*` consumes the `Bytes`, returning the respective value.
pub into_vec: unsafe fn(*mut (), *const u8, usize) -> Vec<u8>,
pub into_mut: unsafe fn(*mut (), *const u8, usize) -> BytesMut,
/// fn(data)
pub is_unique: unsafe fn(&AtomicPtr<()>) -> bool,
/// fn(data, ptr, len)
pub drop: unsafe fn(*mut (), *const u8, usize),
}
它是 pub(crate):crate 外面写不出新的表,Bytes 能有哪几种存储是 bytes 自己定的。想挂外部内存,用的是 from_owner,它背后也是 bytes 自己的一张表。
五个函数各自的入口:
| 函数 | data 怎么传 | 调用方 |
|---|---|---|
clone | &AtomicPtr<()> | Clone for Bytes,slice、split_to、split_off 也经由它 |
into_vec | *mut (),按值 | From<Bytes> for Vec<u8> |
into_mut | *mut (),按值 | From<Bytes> for BytesMut,try_into_mut 在 is_unique 为真时也走它 |
is_unique | &AtomicPtr<()> | Bytes::is_unique |
drop | *mut (),按值 | Drop for Bytes |
调用方都很短,就是把三个字段交给表里的函数:
impl Drop for Bytes {
#[inline]
fn drop(&mut self) {
let data = self.data_mut();
unsafe { (self.vtable.drop)(data, self.ptr, self.len) }
}
}
impl Clone for Bytes {
#[inline]
fn clone(&self) -> Bytes {
unsafe { (self.vtable.clone)(&self.data, self.ptr, self.len) }
}
}
impl From<Bytes> for Vec<u8> {
fn from(bytes: Bytes) -> Vec<u8> {
let mut bytes = ManuallyDrop::new(bytes);
let data = bytes.data_mut();
unsafe { (bytes.vtable.into_vec)(data, bytes.ptr, bytes.len) }
}
}
into_vec / into_mut 先把 Bytes 包进 ManuallyDrop:所有权已经交给了表里的函数,它负责释放或者复用存储,外面不能再跑一次 drop。
clone 的返回值是一个完整的 Bytes,包括 vtable 字段。所以一张表的 clone 可以返回挂着另一张表的 Bytes:可提升存储 clone 出来的就是挂 SHARED_VTABLE 的句柄(五种存储里有实测)。
这五个函数不是一次加齐的:
| 版本 | 变化 | PR |
|---|---|---|
| 0.5.0 | 引入 vtable,只有 clone 和 drop | #298 |
| 1.2.0 | 加 to_vec,支持 Bytes 转 Vec<u8> | #547 |
| 1.6.0 | 加 is_unique | #643 |
| 1.7.0 | 加 to_mut,Bytes 转 BytesMut 不拷贝 | #695 |
| 1.11.0 | to_vec / to_mut 改名 into_vec / into_mut | #776 |
| 1.12.0 | drop、into_* 改成按值拿 data | #826 |
into_* 这个名字是为了防一类泄漏1.11.0 之前这两个字段叫 to_vec / to_mut。PR #776(「rename Vtable::to_* -> Vtable::into_*」)的理由是:「The new name matches the Rust convention for methods that consume self」,并且「This may help avoid bugs such as that fixed in #773」。
#773(1.10.1,「Memory leak in owned_to_vec」)修的正是名字带来的误会:from_owner 那张表的 owned_to_vec 只拷贝了数据,没有释放 owner,「the expectation for the vtable *_to_vec is that it takes ownership of the instance, but owned_to_vec was not」。to_ 前缀在 Rust 里通常表示借用、不消耗,写实现的人照这个理解就漏了释放。
改名只改了 Vtable 的字段,实现函数没改:1.12.1 里仍然叫 static_to_vec、owned_to_vec、shared_to_mut。现在的 owned_to_vec 在拷贝之后调用 owned_drop_impl。
为什么手写表,不用 trait object
能想到的直接写法是「视图 + 一个共享的拥有者」:ptr、len 加一个 Arc<dyn AsRef<[u8]> + Send + Sync>。下面把它叫 DynBytes,和 Bytes 比大小和分配次数。
bytes = "=1.12.1"
use std::alloc::{GlobalAlloc, Layout, System};
use std::mem::size_of;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
use bytes::{Bytes, BytesMut};
struct Counting;
static ALLOCS: AtomicUsize = AtomicUsize::new(0);
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
ALLOCS.fetch_add(1, Relaxed);
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
}
#[global_allocator]
static A: Counting = Counting;
// 用 trait object 写的「Bytes」:视图 + 一个共享的拥有者
#[derive(Clone)]
struct DynBytes {
ptr: *const u8,
len: usize,
#[allow(dead_code)]
owner: Arc<dyn AsRef<[u8]> + Send + Sync>,
}
impl DynBytes {
fn from_owner<T: AsRef<[u8]> + Send + Sync + 'static>(owner: T) -> Self {
let owner: Arc<dyn AsRef<[u8]> + Send + Sync> = Arc::new(owner);
let s = (*owner).as_ref();
DynBytes { ptr: s.as_ptr(), len: s.len(), owner }
}
}
fn allocs<R>(f: impl FnOnce() -> R) -> (R, usize) {
let before = ALLOCS.load(Relaxed);
let r = f();
(r, ALLOCS.load(Relaxed) - before)
}
fn main() {
println!("size_of::<Bytes>() = {}", size_of::<Bytes>());
println!("size_of::<BytesMut>() = {}", size_of::<BytesMut>());
println!("size_of::<Vec<u8>>() = {}", size_of::<Vec<u8>>());
println!("size_of::<Arc<[u8]>>() = {}", size_of::<Arc<[u8]>>());
println!("size_of::<Box<dyn AsRef<[u8]>>>() = {}", size_of::<Box<dyn AsRef<[u8]>>>());
println!("size_of::<DynBytes>() = {}", size_of::<DynBytes>());
let (b, n) = allocs(|| Bytes::from_static(b"hello"));
println!("Bytes::from_static allocs = {n}");
let (_c, n) = allocs(|| b.clone());
println!(" .clone() allocs = {n}");
let (d, n) = allocs(|| DynBytes::from_owner(&b"hello"[..]));
println!("DynBytes::from_owner(&'static) allocs = {n}");
let (_e, n) = allocs(|| d.clone());
println!(" .clone() allocs = {n}");
let v = vec![1u8; 1024];
let (b, n) = allocs(|| Bytes::from(v));
println!("Bytes::from(Vec len==cap) allocs = {n}");
let (_c, n) = allocs(|| b.clone());
println!(" .clone() #1 allocs = {n}");
let (_c2, n) = allocs(|| b.clone());
println!(" .clone() #2 allocs = {n}");
let v = vec![1u8; 1024];
let (d, n) = allocs(|| DynBytes::from_owner(v));
println!("DynBytes::from_owner(Vec) allocs = {n}");
let (_e, n) = allocs(|| d.clone());
println!(" .clone() allocs = {n}");
println!("DynBytes len = {}, ptr non-null = {}", d.len, !d.ptr.is_null());
}
M2 Max、rustc 1.99.0,--release,跑 3 次,输出相同:
size_of::<Bytes>() = 32
size_of::<BytesMut>() = 32
size_of::<Vec<u8>>() = 24
size_of::<Arc<[u8]>>() = 16
size_of::<Box<dyn AsRef<[u8]>>>() = 16
size_of::<DynBytes>() = 32
Bytes::from_static allocs = 0
.clone() allocs = 0
DynBytes::from_owner(&'static) allocs = 1
.clone() allocs = 0
Bytes::from(Vec len==cap) allocs = 0
.clone() #1 allocs = 1
.clone() #2 allocs = 0
DynBytes::from_owner(Vec) allocs = 1
.clone() allocs = 0
DynBytes len = 1024, ptr non-null = true
大小上两者一样,都是 32 字节:Arc<dyn ...> 是两个字的胖指针,加上 ptr、len 正好四个字。手写 vtable 省下的不是结构体的大小,而是分配:
- 静态数据零分配。
Bytes::from_static的data是空指针,表里的clone什么都不做、drop是空函数,构造和 clone 都是 0 次分配。DynBytes哪怕包的是&'static [u8],也要为Arc的引用计数块分配一次。Bytes::new()是const fn,正是靠这条路(new调用from_static)。 Vec转过来先不分配。Bytes::from(Vec)(len == cap时)把缓冲地址打上最低位标记直接放进data,0 次分配;第一次 clone 才分配引用计数块,之后的 clone 又是 0 次。DynBytes在构造时就必须分配Arc,不管以后有没有人 clone。
后一条要求 data 能做到三件 trait object 做不到的事:
- 装的不一定是一个对象的地址。trait object 的数据指针必须指向一个合法的、实现了该 trait 的值,方法通过
&self拿到它。Bytes的data可以是空指针(静态)、缓冲地址加 1(可提升,偶数地址),或者缓冲地址本身(可提升,奇数地址)。这些都不是「指向某个对象」,只有对应那张表里的函数知道怎么解读。 - 能原地原子地换。可提升存储第一次 clone 时,
data从「缓冲地址 + 标记」被 CAS 成一个新分配的Shared的地址,而vtable不变。胖指针的两半是两个字,没法用一次 CAS 一起换;这里只需要换一个字,因为表的函数会看data的最低位判断现在是哪种形态。这一步的细节在 clone 与提升。 - 按值消耗。
into_vec/into_mut/drop拿走data,可以把缓冲还原成Vec交出去。trait object 上要做同样的事,方法得写成self: Box<Self>一类的形式,而这里的data有时根本不是一个Box。
代价是这些函数全是 unsafe fn,每张表都要自己保证引用计数、释放和线程安全的正确性。
问: Bytes 里有 *const u8 和裸 AtomicPtr<()>,按规则它既不是 Send 也不是 Sync。凭什么能跨线程传、跨线程共享?
答: 靠两行 unsafe impl(源码),上面一行注释是「Vtable must enforce this behavior」。编译器没法检查,由每张表自己保证:多个句柄共享的计数一律用原子操作(fetch_add、fetch_sub、CAS);共享期间 ptr 指向的字节只读,只有确认自己是唯一持有者之后(比如 into_vec)才会挪动数据。这个承诺也落在 from_owner 的约束上:它要求 T: AsRef<[u8]> + Send + 'static,没有要求 Sync。构造之后,owner 只在 from_owner 里被 as_ref() 一次,之后各个句柄只碰它旁边的 ref_cnt,最后一个句柄在自己的线程上 drop 它。这需要能把 owner 送到别的线程(Send),不需要多个线程同时拿 &T(Sync)。
Bytes 的 vtable 字段是 &'static Vtable,一个非空引用,Option<Bytes> 可以用它的空值表示 None。
size_of::<Option<Bytes>>() 是多少?平台 M2 Max、rustc 1.99.0,bytes = "=1.12.1":
use std::mem::size_of;
use bytes::{Bytes, BytesMut};
fn main() {
println!("Option<Bytes> = {}", size_of::<Option<Bytes>>());
println!("Option<BytesMut> = {}", size_of::<Option<BytesMut>>());
}
1.12.0:data 按值传(#826)
1.11.1 的 Vtable 里,五个函数都通过引用拿 data:
pub(crate) struct Vtable {
/// fn(data, ptr, len)
pub clone: unsafe fn(&AtomicPtr<()>, *const u8, usize) -> Bytes,
/// fn(data, ptr, len)
///
/// `into_*` consumes the `Bytes`, returning the respective value.
pub into_vec: unsafe fn(&AtomicPtr<()>, *const u8, usize) -> Vec<u8>,
pub into_mut: unsafe fn(&AtomicPtr<()>, *const u8, usize) -> BytesMut,
/// fn(data)
pub is_unique: unsafe fn(&AtomicPtr<()>) -> bool,
/// fn(data, ptr, len)
pub drop: unsafe fn(&mut AtomicPtr<()>, *const u8, usize),
}
Drop 那边是 (self.vtable.drop)(&mut self.data, self.ptr, self.len)。PR #826(「Pass vtable data by value」)把 drop、into_vec、into_mut 三个改成 *mut (),调用前用 data_mut 读出来:
#[inline]
fn data_mut(&mut self) -> *mut () {
self.data.with_mut(|p| *p)
}
with_mut 在非 loom 构建下就是 AtomicPtr::get_mut,一次普通读,不是原子读。能这样读,是因为这三个函数都在拿到 &mut self 或 self 之后才被调用,此时不可能有别的线程正通过 &self 对同一个 Bytes 做 clone。改动之前,promotable_to_vec 一类的函数自己 data.load(Ordering::Acquire);改动之后,它们直接拿到这个值。
PR 说明给出的理由是代码生成:「This breaks the borrow-back into Bytes, letting LLVM infer captures(none) on the indirect call and elide the temporary memcpy when Bytes is passed by value.」把 &mut self.data 交给一个间接调用,LLVM 只能假设被调函数可能把这个地址存起来,于是包含这个 Bytes 的整个值的地址都「逃逸」了。clone 和 is_unique 没改:它们经由 &self 调用,可提升存储的 clone 还要对 data 的地址做 compare_exchange。
PR 里附了一个最小复现:一个 232 字节、末尾放着 Bytes 的结构体按值传给函数,函数在冷路径上把它再按值传下去。用 1.11.1 和 1.12.1 各编一次,只看 lookup 的 LLVM IR 里函数签名、alloca、memcpy 和间接调用这几行:
use bytes::Bytes;
pub struct MyStuff {
pub _stuff: [u8; 200],
pub bytes: Bytes,
}
#[unsafe(no_mangle)]
#[inline(never)]
pub fn lookup(blocking: bool, val: MyStuff) -> u64 {
if blocking {
return lookup_blocking(val);
}
1
}
#[cold]
#[inline(never)]
pub fn lookup_blocking(_val: MyStuff) -> u64 {
0
}
cargo rustc --release --lib -- --emit=llvm-ir
awk '/^define.*@lookup\(/,/^}/' target/release/deps/ir-*.ll \
| grep 'define\|memcpy\|alloca\|call void %' | sed 's/^ *//'
M2 Max、rustc 1.99.0,两个版本各编 3 次,输出相同:
== bytes 1.11.1
define noundef range(i64 0, 2) i64 @lookup(i1 noundef zeroext %blocking, ptr noalias nofree noundef align 8 captures(address) dead_on_return dereferenceable(232) %val) unnamed_addr #1 !guid !18 {
%_3 = alloca [232 x i8], align 8
tail call void %_2.i.i.i(ptr noalias nofree noundef nonnull align 8 dereferenceable(8) %_3.i.i.i, ptr noundef %_4.i.i.i, i64 noundef %_5.i.i.i), !inline_history !17
call void @llvm.memcpy.p0.p0.i64(ptr noundef nonnull align 8 dereferenceable(232) %_3, ptr noundef nonnull align 8 dereferenceable(232) %val, i64 232, i1 false)
== bytes 1.12.1
define noundef range(i64 0, 2) i64 @lookup(i1 noundef zeroext %blocking, ptr noalias nofree noundef readonly align 8 captures(none) dead_on_return dereferenceable(232) %val) unnamed_addr #1 !guid !18 {
tail call void %_3.i.i.i(ptr noundef %data.i.i.i, ptr noundef %_4.i.i.i, i64 noundef %_5.i.i.i), !noalias !29, !inline_history !17
- 1.11.1:
val被标成captures(address)。blocking为假的分支要 dropval,间接调用 vtable 的drop,第一个参数是val内部data字段的地址(dereferenceable(8))。因为这个地址可能被记住,blocking为真的分支不能直接把val交给lookup_blocking,先alloca一块 232 字节的临时空间,memcpy过去再传。 - 1.12.1:
val是readonly且captures(none)。drop 时先把data读成一个值%data,间接调用拿到的是值,不是地址。alloca和memcpy都没有了。
这个变化对 Bytes 的使用者没有任何 API 影响(Vtable 是 pub(crate)),CHANGELOG 1.12.0 把它列在「Changed」下:「Pass vtable data by value (#826)」。
动脑筋答案
Option<Bytes> = 32
Option<BytesMut> = 32
跑 3 次,输出相同。
Option<Bytes>是 32,和Bytes一样大。vtable字段是&'static Vtable,引用不可能为空,编译器用这个字段为 0 表示None,不需要额外的判别字段。ptr是*const u8、data是AtomicPtr<()>,都允许为空,帮不上忙;这里只要有一个字段带空值空位就够了。Option<BytesMut>也是 32。BytesMut的ptr是NonNull<u8>,提供了同样的空位。
小结
Bytes是四个字、32 字节:ptr和len是这个句柄看到的那一段,data和vtable是拆开放的「trait object」。读数据不经过 vtable。Vtable是五个函数指针:clone和is_unique借用&AtomicPtr<()>,into_vec、into_mut、drop消耗Bytes,按值拿*mut ()。表是pub(crate),存储种类由 bytes 自己定。- 手写表和「视图 +
Arc<dyn ...>」一样大,好处在分配:静态数据 0 次,Vec转来的缓冲第一次 clone 时才分配引用计数。前提是data可以是空指针、带标记的地址或缓冲本身,并且能被一次 CAS 原地换掉,这些 trait object 都做不到。 Send/Sync是unsafe impl,由每张表用原子计数保证;from_owner只要求 owner 是Send。- 1.12.0(#826)让
drop和into_*按值拿data,LLVM 不再认为Bytes的地址被间接调用捕获,按值传含Bytes的大结构体时少一次栈拷贝。1.11.0(#776)把to_*改名into_*,起因是owned_to_vec漏释放 owner 的泄漏(#773)。