0%

重新认识 c++ 系列 - 智能指针

旧印象

对于 c++,一直以来的感觉是,就像现在的邻居,明明经常能见到,还很熟一样的打打招呼,但是对他的底细却一点也不清楚,能看到他每天也去上班,连他干啥的也不清楚。

大学的入门编程语言是 c,虽也曾看过一点 c++ 的书,但是也不得要领,留下了一个 带类的 c 语言,这么个初步印象。

工作之后,写过一些 c 代码,对 c 还算得上有一些了解,还看过一些 c 和 c++ 的语言之争,留下一个 c++ 非常复杂,让人望而却步的印象。

缘起

近来要搞 Envoy,需要用到 c++,开始认真的学习 c++,目前有了一些体会,准备写几篇记录一下,加深下理解。

目前计划的有:

  1. 智能指针,也就是本文
  2. 变量引用
  3. 并发冲突

一句话解释

智能指针就是,基于引用计数实现的,自动垃圾回收。

垃圾回收

现代语言的演进,有一个方向就是,降低使用者的心智负担,把复杂留给编译器。
其中,自动垃圾回收(GC),就是其中一个进展良好的细分技术。

比如 c,作为一个 “古老” 的语言,提供了直接面向硬件资源的抽象,内存是要自己管理的,申请释放都完全由自己控制。

然,很多后辈语言,都开始提供了自动垃圾回收,比如,我之前用得比较用的 Lua,前一阵学的 go 语言,都是有 GC 加持的。
有了 GC 加持,确实很大程度的较低了程序员的心智负担,谁用谁知道。

既然效果这么好,那为什么不是每个语言都提供呢?

因为 GC 要实现好了,是挺复杂的。
想想 Java 的 GC 都演进了多少代了,多少牛人的聪明才智投入其中,没一定的复杂度,是对不起观众的。

在 GC 实现方式里,有两个主要方案:

标记清除

这个方案里,最简单的实现里,会将程序运行时,简单分为两种状态:

  1. 正常执行代码逻辑状态,期间会申请各种需要的 GC 对象(内存块)
  2. GC 状态,期间会扫描所有的 GC 对象,如果一个对象没有引用了,那就是 了,会被清除掉(释放)。

这个方案,有一个弊端,GC 状态的时候,正常的代码逻辑就没法跑了,处于世界停止的状态 STW

虽然,有很多的优化手段,可以将这个 GC 状态,并发执行,或者将其打散,拆分为很多的小段,使得 STW 时间更多。
比如有 Go,Java 这种有多线程的,可以有并发的线程来执行 GC;Lua 这种单线程的,也会将标记拆分为分步执行。
甚至,Java 还有分代 GC 等优化技术,减少扫描标记的消耗。

但是,终于是有一些很难绕过去的点,GC 过程中还是需要一些 STW 来完成一些状态的同步。
且,GC 终究是一个较大的批处理任务,即使并发 + 打散,对于运行的程序而言,始终是一些 额外 的负担。

GC 对于实时在线提供服务的系统而言,就是不确定的突发任务来源,搞不好就来个波动,造成业务系统的突发毛刺。

引用计数

简单来说,每个 GC 对象,都有一个计数器,被引用的数量。

如下示例中,第一行,new Foo() 创建了一个对象,f 则是引用了这个对象,此时对象的引用技术器为 1
当前作用域结束之后,f 作为局部变量,也到了生命的尽头,对象也少了这种引用,引用计数为 0
如果有 GC 加持的话,这里新建的对象,也就会被自动释放了。

1
2
3
4
{
Foo *f = new Foo();
...
}

引用计数器的变化,是混在正常的逻辑代码执行流中的,天然就是打散的。
引用计数,可以很好的解决上一个方案的问题。

只不过,引用计数有个致命的弱点,对于循环引用无解,所以,通常使用引用计数的语言,也会混合使用标记清除办法,解决循环引用的问题。

智能指针

c++ 提供了 share_ptr, unique_ptrweak_ptr 这三种智能指针。

shared_ptr 就是带了引用计数的指针,所以上面的示例代码中,在 c++ 的真实实现就是:

1
2
3
4
{
Foo *f = std::make_shared<Foo>();
... // using f, even passing to another function.
}

另外,为了解决循环引用的问题,又提供了 weak_ptr,被弱引用的 share_ptr 计数器不会 +1。

至于什么时候需要用 weak_ptr,这个锅又甩给使用者了,如果不小心写出了引用循环,那也是程序员的锅。

至于,unique_ptr 更像是针对一种常用的使用情况的定制,优化。

实现机制

比如这个示例:

1
auto f = std::make_shared<Foo>();

简单来说,分为这两层:

1
share_ptr => _Sp_counted_base

通过 make_shared 构造出来的智能指针,实际是一个 shared_ptr 对象,这个对象基本就是个空壳,指向内部的 _Sp_counted_base 对象。
_Sp_counted_base 对象则包含了:

  1. use_count 计数器
  2. weak_count 计数器
  3. 实际 Foo 对象的指针

shared_ptr 对象拷贝的时候,会让 _Sp_counted_base->use_count + 1,析构的时候,会让 _Sp_counted_base->use_count - 1
_Sp_counted_base 会在 use_count = 0 时,销毁 Foo 对象。

类似的,weak_ptr 则影响的是 weak_count,当 use_count = 0 && weak_count = 0 时,_Sp_counted_base 自身会被析构销毁。
这也就保证了 weak_ptr 不会访问野指针。

通过汇编代码,我们也可以看到 ~shared_ptr() 这个关键的析构调用,这是智能指针操作计数器的精髓。

1
2
3
40a0bf:       e8 68 02 00 00          call   40a32c <std::shared_ptr<Foo> std::make_shared<Foo>()>
...
40a0f2: e8 9d 01 00 00 call 40a294 <std::shared_ptr<Foo>::~shared_ptr()>

总结

个人感觉,c++ 的智能指针设计,还是很精巧的。
利用了局部对象的自动析构,自定义拷贝函数,析构函数,等等隐藏了很多的细节,使用体验上也很大程度的接近于自动 GC,确实能很大程度的降低程序员在这方面的心智负担。

不过,也是有些坑需要避免的,比如循环引用。我们多了解一些内部实现机制,则可以更好的用好智能指针,尽量少踩坑。