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 复制到另一个一样慢吗?

4

2 回答 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*用于计数的为((unsigned int*)ptr)-1

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

指针的创建比正常需要多 3 条 CPU 指令(访问位置 4 正在进行操作,添加 1 和写入位置 -4)

现在我们只在调试时使用弱指针(因此我们将使用定义的 DEBUG(宏定义)进行编译),因为那时我们希望查看所有分配以及发生的事情等等。这很有用。

弱指针必须知道他们指向的东西什么时候消失了,而不是让他们指向的东西保持活动状态(在我的情况下,如果弱指针保持分配活动,引擎将永远不会回收或释放任何内存,那么它基本上就是无论如何都是共享指针)

所以每个弱指针都有一个 boolalive或其他东西,并且是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 的指针并使用了 xor 技巧。 ... 美好时光。

无论如何:指向 shared_pointers 指向的东西的弱指针被放在一个列表中,以某种方式存储在上面的“v”中。

当引用计数达到零时,您将遍历该列表(这是一个指向实际弱指针的指针列表,它们在明显删除时会自行删除)并为每个弱指针设置alive = false(或其他内容)。

weak_pointers 现在知道他们指向的东西不再存在(所以在取消引用时抛出)

在这个例子中

没有开销(与系统对齐是 4 个字节。64 位系统倾向于喜欢 8 个字节对齐......在这种情况下,将 ref-counter 与 int[2] 联合起来以填充它。记住这一点涉及就地新闻(没有人反对,因为我提到了它们:P)等等。您需要确保struct您对分配施加的内容与您分配和制作的内容相匹配。编译器可以为自己对齐内容(因此 int[2] 不是 int,int )。

您可以毫无开销地取消引用 shared_pointer。

生成的新共享指针根本不会破坏缓存,并且需要 3 个 CPU 指令,它们不是很...可以使用管道,但编译器将始终内联 getter 和 setter(如果可能不总是:P)并且那里'将是呼叫站点周围可以填充管道的东西。

共享指针的析构函数也很少做(递减,就是这样),所以很棒!

高性能说明

如果你有这样的情况:

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 标准库的实现很可爱。您可以轻松阅读它,它可以正常工作(遵循 OO 原则),但速度有点慢,并且这些天可能会在蹩脚的芯片上破坏缓存。

UBER 高性能说明

您可能在想,为什么不拥有XXXX...XXXXiiii(最后的引用计数),那么您将获得分配器最佳的对齐方式!

回答:

因为不得不做的pointer+sizeof(T)可能不是一条 CPU 指令!(减去 4 或 8 是 CPU 可以轻松完成的事情,因为这很有意义,它会经常这样做)

于 2013-11-29T17:19:08.470 回答
12

除了Alec 对他以前项目中使用的 shared/weak_ptr 系统的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);

this 构造函数的主要开销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 时,移动赋值运算符才需要原子递减。大部分时间(例如在 a 内vector<shared_ptr<T>>) lhs == nullptr 在移动分配之前,因此根本没有原子操作。

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

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

我指出这一点是为了让您知道,如果您有机会移动而不是复制 a shared_ptr/weak_ptr,那么输入几个额外的字符是值得的。

于 2013-11-29T19:00:46.770 回答