深入探讨C++内存池技术:从生命周期到指针来源的正确实践

本文深入探讨了C++中内存池(竞技场)分配器的实现细节,重点分析了C++20前后对象生命周期的严格规定、指针来源的重要性,以及如何正确使用`placement new`和`new[]`来避免未定义行为。文章通过具体的代码对比,揭示了教科书式C内存管理在C++中的潜在问题,并提出了改进的内存池实现方案。

对C++内存池的进一步思考

关于C++内存池的更多思考

September 30, 2025


nullprogram.com/blog/2025/09/30/


(作者目前正在美国寻求就业机会。)

2025年10月更新:进一步改进。 Patrice Roy的新书《C++ Memory Management》让我对对象的生命周期有了更清晰的认识。C++在生命周期方面比C更严格,而在C中是安全的、教科书式的常见内存管理,在C++中则不那么安全——这一点我以前认识得不够深刻。这本书还提出了一种被严重削弱的内存池分配形式,以至于它无法享受到任何好处。(尽管书的后半部分在其他方面很精确,但也充斥着缺乏适当检查的整数溢出,并且在接近结尾处有一些指针溢出,导致检查无效。)不过,我很感激这些新的见解,这让我重新审视了自己的C++内存池分配。在这种新的视角下,我发现自己之前也犯了一些细微的错误!

对于大多数C++程序员来说可能出乎意料,但对于语言专家(language lawyers)来说并不奇怪:直到最近,习惯性的C内存分配在C++中都是不合法的:

1
2
3
4
5
6
7
8
int *newint(int v)
{
    int *r = (int *)malloc(sizeof(*r));
    if (r) {
        *r = v;  // <-- 在C++20之前是未定义行为
    }
    return r;
}

这段程序为一个对象分配了内存,但从未开始其生命周期。在没有生命周期的情况下进行赋值是无效的。指针转换在C++中更可疑,并且由于生命周期语义,在许多情况下都表明代码不正确。(需要明确的是,我并非支持这些语义,而是基于实际情况进行推理。)C++20为malloc及其相关函数开辟了特殊的例外,但解决这类问题的普遍方法是使用全新的start_lifetime_as(及类似功能)、稍早的construct_at,或者经典的placement new。它们都用于开始生命周期。最后一种方法如下所示:

1
2
3
4
5
6
7
8
int *newint(int v)
{
    void *r = malloc(sizeof(int));
    if (r) {
        return new(r) int{v};
    }
    return nullptr;
}

这作为C/C++多语言程序并不好,不过根据不同的旧语义,没有宏本来也不可能实现。宏基本上算是一种“作弊”。一个重要细节是:修正后的版本没有类型转换,并且返回的是new的结果。这很重要,因为只有new返回的指针被赋予了指向新生命周期的身份,而不是r。没有副作用影响r的来源(provenance),就语言而言,r仍然指向原始内存。

考虑到这一点,让我们重新审视我上次的内存池,它不一定从最近的变更中受益,因为它不属于特殊的C标准库函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
struct Arena {
    char *beg;
    char *end;
};

template<typename T>
T *alloc(Arena *a, ptrdiff_t count = 1)
{
    ptrdiff_t size = sizeof(T);
    ptrdiff_t pad  = -(uintptr_t)a->beg & (alignof(T) - 1);
    assert(count < (a->end - a->beg - pad)/size);  // 内存耗尽策略
    T *r = (T *)(a->beg + pad);
    a->beg += pad + count*size;
    for (ptrdiff_t i = 0; i < count; i++) {
        new((void *)&r[i]) T{};
    }
    return r;
}

嘿,看,placement new!我这样做是为了提供一个更好的接口,但我也碰巧正确地开始了生命周期。只是它返回了错误的指针。这个分配器丢弃了被赋予新生命周期的指针。两个指针具有相同的地址但不同的来源。这很重要。但是我多次调用了new,那我该如何修复这个问题呢?用数组new,没错。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
template<typename T>
T *alloc(Arena *a, ptrdiff_t count = 1)
{
    ptrdiff_t size = sizeof(T);
    ptrdiff_t pad  = -(uintptr_t)a->beg & (alignof(T) - 1);
    assert(count < (a->end - a->beg - pad)/size);  // 内存耗尽策略
    void *r = a->beg + pad;
    a->beg += pad + count*size;
    return new(r) T[count]{};
}

哇……这实际上好多了。没有显式类型转换,没有循环。为什么我一开始没想到呢?问题在于我无法像emplace风格那样转发构造函数参数——这正是我在完美转发方面遇到的麻烦——但这样其实更好。多次转发是不安全的,使用new[]能更清楚地看到这一点。

需要注意:这仅在C++20及以后版本中有效,并且严格来说需要使用operator new[](size_t, void *)。任何其他形式的placement new[]可能需要数组开销——例如,它会预先添加一个数组大小,以便delete[]能够运行非平凡的析构函数——这是不可知的,因此不可能提供或正确对齐。placement new[]的开销当然是荒谬的,但截至撰写本文时,所有三大主流C++编译器都这样做,实质上破坏了自定义的placement new[]

既然我在思考生命周期,那另一头呢?我的内存池按设计不调用析构函数,并在技术上仍然存活的对象之上开始新的生命周期。这是未定义行为吗?据我所知,这是允许的,即使对于非平凡的析构函数也是如此,但需要注意的是它可能会泄漏资源。在这种情况下,资源是由内存池管理的内存,所以这当然没问题。

因此,解决指针来源问题也产生了更好的定义。阅读那本书真是带来了很棒的结果!在研究过程中,我注意到Jonathan Müller(他曾亲自对我之前的文章提出了很好的建议和反馈)在几周后也谈到了生命周期。我推荐这两者。

cpp

对此文章有评论吗?请通过发送电子邮件至~skeeto/public-inbox@lists.sr.ht到我的公共收件箱开始讨论,或查看现有讨论。

«

    使用字符串驻留进行分层字段排序

»

    关于内存池和非平凡析构函数的思考

null program Chris Wellons

wellons@nullprogram.com (PGP) ~skeeto/public-inbox@lists.sr.ht (查看)

索引 标签 订阅 关于 工具 玩具 GitHub

除非另有说明,本博客上的所有信息均在此释放到公共领域,无任何权利保留。
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计