Will the native buffer owned by BytesMut keep growing?

Viewed 64

Suppose I have a BytesMut object, and then keep writing data to its interior. Then split some frames out of it according to the frame segmentation format.

According to my idea, this memory is constantly reallocate out. So the question is, I can make its capacity smaller by constantly splitting, but at what point does it actually allocate contiguous memory to be freed?

If I keep taking data splits from the front, won't this memory usage get larger and larger? Or maybe I have a problem understanding that different BytesMut objects will have different native buffer when split, but how is this done?

#[test]
fn test_bytesmut_growth() {
    use bytes::{BufMut, BytesMut};
    let mut bm = BytesMut::with_capacity(16);
    for i in 0..10000 {
        bm.put(&b"1234567890"[..]);
        let front = bm.split();
        drop(front);
    }
    //println!("current cap={}, len={}", bm.capacity(), bm.len());
}
2 Answers

If you split a BytesMut object into two, the two objects will still share the underlying reference-counted buffer. Here's an attempt at a visualization, containing a few implementation details. Before splitting:

 Underlying buffer, ref count 1
┌────────────────────────────────┐
│0123456789ABCDEFGHIJ            │
└▲───────────────────────────────┘
 │
 │   first
 │  ┌────────────┐
 ├──┤ptr         │
 │  │len: 20     │
 │  │cap: 32     │
 └──┤data        │
    └────────────┘

After calling let second = first.split_off(10), we will get

 Underlying buffer, ref count 2
┌────────────────────────────────┐
│0123456789ABCDEFGHIJ            │
└▲─────────▲─────────────────────┘
 │         │
 │         └──────────┐
 │   first            │      second
 │  ┌────────────┐    │     ┌────────────┐
 ├──┤ptr         │    └─────┤ptr         │
 │  │len: 10     │          │len: 10     │
 │  │cap: 10     │          │cap: 22     │
 ├──┤data        │    ┌─────┤data        │
 │  └────────────┘    │     └────────────┘
 │                    │
 └────────────────────┘

Once we drop first, we will have

 Underlying buffer, ref count 1
┌────────────────────────────────┐
│0123456789ABCDEFGHIJ            │
└▲─────────▲─────────────────────┘
 │         │
 │         │
 │         │                 second
 │         │                ┌────────────┐
 │         └────────────────┤ptr         │
 │                          │len: 10     │
 │                          │cap: 22     │
 └──────────────────────────┤data        │
                            └────────────┘

If you now call second.reserve(10), or call an operation that implicitly reserves, like writing more than fits in the current capacity, the BytesMut implementation can detect that second actually owns its underlying buffer, since the reference count is one. The implementation then may be able to reuse spare capacity in the buffer by moving the existing buffer contents to the beginning, so after second.reserve(20), the result could look like this:

 Underlying buffer, ref count 1
┌────────────────────────────────┐
│ABCDEFGHIJ                      │
└▲───────────────────────────────┘
 │
 │   second
 │  ┌────────────┐
 ├──┤ptr         │
 │  │len: 10     │
 │  │cap: 32     │
 └──┤data        │
    └────────────┘

However, the conditions for this optimization to be applied are not guaranteed. The documentation for reserve() states (emphasis mine)

Before allocating new buffer space, the function will attempt to reclaim space in the existing buffer. If the current handle references a view into a larger original buffer, and all other handles referencing part of the same original buffer have been dropped, then the current view can be copied/shifted to the front of the buffer and the handle can take ownership of the full buffer, provided that the full buffer is large enough to fit the requested additional capacity.

This optimization will only happen if shifting the data from the current view to the front of the buffer is not too expensive in terms of the (amortized) time required. The precise condition is subject to change; as of now, the length of the data being shifted needs to be at least as large as the distance that it’s shifted by. If the current view is empty and the original buffer is large enough to fit the requested additional capacity, then reallocations will never happen.

In summary, this optimization is only guaranteed if the reference count is one and the view is empty. This is the case in your example, so your code is guaranteed to reuse the buffer.

According to the documentation, BytesMut::split 'Removes the bytes from the current view, returning them in a new BytesMut handle. Afterwards, self will be empty, but will retain any additional capacity that it had before the operation.'

This is done by creating a new BytesMut (which is then owned by front) that contains exactly the items of bm, after which bm is modified such that it contains only the remaining empty capacity. This way, BytesMut::split doesn't allocate any new memory.

You then drop the BytesMut (owned by front), making it so that there is no view into the memory owned by the backing Vec, from the start of the Vec until the start of bm. When you then put, the implementation first checks if there is enough space before the view of bm, but still inside the backing Vec, and tries to store the data there.

Because the amount of memory you put is the same as the memory 'freed' by dropping front, the implementation is able to store that data before the view of bm, and no data is allocated.

Related