Rust in Everything
基于 bytes 1.12.1

结构与 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 写。

四个字段

Bytes:

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<()>。

📝NOTE
冷知识:vtable 是 0.5.0 才有的,换掉的是一套写死的策略

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:五个函数

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.0to_vec / to_mut 改名 into_vec / into_mut#776
1.12.0drop、into_* 改成按值拿 data#826
📝NOTE
冷知识: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 做不到的事:

  1. 装的不一定是一个对象的地址。trait object 的数据指针必须指向一个合法的、实现了该 trait 的值,方法通过 &self 拿到它。Bytes 的 data 可以是空指针(静态)、缓冲地址加 1(可提升,偶数地址),或者缓冲地址本身(可提升,奇数地址)。这些都不是「指向某个对象」,只有对应那张表里的函数知道怎么解读。
  2. 能原地原子地换。可提升存储第一次 clone 时,data 从「缓冲地址 + 标记」被 CAS 成一个新分配的 Shared 的地址,而 vtable 不变。胖指针的两半是两个字,没法用一次 CAS 一起换;这里只需要换一个字,因为表的函数会看 data 的最低位判断现在是哪种形态。这一步的细节在 clone 与提升。
  3. 按值消耗。into_vec / into_mut / drop 拿走 data,可以把缓冲还原成 Vec 交出去。trait object 上要做同样的事,方法得写成 self: Box<Self> 一类的形式,而这里的 data 有时根本不是一个 Box。

代价是这些函数全是 unsafe fn,每张表都要自己保证引用计数、释放和线程安全的正确性。

📝NOTE
没有蠢问题

问: 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。

❗IMPORTANT
动脑筋: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 为假的分支要 drop val,间接调用 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)。

💬 关联讨论

发起讨论
加载中...