make_shared是否真的比new更高效?

57

我正在尝试使用C++11的shared_ptrmake_shared,并编写了一个小的玩具示例来查看调用make_shared时实际发生了什么。作为基础设施,我正在使用带有XCode4的llvm/clang 3.0以及llvm标准C++库。

class Object
{
public:
    Object(const string& str)
    {
        cout << "Constructor " << str << endl;
    }

    Object()
    {
        cout << "Default constructor" << endl;

    }

    ~Object()
    {
        cout << "Destructor" << endl;
    }

    Object(const Object& rhs)
    {
        cout << "Copy constructor..." << endl;
    }
};

void make_shared_example()
{
    cout << "Create smart_ptr using make_shared..." << endl;
    auto ptr_res1 = make_shared<Object>("make_shared");
    cout << "Create smart_ptr using make_shared: done." << endl;

    cout << "Create smart_ptr using new..." << endl;
    shared_ptr<Object> ptr_res2(new Object("new"));
    cout << "Create smart_ptr using new: done." << endl;
}
请看下面的输出结果:

使用make_shared创建智能指针...

构造函数make_shared

复制构造函数...

复制构造函数...

析构函数

析构函数

使用make_shared创建智能指针:完成。

使用new创建智能指针...

构造函数new

使用new创建智能指针:完成。

析构函数

析构函数

看起来`make_shared`调用了两次复制构造函数。如果我使用常规的`new`为一个Object分配内存,则不会发生这种情况,只会构造一个Object。
我想知道的是,为什么`make_shared`被认为比使用`new`更有效(1, 2)。原因之一是因为`make_shared`将引用计数与要管理的对象一起分配在同一块内存中。好的,我明白了。这当然比两个单独的分配操作更有效率。
相反,我不明白为什么这要以调用两次`Object`的复制构造函数为代价。因为这一点,我并不认为在每种情况下使用`make_shared`都比使用`new`更有效。我错了吗?好吧,可以为`Object`实现移动构造函数,但我仍然不确定这是否比仅通过`new`分配`Object`更有效。至少在每种情况下都是正确的。如果复制`Object`比为引用计数器分配内存更便宜,则可能是真的。但是,`shared_ptr`内部的引用计数器可以使用几个原始数据类型来实现,对吗?
您能帮忙解释一下为什么在效率方面`make_shared`是首选,尽管存在上述复制开销吗?

6
你认为 auto 是什么意思? - Kerrek SB
2
在测试 C++11 函数的速度之前,你应该在你的类中实现一个移动构造函数并且完全使用 C++11 的语言特性。 - ssube
9
代码与输出不匹配。你展示的代码存在泄漏问题。 - Ben Voigt
2
那会有什么不同,@Ildjarn?这段代码中没有任何Object实例的移动或复制将被触发。这段代码与报告的输出结果根本不匹配。 - Rob Kennedy
4
@Rob: 忽略“应该”,如果没有移动构造函数,可能会发生本应是移动的拷贝操作,因此在没有移动构造函数的情况下计算拷贝次数是没有意义的。 - ildjarn
显示剩余6条评论
4个回答

42
作为基础设施,我使用了XCode4中的llvm/clang 3.0和llvm std c++库。问题可能出在这里。C++11标准在第20.7.2.2.6节中对make_shared(和allocate_shared)提出了以下要求:
需要:表达式::new(pv) T(std::forward(args)...),其中pv的类型为void*,指向适合容纳T类型对象的存储空间,应该是良好的形式。A应该是分配器(17.6.3.5)。A的复制构造函数和析构函数不应抛出异常。
并非必须具备T的复制构造能力。实际上,T甚至不需要具备非放置新构造的能力。它只需要能够在原地构造。这意味着make_shared唯一能做的就是在原地new它。
因此,您得到的结果与标准不一致。LLVM的libc++在这方面存在问题。请提交错误报告。
供参考,以下是当我将您的代码带入VC2010时发生的情况:
Create smart_ptr using make_shared...
Constructor make_shared
Create smart_ptr using make_shared: done.
Create smart_ptr using new...
Constructor new
Create smart_ptr using new: done.
Destructor
Destructor

我也将其移植到Boost的原始shared_ptrmake_shared,并且我得到了与VC2010相同的结果。 我建议提交错误报告,因为libc++的行为有问题。

你得到的结果完全符合C++标准允许的内容。我没有在代码中看到任何应该导致Object实例被复制/移动构造的东西(无论编译器是否选择省略这样的构造)。 - Andrew Durward
1
@AndrewDurward:其实,你既对又错。标准对 make_shared<T> 的要求并没有规定 T 必须是可复制的(copy constructable)。因此,make_shared<T> 不能 调用复制构造函数。如果标准允许 T 是可复制的,那么 make_shared<T> 的一个实现可以调用它,这一点你说错了。 - Nicol Bolas
15
@NicolBolas:感谢您对libc++的错误报告。我同意您的分析。这已经在libc++公共svn主干中修复,并且不再调用复制构造函数。 - Howard Hinnant
我因为定义了一个用户声明的移动构造函数而隐式删除了复制构造函数。现在clang抱怨make_shared调用被隐式删除的复制构造函数。所以如果make_shared不需要复制构造函数,这是一个bug吗? - dashesy
我之前将一个临时对象传递给 make_shared,像这样:std:make_shared(M(..))。现在我改成了 std:make_shared(std::move(M(..))),现在它运行良好。 - dashesy

34

你需要比较这两个版本:

std::shared_ptr<Object> p1 = std::make_shared<Object>("foo");
std::shared_ptr<Object> p2(new Object("foo"));
在您的代码中,第二个变量只是裸指针,根本不是共享指针。
现在来谈谈实质问题。实际上,make_shared更加高效,因为它将引用控制块和实际对象一起分配在单个动态分配中。相比之下,采用裸对象指针的shared_ptr构造函数必须为引用计数分配另一个动态变量。这种权衡的结果是make_shared(或其近亲allocate_shared)不允许您指定自定义删除器,因为分配由分配器执行。
(这并不影响对象本身的构造。从Object的角度来看,两个版本之间没有区别。更高效的是共享指针本身,而不是被管理的对象。)

make_shared在实践中更有效率,因为它将引用控制块与实际对象一起分配在一个单独的动态分配中。我认为这只适用于VS2012,他们进行了这种优化,但Linux标准库尚未进行此优化(还没有?)。 - Ela782
3
是的,GCC已经这样做了一段时间。这是标准20.7.2.2.6/6中明确推荐的做法。 - Kerrek SB
@bigxiao:不应该有这种情况。一个好的实现会在shared_ptr的开头存储对象的实际指针,无论shared_ptr是如何创建的,因此解引用永远不需要比原始指针更多的计算。当然,最终的释放方式是不同的(特别是在存在弱指针的情况下)。 - Kerrek SB
@bigxiao:当然,但智能指针最重要的操作——即解引用,不需要访问控制块。 - Kerrek SB
“第二个变量只是一个裸指针,根本不是共享指针。” 等等,什么?真的吗?怎么可能呢? - Andrew
显示剩余2条评论

6
请注意你的优化设置。在衡量性能方面,特别是涉及C++时,如果未启用优化,则毫无意义。我不知道你是否编译时启用了优化,所以我认为有必要提一下。
话虽如此,你现在测试得出的结果并不能说明make_shared更有效率。简而言之,你正在测试错误的东西。:-P
正常情况下,当你创建shared pointer时,它至少有两个数据成员(可能更多)。一个是指针,一个是引用计数。这个引用计数分配在堆上(这样它就可以在生命周期不同的shared_ptr之间共享……这毕竟是它的目的!)
因此,如果你像这样创建一个对象:std::shared_ptr<Object> p2(new Object("foo"));,那么就至少调用了2次new。一次是为了Object,另一次是为了引用计数对象。 make_shared有一个选项(我不确定它是否必须),可以使用一个足够大的单一的new来同时保存指向的对象和引用计数,以连续的块的形式分配内存。实际上,分配的对象看起来有点像这样(仅供参考,不是字面意思)。
struct T {
    int reference_count;
    Object object;
};

由于引用计数和对象的生命周期是绑定在一起的(一个比另一个活得更久是没有意义的)。因此,整个块也可以同时被delete

所以效率在于分配,而不在于复制(我认为这与优化有关)。

明确一点,这就是Boost关于make_shared的说法:

http://www.boost.org/doc/libs/1_43_0/libs/smart_ptr/make_shared.html

除了方便和风格外,这样的函数还具有异常安全性,并且速度相当快,因为它可以使用单个分配来处理对象及其相应的控制块,从而消除了shared_ptr构造的重要部分。这消除了shared_ptr的主要效率投诉之一。


一个小问题是,在控制块refc之前,Object实例可能会死亡,如果使用了weak_ptr。这不是一个问题,只是一个小问题,直到控制块也消失,Object布局直接持有的内存才能被回收;在普通的shared_ptr中,对象的堆块可以在它过期后立即被回收。 - yonil

3
您不应该在那里获得任何额外的副本。输出应为:
Create smart_ptr using make_shared...
Constructor make_shared
Create smart_ptr using make_shared: done.
Create smart_ptr using new...
Constructor new
Create smart_ptr using new: done.
Destructor

我不知道为什么你会得到额外的副本。(尽管我看到你得到了一个额外的'Destructor',所以你用来得到输出的代码必须与你发布的代码不同)
make_shared更有效率,因为它只需要一个动态分配来实现,而非两个,并且每个共享对象只需要一个指针大小的内存空间用来跟踪记录。
编辑:我没有使用Xcode 4.2进行检查,但在Xcode 4.3中,我得到了上面显示的正确输出,而不是问题中显示的错误输出。

谈论好时机!;-) 感谢Xcode 4.3报告。 - Howard Hinnant

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