bytes 解决什么问题
主线位置:两条主线的起点。本页用一个计数分配器说明为什么需要
Bytes(共享主线)和BytesMut(构建主线)这两个类型,以及Buf/BufMut在两条线上分别管什么。机制本身留给 02、03、04 三部分。
flowchart TB buf["一块 64 KiB 缓冲"] -->|"to_vec 每帧拷贝"| vecs["100 个 Vec"] buf -->|"Arc::from 整块拷贝"| arcr["Arc 加下标"] buf -->|"split_to 不拷贝"| bys["100 个 Bytes"] bys --> shared["共用一个 Shared"] arcr --> owners["100 个所有者"] vecs --> owners shared --> owners
lib.rs 开头的文档用一句话说了 Bytes 的用途:
Bytesvalues facilitate zero-copy network programming by allowing multipleBytesobjects to point to the same underlying memory. This is managed by using a reference count to track when the memory is no longer needed and can be freed.
紧接着是一个例子:往一个 BytesMut::with_capacity(1024) 里写 13 字节、split 走,再写 13 字节、再 split 走,最后断言 buf.capacity() 是 998。文档的解释是「only a single buffer of 1024 is allocated」,两个切出去的值 a、b 和 buf 共用这一块,各自只记着自己能看到的那一段。这一页就把这件事放大,用分配次数说清楚它省下了什么。
问题:一块缓冲,很多所有者
网络程序读数据的方式几乎都一样:准备一块缓冲,系统调用往里填,填进来的字节里可能有好几条消息,也可能只有半条。解析器从里面认出一帧帧的边界,再把每一帧交出去:交给另一个任务、放进通道、存进 HeaderMap、作为 HTTP body 的一块返回给用户。
用借用 &buf[a..b] 交出去不行,有两个原因:
- 接收方经常要求
'static。tokio::spawn的 future、通道里的消息都不能借用一个局部变量。 - 读缓冲还要接着用。下一次
read要拿&mut buf往后面写,而前面借出去的&[u8]还活着,借用检查器不会允许。
所以每一帧都得是一个有所有权的值。问题变成:怎样让 100 个有所有权的值指向同一块内存的 100 个不同区间,并且在最后一个值消失时才释放这块内存。
三种做法的代价
拿一个具体的任务比较:64 KiB 的缓冲,按 656 字节切成 100 帧(最后一帧 592 字节),每帧交给一个所有者,然后全部 drop。用一个计数用的全局分配器记下切分这一步的分配次数和字节数,再看每一帧的地址是不是还在原来那块缓冲里,不在就说明它被拷贝过。
bytes = "=1.12.1"
计数分配器和打印用的几个小函数放在 src/common.rs,下一页的程序也用它:
#![allow(dead_code)]
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
use bytes::{Bytes, BytesMut};
// 计数分配器:只数次数和字节,真正的分配交给 System
struct Counting;
static ALLOCS: AtomicUsize = AtomicUsize::new(0);
static ALLOC_BYTES: AtomicUsize = AtomicUsize::new(0);
static FREES: AtomicUsize = AtomicUsize::new(0);
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
ALLOCS.fetch_add(1, Relaxed);
ALLOC_BYTES.fetch_add(layout.size(), Relaxed);
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
FREES.fetch_add(1, Relaxed);
unsafe { System.dealloc(ptr, layout) }
}
}
#[global_allocator]
static GLOBAL: Counting = Counting;
#[derive(Clone, Copy)]
pub struct Snap(pub usize, pub usize, pub usize);
pub fn snap() -> Snap {
Snap(ALLOCS.load(Relaxed), ALLOC_BYTES.load(Relaxed), FREES.load(Relaxed))
}
// 这一步的分配增量。操作一结束就取,后面拼字符串、打印的分配不算进去
pub fn delta(before: Snap) -> Snap {
let now = snap();
Snap(now.0 - before.0, now.1 - before.1, now.2 - before.2)
}
// 打印,并返回打印之后的快照作为下一步的起点
pub fn report(step: &str, d: Snap, state: String) -> Snap {
println!("{step:<22} alloc+{} ({}B) free+{} {state}", d.0, d.1, d.2);
drop(state);
snap()
}
// 指针落在最初那块 8 KiB 里就打印偏移,否则是别的分配
fn loc(p: *const u8, base: usize) -> String {
let p = p as usize;
if base <= p && p <= base + 8192 { format!("base+{}", p - base) } else { "elsewhere".to_string() }
}
pub fn mut_state(name: &str, b: &BytesMut, base: usize) -> String {
format!("{name}: ptr={} len={} cap={}", loc(b.as_ptr(), base), b.len(), b.capacity())
}
pub fn bytes_state(name: &str, b: &Bytes, base: usize) -> String {
format!("{name}: ptr={} len={} unique={}", loc(b.as_ptr(), base), b.len(), b.is_unique())
}
src/bin/why.rs:
use std::ops::Range;
use std::sync::Arc;
use bytes::{Bytes, BytesMut};
#[path = "../common.rs"]
mod common;
use common::*;
const SIZE: usize = 64 * 1024;
const FRAME: usize = 656; // 65536 / 656 向上取整 = 100 帧
// 一次「从 socket 读到的」64 KiB,长度等于容量
fn input() -> Vec<u8> {
let mut v = Vec::with_capacity(SIZE);
v.extend((0..SIZE).map(|i| i as u8));
v
}
// data 是切分前的缓冲,lo 是它的起始地址;只数 split 这一段的分配
fn measure<D, T>(
name: &str,
data: D,
lo: usize,
split: impl FnOnce(D, &mut Vec<T>),
view: impl Fn(&T) -> &[u8],
) {
let mut frames = Vec::with_capacity(100);
let s = snap();
split(data, &mut frames);
let d = delta(s);
// 帧的字节不在原缓冲 [lo, lo + SIZE) 里,就是被拷贝过的
let copied: usize = frames
.iter()
.map(&view)
.filter(|f| !(lo..lo + SIZE).contains(&(f.as_ptr() as usize)))
.map(|f| f.len())
.sum();
let n = frames.len();
let s = snap();
frames.clear();
let freed = delta(s);
println!("{name:<26}{n:>7}{:>7}{:>12}{copied:>8}{:>15}", d.0, d.1, freed.2);
}
fn main() {
println!("{:<26}{:>7}{:>7}{:>12}{:>8}{:>15}", "way", "frames", "allocs", "alloc_bytes", "copied", "frees_on_drop");
// 1. 每个所有者一份自己的 Vec
let data = input();
let lo = data.as_ptr() as usize;
measure("Vec<u8> to_vec", data, lo, |data, out| {
out.extend(data.chunks(FRAME).map(|c| c.to_vec()));
}, |f: &Vec<u8>| &f[..]);
// 2. 每帧一个 Arc<[u8]>
let data = input();
let lo = data.as_ptr() as usize;
measure("Arc<[u8]> per frame", data, lo, |data, out| {
out.extend(data.chunks(FRAME).map(Arc::<[u8]>::from));
}, |f: &Arc<[u8]>| &f[..]);
// 3. 整块转成一个 Arc<[u8]>,每帧共享它、自己记范围
let data = input();
let lo = data.as_ptr() as usize;
measure("Arc<[u8]> + Range", data, lo, |data, out| {
let whole: Arc<[u8]> = Arc::from(data);
out.extend((0..SIZE).step_by(FRAME).map(|i| (whole.clone(), i..(i + FRAME).min(SIZE))));
}, |(a, r): &(Arc<[u8]>, Range<usize>)| &a[r.clone()]);
// 4. Vec 转成 Bytes,再一段段 split_to
let data = input();
let lo = data.as_ptr() as usize;
measure("Bytes split_to", data, lo, |data, out| {
let mut rest = Bytes::from(data);
while !rest.is_empty() {
out.push(rest.split_to(FRAME.min(rest.len())));
}
}, |f: &Bytes| &f[..]);
// 5. 直接读进 BytesMut,split_to + freeze
let mut buf = BytesMut::with_capacity(SIZE);
buf.extend_from_slice(&input());
let lo = buf.as_ptr() as usize;
measure("BytesMut split_to+freeze", buf, lo, |mut buf, out| {
while !buf.is_empty() {
out.push(buf.split_to(FRAME.min(buf.len())).freeze());
}
}, |f: &Bytes| &f[..]);
}
M2 Max、rustc 1.99.0,cargo run --release --bin why,跑 3 次,输出相同:
way frames allocs alloc_bytes copied frees_on_drop
Vec<u8> to_vec 100 100 65536 65536 100
Arc<[u8]> per frame 100 100 67136 65536 100
Arc<[u8]> + Range 100 1 65552 65536 1
Bytes split_to 100 1 24 0 2
BytesMut split_to+freeze 100 1 40 0 2
allocs 和 alloc_bytes 只算切分那一步;copied 是地址不在原缓冲里的帧的字节数之和;frees_on_drop 是把 100 帧全部 drop 时释放了几块内存。逐行看:
| 做法 | 分配 | 拷贝 | 说明 |
|---|---|---|---|
Vec<u8> | 100 次,共 65536 字节 | 64 KiB | 每帧一份独立的内存,互不相干,所以 drop 时也是 100 次释放 |
每帧一个 Arc<[u8]> | 100 次,共 67136 字节 | 64 KiB | 多出的 1600 字节是每个 Arc 的强、弱两个计数,每个 16 字节 |
整块 Arc<[u8]> 加下标 | 1 次,65552 字节 | 64 KiB | 只有一块共享内存,但 Arc::from(Vec) 要把 64 KiB 搬进一块新分配 |
Bytes::from(Vec) 再 split_to | 1 次,24 字节 | 0 | 原缓冲原地被共享,只多了一个引用计数块 |
BytesMut 的 split_to + freeze | 1 次,40 字节 | 0 | 同上,计数块大一些 |
第三行说明光有引用计数不够。Arc<[u8]> 的计数和数据在同一块分配里,计数在前,数据在后,Vec 那块内存前面没有放计数的位置,所以 Arc::from(Vec<u8>) 只能新分配一块再拷贝。Bytes 的计数放在单独的一块小分配里,数据留在原地。
最后两行的 24 和 40 是两个不同的 Shared 结构。Bytes::from(Vec<u8>) 用的是 bytes.rs 里的 Shared(缓冲指针、容量、引用计数,3 个字);BytesMut 第一次 split_to 时建的是 bytes_mut.rs 里的 Shared(一个 Vec<u8>、original_capacity_repr、ref_count,5 个字)。两个类型各自怎么存这个计数、什么时候建它,分别在 五种存储 和 split 与 freeze。drop 时都是 2 次释放:数据缓冲一次,Shared 一次,发生在最后一帧被 drop 的时候。
图书馆只有一本很厚的书(64 KiB 的缓冲),100 个读者各要其中几页。Vec<u8> 的做法是把每人要的那几页复印一份(to_vec),复印件归读者所有,书还回书架。Bytes 的做法是不复印,给每个读者发一张借阅条,上面写着「第几页到第几页」(ptr 和 len);前台的登记本记着这本书还有几张借阅条在外面(Shared 里的引用计数)。读者还条时登记本减一,减到零书才下架(释放缓冲)。
比方到这里为止:真实的借阅条之间可以互相复制(clone 只是登记本加一),而且拿着借阅条只能看、不能在书上写字。要写,得先拿到 BytesMut,那是一张「这几页只归我」的条子。
从 Vec 来的 Bytes:计数块什么时候分配
第四行里 Bytes::from(data) 用的 Vec 长度正好等于容量(input() 里 with_capacity(SIZE) 后填满)。From<Vec<u8>> 的开头:
impl From<Vec<u8>> for Bytes {
fn from(vec: Vec<u8>) -> Bytes {
// Avoid an extra allocation if possible.
if vec.len() == vec.capacity() {
return Bytes::from(vec.into_boxed_slice());
}
let shared = Box::new(MaybeUninit::<Shared>::uninit());
// ...
长度等于容量时转成 Box<[u8]>,不分配;否则立刻分配一个 Shared。走 Box<[u8]> 那条路的 Bytes 叫「可提升」存储:一开始只有数据,没有计数,第一次 clone 时才补上。下面的动脑筋看的就是这两条路的分配时机。可提升存储的细节在 clone 与提升。
Bytes::from(v) 和它之后的第一次、第二次 split_to 各分配几次?M2 Max、rustc 1.99.0。v 里有 65536 字节,分两种情况:容量 65536(等于长度)和容量 65537(多 1 字节)。common.rs 同上。
use bytes::Bytes;
#[path = "../common.rs"]
mod common;
use common::*;
fn run(cap: usize, label: &str) {
let mut v = Vec::with_capacity(cap);
v.extend_from_slice(&[7u8; 65536]);
let base = v.as_ptr() as usize;
let s = snap();
let mut b = Bytes::from(v);
let d = delta(s);
let s = report(label, d, String::new());
let first = b.split_to(656);
let d = delta(s);
let s = report("split_to #1", d, String::new());
let second = b.split_to(656);
let d = delta(s);
report("split_to #2", d, format!("same buffer: {}", second.as_ptr() as usize == base + 656));
drop((first, second, b));
}
fn main() {
run(65536, "cap=65536 Bytes::from");
run(65537, "cap=65537 Bytes::from");
}
Bytes 与 BytesMut 的分工
上面的第四、五行是两种不同的出发点:手里已经有一个填好的 Vec<u8>,还是从一开始就在 bytes 的类型里写。bytes 用两个类型分别对应「只读、到处共享」和「可写、唯一拥有」:
pub struct Bytes {
ptr: *const u8,
len: usize,
// inlined "trait object"
data: AtomicPtr<()>,
vtable: &'static Vtable,
}
pub struct BytesMut {
ptr: NonNull<u8>,
len: usize,
cap: usize,
data: *mut Shared,
}
Bytes | BytesMut | |
|---|---|---|
| 能不能写 | 不能,只实现 Deref<Target = [u8]> | 能,还实现 DerefMut 和 BufMut |
| 共享 | clone、slice、split_to 都只增加引用计数 | clone 是深拷贝(Clone 实现 是 BytesMut::from(&self[..]));split_to / split_off 切出的两段共用一块内存但互不重叠,各自可写 |
| 第三个字段 | data:存储相关的指针,含义由 vtable 决定 | cap:这一段之后还能写多少 |
| 背后的存储 | vtable 指向的五种之一,见 结构与 Vtable | 两种:还是一个 Vec(KIND_VEC),或者已经和别人共用一个 Shared(KIND_ARC),见 data 字段的编码 |
| 互相转换 | try_into_mut:is_unique() 为真时转回 BytesMut,否则原样放在 Err 里还给你 | freeze:转成 Bytes,不拷贝数据 |
Bytes 的 vtable 是一张手写的函数表(clone、into_vec、into_mut、is_unique、drop),所以同一个类型背后可以是 &'static [u8]、从 Vec 来的内存、BytesMut 冻结来的内存等等,clone 和 drop 时各走各的实现。BytesMut 不需要这么多变化,它把「是不是 Vec」和 Vec 的一些元数据塞进 data 指针的低位里。
两个结构都只有 4 个字。在 M2 Max 上用 size_of 量,Bytes、BytesMut 都是 32 字节,Option<Bytes> 也是 32(vtable 是引用,不会是空指针,None 用它表示);Vec<u8> 是 24,(Arc<[u8]>, Range<usize>) 也是 32。所以和上面第三行的手工做法相比,Bytes 并没有多占地方。
问: 第三行的 Arc<[u8]> 加下标也只有一次分配。如果数据一开始就是我自己读进 Arc<[u8]> 的,不存在从 Vec 转换的拷贝,是不是就不需要 bytes 了?
答: 只看「共享 + 切片」这一件事,(Arc<[u8]>, Range<usize>) 确实够用,它和 Bytes 一样是 32 字节。差别在两头。读的那头:Arc<[u8]> 一旦建好就不能再往里写(Arc::get_mut 只在唯一时给出可变引用,而且给的是整块),没法像 BytesMut 那样「一边往后读、一边把前面切出去,等切出去的都释放了再回收整块」,下一页的程序就是这个过程。用的那头:每个收到帧的地方都要认识你的元组类型,而 hyper 交出的 body 块、axum 的 Bytes 提取器用的都是 Bytes(见 Incoming 与 body 通道 和 body 提取器)。Bytes 还能零分配地包住 &'static [u8](Bytes::from_static),这在 Arc<[u8]> 上做不到。
Buf 与 BufMut:把游标抽出来
文档的第二节讲两个 trait:
BufandBufMutmaintain cursors tracking the current position in the underlying byte storage. When bytes are read or written, the cursor is advanced.
Buf 必须实现的只有三个方法:remaining(还剩多少)、chunk(从当前位置开始的一段连续字节)、advance(游标前进)。get_u16、copy_to_bytes 这些几十个方法都是在这三个上面写的默认实现。BufMut 对称:remaining_mut、chunk_mut、advance_mut,put_u16、put_slice 是默认实现。
「游标」和「存储」分开有两个好处。第一,存储可以不连续:chunk 只要求给出当前这一段,两个缓冲用 chain 拼起来、一个 VecDeque<u8> 都能当一个 Buf 读。第二,解析代码只写一遍,对 &[u8]、Bytes、BytesMut 都能用,而每个类型可以覆盖默认方法、换成更便宜的实现。下面这个解析长度前缀帧的函数对 &[u8] 和 Bytes 各跑一次:
use bytes::{Buf, BufMut, Bytes, BytesMut};
#[path = "../common.rs"]
mod common;
use common::*;
// 长度前缀帧:2 字节大端长度 + 内容。对任何 Buf 都能用
fn parse<B: Buf>(label: &str, mut buf: B) -> Vec<Bytes> {
let mut out = Vec::with_capacity(4);
let s = snap();
while buf.remaining() >= 2 {
let n = buf.get_u16() as usize; // 读 2 字节,游标前进 2
out.push(buf.copy_to_bytes(n)); // 取 n 字节,游标前进 n
}
let d = delta(s);
report(label, d, format!("frames={:?}", out));
out
}
fn main() {
let mut msg = BytesMut::with_capacity(64);
msg.put_u16(5);
msg.put_slice(b"hello");
msg.put_u16(3);
msg.put_slice(b"abc");
let wire = msg.freeze();
let raw: Vec<u8> = wire.to_vec();
parse("&[u8]", &raw[..]);
parse("Bytes", wire.clone());
}
跑 3 次,输出相同:
&[u8] alloc+2 (8B) free+0 frames=[b"hello", b"abc"]
Bytes alloc+0 (0B) free+0 frames=[b"hello", b"abc"]
同一个 parse,源是 &[u8] 时每帧分配一次、拷贝内容(5 + 3 = 8 字节),源是 Bytes 时一次也不分配。区别在 copy_to_bytes:默认实现新建一个 BytesMut 把字节拷进去再 freeze,而 Bytes 的 Buf 实现把 copy_to_bytes 覆盖成了 split_to:
fn copy_to_bytes(&mut self, len: usize) -> Self {
self.split_to(len)
}
文档还特意说明了 Buf / BufMut 和 std::io::Read / Write 的区别:「A buffer is the value that is provided as an argument to Read::read and Write::write」,Read / Write 可能做系统调用、可能失败,而「Operations on Buf and BufMut are infallible」。所以 get_u16 返回的是 u16 而不是 Result,数据不够时 panic(不想 panic 有 try_get_u16)。三个必需方法和 get_* 的快慢路径在 Buf;chunk_mut 为什么返回 UninitSlice 而不是 &mut [u8],在 BufMut 与 UninitSlice;chain、take 这些组合在 适配器。
动脑筋答案
跑 3 次,输出相同:
cap=65536 Bytes::from alloc+0 (0B) free+0
split_to #1 alloc+1 (24B) free+0
split_to #2 alloc+0 (0B) free+0 same buffer: true
cap=65537 Bytes::from alloc+1 (24B) free+0
split_to #1 alloc+0 (0B) free+0
split_to #2 alloc+0 (0B) free+0 same buffer: true
两种情况总共都是 1 次、24 字节,只是时机不同:
- 容量等于长度:
From<Vec<u8>>走into_boxed_slice,Vec的长度和容量一样,不需要重新分配,Bytes::from本身 0 次。这时的Bytes是可提升存储,没有计数。第一次split_to内部要clone,这才分配那个 24 字节的Shared,把存储换成共享的;第二次split_to只是计数加一。 - 容量多 1 字节:
into_boxed_slice会把多出的容量缩掉,可能要重新分配并拷贝,所以From不走这条路,当场分配Shared(里面记着真实的cap,释放时用它),之后的split_to都只加计数。 - 两种情况下第二帧的地址都是原缓冲起点加 656,数据一直留在原来那块里。
小结
- 网络程序要把一块读缓冲切成很多段,交给不同的所有者,读缓冲本身还要继续用;借用做不到,每段必须是有所有权的值。
- 64 KiB 切 100 帧:
Vec<u8>100 次分配、拷贝 64 KiB;Arc<[u8]>加下标 1 次分配,但从Vec转过去要整块拷贝;Bytes、BytesMut只分配一个 24 或 40 字节的Shared,0 字节拷贝。 - 区别在计数放在哪里:
Arc<[u8]>的计数和数据在同一块分配里,Bytes的计数单独一小块,数据留在原地。 Bytes只读、可以廉价地 clone 和切片,背后的存储由手写的 vtable 决定;BytesMut可写、唯一拥有,split_to切出互不重叠的段,freeze不拷贝地转成Bytes。两者都是 32 字节。Buf/BufMut把游标从存储里抽出来,必需方法各三个;解析代码写一遍对所有类型都能用,Bytes把copy_to_bytes覆盖成split_to,不分配。