如何在 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<typename T, typename Cleaner = decltype([](T& o) o.clear(); )> T& 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<int>
的函数。如果我们调用该函数,然后请求一个相同类型的向量,我们将得到容量为 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++ 中安全地实现可重用的暂存存储器?的主要内容,如果未能解决你的问题,请参考以下文章