Rust in Everything
基于 bytes 1.12.1

一个小切片让整块缓冲一直不释放

主线位置:共享主线的 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 0008 232 = 8 192 + 40
copy(HeaderValue::from_bytes)32 00032

存的数据一共 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() 只改长度,计数不动。

📝NOTE
没有蠢问题

问: 上面 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()。

修法

情形怎么改
要长期保存一小段 BytesBytes::copy_from_slice(&value),只分配这一段
要长期保存一个 HeaderValueHeaderValue::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 头、再为 body reserve,能把留住的量从 1 MiB 降到 8 KiB。
  • 1.12.1 的 clear() / truncate(0) 对共享存储只把长度置 0,不放计数;#842 改了这一点,但写作时未发版。

💬 关联讨论

发起讨论
加载中...