零开销的 C++ 作用域保护

C++ scope guard with zero overhead

在 C++ 中,我们可以通过将 foo() 放在局部对象的析构函数中来确保在退出作用域时调用 foo。当我领导“范围守卫”时,我就是这么想的。有很多通用实现。

我想知道——只是为了好玩——与只在每个退出点写入 foo() 相比,是否有可能以零开销实现作用域守卫的行为。

零开销,我认为:

{
  try {
    do_something();
  } catch (...) {
    foo();
    throw;
  }
  foo();
}

至少 1 个字节的开销来给作用域守卫一个地址:

{
  scope_guard<foo> sg;
  do_something();
}

编译器会优化给 sg 地址吗?

稍微复杂一点的案例:

{
  Bar bar;
  try {
    do_something();
  } catch (...) {
    foo(bar);
    throw;
  }
  foo(bar);
}

对比

{
  Bar bar;
  scope_guard<[&]{foo(bar);}> sg;
  do_something();
}

bar 的生命周期完全包含 sg 的生命周期和它持有的 lambda(析构函数以相反的顺序调用)但是 sg 持有的 lambda 仍然必须持有一个参考 bar。我的意思是,例如 int x; auto l = [&]{return x;}; 在我的 64 位系统上给出 sizeof(l) == 8

是否有一些模板元编程魔术可以在没有任何开销的情况下实现 scope_guard 糖?

这里的 'zero overhead' 不是很清楚你的意思。

Do compilers optimize away giving sg an address?

现代主流编译器很可能会在 运行 处于优化模式时执行此操作。不幸的是,这是尽可能确定的。它取决于环境,必须经过测试才能依赖。

如果问题是if there is a guaranteed way to avoid <anything> in the resulting assembly,答案是否定的。正如@Peter 在评论中所说,编译器可以做任何事情来产生等效的结果。它可能根本不会调用 foo()即使您逐字将其写在那里 - 当它可以证明观察到的程序行为中的任何内容都不会改变时。

如果您所说的开销是指作用域保护变量占用了多少 space,那么如果功能对象是编译时值,则零开销是可能的。我编写了一小段代码来说明这一点:

Try it online!

#include <iostream>

template <auto F>
class ScopeGuard {
public:
    ~ScopeGuard() { F(); }
};

void Cleanup() {
    std::cout << "Cleanup func..." << std::endl;
}

int main() {
    {
        char a = 0;
        ScopeGuard<&Cleanup> sg;
        char b = 0;
        std::cout << "Stack difference "
            << int(&a - &b - sizeof(char)) << std::endl;
    }
    {
        auto constexpr f = []{
            std::cout << "Cleanup lambda..." << std::endl; };
        
        char a = 0;
        ScopeGuard<f> sg;
        char b = 0;
        std::cout << "Stack difference "
            << int(&a - &b - sizeof(char)) << std::endl;
    }
}

输出:


Stack difference 0
Cleanup func...
Stack difference 0
Cleanup lambda...

上面的代码甚至不会在堆栈上创建一个字节,因为任何 class 没有字段的变量都会占用堆栈 0 字节,这是任何编译器所做的明显优化之一。当然,除非您使用指向此类对象的指针,否则编译器必须创建 1 字节的内存对象。但在你的情况下,你不会将地址发送给 scoped guard。

上面的代码Try it online! link可以看到没有一个字节被占用,它显示了CLang的汇编输出。

完全没有字段作用域保护 class 应该只使用编译时函数对象,比如没有捕获的 lambda 的全局函数指针。这两种对象在我上面的代码中都用到了。

在上面的代码中你甚至可以看到我输出了 scoped guard 变量前后 char 变量的堆栈差异,以表明 scoped guard 实际上占用了 0 个字节。


让我们更进一步,使函数对象的非编译时值成为可能。

为此,我们再次创建没有字段的 class,但现在将所有功能对象存储在一个具有线程本地存储的共享向量中。

同样,由于我们在 class 中没有字段,并且不接受任何指向作用域保护对象的指针,因此编译器不会在堆栈上为作用域保护对象创建一个字节。

而是在堆中分配单个共享向量。这样,如果堆栈内存不足,您可以将堆栈存储换成堆存储。

同样拥有共享向量将允许我们使用尽可能少的内存,因为向量只使用尽可能多的内存,因为嵌套块使用作用域守卫。如果所有作用域守卫顺序位于不同的块中,那么 vector 内部将只有 1 个元素,因此使用的所有作用域守卫仅使用几个字节的内存。

为什么共享向量的堆内存在内存方面比作用域守卫的堆栈存储内存更经济。因为在堆栈内存的情况下,如果你有几个连续的守卫块:

void test() {
    {
        ScopeGuard sg(f0);
    }
    {
        ScopeGuard sg(f1);
    }
    {
        ScopeGuard sg(f2);
    }
}

然后所有 3 个守卫占用堆栈上三倍的内存,因为对于像上面 test() 这样的每个函数,编译器为函数变量中使用的所有函数分配堆栈内存,所以对于 3 个守卫,它分配三倍的内存。

在共享向量的情况下 test() 上述函数将仅使用 1 个向量的元素,因此向量的大小最多为 1,因此将仅使用单个内存量来存储函数对象。

因此,如果您在一个函数中有许多非嵌套作用域守卫,那么共享向量会更加经济。

下面我将展示零字段和零堆栈内存开销的共享向量方法的代码片段。提醒一下,这种方法允许使用非编译时功能对象,这与我的答案的第一部分中的解决方案不同。

Try it online!

#include <iostream>
#include <vector>
#include <functional>

class ScopeGuard2 {
public:
    static auto & Funcs() {
        thread_local std::vector<std::function<void()>> funcs_;
        return funcs_;
    }
    ScopeGuard2(std::function<void()> f) {
        Funcs().emplace_back(std::move(f));
    }
    ~ScopeGuard2() {
        Funcs().at(Funcs().size() - 1)();
        Funcs().pop_back();
    }
};

void Cleanup() {
    std::cout << "Cleanup func..." << std::endl;
}

int main() {
    {
        ScopeGuard2 sg(&Cleanup);
    }
    {
        auto volatile x = 123;
        auto const f = [&]{
            std::cout << "Cleanup lambda... x = "
                << x << std::endl;
        };

        ScopeGuard2 sg(f);
    }
}

输出:

Cleanup func...
Cleanup lambda... x = 123