C++内存池技术深度解析:生命周期管理与指针来源的正确处理

本文深入探讨C++中内存池分配器的实现细节,重点分析对象生命周期管理和指针来源问题。通过对比C++20前后内存分配语义的变化,展示如何正确使用placement new和数组new来避免未定义行为。

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

2025年9月30日更新:进一步优化。

Patrice Roy的新书《C++内存管理》让我更加关注对象生命周期。C++在生命周期方面比C更严格,在C中合理的教科书式内存管理在C++中就不那么合理了——比我意识到的还要严重。这本书还提出了一种被弱化到没有任何优势的内存池分配形式。(尽管在其他方面很精确,但后半部分也充斥着缺乏适当检查的整数溢出,接近结尾处还有一些使检查无效的指针溢出。)不过,我很感激这些新见解,它让我重新审视自己的C++内存池分配。在这种新的视角下,我发现我自己也犯了一些细微的错误!

对大多数C++程序员来说很惊讶,但对语言律师来说不是,惯用的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的来源,就语言而言,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);  // OOM策略
    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);  // OOM策略
    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——他个人对我之前的文章给出了很好的建议和反馈——在几周后谈到了生命周期。我推荐两者。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计