【发布时间】: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 = {}))
标签: c++ dynamic-memory-allocation thread-local-storage