Seastar: temporary buffer

Jianyong Chen

2022-07-16

Preface

temporary_buffer 是 Seastar 提供的一种自我管理(self-managed)字节缓冲区,它类似于 std::string 或者 std::unique_ptr<char[]>,但是提供了一些更加灵活的内存管理机制,比如它可以独占底层的缓冲区,也可以和其他 temporary_buffer 共享底层缓冲区、甚至只共享其他 temporay_buffer 底层缓冲区的一部分…,因为这些功能,这个数据结构在 Seastar 以及 Scylla/RedPanda 等基于 Seastar 的项目中使用得非常广泛

temporary buffer

首先来看看其结构定义:

template<typename CharType>
temporary_buffer {
    CharType *_buffer;
    size_t _size;
    deleter _deleter;
};

非常简单的一个结构,其中 _buffer 就是其底层缓冲区,_size 就是该缓冲区的大小;但是需要注意的是,由于 temporay_buffer 是可以从其他 temporary_buffer 共享的(甚至只共享一部分),所以 _buffer 可能并是最初分配的内存的起始地址,因此 _size 也可能并不是最初分配内存的大小

二者的关系可能是这样:

seastar-temporary-buffer

deleter 自然就是用来释放该内存的工具了,相关逻辑暂且按下不表,后面会用另外一个 section 来解读,还是很有趣的

构造 & 析构

首先来看看它的构造和析构函数:

explicit temporary_buffer(size_t size)
    : _buffer(static_cast<CharType*>(malloc(size * sizeof(CharType)))), _size(size)
    , _deleter(make_free_deleter(_buffer)) {
        if (size && !_buffer) {
            throw std::bad_alloc();
        }
    }

temporary_buffer(CharType* buf, size_t size, deleter d) noexcept
    : _buffer(buf), _size(size), _deleter(std::move(d)) {}

temporary_buffer() noexcept
    : _buffer(nullptr)
    , _size(0) {}

一个是默认构造函数,另一个则是指定缓冲区的大小,然后用 malloc 分配指定大小的缓冲区,此时 deleter 通过 make_free_deleter 构造出来,它的功能和它的名字一样:通过 std::free 释放 _buffer;还有一个则是直接传入 raw buffer 和 size,以及自定义deleter

此外它还提供了移动构造函数/移动赋值,但是不支持拷贝构造/拷贝赋值——他们是删除的(=delete)

temporary_buffer 并没有显式提供析构函数,所以他们是编译期生成的默认版本——所以可以想象的出来,deleter 会在其析构时释放 _buffer 的内存空间

常用操作

temporary_buffer 提供了一些常见的类 STL 容器的操作,比如 operator[]size()empty() 以及 begin()end() 操作,除此之外还有一些特有的操作:

CharType *get_write() noexcept { return _buffer; }

前面提到的场景操作基本都是将 temporary_buffer 看作一个只读的数据结构来实现的——比如 begin()end() 返回的都是 const CharType *operator[] 返回的是 CharType 而不是 CharType &;这是因为 temporary_buffer 是支持共享的,也就是说一个这样的数据结构可能有多个 user 在使用着,所以如果不是非常确定,最好不要改动里面的数据,否则可能造成不可预料的后果

get_write() 则是以 CharType * 返回底层缓冲区,也就是说可以通过它往底层缓冲区里面写数据——这也是大多数网络 I/O 所使用的方法(除此之外还有 net::packet ,不过这个过于底层一般也用不着)

void trim(size_t pos) { _size = _pos; }
void trim_front(size_t pos) {
    _buffer += pos;
    _size_t -= pos;
}

“修剪” 操作,trim 是移除 suffix,trim_front 是移除 prefix;不过二者的 pos 参数意义不同,trim 中的 pos 参数并不指明需要移除的 suffix 的长度,而是说移除 suffix 之后剩余的长度(或许改名叫 trim_to 更好?);trim_front 中的 pos 则是实打实的指明需要移除的 prefix 的长度

这俩方法也说明,_buffer 并不就是初始分配的缓冲区,而是有可能只是它的一部分

temporary_buffer share() {
    return temporary_buffer(_buffer, _size, deleter.share());
}

temporary_buffer share(size_t pos, size_t len) {
    auto ret = share();
    _buffer += pos;
    _size = len;
    return ret;
}

重头戏来了,这是我觉得 temporary_buffer 相比于 std::stringstd::unique_ptr<char[]> 最有价值的一个功能

经常有这种场景,对于一块数据,我们需要从其中拿出一部分来处理,协议处理中常见的 header 处理;如果用 std::string,我们或许可以通过 substr 拿出一个子串,不过这存在着拷贝,效率太低;或者通过 std::string_view 引用原始 std::string 的一部分,不过这样的话二者之间其实并没有建立联系,所以倘若 std::string 被释放那么再使用这个 std::string_view 就会出问题——所以我们需要将 std::string 保持直到 std::string_view 不再被使用——但是在异步场景下做到这一点也很难,至少不那么直观,或者不那么自动化

temporary_buffer() 则在不同的 share 之间建立了联系——通过传统的 RAII,外加引用计数——从而优雅地解决了这个问题——无需使用者关心何时释放原始字符串;不过这一点也留在 deleter 这个 section 去探究

为什么 temporary

一开始我看到这个数据结构时,我就在想,为什么它要叫 temporary_buffer 呢,改叫 bytes_buffer 不行么?代码中的注释给我们做了解答:

A temporary_buffer should not be held indefinitely. It can be held while a request is processed, or for a similar duration, but not longer, as it can tie up more memory that its size indicates.

首先不推荐长时间持有一个 temporary_buffer,它最好只在一个请求的生命周期内使用(或者与之类似的时长),再长就不太好了,这就是它叫做 temporary_buffer 的原因;但是为什么不推荐长时间持有呢?因为它表面看起来的 size 可能并不代表它实际占用的内存——想象 trimshare 操作:一个 size() 为 16B 的 temporary_buffer 可能是从另一个 size() 为 1GB 的 temporary_buffer 中共享出来的(所谓冰山一角),而如果我们长时间持有它,那么原始的缓冲区则将无法得到释放,最终造成系统内存使用量过高,而且还难以 debug

deleter

deleter 一开始并不是为 temporary_buffer 设计的,而只是 net::packet 实现 zero copy 功能的一个 utility,不过后面被抽取出来变成了一个通用的内存管理工具

class deleter final {
public:
    struct impl;
    struct raw_object_tag{};
private:
    impl *_impl = nullptr;
};

struct deleter::impl {
    unsigned refs = 1;
    deleter next;
    impl(deleter next) : next(std::move(next)) {}
    virtual ~impl() {}
};

deleter 用了 pImpl idiomdeleter::impl 的结构也很简单:其中 refs 为引用计数,这是用来解决 share buffer 的问题,还有一个 deleter 类型的 next 字段(注意并不是 deleter::impl 类型),它比较难理解,不过暂时不用管他,后面有了更多的背景知识就可以理解了

构造 & 析构

首先看看其析构函数:

inline deleter::~deleter() {
    if (is_raw_object()) {
        std::free(to_raw_object());
        return;
    }
    if (_impl && --_impl->refs == 0) {
        delete _impl;
    }
}

通过前面的 temporary_buffer 我们已经发现:其内部的 _buffer 指针可能并不是指向初识分配的内存块首字节而是中间的一段,所以我们不能直接 std::free(_buffer),但是 temporary_buffer 中又没有记录下内存块的首地址,所以如果 _deleter 想要知道该释放哪块内存,就必须由它自己去保存这个信息

虽然 deleter 使用了 pImpl idiom,但是其 _impl 指针有多个用途——它可以是指向实际的 implementation 对象,也可是字节缓冲区的首地址;这两种情况如何区分呢?借助 tagged pointer 这个 trick:如果它的最后一位为 1 的话,说明它直接指向的是要释放的内存块首地址,可以直接 std::free() 掉;否则的话它就是一个 implementation pointer,此时需要递减引用计数,当它为 0 时才可以删除 implementation 对象;

implementation object 是用来存储引用计数的,但是在没有调用 share()/trim() 操作时,我们并不需要引用计数,也没有必要一上来就分配一个 implementation object,通过 tagged pointer,我们减少了不必要的内存分配

is_raw_object 就是检查 tag,from_raw_objectto_raw_object 分别是在 _impl 中加上/清除 tag:

explicit deleter(impl* i) noexcept : _impl(i) {}
deleter(raw_object_tag, void* object) noexcept
    : _impl(from_raw_object(object)) {}

在指定长度创建 temporary_buffer 时,其 _deleter 成员通过 make_free_deleter(_buffer) 初始化,里面就调用了 deleterraw_object_tag 的构造函数:

inline
deleter
make_free_deleter(void* obj) {
    if (!obj) {
        return deleter();
    }
    return deleter(deleter::raw_object_tag(), obj);
}

那么问题来了,什么时候 _impl 才会真正指向其 implementation 对象呢?那时又是在何处保存缓冲区首地址呢(deleter::impl 中似乎没有地方)?这个就是常用操作中的重点了

常用操作:

inline deleter
deleter::share() {
    if (!_impl) {
        return deleter();
    }

    if (is_raw_object()) {
        _impl = new free_deleter_impl(to_raw_object());
    }
    ++_impl->refs;
    return deleter(_impl);
}

这是 deleter 最重要的方法之一,temporary_buffer()share() 方法就是在该方法之上实现的,通过这个方法我们可以看到它是如何在两个共享底层 buffer 的 temporary_buffer 之间建立联系并处理内存释放这个问题的

如果 _impl 指向的是 raw object,那么需要将其转换为一个 deleter::_impl 结构——这样才能记录下引用计数,当然也不完全是 deleter::_impl——因为它里面没有地方可以存储 raw object,所以是它的一个子类 free_deleter_impl

struct free_deleter_impl final : deleter::impl {
    void* obj;
    free_deleter_impl(void* obj) : impl(deleter()), obj(obj) {}
    virtual ~free_deleter_impl() override { std::free(obj); }
};

其中的 obj 指针就可以用来存储 raw object。然后递增其引用计数,并通过 _impl 构造一个新的 deleter,这样两个 deleter 就共享同一个 _impl,并且其引用计数为 2——每个 deleter 析构时都会递减引用计数,当引用计数递减至 0 时会 delete _impl 从而调用其析构函数,在其析构函数中会真正地释放缓冲区内存

deleter 是作为一个 data member 存储在 temporary_buffer 中,所以只要它析构,就会导致 deleter 析构,最终只有在所有 share 副本都析构时,其缓冲区才会真正被释放

void deleter::append(deleter d) {
    if (!d._impl) { return; }
    
    impl *next_impl = _impl;
    deleter *next_d = this;
    while (next_impl) {
        /* ... */
    }
}

TODO: 现在对这个方法的使用场景和实现原理还不理解,后面看 net::packet 时再回过头来看看它吧

总结

temporary_buffer 其实就是一个类似于 std::string 的字节缓冲区,但是相比于 std::string 它最大的特点就是可以和其他的 temporary_buffer 共享底层的字节缓冲区(所有或者只是一部分),而不用使用者去操心该何时去释放这块内存,这一点在异步场景中非常有用——虽然对于 std::string 我们可以搭配 std::string_view 构造出类似的共享底层缓冲区的功能,但是使用起来割裂感就很强

deleter 则是一个通用的内存管理工具,它是 temporary_buffer 实现底层缓冲区共享的关键;其实它的实现也不复杂,其实还是很常见的引用计数,外加一些小技巧来简化实现/减少内存占用

Reference