C++ weak_ptr 的创建性能问题

5

我看过一些资料,创建或复制 std::shared_ptr 会涉及一些开销(比如原子引用计数增加等)。

那么如果从中创建 std::weak_ptr 呢?

Obj * obj = new Obj();
// fast
Obj * o = obj;
// slow
std::shared_ptr<Obj> a(o);
// slow
std::shared_ptr<Obj> b(a);
// slow ?
std::weak_ptr<Obj> c(b);

我希望能有更快的性能,但是我知道共享指针仍然需要增加弱引用计数器。那么这个速度是否和将shared_ptr复制到另一个中一样慢呢?


1
做一百万次并测量每个 :) - Drax
@Drax 不要鼓励那样做,现在的优化器(我很清楚GCC的内部工作 - 所以肯定不会这样做)实际上不会执行那么多次。模板的好处在于编译器手头有定义(不仅仅是声明)。请看我的回答。 - Alec Teal
这里并没有语言版本特定的要求;也不需要使用boost指针,普通的智能指针就可以了。这是一个引用计数问题。 - curiousguy
2个回答

16

这是关于游戏引擎的经历:

需要一个快速的共享指针实现,不会使高速缓存失效(顺便说一下,现在的高速缓存更加智能了)。

一个普通指针:

XXXXXXXXXXXX....
^--pointer to data

我们的共享指针:

iiiiXXXXXXXXXXXXXXXXX...
^   ^---pointer stored in shared pointer
|
+---the start of the allocation, the allocation is sizeof(unsigned int)+sizeof(T)

((unsigned int*)ptr)-1指向计数的unsigned int*

这样,“共享指针”大小为指针大小,它包含的数据是实际数据的指针。因此(因为template=>inline并且任何编译器都会内联返回数据成员的运算符),访问开销与普通指针相同。

创建指针比正常情况多花费了约3个CPU指令(访问位置-4是一项操作,加1和写入位置-4)。

现在我们只有在调试时才使用弱引用(因此我们将定义DEBUG(宏定义)进行编译),因为然后我们想要看到所有分配和发生的情况等。它很有用。

弱指针必须知道它们指向的内容何时消失,而不能保持其指向的内容处于活动状态(在我的情况下,如果弱指针保持分配活动,则引擎永远无法回收或释放任何内存,那么它基本上仍然是一个共享指针)。

因此,每个弱指针都有一个布尔值alive或类似的,并且是shared_pointer的朋友。

调试时,我们的分配看起来像这样:

vvvvvvvviiiiXXXXXXXXXXXXX.....
^       ^   ^ the pointer we stored (to the data)
|       +that pointer -4 bytes = ref counter
+Initial allocation now 
    sizeof(linked_list<weak_pointer<T>*>)+sizeof(unsigned int)+sizeof(T)

你使用的链表结构取决于你关心什么,我们希望尽可能接近sizeof(T)(我们使用伙伴算法管理内存),因此我们存储了一个指向weak_pointer的指针并使用异或技巧...好时光。

无论如何:指向shared_pointers所指向的弱引用放在一个列表中,在上面的"v"中以某种方式存储。

当引用计数变为零时,您会遍历该列表(该列表是指向实际weak_pointers的指针列表,它们在删除时会自动移除),并将alive=false(或其他)设置为每个weak_pointer。

现在,weak_pointers知道它们指向的内容已不存在(因此在取消引用时会抛出异常)

在这个例子中

没有额外开销(系统对齐为4字节。64位系统倾向于喜欢8字节对齐...在这种情况下,将ref-counter与int[2]联合起来进行填充。请记住,这涉及到原地new(别因为我提到它们而投票反对:P)等操作。您需要确保施加在分配上的struct与您分配和创建的内容相匹配。编译器可以自行对齐(因此是int[2]而不是int, int)。

你可以完全无开销地取消引用shared_pointer。
制作新的shared pointer不会在缓存中产生任何影响,它们只需要3个CPU指令,虽然不太适合流水线处理,但编译器将始终内联getter和setter(如果不是总是:P),并且在调用站点周围会有一些东西来填充流水线。
shared pointer的析构函数也很简单(只有减量),因此非常好!
高性能注释
如果您遇到以下情况:
f() {
   shared_pointer<T> ptr;
   g(ptr);
}

不能保证优化器不把通过按"值"传递shared_pointer的加减操作放到函数g中。

这就是你需要使用普通引用(实现为指针)的地方。

所以你应该做g(ptr.extract_reference()); - 再次编译器将内联简单的getter。

现在你有了一个T&,因为ptr的范围完全包围g(假设g没有副作用等),那个引用将在g的持续时间内有效。

删除引用非常丑陋,而且你可能不会无意中这样做(我们依赖于这一事实)。

回顾

我应该创建一个名为“extracted_pointer”之类的类型,这样很难错误地键入一个类成员。

stdlib++使用的弱/共享指针

http://gcc.gnu.org/onlinedocs/libstdc++/manual/shared_ptr.html

速度不是很快...

但是,除非你正在制作一个无法轻松运行负载> 120fps的游戏引擎,否则不要担心奇怪的缓存未命中:P仍然比Java好得多。

stdlib的方法更好。每个对象都有自己的分配和工作。对于我们的shared_pointer来说,这是一个真正的案例,“相信我它有效,尽量不用担心如何”(虽然并不难),因为代码看起来非常混乱。

如果您撤消了...他们在实现中对变量名称所做的任何更改,那么阅读起来将更加容易。请参见Boost的实现,正如该文档中所述。

除了变量名之外,GCC stdlib的实现非常出色。您可以轻松阅读它,它会按照OO原则正确地执行其工作,但速度略慢,并且可能会在这些日子里搞砸垃圾芯片的缓存。

超高性能说明

您可能会想,为什么不使用XXXX ... XXXXiiii(最后的引用计数)然后您将获得最适合分配器的对齐方式!

答案:

因为执行pointer+sizeof(T)可能不止一条CPU指令!(减去4或8是CPU易于执行的操作,因为这样做很有意义,CPU会经常这样做)

非常好的帖子,不过我正在寻找我正在使用的STL版本。如果我需要速度,也许我会实现类似的东西! - Luke Givens
@LukeGivens 你没看到那个链接吗?我还把它作为注释放在那儿了。一个东西的工作原理并不是标准定义的,但它必须具备一些属性和名称。 - Alec Teal
顺便说一句,标准库的编写者在变量名前面加上双下划线是为了避免宏冲突。曾经有一个提案试图消除这种做法(http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2012/n3400.html)。 - TemplateRex
@TemplateRex,为什么它没有被接受!?你知道当来自与STL有关的任何警告/错误时,它有多可怕吗?(是的,你知道,这是修辞!)谢谢你告诉我,这解释了很多问题。 - Alec Teal

13

除了Alec之前项目中使用的共享/弱指针系统,我想进一步解释一下一个典型的std::shared_ptr/weak_ptr实现可能会发生的事情:

// slow
std::shared_ptr<Obj> a(o);
在上述构造中的主要开销是分配一块内存来保存两个引用计数。这里不需要执行原子操作(除了实现在operator new下可能或可能不会执行的操作)。
// slow
std::shared_ptr<Obj> b(a);

在复制构造函数中的主要开销通常是单个原子递增。

// slow ?
std::weak_ptr<Obj> c(b);

在这个weak_ptr构造函数中的主要开销通常是一个原子递增操作。我预计这个构造函数的性能与shared_ptr复制构造函数几乎相同。

另外还有两个重要的构造函数需要注意:

std::shared_ptr<Obj> d(std::move(a));  // shared_ptr(shared_ptr&&);
std::weak_ptr<Obj> e(std::move( c ));  // weak_ptr(weak_ptr&&);

(并且也匹配移动赋值运算符)

移动构造函数根本不需要任何原子操作。它们只是将 rhs 的引用计数复制到 lhs,并使 rhs == nullptr。

移动赋值运算符仅在赋值之前 lhs != nullptr 时需要原子递减。大多数情况下(例如在 vector<shared_ptr<T>> 中),在移动赋值之前 lhs == nullptr,因此根本没有原子操作。

后者(weak_ptr 移动成员)实际上并不属于 C++11,但已由 LWG 2315 处理。然而,我希望大多数实现已经实现了它(我知道在 libc++ 中已经实现了)。

当在容器中调整智能指针的位置时,例如在 vector<shared_ptr<T>>::insert/erase 下,将使用这些移动成员,并与使用智能指针复制成员相比具有可衡量的积极影响。

我指出这一点,以便您知道如果有机会移动而不是复制 shared_ptr/weak_ptr,值得费点力气去打几个额外的字符。


再次查看http://gcc.gnu.org/onlinedocs/libstdc++/manual/shared_ptr.html,你会发现它比我概述的高性能版本更加复杂。这就是为什么我进行了概述。虽然你可能认为它会像我所描述的那样工作,但实际上它更具有通用性和面向对象的特点! - Alec Teal
@AlecTeal:是的,我认为std::shared_ptr是引用计数指针中的“谢尔曼坦克”。它非常坚固,具有许多功能。虽然油耗不如其他设计,但经过了十多年的Boost火热考验,并在C++11标准化过程中接受了国际审查。委员会已经考虑并拒绝了一些其他方法,例如非线程安全变体。您提供的gcc链接中大部分记录的功能都是实现细节,而不是标准规定的。 - Howard Hinnant

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