一个小切片让整块缓冲一直不释放
主线位置:共享主线的
slice和 drop,以及构建主线的split_to/reserve。切出来的Bytes只是对同一个Shared多加了一份计数;只要它还活着,这个Shared管的整块分配就不会释放,读缓冲的reserve也没法把这块收回来复用。
flowchart TB
rb["读缓冲 8 KiB"] -->|"split_to + freeze"| head["请求头 Bytes"]
head -->|slice| val["头部值 32 B"]
val --> store["存进长期容器"]
head -->|"请求结束 drop"| gone["计数减一"]
rb -->|"下次读 reserve"| shared{还有别的句柄}
shared -->|是| fresh["另分配 8 KiB"]
shared -->|否| reuse["原地收回"]
store -.->|一直持有| rb
机制在前面几章讲过:切片为什么共享整块分配,见 切片 的「一个小切片留住整块缓冲」;第一次 clone 时的提升和引用计数,见 clone 与提升;reserve 在缓冲共享时为什么只能另分配,见 reserve 的「KIND_ARC 且共享:另起一个 Vec」。这一页看它在真实程序里的样子,量出留住了多少内存,再比较几种修法。
本页的实验都在 M2 Max、rustc 1.99.0、--release 下运行,每个程序跑 3 次,输出相同。依赖:
bytes = "=1.12.1"
tokio = { version = "=1.54.1", features = ["full"] }
hyper = { version = "=1.12.0", features = ["server", "http1"] }
hyper-util = { version = "=0.1.21", features = ["tokio"] }
http-body-util = "0.1"
内存用一个包住 System 的全局分配器来量:LIVE 记当前存活的字节数(alloc 加、dealloc 减、realloc 按新旧大小调整),live() 读出它。
现象:10 字节留住 1 MiB
use bytes::{BufMut, Bytes, BytesMut};
use exp_bytes_pit1::live;
fn show(step: &str, base: usize) {
println!("{step:<44} live = {:>8} bytes", live() - base);
}
fn main() {
println!("(stdout warmed up)");
let base = live();
let big = Bytes::from(vec![b'x'; 1 << 20]);
let small = big.slice(100..110);
drop(big);
show("Vec -> Bytes, slice(100..110), drop(big)", base);
println!("small.len() = {}, small.is_unique() = {}", small.len(), small.is_unique());
let copy = Bytes::copy_from_slice(&small);
drop(small);
show("copy_from_slice, drop(small)", base);
drop(copy);
// clear() on the kept slice
let mut small = Bytes::from(vec![b'x'; 1 << 20]).slice(100..110);
small.clear();
show("Vec -> Bytes slice, clear()", base);
drop(small);
let mut m = BytesMut::with_capacity(1 << 20);
m.put_bytes(b'x', 1 << 20);
let mut small = m.freeze().slice(100..110);
show("BytesMut -> freeze, slice(100..110)", base);
small.clear();
show(" then clear()", base);
small = Bytes::new();
show(" then assign Bytes::new()", base);
drop(small);
}
(stdout warmed up)
Vec -> Bytes, slice(100..110), drop(big) live = 1048600 bytes
small.len() = 10, small.is_unique() = true
copy_from_slice, drop(small) live = 10 bytes
Vec -> Bytes slice, clear() live = 1048600 bytes
BytesMut -> freeze, slice(100..110) live = 1048600 bytes
then clear() live = 1048600 bytes
then assign Bytes::new() live = 0 bytes
big已经 drop,10 字节的small还留着 1 048 600 字节:1 MiB 的缓冲,加上第一次 clone 时提升出来的 24 字节Shared。small.is_unique()是true。它确实是唯一的句柄,但「唯一」说的是计数为 1,不是「只占 10 字节」。clear()之后长度是 0,内存一个字节都没少。直到small被换成Bytes::new()(旧值随之 drop),1 MiB 才释放。
真实场景:把请求头的值存进长期容器
hyper 解析请求头时不拷贝头部的值。Server::parse 先把整个请求头从读缓冲切下来冻结,再对每个头切一段交给 header_value!:
let slice = buf.split_to(len).freeze();
// ...
let value = header_value!(slice.slice(header.value.0..header.value.1));
宏调用的是 HeaderValue::from_maybe_shared_unchecked。在 http 1.5.0 里,参数是 Bytes 时它被原样存进 HeaderValue 的 inner: Bytes 字段,没有拷贝。所以 handler 里拿到的每个 HeaderValue 都是读缓冲上的一个切片,clone() 它只是再加一份计数。
下面的程序起一个 hyper 服务器,在一个 keep-alive 连接上发 1000 个请求,每个请求头约 600 字节,带一个 32 字节的 x-session-id。handler 按参数决定怎么处理这个值:none 不存,keep 把 clone() 存进全局的 Vec,copy 用 HeaderValue::from_bytes 拷一份再存。连接关闭、服务端任务结束后看还剩多少内存:
use std::convert::Infallible;
use std::sync::Mutex;
use bytes::Bytes;
use exp_bytes_pit1::live;
use http_body_util::Full;
use hyper::header::HeaderValue;
use hyper::{Request, Response};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
const N: usize = 1000;
static STORE: Mutex<Vec<HeaderValue>> = Mutex::new(Vec::new());
fn request(i: usize) -> Vec<u8> {
// about 600 bytes of request head, one 32-byte session id
format!(
"GET /item/{i} HTTP/1.1\r\nHost: example.com\r\nx-session-id: {i:032x}\r\n\
user-agent: {}\r\naccept: */*\r\n\r\n",
"m".repeat(480)
)
.into_bytes()
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let mode = std::env::args().nth(1).unwrap_or_default();
STORE.lock().unwrap().reserve_exact(N);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let label = mode.clone();
let base = live();
let server = tokio::spawn(async move {
let (sock, _) = listener.accept().await.unwrap();
let svc = hyper::service::service_fn(move |req: Request<hyper::body::Incoming>| {
let mode = mode.clone();
async move {
let v = req.headers().get("x-session-id").unwrap();
let kept = match mode.as_str() {
"keep" => Some(v.clone()),
"copy" => Some(HeaderValue::from_bytes(v.as_bytes()).unwrap()),
_ => None,
};
if let Some(kept) = kept {
STORE.lock().unwrap().push(kept);
}
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"ok"))))
}
});
hyper::server::conn::http1::Builder::new()
.serve_connection(hyper_util::rt::TokioIo::new(sock), svc)
.await
.unwrap();
});
let mut c = TcpStream::connect(addr).await.unwrap();
let mut buf = vec![0u8; 4096];
for i in 0..N {
c.write_all(&request(i)).await.unwrap();
let mut got = Vec::new();
while !got.ends_with(b"\r\n\r\nok") {
let n = c.read(&mut buf).await.unwrap();
got.extend_from_slice(&buf[..n]);
}
}
drop(c);
server.await.unwrap();
drop(buf);
let kept = STORE.lock().unwrap().len();
let extra = live() as isize - base as isize;
println!("mode={label:<5} kept={kept:>4} live after connection closed = {extra:>8} bytes");
}
STORE 的容量在量基线之前就预留好,存值时不会再分配。三种参数各跑 3 次,输出相同:
mode=none kept= 0 live after connection closed = -388 bytes
mode=keep kept=1000 live after connection closed = 8231612 bytes
mode=copy kept=1000 live after connection closed = 31612 bytes
none 的 -388 是基线之前分配、之后释放的零碎内存,三行都有。减掉它:
| 方式 | 留住的字节 | 每个值 |
|---|---|---|
keep(v.clone()) | 8 232 000 | 8 232 = 8 192 + 40 |
copy(HeaderValue::from_bytes) | 32 000 | 32 |
存的数据一共 32 000 字节,keep 却留住了 8 MiB 多,约是数据量的 257 倍。每个值正好对应一块 8 KiB 的读缓冲和它的 40 字节 Shared(bytes_mut.rs 的 Shared:vec、original_capacity_repr、ref_count,共 40 字节)。
为什么是每个值一整块
一个 8 KiB 的缓冲能装下十几个 600 字节的请求,可实测是每个请求一块。原因在 hyper 准备下一次读的方式。poll_read_from_io 在剩余容量不够 next(默认 8192)时调用 reserve(next):
if self.read_buf_remaining_mut() < next {
self.read_buf.reserve(next);
}
切走一个 600 字节的请求头之后,剩余容量只有 7 000 多字节,每次读之前都会 reserve(8192)。这时:
- 请求头没被留下:读缓冲是唯一的句柄,
reserve把整块收回,从头开始用,不分配(一块缓冲的一生 的「再 reserve:整块收回」)。 - 请求头里的值被留下:计数大于 1,不能收回,
reserve另分配一个新Vec。hyper 的读缓冲以with_capacity(0)出生,原始容量是 0,新块的大小是max(len + 8192, 0),正好 8192。旧块从此只为那 32 字节活着。
用纯 bytes 把这个读循环模拟一遍,可以把「每 100 个请求留一个」也量出来。每轮剩余容量不足 8 KiB 就 reserve(8192),写入 606 字节的请求头,split_to 冻结,切出 32 字节的值:
use bytes::{BufMut, Bytes, BytesMut};
use exp_bytes_pit1::live;
const N: usize = 1000;
fn request(i: usize) -> Vec<u8> {
format!("GET /item/{i} HTTP/1.1\r\nx-session-id: {i:032x}\r\nuser-agent: {}\r\n\r\n", "m".repeat(520))
.into_bytes()
}
// hyper-like read loop: reserve 8 KiB when spare < 8 KiB, read one request, split it off, freeze
fn run(name: &str, keep_every: usize, copy: bool) {
let mut store: Vec<Bytes> = Vec::with_capacity(N);
let reqs: Vec<Vec<u8>> = (0..N).map(request).collect();
let base = live();
let mut read_buf = BytesMut::with_capacity(0);
for (i, req) in reqs.iter().enumerate() {
if read_buf.capacity() - read_buf.len() < 8192 {
read_buf.reserve(8192);
}
read_buf.put_slice(req); // stands in for socket.read
let head = read_buf.split_to(read_buf.len()).freeze();
let start = head.windows(14).position(|w| w == b"x-session-id: ").unwrap() + 14;
let value = head.slice(start..start + 32);
if keep_every > 0 && i % keep_every == 0 {
store.push(if copy { Bytes::copy_from_slice(&value) } else { value });
}
}
let during = live() as isize - base as isize;
drop(read_buf);
let after = live() as isize - base as isize;
println!(
"{name:<28} kept={:>4} ({:>5} data bytes) live with read_buf = {during:>8} after drop(read_buf) = {after:>8}",
store.len(),
store.iter().map(|b| b.len()).sum::<usize>()
);
}
fn main() {
println!("request head = {} bytes", request(0).len());
run("keep none", 0, false);
run("keep every slice", 1, false);
run("keep 1 in 100 slices", 100, false);
run("keep every copy_from_slice", 1, true);
}
request head = 606 bytes
keep none kept= 0 ( 0 data bytes) live with read_buf = 8232 after drop(read_buf) = 0
keep every slice kept=1000 (32000 data bytes) live with read_buf = 8232000 after drop(read_buf) = 8232000
keep 1 in 100 slices kept= 10 ( 320 data bytes) live with read_buf = 90552 after drop(read_buf) = 82320
keep every copy_from_slice kept=1000 (32000 data bytes) live with read_buf = 40232 after drop(read_buf) = 32000
keep every slice和 hyper 实测的 8 232 000 一致,模拟没有偏离 hyper 的行为。keep 1 in 100 slices:10 个值留住 82 320 字节,还是每个 8 232。留得少只是让总量变小,每个值的代价不变:它留住的是它出生时所在的那一整块。keep none:整个循环只有一块 8 KiB 缓冲和一个Shared,1000 个请求都在里面原地收回复用。
源码:计数只管句柄数
Bytes 这边的 Shared 只有三个字段:
struct Shared {
// Holds arguments to dealloc upon Drop, but otherwise doesn't use them
buf: *mut u8,
cap: usize,
ref_cnt: AtomicUsize,
}
buf 和 cap 只在计数归零时交给 dealloc。每个句柄自己的 ptr / len 存在句柄里,Shared 不知道谁看的是哪一段,也就没法「只释放没人看的部分」:分配器只能整块释放。shallow_clone_arc 做的就是计数加一,再造一个指向同一个 Shared、挂 SHARED_VTABLE 的句柄;BytesMut 冻结过来的存储同理,只是换成 bytes_mut.rs 那张表。
所以 clear() 也帮不上忙。clear 调 truncate(0),truncate 只对可提升存储走 split_off,其他存储一律 self.len = len。从别的 Bytes 切出来的非空句柄都挂共享的表,于是 clear() 只改长度,计数不动。
问: 上面 clear() 之后长度已经是 0,再也读不到任何字节,为什么不顺手放掉计数?
答: 1.12.1 里没有这么做,有人也觉得这不对。issue #840(「Bytes::clear() and Bytes::truncate(0) do not decrement refcount」)写道:「an empty Bytes can still retain a reference to the parent buffer and prevent it from being reused or freed」,并指出 split_off(0) 反而会把 self 换成一个空句柄。PR #842(「Release shared reference on zero truncate」)在 truncate 开头加了 len == 0 的分支,用 mem::replace 换成空句柄并 drop 旧的。它在 2026-07-16 合进 master,但写作时(2026-10-24)还没有发版,最新的 tag 仍是 v1.12.1。在 1.12.1 上要放手,就让句柄本身 drop,或者像上面那样赋一个 Bytes::new()。
修法
| 情形 | 怎么改 |
|---|---|
要长期保存一小段 Bytes | Bytes::copy_from_slice(&value),只分配这一段 |
要长期保存一个 HeaderValue | HeaderValue::from_bytes(v.as_bytes()),它内部用 Bytes::copy_from_slice;或者转成 String / Vec<u8> |
| 只在请求处理期间用 | 不用改:请求结束、句柄 drop,reserve 就能收回整块 |
| 自己的解码器既切头又读大 body | 先把头 split_to 出去,再为 body reserve(见下) |
| 已经存着一个大切片,想放手 | drop 它,或赋 Bytes::new();1.12.1 的 clear() 不放手 |
拷贝的代价很小:上面 copy 一行每个值多一次 32 字节的分配,换回 8 KiB。判断标准是「这段数据会不会活得比它所在的缓冲长」,会的话就拷。
split 的时机决定留住哪一块
切片留住的是切的那一刻它所在的那块分配,包括那一刻已经预留、还没写的容量。自己写协议解码器时,同一个 BytesMut 里先到头、后到大 body 很常见,split_to 和 reserve 谁先谁后,结果差很多:
use bytes::{BufMut, Bytes, BytesMut};
use exp_bytes_pit1::live;
const HEAD: usize = 600;
const BODY: usize = 1 << 20;
// returns the 32-byte id kept from the head, and how many bytes stay alive with it
fn run(name: &str, split_first: bool) {
let base = live();
let mut buf = BytesMut::with_capacity(8192);
buf.put_bytes(b'h', HEAD); // the head has arrived
let mut head = None;
if split_first {
head = Some(buf.split_to(HEAD).freeze());
}
buf.reserve(BODY); // make room for a 1 MiB body
buf.put_bytes(b'b', BODY);
let head = head.unwrap_or_else(|| buf.split_to(HEAD).freeze());
let id: Bytes = head.slice(10..42);
drop(head);
let body = buf.split().freeze();
drop((buf, body)); // the message is done; only `id` is kept
println!("{name:<32} id.len()={} live = {:>8} bytes", id.len(), live() - base);
}
fn main() {
run("reserve body, then split head", false);
run("split head, then reserve body", true);
}
reserve body, then split head id.len()=32 live = 1049216 bytes
split head, then reserve body id.len()=32 live = 8232 bytes
- 先
reserve再切:缓冲还是唯一的KIND_VEC,reserve交给Vec::reserve,整块扩成 600 + 1 048 576 = 1 049 176 字节;之后切出的头和id都在这块里,再加 40 字节Shared,共 1 049 216。 - 先切再
reserve:头切走时缓冲变成共享的,reserve只能另分配一块放 body,id留住的只是最初那块 8 KiB(8 192 + 40)。
这只能减小留住的量,不能消除它:id 还是留着一整块。要长期保存,仍然是拷贝。
怎么确认自己没踩
Bytes没有公开方法告诉你它背后那块分配有多大:没有capacity(),is_unique()在上面的实验里是true。只看句柄本身发现不了。- 在测试里用一个计数分配器,像本页这样:跑一批请求,把要长期保存的东西留下,其余全部 drop,比较「留下的字节」和「数据本身的字节」。差几十倍以上,就是切片在留缓冲。
- 线上看现象:内存随处理过的请求数增长,而不是随存下的数据量增长;heap profiler 里大块分配的调用栈落在读缓冲的
reserve上(hyper 是poll_read_from_io)。 - 审代码时找「从请求、响应、帧里取出来,再存进缓存、map、全局状态、channel 另一头长期任务」的
Bytes、HeaderValue,以及包着它们的类型:hyper 用slice_ref从同一个请求头里切出路径交给Uri::from_maybe_shared,http 1.5.0 的Uri把它原样存在路径部分里,所以长期保存的Uri也一样。
小结
- 切片和原缓冲共享同一个
Shared,计数只记句柄数;最后一个句柄 drop 之前,整块分配都不释放,is_unique()为真也不代表它小。 - hyper 的
HeaderValue是读缓冲上的切片。把 1000 个 32 字节的值clone()进长期容器,留住 8 232 000 字节:每个值一块 8 KiB 读缓冲加 40 字节Shared,因为缓冲共享时reserve(8192)只能另分配。 - 修法是拷贝:
Bytes::copy_from_slice、HeaderValue::from_bytes,实测 1000 个值只占 32 000 字节。 - 切片留住的是切的那一刻所在的分配,包括已预留的容量;自己写解码器时先
split_to头、再为 bodyreserve,能把留住的量从 1 MiB 降到 8 KiB。 - 1.12.1 的
clear()/truncate(0)对共享存储只把长度置 0,不放计数;#842 改了这一点,但写作时未发版。