这不是一篇介绍如何使用c++中std::unique_ptr的文章。假定读者已经对unique_ptr的使用和实现非常熟悉了。

最近看了这个视频 ,然后自己再收集了一些资料,有些感悟记录如下。

unique_ptr不是零开销

std::unique_ptr一般被认为是zero-overhead或者说和raw pointer(裸指针)一样高效的。但是实际上不是这样的。 下面我们举例来说明。

我们以raw pointer为基准,一个简单的c++程序为:

void bar(int*p) noexcept;

__attribute__((noinline)) void baz_rawpointer(int *p)  noexcept {
    global_v = *p;
    delete p;
}
void use_rawpinter(int *p)
{
    bar(p);
    baz_rawpointer(p);
}

然后我们使用std::unique_ptr实现相同的功能:

__attribute__((noinline)) void baz_unique_ptr(std::unique_ptr<int> p) noexcept { 
    global_v = *p;
}

void use_unique_ptr(std::unique_ptr<int> p)
{
    bar(p.get());
    baz_unique_ptr(std::move(p));
}

我们比较他们的反编译代码:

baz_rawpointer(int*): 
  movl (%rdi), %eax          # load the integer from pointer
  movl %eax, global_v(%rip)  # save the integer to global
  jmp operator delete(void*)
use_rawpinter(int*):
  pushq %rbx
  movq %rdi, %rbx
  callq bar(int*)
  movq %rbx, %rdi
  popq %rbx
  jmp baz_rawpointer(int*)
baz_unique_ptr(std::unique_ptr<int, std::default_delete<int> >):
  movq (%rdi), %rax          # load pointer from pointer
  movl (%rax), %eax          # load integer from pointer
  movl %eax, global_v(%rip)  # save integer to global
  retq
use_unique_ptr(std::unique_ptr<int, std::default_delete<int> >): 
  pushq %r14
  pushq %rbx
  pushq %rax
  movq %rdi, %rbx
  movq (%rdi), %rdi
  callq bar(int*)
  movq (%rbx), %r14
  movq %r14, (%rsp)
  movq $0, (%rbx)
  movq %rsp, %rdi
  callq baz_unique_ptr(std::unique_ptr<int, std::default_delete<int> >)
  testq %r14, %r14           # here is test instruction
  je .LBB3_1
  movq %r14, %rdi
  addq $8, %rsp
  popq %rbx
  popq %r14
  jmp operator delete(void*)

可以看到std::unique_ptr代码更长一些,所以通常也更慢一些。 我们总结std::unique_ptr对程序的两个负面影响

  • unique_ptr必须通过压栈,再传递地址的方式来传递参数。被调用的函数要访问unique_ptr中的原始指针,多了一个间接层。

  • 调用者清理临时unique_ptr,但是调用者不知道被调用者如何蹂躏了这个临时变量,所以调用者无法做出足够的优化。

Itanium C++ ABI 调用约定

为了说明std::unique_ptr为什么生成的指令更多,我们下考虑一个更简单的例子。考虑如下的代码:

void foo(unique_ptr<int> t);
void bar() {
    unique_ptr<int> p(new int);
    foo(std::move(p));
}

现在考虑一个问题,调用foo的时候,参数是如何传递进去的? GCC/Clang通常使用Itanium C++ ABI约定。 在Itanium C++ ABI调用约定里,对于non-trivial的pass-by-value参数,我们首先会在中创建一个临时变量, 然后把临时变量的地址传递给被调用的函数, 函数调用完毕后,调用者再清理临时变量(比如调用析构函数)。 std::unique_ptr就是non-trivial的,因为它具有一个non-trivial的析构函数。

所以上边的代码就类似于

void foo(unique_ptr<int> *pt);
void bar() {
    unique_ptr<int> p(new int);
    unique_ptr<int> t = std::move(p);
    foo(&t);
    t.~unique_ptr<int>(); // if(t.get()) delete t.get()
    // p.~unique_ptr<int>(); 可以被优化掉
}

注意在foo函数中不会调用t的析构函数。 这种调用者负责清理临时变量的做法,使得这种调用约定不能实现真的的move语义。 如果可以实现真正的move语义的话,那么指针的所有权已经转移给foo了,我们应当在foo函数里调用析构函数。 真转移所有权具有稍微更高的效率。 上面的代码中,注意,我们把t的地址传递给了foo。 如果foot移动到了别的地方,那么bar运行 t.~unique_ptr<int>()时候,就不应该去释放内存。 如果foo没有动t,那么bar运行t.~unique_ptr<int>()时候,就应该释放资源。但是调用者根本不知道foo是否真的消化了这个unique_ptr,只能老老实实的生成如下的代码

if(t.get()) delete t.get();

对于真转移所有权的做法,foo函数知道自己拥有所有权或者所有权又被转移了,因而不需要判断t.get()是否为空,直接delete t.get()就好了。

Clang的[[clang::trivial_abi]]

clang允许对类添加 [[clang::trivial_abi]] 属性,这是非标准c++的拓展 [1] [2],具有如下效果

  • 第一,类本身(它本身的全部就是一个原始指针)通过寄存器传递。

  • 第二,被调用者清理临时变量。

为了验证[[clang::trivial_abi]]的效果,我们测试c++代码如下:


#ifdef __clang__ 
#define TRIVAIL_ABI [[clang::trivial_abi]]
#else
#define TRIVAIL_ABI
#endif

template<class T>
struct TRIVAIL_ABI trivial_unique_ptr {
    trivial_unique_ptr() = default;
    explicit trivial_unique_ptr(T *p) : p_(p) {}    
    trivial_unique_ptr(trivial_unique_ptr && r) : p_(std::exchange(r.p_, nullptr)) { }    

    trivial_unique_ptr &operator=(trivial_unique_ptr && r)  {
        std::swap(p_, r.p_);
    }    

    trivial_unique_ptr(trivial_unique_ptr const &r) = delete;  
    trivial_unique_ptr &operator=(trivial_unique_ptr const &r)  = delete;

    T * get() { return p_; }
    ~trivial_unique_ptr() { if(p_) delete p_; }
private:
    T *p_ = nullptr;
};


__attribute__((noinline)) void baz_trivial_unique_ptrt(trivial_unique_ptr<int>) noexcept {
    global_v = *p;
}


void use_trivial_unique_ptrt(trivial_unique_ptr<int> t)
{
    bar(t.get());
    baz_trivial_unique_ptrt(std::move(t));
}

其生成的反汇编代码与raw pointer结果一样。

但是这种改变abi的方法也是有缺点的。

  1. 只适用于clang编译器
  2. 不是标准Itanium C++ ABI,不能适用于std::unique_ptr。因为Itanium C++ ABI的std::unique_ptr已经广泛被使用了。
  3. 如果只是对unique_ptr开后门的话,那么在所有参数的临时变量中,unique_ptr是最先析构的。那么就不能保证参数的构造顺序和析构顺序就完全的相反。(见视频问答环节 )。因而这种ad-hoc的处理方法,可能永远无法成为c++标准的一部分。

ticket类

std::unique_ptr作为参数传递效率不高,而raw pointer又太危险,有没有折中的方法呢? 在Itanium C++ ABI里,std::unique_ptr不能通过寄存器传递的主要阻碍是Std::unique_ptr有非trivial的析构函数。 我们可以实现一个只包含trivial析构函数的智能指针。 注意下面的代码和原始代码[3]有些许不同。



template<class T>
struct ticket {
    ticket() = default;
    explicit ticket(T *p) : p_(p) {}
    ticket(std::unique_ptr<int>&& p) : p_(p.release()) {}
    std::unique_ptr<T> redeem() { return std::unique_ptr<T>(std::exchange(p_, nullptr)); }
private:
    T *p_ = nullptr;
};

__attribute__((noinline)) void baz_ticket(ticket<int> t) noexcept {
    auto p  = t.redeem();
    global_v = *p;
}

void use_ticket(ticket<int> t)
{
    auto p = t.redeem();
    bar(p.get());
    baz_ticket(std::move(p));
}

使用ticket类有几点好处:

  1. 性能和使用raw point完全一样。指针本身通过寄存器传递,被所有权拥有者负责析构。
  2. 易用性,表义性比raw pointer好,你知道它试图起到类似unique_ptr的作用。
  3. 不需要改变ABI。

但是ticket类依然有几个缺点:

  1. 本质上还是raw pointer,没有任何资源管理能力. 在参数传递过程中如果发生异常,会出现资源泄露。
  2. 本质上还是raw pointer,没有任何资源管理能力. 在参数传递过程中如果发生异常,会出现资源泄露。
  3. 本质上还是raw pointer,没有任何资源管理能力. 在参数传递过程中如果发生异常,会出现资源泄露。

参考阅读:

完整代码见链接

[1] https://reviews.llvm.org/D63748

[2] https://quuxplusone.github.io/blog/2018/05/02/trivial-abi-101/

[3] https://quuxplusone.github.io/blog/2019/09/21/ticket-for-unique-ptr/