作者 Eaton
导语 在 C++ 中,内存管理是十分重要的问题,一不小心就会造成程序内存泄露,那么怎么避免呢?通过智能指针可以优雅地管理内存,让开发者只需要关注内存的申请,内存的释放则会被自动管理。在文章 开源微服务框架 TARS 之 基础组件 中已经简要介绍过,TARS 框架组件中没有直接使用 STL 库中的智能指针,而是实现了自己的智能指针。本文将会分别对 STL 库中的智能指针和 TarsCpp 组件中的智能指针进行对比分析,并详细介绍 TARS 智能指针的实现原理。
目录
智能指针
简介
在计算机程序中,泄露是常见的问题,包括内存泄露和资源泄露。其中资源泄露指的是系统的 socket
、文件描述符等资源在使用后,程序不再需要它们时没有得到释放;内存泄露指的是动态内存在使用后,程序不再需要它时没有得到释放。
内存泄露会使得程序占用的内存越来越多,而很大一部分往往是程序不再需要使用的。在 C++ 程序中,内存泄露常见于我们使用了 new
或者 malloc
申请动态存储区的内存,却忘了使用 delete
或者 free
去释放内存,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
随着计算机应用需求的日益增加,应用的设计与开发日趋复杂,开发人员在开发过程中处理的变量也越来越多。如何有效进行内存分配和释放、防止内存泄漏逐渐成为开发者面临的重要难题。为了解决忘记手动释放内存造成的内存泄露问题,智能指针诞生了。
常见的智能指针的使用场景,包括类中的成员变量(指针型)和普通的变量(指针型)。智能指针可以实现指针指向对象的共享,而无需关注动态内存的释放。通用实现技术是引用计数(Reference count),下一部分会介绍,简单讲就是将一个计数器与类指向的对象相关联,跟踪有多少个指针指向同一对象,新增一个指针指向该对象则计数器 +1
,减少一个则执行 -1
。
引用计数原理
引用计数是智能指针的一种通用实现技术,上图为大致流程,基本原理如下:
- 在每次创建类的新对象时,初始化指针并将引用计数置
1
; - 当对象作为另一对象的副本而创建时(复制构造函数),复制对应的指针并将引用计数
+1
; - 当对一个对象进行赋值时,赋值操作符
=
将左操作数所指对象的引用计数-1
,将右操作数所指对象的引用计数+1
; - 调用析构函数数,引用计数
-1
; - 上述操作中,引用计数减至
0
时,删除基础对象;
STL 库中的智能指针 shared_ptr
和 TARS 智能指针都使用了该引用计数原理,后面会进行介绍。
STL 库的智能指针
C++ 标准模板库 STL 中提供了四种指针 auto_ptr
, unique_ptr
, shared_ptr
, weak_ptr
。
auto_ptr
在 C++98 中提出,但其不能共享对象、不能管理数组指针,也不能放在容器中。因此在 C++11 中被摒弃,并提出 unique_ptr
来替代,支持管理数组指针,但不能共享对象。
shared_ptr
和 weak_ptr
则是 C++11 从标准库 Boost 中引入的两种智能指针。shared_ptr
用于解决多个指针共享一个对象的问题,但存在循环引用的问题,引入 weak_ptr
主要用于解决循环引用的问题。
接下来将详细介绍 shared_ptr
,关于其它智能指针的更多信息和用法请读者自行查阅。
shared_ptr
shared_ptr
解决了在多个指针间共享对象所有权的问题,最初实现于 Boost 库中,后来收录于 C++11 中,成为了标准的一部分。shared_ptr
的用法如下
#include <memory>
#include <iostream>
using namespace std;
class A
{
public:
A() {};
~A()
{
cout << "A is destroyed" << endl;
}
};
int main()
{
shared_ptr<a> sptrA(new A);
cout << sptrA.use_count() << endl;
{
shared_ptr</a><a> cp_sptrA = sptrA;
cout << sptrA.use_count() << endl;
}
cout << sptrA.use_count() << endl;
return 0;
}
上述代码的意思是 cp_sptrA
声明并赋值后,引用计数增加 1
,cp_sptrA
销毁后引用计数 -1
,但是没有触发 A
的析构函数,在 sprtA
销毁后,引用计数变为 0
,才触发析构函数,实现内存的回收。执行结果如下
1
2
1
A is destroyed
shared_ptr
主要的缺陷是遇到循环引用时,将造成资源无法释放,下面给出一个示例:
#include <memory>
#include <iostream>
using namespace std;
class B;
class A
{
public:
A() : m_sptrB(nullptr) {};
~A()
{
cout << " A is destroyed" << endl;
}
shared_ptr<b> m_sptrB;
};
class B
{
public:
B() : m_sptrA(nullptr) {};
~B()
{
cout << " B is destroyed" << endl;
}
shared_ptr</b></iostream></memory></a><b><a> m_sptrA;
};
int main( )
{
{
shared_ptr<b> sptrB( new B );//sptrB对应的引用计数置为1
shared_ptr</b></a><b><a> sptrA( new A );//sptrA对应的引用计数置为1
sptrB->m_sptrA = sptrA;//sptrA对应的引用计数变成2,sptrB仍然是1
sptrA->m_sptrB = sptrB;//sptrB对应的引用计数变成2,sptrA是2
}
//退出main函数后,sptrA和sptrB对应的引用计数都-1,变成1,
//此时A和B的析构函数都不能执行(引用计数为0才能执行),无法释放内存
return 0;
}
在上述例子中,我们首先定义了两个类 A
和 B
:A
的成员变量是指向 B
的 shared_ptr
指针,B
的成员变量是指向 A
的 shared_ptr
指针。
然后我们创建了 sptrB
和 sptrA
两个智能指针对象,并且相互赋值。这会造成环形引用,使得 A
和 B
的析构函数都无法执行(可以通过 cout
观测),从而内存无法释放。当我们无法避免循环使用时,可以使用 weak_ptr
来解决,这里不再展开,感兴趣的读者可以自行查阅。
TARS 智能指针 TC_AutoPtr 实现详解
TARS 诞生于 2008 年,当时 shared_ptr
还没有被收录到 STL 标准库中,因此自己实现了智能指针 TC_AutoPtr
。TARS 的智能指针主要是对 auto_ptr
的改进,和 share_ptr
的思想基本一致,能够实现对象的共享,也能存储在容器中。与 shared_ptr
相比,TC_AutoPtr
更加轻量化,拥有更好的性能,本文后续会对比。
在 TARS 中,智能指针类 TC_AutoPtr
是一个模板类,支持拷贝和赋值等操作,其指向的对象必须继承自智能指针基类 TC_HandleBase
,包含了对引用计数的加减操作。计数采用的是 C++ 标准库 <atomic>
中的原子计数类型 std::atomic
。
计数的实现封装在类 TC_HandleBase
中,开发者无需关注。使用时,只要将需要共享对象的类继承 TC_HandleBase
,然后传入模板类 TC_AutoPtr
声明并构造对象即可,如下
#include <iostream>
#include "util/tc_autoptr.h"
using namespace std;
// 继承 TC_HandleBase
class A : public tars::TC_HandleBase
{
public:
A()
{
cout << "Hello~" << endl;
}
~A()
{
cout << "Bye~" << endl;
}
};
int main()
{
// 声明智能指针并构造对象
tars::TC_AutoPtr</iostream></atomic></a><a> autoA = new A();
// 获取计数 1
cout << autoA->getRef() << endl;
// 新增共享
tars::TC_AutoPtr</a><a> autoA1(autoA);
// 获取计数 2
cout << autoA->getRef() << endl;
}
使用方式和 shared_ptr
相似,可以通过函数 getRef
获取当前计数,getRef
定义于 TC_HandleBase
类中。运行结果如下
Hello~
1
2
Bye~
下面我们将自底向上介绍分析原子计数器 std::atomic
、智能指针基类 TC_HandleBase
和智能指针模板类 TC_AutoPtr
,并对 TC_AutoPtr
与 shared_ptr
的性能进行简单的对比测试。
原子计数类 std::atomic
std::atomic
在 C++11 标准库 <atomic>
中定义。std::atomic
是模板类,一个模板类型为 T
的原子对象中封装了一个类型为 T
的值。
template <class t> struct atomic;
原子类型对象的主要特点就是从不同线程访问不会导致数据竞争(data race)。因此从不同线程访问某个原子对象是良性 (well-defined) 行为。而通常对于非原子类型而言,并发访问某个对象(如果不做任何同步操作)会导致未定义 (undifined) 行为发生。
C++11 标准库 std::atomic
提供了针对整型(integral
)和指针类型的特化实现。下面是针对整型的特化实现的主要部分
template <> struct atomic<integral> {
...
...
operator integral() const volatile;
operator integral() const;
atomic() = default;
constexpr atomic(integral);
atomic(const atomic&) = delete;
atomic& operator=(const atomic&) = delete;
atomic& operator=(const atomic&) volatile = delete;
integral operator=(integral) volatile;
integral operator=(integral);
integral operator++(int) volatile;
integral operator++(int);
integral operator--(int) volatile;
integral operator--(int);
integral operator++() volatile;
integral operator++();
integral operator--() volatile;
integral operator--();
integral operator+=(integral) volatile;
integral operator+=(integral);
integral operator-=(integral) volatile;
integral operator-=(integral);
integral operator&=(integral) volatile;
integral operator&=(integral);
integral operator|=(integral) volatile;
integral operator|=(integral);
integral operator^=(integral) volatile;
integral operator^=(integral);
};
可以看到重载了大部分整型中常用的运算符,包括自增运算符 ++
和自减运算符 --
,可以直接使用自增或自减运算符直接对原子计数对象的引用值 +1
或 -1
。
智能指针基类 TC_HandleBase
TC_HandleBase
是 TARS 的智能指针基类,包含两个成员变量 _atomic
和 _bNoDelete
,定义如下
protected:
/**
* 计数
*/
std::atomic<int> _atomic;
/**
* 是否自动删除
*/
bool _bNoDelete;
TC_HandleBase
,为 TARS 智能指针模板类 TC_AutoPtr<t>
提供引用计数的相关操作,增加计数和减少计数接口的相关代码如下
/**
* @brief 增加计数
*/
void incRef() { ++_atomic; }
/**
* @brief 减少计数
* 当计数==0时, 且需要删除数据时, 释放对象
*/
void decRef()
{
if((--_atomic) == 0 && !_bNoDelete)
{
_bNoDelete = true;
delete this;
}
}
/**
* @brief 获取计数.
* @return int 计数值
*/
int getRef() const { return _atomic; }
可以看到,这里通过整型的原子计数类的对象 _atomic
实现引用计数,管理智能指针指向对象的引用计数。
智能指针模板类 TC_AutoPtr
TC_AutoPtr
的定义及其构造函数和成员变量如下述代码,成员变量 _ptr
是一个 T*
指针。构造函数初始化该指针并调用了 TC_HandleBase
成员函数 incRef
进行引用计数 +1
,这要求类 T
是继承自 TC_HandleBase
的。
/**
* @brief 智能指针模板类.
*
* 可以放在容器中,且线程安全的智能指针.
* 通过它定义智能指针,该智能指针通过引用计数实现,
* 可以放在容器中传递.
*
* template<typename t> T必须继承于TC_HandleBase
*/
template<typename t>
class TC_AutoPtr
{
public:
/**
* @brief 用原生指针初始化, 计数+1.
*
* @param p
*/
TC_AutoPtr(T* p = 0)
{
_ptr = p;
if(_ptr)
{
_ptr->incRef();
}
}
...
public:
T* _ptr;
};
TC_AutoPtr
在使用时可以简单的当作 STL 的 shared_ptr
使用,需要注意的是指向的对象必须继承自 TC_HandleBase
(当然也可以自己实现智能指针基类,并提供与 TC_HandleBase
一致的接口),同时还要避免环形引用。下面我们看一下 TC_AutoPtr
其他接口的定义:
/**
* @brief 用其他智能指针r的原生指针初始化, 计数+1.
*
* @param Y
* @param r
*/
template<typename y>
TC_AutoPtr(const TC_AutoPtr<y>& r)
{
_ptr = r._ptr;
if(_ptr)
{
_ptr->incRef();
}
}
/**
* @brief 拷贝构造, 计数+1.
*
* @param r
*/
TC_AutoPtr(const TC_AutoPtr& r)
{
_ptr = r._ptr;
if(_ptr)
{
_ptr->incRef();
}
}
/**
* @brief 析构,计数-1
*/
~TC_AutoPtr()
{
if(_ptr)
{
_ptr->decRef();
}
}
/**
* @brief 赋值, 普通指针
* @param p
* @return TC_AutoPtr&
*/
TC_AutoPtr& operator=(T* p)
{
if(_ptr != p)
{
if(p)
{
p->incRef();
}
T* ptr = _ptr;
_ptr = p;
//由于初始化时_ptr=NULL,因此计数不会-1
if(ptr)
{
ptr->decRef();
}
}
return *this;
}
可以看到,这些接口都满足通用的引用计数规则。
- 构造函数 :除了初始化指针对象之外,将引用计数
+1
; - 拷贝构造函数:拷贝指针,引用计数
+1
; - 赋值操作符:拷贝指针,操作符右边的智能指针对应的引用计数
+1
,左边的-1
; - 析构函数:引用计数
-1
;
TC_AutoPtr 优势
经过上述分析,可以发现 TC_AutoPtr
和 shared_ptr
在用法和功能上非常相似,都支持多个指针共享一个对象,支持存储在容器中,那 TC_AutoPtr
有什么优势呢?
相比于 STL 库中的 shared_ptr
,TC_AutoPtr
更加轻量,具有更好的性能,我们可以通过如下简单的测试代码,通过测试二者构造和复制的耗时来衡量它们的性能
#include <iostream>
#include <chrono>
#include <memory>
#include <vector>
#include "util/tc_autoptr.h"
using namespace tars;
using namespace std;
using namespace chrono;
// 测试类
class Test : public TC_HandleBase {
public:
Test() {}
private:
int test;
};
// 打印时间间隔
void printDuration(const string & info, system_clock::time_point start, system_clock::time_point end) {
auto duration = duration_cast<microseconds>(end - start);
cout << info
<< double(duration.count()) * microseconds::period::num / microseconds::period::den
<< " s" << endl;
}
int main() {
int exec_times = 10000000; // 次数
// 构造耗时对比
{
auto start = system_clock::now();
for (int i = 0; i < exec_times; ++i) {
TC_AutoPtr<test> a = TC_AutoPtr<test>(new Test);
}
auto end = system_clock::now();
printDuration("TC_AutoPtr construct: ", start, end);
}
{
auto start = system_clock::now();
for (int i = 0; i < exec_times; ++i) {
shared_ptr<test> a = shared_ptr<test>(new Test);
}
auto end = system_clock::now();
printDuration("shared_ptr construct: ", start, end);
}
// 复制耗时对比
{
auto start = system_clock::now();
TC_AutoPtr<test> a = TC_AutoPtr<test>(new Test);
for (int i = 0; i < exec_times; ++i) {
TC_AutoPtr<test> b = a;
}
auto end = system_clock::now();
printDuration("TC_AutoPtr copy: ", start, end);
}
{
auto start = system_clock::now();
shared_ptr<test> a = shared_ptr<test>(new Test);
for (int i = 0; i < exec_times; ++i) {
shared_ptr<test> b = a;
}
auto end = system_clock::now();
printDuration("shared_ptr copy: ", start, end);
}
}
最后运行测试,输出的结果如下
TC_AutoPtr construct: 0.208995 s
shared_ptr construct: 0.423324 s
TC_AutoPtr copy: 0.107914 s
shared_ptr copy: 0.107716 s
可以看出,二者的复制性能相近,而构造性能上, TC_AutoPtr
要比 shared_ptr
快一倍以上。
总结
本文主要介绍了 TARS 的智能指针组件 TC_AutoPtr
和 STL 的智能指针 shared_ptr
。TC_AutoPtr
指向继承自智能指针基类 TC_HandleBase
的对象。TC_HandleBase
通过原子计数器 std::atomic<int>
实现引用计数,确保引用计数是线程安全的。相比于 shared_ptr
,TC_AutoPtr
拥有更好的性能;而 shared_ptr
有更加完善的功能。TarsCpp 框架已经支持 C++11,开发者能够根据业务具体需求自由选择。
TARS可以在考虑到易用性和高性能的同时快速构建系统并自动生成代码,帮助开发人员和企业以微服务的方式快速构建自己稳定可靠的分布式应用,从而令开发人员只关注业务逻辑,提高运营效率。多语言、敏捷研发、高可用和高效运营的特性使 TARS 成为企业级产品。
TARS微服务助您数字化转型,欢迎访问:
TARS官网:https://TarsCloud.org
TARS源码:https://github.com/TarsCloud
Linux基金会官方微服务免费课程:https://www.edx.org/course/building-microservice-platforms-with-tars
获取《TARS官方培训电子书》:https://wj.qq.com/s2/6570357/3adb/
或扫码获取: