C++竞技场分配器的非平凡析构函数探索

本文探讨了在C++中结合使用竞技场分配器与非平凡析构函数对象的技术方案。作者提出了一种修改思路,通过链表追踪需要析构的对象,使竞技场能够安全容纳文件句柄、套接字等资源管理对象。这种改动保持了原有的简洁接口,几乎不影响普通对象。

对竞技场和非平凡析构函数的思考

Speculations on arenas and non-trivial destructors

2025年10月16日

在持续反思C++中的竞技场和生存期管理时,我意识到处理析构函数并非那么繁琐。事实上,它甚至不影响我既定的竞技场使用模式!即在作用域结束时隐式地进行RAII风格的释放,这即使在普通的旧式C中也能工作。通过一个小改动,我们可以安全地将资源管理对象(如拥有文件句柄、套接字、线程等的对象)放入竞技场。(不过理想情况仍然是尽可能避免资源管理。)我们也可以将传统的、管理内存的C++对象放入竞技场。它们自身的分配不会来自竞技场——要么是因为它们缺少相应的接口,要么是因为它们这样做效率不高(pmr)——但它们在完成后会可靠地进行自我清理。这一切也都是异常安全的。在本文中,我将用这个新功能更新我的竞技场分配器。这个改动需要一个额外的竞技场指针成员,为非平凡析构函数的对象带来一点开销,而对其他对象没有影响。

我继续将其标题定为"思考",因为与C中的竞技场不同,我(还?)没有在实际软件中实践这些C++技术。我还没有通过使用来完善它们。即使像这里一样忽略其标准库,C++也是一个极其复杂的编程语言——远比C复杂得多——因此我对我没有无意中违反规则这一点不那么有信心。我只想有意识地打破规则!

回顾

提醒一下,我们上次停止的地方:

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

template<typename T>
T *raw_alloc(Arena *a, ptrdiff_t count = 1)
{
    ptrdiff_t size = sizeof(T);
    ptrdiff_t pad  = -(uintptr_t)a->beg & (alignof(T) - 1);
    if (count >= (a->end - a->beg - pad)/size) {
        throw std::bad_alloc{};  // OOM policy
    }
    void *r = a->beg + pad;
    a->beg += pad + count*size;
    return new(r) T[count]{};
}

我在内存不足时使用throw主要是为了强调这可以工作,但你可以为你的程序自由选择任何合适的方式。记住,这就是整个分配器,包括隐式释放,足以满足大多数程序的分配需求,不过程序需要为此设计。另外请注意,它现在是raw_alloc,因为我们将在此基础上编写一个新的、增强的alloc

使用示例

同样回顾一下用法,我将引用一个更新到C++的旧示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
wchar_t   *towidechar(Str, Arena *);   // convert to UTF-16
Str        slurpfile(wchar_t *path);   // read an entire file
Slice<Str> split(Str, char, Arena *);  // split on delimiter

Slice<Str> getlines(Str path, Arena *perm, Arena scratch)
{
    // Use scratch for path conversion, auto-free on return
    wchar_t *wpath = towidechar(path, &scratch);

    // Use perm for file contents, which are returned
    Str buf = slurpfile(wpath, perm);

    // Use perm for the slice, pointing into buf
    return split(buf, '\n', perm);
}

scratch的更改在getlines返回后不会保留,因此从该竞技场分配的对象在返回时会自动释放。到目前为止,这并不依赖于C++ RAII特性,只是简单的值语义。它工作得很好,因为所有相关对象都具有平凡的析构函数。但是,假设有一个资源需要管理:

1
2
3
4
5
6
7
8
9
struct TcpSocket {
    int socket = ::socket(AF_INET, SOCK_STREAM, 0);
    TcpSocket() = default;
    TcpSocket(TcpSocket &) = delete;
    void operator=(TcpSocket &) = delete;
    // TODO: move ctor/operator
    ~TcpSocket() { if (socket >= 0) close(socket); }
    operator int() { return socket; }
};

如果我们在竞技场中分配一个TcpSocket,包括作为另一个对象的成员,析构函数永远不会运行,除非我们手动调用它。为了解决这个问题,我们需要跟踪需要析构的对象,我们将使用析构函数的链表来完成,形成一个LIFO栈:

1
2
3
4
5
6
struct Dtor {
    Dtor     *next;
    void     *objects;
    ptrdiff_t count;
    void     (*dtor)(void *objects, ptrdiff_t count);
};

每个Dtor指向一个同质数组、一个计数(通常为一)以及一个知道如何销毁这些对象的函数指针。链表本身是异质的,具有动态类型。函数指针有点像一种类型标签。dtor函数将使用模板函数生成:

1
2
3
4
5
6
7
8
template<class T>
void destroy(void *ptr, ptrdiff_t count)
{
    T *objects = (T *)ptr;
    for (ptrdiff_t i = count-1; i >= 0; i--) {
        objects[i].~T();
    }
}

注意它以从尾到头的顺序销毁,与placement new[]实例化这些对象的顺序相反。它本质上是一个placement delete[]。竞技场初始化时有一个空的Dtor链表作为新成员:

1
2
3
4
5
6
7
8
struct Arena {
    char *beg;
    char *end;
    Dtor *dtors = 0;

    // ...

};

有两种不同的方式来构造竞技场:覆盖一块原始内存(非拥有),或者从现有竞技场借用其空闲空间来创建一个暂存竞技场。所以有两个构造函数:

1
2
3
4
5
6
7
8
struct Arena {
    // ...

    Arena(char *mem, ptrdiff_t len) : beg{mem}, end{mem+len} {}
    Arena(Arena &a) : beg{a.beg}, end{a.end} {}

    // ...
};

最后是一个析构函数,当竞技场被销毁时,它弹出Dtor链表直到为空,按相反顺序运行析构函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
struct Arena {
    // ...

    void operator=(Arena &) = delete;  // rule of three

    ~Arena()
    {
        while (dtors) {
            Dtor *dead = dtors;
            dtors = dead->next;
            dead->dtor(dead->objects, dead->count);
        }
    }
};

(注意:这或许应该使用局部变量而不是直接操作dtors成员。对dtors的更新可能对析构函数可见,这会抑制优化。)

增强的分配器

新的、增强的alloc建立在raw_alloc之上:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
template<typename T>
T *alloc(Arena *a, ptrdiff_t count = 1)
{
    if (__has_trivial_destructor(T) || !count) {
        return raw_alloc<T>(a, count);
    }

    Dtor *dtor    = raw_alloc<Dtor>(a);  // allocate first
    T    *r       = raw_alloc<T>(a, count);
    dtor->next    = a->dtors;
    dtor->objects = r;
    dtor->count   = count;
    dtor->dtor    = destroy<T>;

    a->dtors = dtor;
    return r;
}

我使用了所有主要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数组:

1
2
3
4
5
void example(Arena scratch)
{
    TcpSocket *sockets = alloc<TcpSocket>(&scratch, 100);
    // ...
}

example通过scratch上的单一Dtor条目退出时,这些套接字都将被关闭。当用竞技场调用这个example时:

1
2
3
4
5
void caller(Arena *perm)
{
    example(*perm);  // creates a scratch arena
    // ...
}

这会调用拷贝构造函数,创建一个带有空dtors列表的暂存竞技场传入example。存在于*perm中的对象不会被example销毁,因为dtors没有被传入。如果我们传递了一个指向竞技场的指针,那么Arena构造函数不会被调用,因此被调用方使用调用方的竞技场,将其Dtors推入被调用方的列表。

换句话说,接口没有改变!这对我来说是最令人兴奋的部分。这种按值拷贝、按指针传递的接口在过去两年里真的让我越来越喜欢。

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