目录
介绍
互斥锁
智能指针
RAII :Resource Acquisition Is Initialisation (RAII)
(任何资源的获取都应该发生在类的构造函数中,资源的释放应发生在析构函数中,即资源的生命周期与对象绑定)
RAII是一种使用在面向对象语言中的资源(内存,互斥锁,或者文件描述符)管理机制,使用RAII的语言中,最出名的当属C++和RUST。对C++来说,许多公司已经开始禁用裸指针(强制使用基于RAII的智能指针)来避免内存泄漏。而RUST,正是因为强制RAII机制使得其拥有了绝对的内存安全。
RAII技术在锁上的应用
互斥锁
std::mutex mut;
int write_to_a_file_descriptor(std::string content)
{
mut.lock();
// critical area below (might throw exception)
// Writing content to a file descriptor...
// Critical areas above
mut.unlock();
}
以上代码展示了一个将字符串写进某个文件描述符的函数,并且这个函数会被很多线程并行调用 (这种情况在高并发线上服务的logger中非常常见),因此这个共用的文件描述符必须用一个互斥锁保护起来,否则不同线程的字符串会混在一起。这段代码看起来仿佛没有问题,但是如果当写IO时是抛出了异常,call stack会被直接释放,也就意味着 unlock
方法不会执行,造成永久的死锁。这个问题可以像java一样用一个try-catch语句来避免但是也会让代码变得臃肿和难看。并且在复杂的逻辑中,往往很可能会忘了解锁,或者花很多精力来管理锁的获得和释放(如果在一个函数调用中有多处返回,每个return statement之前都需要 unlock
)。
这就是RAII发挥其威力的时候了,下面一段代码将展示如何用 lock_guard
来使我们的代码异常安全并且整洁。
std::mutex mut;
int write_to_a_file_descriptor(std::string content)
{
std::lock_guard<std::mutex> lock(mut);
// critical area below (might throw exception)
// Writing content to a file descriptor...
// Critical areas above
}
lock_guard
保证在函数返回之后释放互斥锁,因此使得开发人员不需要为抛出异常的情况担心且不需手动释放锁。但是 lock_guard
是如何做到的呢?笔者将尝试自己手动实现一个 lock_guard
template <typename T>
class lock_guard
{
private:
T _mutex;
public:
explicit lock_guard(T &mutex) : _mutex(mutex)
{
_mutex.lock();
}
~lock_guard()
{
_mutex.unlock();
}
};
从以上实现中可看出, lock_guard
在构造函数中锁住了引用传入的mutex (resource acquisition is initialisation),并且在析构函数中释放锁。
RAII技术实现自动化解锁
一般人写的加锁代码:
{
mutex_.lock();
//XXX
if(....)
return;
//XXX
mutex_.unlock();
}
显然,这段代码如果函数if成立就会造成忘记解锁,或者中途异常,走不到 mutex_.unlock();也会造成一直锁住。
那么如何防止这种情况,我们采用和智能指针相同的策略:对象生命周期结束自动释放资源 -----RAII
class MutexLockGuard : NonCopyable
{
public:
MutexLockGuard(MutexLock &mutex) : mutex_(mutex)
{
mutex_.lock();
}
~MutexLockGuard()
{
mutex_.unlock();
}
private:
MutexLock &mutex_;
};
这个类对于我们编写优雅的代码,好处是显而易见的,例如:
size_t Buffer::size() const
{
mutex_.lock();
int ret = queue_.size();
mutex_.unlock();
return queue_.size();
}
这段代码实在称不上美观,但是有了MutexLockGuard,我们可以写出:
size_t Buffer::size() const
{
MutexLockGuard lock(mutex_);
return queue_.size();
}
代码的美观性提高了许多。
当然,有一种使用方式是错误的,例如:
size_t Buffer::size() const
{
MutexLockGuard(mutex_);
return queue_.size();
}
这段代码的加锁周期仅限于那一行,为了防止错误使用,我们增加一个宏:
#define MutexLockGuard(m) "Error MutexLockGuard"
这样当错误使用的时候,会导致编译错误,使得我们早些发现问题。
RAII技术在指针上的应用(资源管理)智能指针
接下来笔者将介绍RAII在C++中最强的应用:智能指针。
C++中一个非常常见的应用场景就是调用一个函数来产生一个对象,然后消费这个对象,最后手动释放指针。如以下代码所示。
class my_struct
{
public:
my_struct() = default;
};
template <typename T>
T* get_object()
{
return new T();
}
int main()
{
auto obj = get_object<my_struct>();
// consume the object
// ...
// consume finish
delete obj;
}
然而,大型应用程序中,指针的产生和消费错综复杂,忘记释放指针,或者读取已经释放的指针,就是C/C++各种内存泄漏的万恶之源。
而自从C++11推出智能指针后,其极大地减轻了C++开发者们内存管理的压力。再也不用通过手动 delete
来释放内存。
下面的代码将展示如何用 std::unique_ptr
来管理指针。
using namespace std;
class my_struct
{
public:
my_struct() = default;
};
template <typename T>
unique_ptr<T> get_object()
{
return unique_ptr<T>(new T());
}
int main()
{
auto obj = get_object<my_struct>();
// consume the object
// ...
// consume finish
}
在上述代码中,当main函数退出时, std::unique_ptr
在自己的析构函数中释放指针,而为了防止有别的 std::unique_ptr
指向自己管理的对象而导致的提早释放与空指针访问, std::unique_ptr
禁止了 copy constructor
与 copy assignment
。有人可能会疑惑, get_object
函数创建的 unique_ptr
为什么没有在函数返回前释放指针?这是因为 std::unique_ptr
实现了 move constructor
(一种可以将资源从另一个对象“偷”过来的构造函数)并在返回时将指针传给了main函数中 obj
变量。如果不太理解发生了什么,可以看一下以下我自己尝试实现的 unique_ptr
.
template <typename T>
class unique_ptr
{
private:
T* _ptr;
public:
// Construct from plain pointer
explicit unique_ptr(T* ptr) : _ptr(ptr) {
std::cout << "unique_ptr constructed" << std::endl;
};
// Move constructor
unique_ptr(unique_ptr &&ptr) noexcept : _ptr(ptr._ptr) {
ptr._ptr = nullptr;
std::cout << "unique_ptr move constructed" << std::endl;
}
// Copy constructor is forbidden
unique_ptr(unique_ptr &ptr) = delete;
// Move assignment
unique_ptr& operator=(unique_ptr &&ptr) noexcept {
if (this == &ptr) {
return *this;
}
_ptr = ptr._ptr;
ptr._ptr = nullptr;
return *this;
}
// Copy assignment is forbidden
unique_ptr& operator=(unique_ptr &ptr) = delete;
~unique_ptr() {
delete _ptr;
std::cout << "unique_ptr destructed" << std::endl;
}
T* operator->() {
return _ptr;
}
};
代码看上去比较复杂,不过我将一个方法一个方法地和大家分析。
- 第8行代码实现了最基本的构造函数:从一个裸指针开始构造。
- 第13行实现了
move constructor
,这个方法会用一个已有的 unique_ptr
来构造一个新的对象,它将旧 unique_ptr
的指针替换为 nullptr
来防止多个指针指向相同对象。 - 第19行禁止了
copy constructor
的使用,因为不允许多个指针指向同一对象。 - 第22行实现了
move assignment
,原理与 move constructor
相同。 - 第32行禁止了
copy assignment
,原理与 copy constructor
相同。 - 第34行是析构函数,将最终释放指针。
- 第39行实现了
operatoroverload
,使得我们可以像访问普通指针一样访问 unique_ptr
。
我们来用我们自己定义的 unique_ptr
运行一下看会发生什么:
class my_struct
{
public:
std::string _name = "name";
my_struct() = default;
explicit my_struct(std::string name) : _name(std::move(name)) {
std::cout << "my_struct constructed" << std::endl;
}
~my_struct() {
std::cout << "my_struct destructed" << std::endl;
}
};
template<typename T>
unique_ptr<T>get_object()
{
return unique_ptr<T>(new T("struct name"));
}
int main() {
unique_ptr<my_struct> obj = get_object<my_struct>();
std::cout << obj->_name << std::endl;
}
console output:
my_struct constructed
unique_ptr constructed
struct name
my_struct destructed
unique_ptr destructed
首先, my_struct
被构造,然后 unique_ptr
被构造,并且可以发现, my_struct
的析构函数会在 unique_ptr
的析构函数返回前执行,这意味着我们成功地将指针的life cycle绑定到了 unique_ptr
上!不过,细心的同学可能发现了,全程 unique_ptr
的 move constructor
都没有被call过,但是我之前明确说了,main函数中的 obj
是用 get_object
函数中构造的 unique_ptr
通过 move constructor
构造的。可是为什么我们没有抓到 move constructor
打印出来的东西呢?这是因为C++编译器做了一个叫做 copy elision
的优化,来避免不必要的构造和析构,例如本例中,两个函数中的 unique_ptr
对象其实是一个东西,因此他们之间的转换和赋值被优化掉了。如果我们通过 std::move
来强制 move constructor
发生,如下所示:
我们将看到这样的信息:
my_struct constructed
unique_ptr constructed
unique_ptr move constructed
unique_ptr destructed
struct name
my_struct destructed
unique_ptr destructed
此时我们可以清晰地看到,main函数中的 obj
是通过 move constructor
构造的,并且在其构造完成之后, get_object
函数中构造的 unique_ptr
对象被析构了,因为我们已经提早将其内部指针替换成了 nullptr
, 其析构函数什么都不会释放。
智能指针中,除了 std::unique_ptr
,还有其他类型,比如允许多个指针指向同一变量的 std::shared_ptr
,其内存管理逻辑会复杂许多,如果有同学有兴趣,可以在评论中告诉我,下次专门写一篇文章讲如何实现 std::shared_ptr
。
技术总结
通过这篇文章,相信大家都体会到了RAII的威力,其将资源绑定到轻量级对象(比如智能指针,内存占用很少,可以像普通指针一样随意传递)的方法使得我们再也不需要关心在获取资源之后对资源的释放。
RAII技术带来的缺点
RAII不是万能的技术,在某些资源需要非常精细控制的情况下,依然需要手动管理。必须提早(对象生命周期结束前)释放的东西你就别用raii。
一定要在用完之后立即释放资源,不想等到作用域结束,那只有纯手工操作。RAII都不行。
RAII的作用是帮助处理发生异常时的资源释放问题以及避免程序员忘记释放资源。
你要说最优化,那当然是手写。但大量程序员离开GC写个内存不泄露的程序都很难,就别指望最优化了。