旧印象
对于 c++,一直以来的感觉是,就像现在的邻居,明明经常能见到,还很熟一样的打打招呼,但是对他的底细却一点也不清楚,能看到他每天也去上班,连他干啥的也不清楚。
大学的入门编程语言是 c,虽也曾看过一点 c++ 的书,但是也不得要领,留下了一个 带类的 c 语言
,这么个初步印象。
工作之后,写过一些 c 代码,对 c 还算得上有一些了解,还看过一些 c 和 c++ 的语言之争,留下一个 c++ 非常复杂,让人望而却步的印象。
缘起
近来要搞 Envoy,需要用到 c++,开始认真的学习 c++,目前有了一些体会,准备写几篇记录一下,加深下理解。
目前计划的有:
- 智能指针,也就是本文
- 变量引用
- 并发冲突
一句话解释
智能指针就是,基于引用计数实现的,自动垃圾回收。
垃圾回收
现代语言的演进,有一个方向就是,降低使用者的心智负担,把复杂留给编译器。
其中,自动垃圾回收(GC),就是其中一个进展良好的细分技术。
比如 c,作为一个 “古老” 的语言,提供了直接面向硬件资源的抽象,内存是要自己管理的,申请释放都完全由自己控制。
然,很多后辈语言,都开始提供了自动垃圾回收,比如,我之前用得比较用的 Lua,前一阵学的 go 语言,都是有 GC 加持的。
有了 GC 加持,确实很大程度的较低了程序员的心智负担,谁用谁知道。
既然效果这么好,那为什么不是每个语言都提供呢?
因为 GC 要实现好了,是挺复杂的。
想想 Java 的 GC 都演进了多少代了,多少牛人的聪明才智投入其中,没一定的复杂度,是对不起观众的。
在 GC 实现方式里,有两个主要方案:
标记清除
这个方案里,最简单的实现里,会将程序运行时,简单分为两种状态:
- 正常执行代码逻辑状态,期间会申请各种需要的 GC 对象(内存块)
- 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 | { |
引用计数器的变化,是混在正常的逻辑代码执行流中的,天然就是打散的。
引用计数,可以很好的解决上一个方案的问题。
只不过,引用计数有个致命的弱点,对于循环引用无解,所以,通常使用引用计数的语言,也会混合使用标记清除办法,解决循环引用的问题。
智能指针
c++ 提供了 share_ptr
, unique_ptr
和 weak_ptr
这三种智能指针。
shared_ptr
就是带了引用计数的指针,所以上面的示例代码中,在 c++ 的真实实现就是:
1 | { |
另外,为了解决循环引用的问题,又提供了 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
对象则包含了:
use_count
计数器weak_count
计数器- 实际
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 | 40a0bf: e8 68 02 00 00 call 40a32c <std::shared_ptr<Foo> std::make_shared<Foo>()> |
总结
个人感觉,c++ 的智能指针设计,还是很精巧的。
利用了局部对象的自动析构,自定义拷贝函数,析构函数,等等隐藏了很多的细节,使用体验上也很大程度的接近于自动 GC,确实能很大程度的降低程序员在这方面的心智负担。
不过,也是有些坑需要避免的,比如循环引用。我们多了解一些内部实现机制,则可以更好的用好智能指针,尽量少踩坑。