对竞技场和非平凡析构函数的思考
Speculations on arenas and non-trivial destructors
2025年10月16日
在持续反思C++中的竞技场和生存期管理时,我意识到处理析构函数并非那么繁琐。事实上,它甚至不影响我既定的竞技场使用模式!即在作用域结束时隐式地进行RAII风格的释放,这即使在普通的旧式C中也能工作。通过一个小改动,我们可以安全地将资源管理对象(如拥有文件句柄、套接字、线程等的对象)放入竞技场。(不过理想情况仍然是尽可能避免资源管理。)我们也可以将传统的、管理内存的C++对象放入竞技场。它们自身的分配不会来自竞技场——要么是因为它们缺少相应的接口,要么是因为它们这样做效率不高(pmr)——但它们在完成后会可靠地进行自我清理。这一切也都是异常安全的。在本文中,我将用这个新功能更新我的竞技场分配器。这个改动需要一个额外的竞技场指针成员,为非平凡析构函数的对象带来一点开销,而对其他对象没有影响。
我继续将其标题定为"思考",因为与C中的竞技场不同,我(还?)没有在实际软件中实践这些C++技术。我还没有通过使用来完善它们。即使像这里一样忽略其标准库,C++也是一个极其复杂的编程语言——远比C复杂得多——因此我对我没有无意中违反规则这一点不那么有信心。我只想有意识地打破规则!
回顾
提醒一下,我们上次停止的地方:
|
|
我在内存不足时使用throw主要是为了强调这可以工作,但你可以为你的程序自由选择任何合适的方式。记住,这就是整个分配器,包括隐式释放,足以满足大多数程序的分配需求,不过程序需要为此设计。另外请注意,它现在是raw_alloc,因为我们将在此基础上编写一个新的、增强的alloc。
使用示例
同样回顾一下用法,我将引用一个更新到C++的旧示例:
|
|
对scratch的更改在getlines返回后不会保留,因此从该竞技场分配的对象在返回时会自动释放。到目前为止,这并不依赖于C++ RAII特性,只是简单的值语义。它工作得很好,因为所有相关对象都具有平凡的析构函数。但是,假设有一个资源需要管理:
|
|
如果我们在竞技场中分配一个TcpSocket,包括作为另一个对象的成员,析构函数永远不会运行,除非我们手动调用它。为了解决这个问题,我们需要跟踪需要析构的对象,我们将使用析构函数的链表来完成,形成一个LIFO栈:
|
|
每个Dtor指向一个同质数组、一个计数(通常为一)以及一个知道如何销毁这些对象的函数指针。链表本身是异质的,具有动态类型。函数指针有点像一种类型标签。dtor函数将使用模板函数生成:
|
|
注意它以从尾到头的顺序销毁,与placement new[]实例化这些对象的顺序相反。它本质上是一个placement delete[]。竞技场初始化时有一个空的Dtor链表作为新成员:
|
|
有两种不同的方式来构造竞技场:覆盖一块原始内存(非拥有),或者从现有竞技场借用其空闲空间来创建一个暂存竞技场。所以有两个构造函数:
|
|
最后是一个析构函数,当竞技场被销毁时,它弹出Dtor链表直到为空,按相反顺序运行析构函数:
|
|
(注意:这或许应该使用局部变量而不是直接操作dtors成员。对dtors的更新可能对析构函数可见,这会抑制优化。)
增强的分配器
新的、增强的alloc建立在raw_alloc之上:
|
|
我使用了所有主要C++实现都支持的非标准内置特性__has_trivial_destructor,这意味着我们仍然不需要C++标准库,但std::is_trivially_destructible通常是这里的工具。LLVM正在推广使用__is_trivially_destructible,但直到GCC 16才被GCC支持。
既然做起来很简单,如果计数为零,那么它就不关心非平凡析构,因为没有东西需要销毁。对于非零数量的非平凡可析构对象,事情变得更有趣。首先分配一个Dtor,这很重要,因为如果第二次分配失败会导致泄漏(没有放置Dtor条目)。然后分配数组,将其附加到Dtor,将Dtor附加到竞技场,注册这些对象以便清理。
如果构造函数抛出,placement new[]将在返回前自动销毁已经创建的对象——即真正的placement delete[]——所以这种情况一开始就已经覆盖了。
更进一步
通过再多一些技巧,我们可以省略objects指针,并通过Dtor对象本身的指针运算来发现数组。这很棘手(考虑对齐),而且通常没有必要,所以我没有为此费心。对于竞技场,分配器的开销已经远低于传统分配,因此余量充足。很可能我们永远也不需要非平凡可析构对象的数组,因此我们或许可以省略count,然后编写一个转发构造函数参数的单对象分配器(例如,一个要管理的资源的句柄)。这并不涉及新概念,我将其留给读者作为练习。
有了这些,我们现在可以分配一个TcpSocket数组:
|
|
当example通过scratch上的单一Dtor条目退出时,这些套接字都将被关闭。当用竞技场调用这个example时:
|
|
这会调用拷贝构造函数,创建一个带有空dtors列表的暂存竞技场传入example。存在于*perm中的对象不会被example销毁,因为dtors没有被传入。如果我们传递了一个指向竞技场的指针,那么Arena构造函数不会被调用,因此被调用方使用调用方的竞技场,将其Dtors推入被调用方的列表。
换句话说,接口没有改变!这对我来说是最令人兴奋的部分。这种按值拷贝、按指针传递的接口在过去两年里真的让我越来越喜欢。