如何在 C++ 中安全地实现可重用的暂存存储器?

Posted

技术标签:

【中文标题】如何在 C++ 中安全地实现可重用的暂存存储器?【英文标题】:How to safely implement reusable scratch memory in C++? 【发布时间】:2021-10-14 08:35:45 【问题描述】:

即使是纯函数也需要一些额外的临时内存来进行操作,这是很常见的。如果在编译时知道这块内存的大小,我们可以用std::array或一个C数组在栈上分配这块内存。但大小通常取决于输入,因此我们经常通过std::vector 诉诸堆上的动态分配。 考虑一个围绕一些 C api 构建包装器的简单示例:

void addShapes(std::span<const Shape> shapes) 
    std::vector<CShape> cShapes;
    cShapes.reserve(shapes.size());

    // Convert shapes to a form accepted by the API
    for (const Shape& shape : shapes) 
        cShapes.push_back(static_cast<CShape>(shape));
    
    cAddShapes(context, cShapes.data(), cShapes.size());

假设我们重复调用此函数并且我们发现std::vector 内存分配的开销很大,即使调用reserve() 也是如此。所以,我们能做些什么? 我们可以将向量声明为static 以在调用之间重用分配的空间,但这会带来一些问题。首先,它不再是线程安全的,但可以通过使用thread_local 来轻松解决。其次,在程序或线程终止之前,内存不会被释放。假设我们对此很好。最后,我们必须记住每次都清除向量,因为在函数调用之间持续存在的不仅仅是内存,还有数据。

void addShapes(std::span<const Shape> shapes) 
    thread_local std::vector<CShape> cShapes;
    cShapes.clear();

    // Convert shapes to a form accepted by the API
    for (const Shape& shape : shapes) 
        cShapes.push_back(static_cast<CShape>(shape));
    
    cAddShapes(context, cShapes.data(), cShapes.size());

每当我想避免每次调用的动态分配时,我都会使用这种模式。问题是,如果您不了解这种模式,我认为它的语义不是很明显。 thread_local 看起来很吓人,你必须记住清除向量,即使对象的生命周期现在超出了函数的范围,返回对它的引用也是不安全的,因为对同一函数的另一个调用会修改它。

我第一次尝试让这更容易一点是定义一个这样的辅助函数:

template <typename T, typename Cleaner = void (T&)>
T& getScratch(Cleaner cleaner = [] (T& o)  o.clear(); ) 
    thread_local T scratchObj;
    cleaner(scratchObj);
    return scratchObj;


void addShapes(std::span<const Shape> shapes) 
    std::vector<CShape>& cShapes = getScratch<std::vector<CShape>>();

    // Convert shapes to a form accepted by the API
    for (const Shape& shape : shapes) 
        cShapes.push_back(static_cast<CShape>(shape));
    
    cAddShapes(context, cShapes.data(), cShapes.size());

当然,这会为getScratch 函数的每个模板实例化创建一个thread_local 变量,而不是为调用该函数的每个位置创建一个thread_local 变量。因此,如果我们一次请求两个相同类型的向量,我们将获得对同一向量的两个引用。不好。

什么是安全和干净地实现这种可重用内存的好方法?是否已经存在现有的解决方案?还是我们不应该以这种方式使用线程本地存储,而只使用本地分配,尽管重用它们带来了性能优势:https://quick-bench.com/q/VgkPLveFL_K5wT5wX6NL1MRSE8c?

【问题讨论】:

你想发明一个分配器吗? @SergeyA 也许吧。我觉得它不是关于它是什么,而是更多关于它是如何使用的。在这种情况下,我正在寻找一种简单、非侵入性且快速的方法来重用临时对象。如果您有一个如何使用 c++ 内存分配器实现的好方法,请考虑将其发布为答案。 我认为您的基准测试有点误导,因为强制 data 跨越 DoNotOptimize() 边界会阻止一些重要的优化开始。例如:quick-bench.com/q/treYWxWP87r2qHJQHWz4bozNSuI 和 quick-bench.com/q/O65r_FSAWg5auNcAwtJCdmtYNII 详细说明:clang 足够聪明,可以确定向量是暂存内存,并据此采取行动。公平地说,其他编译器在堆省略方面几乎没有那么好,所以努力仍然是值得的。 您可以将 getScratch 函数与相同类型的标签/区分类型重用(这里使用 lambda 类型:godbolt.org/z/5TYEz4Kh1 或者您可以简单地将其更改为 typename&lt;typename T, typename Cleaner = decltype([](T&amp; o) o.clear(); )&gt; T&amp; getScratch(Cleaner cleaner = ) 【参考方案1】:

为了回答我自己的问题,我想出了一个基于上一个示例的解决方案。与其为每个线程和类型只保留一个对象,不如为它们保留一个空闲列表。根据要求,我们要么重用空闲列表中的一个对象,要么创建一个新对象。用户保留一个 RAII 样式的句柄,当它离开作用域时,它会将对象返回到空闲列表中。由于我们仍然使用thread_local,因此这是线程安全的,无需任何努力。我们可以将所有这些包装到一个简单的类中:

template <typename T>
class Scratch 
public:
    template <typename Cleaner = void (T&)>
    explicit Scratch(Cleaner cleaner = [] (T& o)  o.clear(); ) : borrowedObj(acquire()) 
        cleaner(borrowedObj);
    
    
    T& operator*() 
        return borrowedObj;
    
    T* operator->() 
        return &borrowedObj;
    
    
    ~Scratch() 
        release(std::move(borrowedObj));
    
private:
    static thread_local std::vector<T> freeList;
    T borrowedObj;

    static T acquire() 
        if (!freeList.empty()) 
            T obj = std::move(freeList.back());
            freeList.pop_back();
            return obj;
         else 
            return T();
        
    
    static void release(T&& obj) 
        freeList.push_back(std::move(obj));
    
;

这可以简单地用作:

void addShapes(std::span<const Shape> shapes) 
    Scratch<std::vector<CShape>> cShapes;

    // Convert shapes to a form accepted by the API
    for (const Shape& shape : shapes) 
        cShapes->push_back(static_cast<CShape>(shape));
    
    cAddShapes(context, cShapes->data(), cShapes->size());

您可能希望根据需要扩展它,如果要与容器一起使用,可能会添加一个 [] 运算符以方便使用。您可以将其预期用途保留为函数中的本地对象,并显式使其不可复制和不可移动,或者可以将其转换为通用句柄,如unique_ptr。但请注意,该对象必须由创建它的同一线程销毁。

在这两种情况下,它都使用原始 thread_local 解决了我的问题。 clear 是隐式的,现在返回对临时对象或其数据的引用显然是错误的。它仍然不会自动释放内存,这毕竟是我们想要的,但至少现在更容易实现按需释放内存的功能。

一般来说,它也应该比原始的thread_local 方法具有更低的内存使用量,因为相同类型的分配可以在不同的调用站点中重复使用。但是在某些情况下,这种行为也会导致更高的内存使用量。假设我们有一个需要大小为 10000 的 std::vector&lt;int&gt; 的函数。如果我们调用该函数,然后请求一个相同类型的向量,我们将得到容量为 10000 的向量。如果我们在按住的同时再次调用该函数这个向量,它必须创建另一个向量,并将其大小调整为 10000 个元素。

出于这些原因,我建议仅在您不希望看到大量数据,而是希望避免大量小但频繁且短暂的分配时使用它。

【讨论】:

我不清楚你为什么想要所有这些脚手架,而实际上你只需要一个thread_local-aware 分配器。 @SergeyA 我认为将这个解决方案装扮成分配器是不合适的。它并不意味着传递给容器,也不意味着创建具有动态生命周期的对象。它与您在分配器中看到的策略(例如内存池)有一些重叠,但从根本上说,它似乎解决了一个更受限制的问题,为具有自动生命周期的对象模拟内存池。 @FrançoisAndrieux OP 确实选择了这个实现。但是,当我看到这个激励性的例子时,它的缺陷和可接受的权衡是一个竞技场分配器用例的教科书示例。【参考方案2】:

static 在调用之间重用分配的空间,但这会带来几个问题。首先,它不再是线程安全的,但可以通过使用 thread_local 来轻松解决。其次,在程序或线程终止之前,内存不会被释放。

没错。因为只有函数的用户知道他想如何以及何时调用函数以及何时调用,只有函数的用户应该是负责的人如果他想重用空间并清理它,因为用户知道他是否会在以后使用它。因此,将缓存对象添加到您的函数中,您可以在其中缓存状态以加快速度。

void addShapes(std::span<const Shape> shapes, std::vector<CShape>& cache) 
    cache.reserve(shapes.size());    
    // Convert shapes to a form accepted by the API
    for (const Shape& shape : shapes) 
        cache.push_back(static_cast<CShape>(shape));
    
    cAddShapes(context, cache.data(), cache.size());

或者你可以把它物化一点,比如:

class shapes 
    std::vector<CShape> cache;
    void add(std::span<const Shape> shapes) 
        cache.reserve(shapes.size());    
        // Convert shapes to a form accepted by the API
        for (const Shape& shape : shapes) 
            cache.push_back(static_cast<CShape>(shape));
        
       cAddShapes(context, cache.data(), cache.size());
    
   void clear_cache() 
      cache.clear();
   
;

【讨论】:

这是一个很好的观点,但我担心的是它会泄露函数的实现细节。也许我们可以改为传递一个通用分配器对象,函数的用户可以根据需要创建和释放它的内存,而不必知道它是如何被实现实际使用的。但它实际上只是一个通用分配器,可能会失去最初促使我提出这个问题的性能优势。

以上是关于如何在 C++ 中安全地实现可重用的暂存存储器?的主要内容,如果未能解决你的问题,请参考以下文章

您如何在不同的暂存环境中维护 java webapps?

如何将提交移动到 git 中的暂存区?

如何快速撤消 git 中的暂存和未暂存的更改?

MySQL内存使用-全局共享

解决开发中未发生的暂存中的 nginx 404 错误

Cordova 3.5.0 iOS 应用程序中的暂存文件夹需要啥?