C++中最快的`finally`实现方式

6

目前C++(不幸的是)并不支持finally子句用于try语句。这导致了如何释放资源的猜测。在互联网上研究了这个问题之后,虽然我找到了一些解决方案,但我对它们的性能不是很清楚(如果性能不那么重要,我会使用Java)。因此,我必须进行基准测试。

可选方案为:

  1. CodeProject提出的基于函数对象的finally类。它很强大,但速度慢。反汇编表明,外部函数局部变量被捕获效率非常低:一个接一个地推到堆栈上,而不是只传递帧指针到内部(lambda)函数。

  2. RAII:手动清理对象在堆栈上:缺点是手动输入并为每个使用的地方进行调整。另一个缺点是需要将所有需要释放资源的变量复制到它中。

  3. 仅限于MSVC++的__try/__finally语句。缺点是它显然不具备可移植性。

我创建了这个小基准测试来比较这些方法的运行时性能:

#include <chrono>
#include <functional>
#include <cstdio>

class Finally1 {
  std::function<void(void)> _functor;
public:
  Finally1(const std::function<void(void)> &functor) : _functor(functor) {}
  ~Finally1() {
    _functor();
  }
};

void BenchmarkFunctor() {
  volatile int64_t var = 0;
  const int64_t nIterations = 234567890;
  auto start = std::chrono::high_resolution_clock::now();
  for (int64_t i = 0; i < nIterations; i++) {
    Finally1 doFinally([&] {
      var++;
    });
  }
  auto elapsed = std::chrono::high_resolution_clock::now() - start;
  double nSec = 1e-6 * std::chrono::duration_cast<std::chrono::microseconds>(elapsed).count();
  printf("Functor: %.3lf Ops/sec, var=%lld\n", nIterations / nSec, (long long)var);
}

void BenchmarkObject() {
  volatile int64_t var = 0;
  const int64_t nIterations = 234567890;
  auto start = std::chrono::high_resolution_clock::now();
  for (int64_t i = 0; i < nIterations; i++) {
      class Cleaner {
        volatile int64_t* _pVar;
      public:
        Cleaner(volatile int64_t& var) : _pVar(&var) { }
        ~Cleaner() { (*_pVar)++; }
      } c(var);
  }
  auto elapsed = std::chrono::high_resolution_clock::now() - start;
  double nSec = 1e-6 * std::chrono::duration_cast<std::chrono::microseconds>(elapsed).count();
  printf("Object: %.3lf Ops/sec, var=%lld\n", nIterations / nSec, (long long)var);
}

void BenchmarkMSVCpp() {
  volatile int64_t var = 0;
  const int64_t nIterations = 234567890;
  auto start = std::chrono::high_resolution_clock::now();
  for (int64_t i = 0; i < nIterations; i++) {
    __try {
    }
    __finally {
      var++;
    }
  }
  auto elapsed = std::chrono::high_resolution_clock::now() - start;
  double nSec = 1e-6 * std::chrono::duration_cast<std::chrono::microseconds>(elapsed).count();
  printf("__finally: %.3lf Ops/sec, var=%lld\n", nIterations / nSec, (long long)var);
}

template <typename Func> class Finally4 {
  Func f;
public:
  Finally4(Func&& func) : f(std::forward<Func>(func)) {}
  ~Finally4() { f(); }
};

template <typename F> Finally4<F> MakeFinally4(F&& f) {
  return Finally4<F>(std::forward<F>(f));
}

void BenchmarkTemplate() {
  volatile int64_t var = 0;
  const int64_t nIterations = 234567890;
  auto start = std::chrono::high_resolution_clock::now();
  for (int64_t i = 0; i < nIterations; i++) {
    auto doFinally = MakeFinally4([&] { var++; });
    //Finally4 doFinally{ [&] { var++; } };
  }
  auto elapsed = std::chrono::high_resolution_clock::now() - start;
  double nSec = 1e-6 * std::chrono::duration_cast<std::chrono::microseconds>(elapsed).count();
  printf("Template: %.3lf Ops/sec, var=%lld\n", nIterations / nSec, (long long)var);
}

void BenchmarkEmpty() {
  volatile int64_t var = 0;
  const int64_t nIterations = 234567890;
  auto start = std::chrono::high_resolution_clock::now();
  for (int64_t i = 0; i < nIterations; i++) {
    var++;
  }
  auto elapsed = std::chrono::high_resolution_clock::now() - start;
  double nSec = 1e-6 * std::chrono::duration_cast<std::chrono::microseconds>(elapsed).count();
  printf("Empty: %.3lf Ops/sec, var=%lld\n", nIterations / nSec, (long long)var);
}

int __cdecl main() {
  BenchmarkFunctor();
  BenchmarkObject();
  BenchmarkMSVCpp();
  BenchmarkTemplate();
  BenchmarkEmpty();
  return 0;
}

我的 Ryzen 1800X @3.9Ghz 配置 DDR4 @2.6Ghz CL13 的结果如下:
Functor: 175148825.946 Ops/sec, var=234567890
Object: 553446751.181 Ops/sec, var=234567890
__finally: 553832236.221 Ops/sec, var=234567890
Template: 554964345.876 Ops/sec, var=234567890
Empty: 554468478.903 Ops/sec, var=234567890

显然,除了函数对象基类(functor-base #1)以外的所有选项都像空循环一样快。

那么,有没有一种快速而强大的C ++替代方法来执行finally,在保证便携性和最小从外部函数栈复制的同时呢?

更新:我对@Jarod42的解决方案进行了基准测试,因此在问题中更新了代码和输出。尽管如@Sopel所提到的,如果未执行复制省略可能会出现问题。

更新2:为了澄清我的问题是要在C++中以方便快捷的方式执行一块代码,即使抛出异常也要执行。出于问题中提到的原因,某些方法是慢或不方便的。


20
当然,RAII。使用自清理的类型,无论退出作用域的方式如何,资源都会被清理。 - NathanOliver
1
这只是纯粹的猜测,但 C++ 没有 finally 子句的一个原因 可能 是在 C++ 中抛出异常很昂贵,因此应该仅在真正异常的情况下使用。当然,这导致 try-catch 块不常见,大多用于进行一些错误报告,然后重新抛出异常以终止应用程序。这意味着 finally 子句实际上没有用处。这与其他语言不同,其他语言中异常是正常的错误处理函数。 - Some programmer dude
1
等等,我不明白。如果你的基准测试显示“除了函数对象基类之外的所有操作都与空循环一样快”,那么这就证明了你的假设是错误的,即 RAII 由于某种“复制开销”而变慢,这意味着这里没有问题。像其他 C++ 程序员一样使用 RAII 即可,不需要寻找“finally”的替代方案。 - Cody Gray
你可以在Andrei Alexandrescu (cppcon 2015)的这个视频中找到一些信息。它解释了如何创建回调函数,当你超出作用域时、当抛出异常时、当没有抛出异常时,这些回调函数会被调用。 - nefas
1
@Someprogrammerdude:并非所有的finally都真正处理资源,有些可能会恢复状态,为每种情况创建RAII类只会重复一种可以通过finally因式分解的模式。 - Jarod42
显示剩余6条评论
2个回答

12

您可以在不进行类型抹除和使用 std::function 的开销的情况下实现 Finally

template <typename F>
class Finally {
    F f;
public:
    template <typename Func>
    Finally(Func&& func) : f(std::forward<Func>(func)) {}
    ~Finally() { f(); }

    Finally(const Finally&) = delete;
    Finally(Finally&&) = delete;
    Finally& operator =(const Finally&) = delete;
    Finally& operator =(Finally&&) = delete;
};

template <typename F>
Finally<F> make_finally(F&& f)
{
    return { std::forward<F>(f) };
}

并像这样使用:

auto&& doFinally = make_finally([&] { var++; });

演示


请确保用 try/catch 包装析构函数,因为 f 可能会抛出异常,或者使用 SFINAE 魔法来根据 noexcept 解释符选择析构函数重载。 - David Haim
@DavidHaim:实际上它更加复杂,请参见finally-scopeexit以及其变体ScopeFailedScopeSuccess - Jarod42
1
或者 C++17 风格的 Finally do_finally { [&]{++var;} } - MSalters
在MSVC++2017中,auto doFinally = make_finally([&] { var++; });Finally do_finally { [&]{++var;} }都无法编译通过。你是不是想让类Finally中的FFunc是相同的? - Serge Rogatch
2
如果没有执行复制省略(pre c++17),这个会出问题吗? - Sopel
显示剩余9条评论

0

嗯,你的基准测试有问题:它实际上没有抛出异常,所以你只能看到非异常路径。这很糟糕,因为优化器可以证明你不会抛出异常,所以它可以丢弃所有实际处理在飞行中发生异常时执行清理的代码。

我认为,你应该重复你的基准测试,在你的try{}块中调用exceptionThrower()nonthrowingThrower()。这两个函数应该编译为单独的翻译单元,并与基准代码一起链接。这将迫使编译器实际生成异常处理代码,无论你是否调用exceptionThrower()nonthrowingThrower()。(确保你不要打开链接时间优化,那可能会破坏效果。)

这也将允许你轻松比较异常和非抛出执行路径之间的性能影响。


除了基准问题外,C++中的异常很慢。你永远不会在一秒钟内抛出数亿个异常。最多也只有几百万个,可能更少。我预计,在抛出异常的情况下,不同finally实现之间的任何性能差异都是完全无关紧要的。您可以优化的是非抛出路径,在那里您的成本仅仅是构造/销毁您的finally实现对象,无论它是什么。

优化器在处理 finally 时非常重要:如果它决定不复制变量并内联释放函数-那对性能来说非常好。当然,非异常情况对性能的影响比异常情况更重要。我的基准测试可能不是很好,但也不完全糟糕:因为变量是 volatile 的,编译器无法丢弃其增量,在释放函数中我会用到这个增量。 - Serge Rogatch
@SergeRogatch 啊,我没有看到那个“volatile”。这确实很好地解决了最终体的问题。然而,异常生成的问题仍然存在:编译已知不会抛出异常的代码和编译未知是否会抛出异常的代码之间存在差异。我现在会编辑我的答案以正确反映“volatile”。 - cmaster - reinstate monica
@SergeRogatch 我已经更新了答案。 - cmaster - reinstate monica

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接