C++ 异常 - 守卫
前几篇文章讲述了异常的基本概念和如何保证基本的异常安全,本文则综合并且结合最佳实践讲述如何编写正确的 C++ 代码。
本文是《C++ 异常》系列第五篇文章。
《C++ 异常》目录:
守卫,很多的守卫
守卫(guard)和句柄类并列为 RAII 最主要的应用,同时,守卫也是保证异常安全的最重要组成。
典型的守卫包括 lock_guard,unique_lock,以及 unique_ptr。
lock_guard
lock_guard 的典型实现如下:
template<class T>
class lock_guard
{
T& t_;
public:
lock_guard(T& t) noexcept: t_(t)
{
t.lock();
}
~lock_guard()
{
t.unlock();
}
};
lock_guard 的特点是无空状态,无条件上锁以及无条件解锁,实际上是第一个性质导致了后两个性质。在实践中,lock_guard 只适合最简单的情况,考虑以下 lock_guard 不适合的例子:
void run_task(mutex& m, task_queue& q)
{
lock_guard l{m};
if (q.empty())
return;
auto task = std::move(q.top());
q.pop();
// #1 其他代码
task(); // 错误
}
lock_guard l{m}; 能保证在 #1 抛出异常时,自动解锁;即使 #1 永不抛出异常,l 的存在也可以避免漏写解锁导致死锁的错误;在 #1 存在使用 return; 提前返回时,这种模式也能避免多次写出 m.unlock(),减少重复。
但以上代码有致命缺陷:l 会在 task 执行后才被析构,会导致代码变成串行执行。这种代码典型的出现在使用独立的锁保护一个线程不安全的结构时。
因此,需要将代码改写为:
void run_task(mutex& m, task_queue& q)
{
task t{};
{
lock_guard l{m};
if (q.empty())
return;
task = std::move(q.top());
q.pop();
}
// #1 其他代码
task();
}
这对 task 是有要求的,如果 task 不支持默认构造,或者移动存在比较大的开销,则不适合。
另一种常见的使用 lock_guard 解决这个问题的方式是将线程不安全的队列转换为线程安全的:
class concurrent_task_queue: private task_queue;
task concurrent_task_queue::pop_value()
{
lock_guard l{m_};
if (task_queue::empty())
throw bizwen::empty_queue;
auto result{std::move(task_queue::top())};
task_queue::pop();
return result;
}
void run_task(concurrent_task_queue& q) try
{
auto task = q.pop_value();
task();
} catch (decltype(bizwen::empty_queue)&)
{
}
但实际上这也不能解决移动存在开销的问题,当然,正如之前的文章《资源管理》所述,最佳实践是让默认构造/移动尽可能做更少的事。
而另外一种做法是扩展 lock_guard,使其变为 unique_lock:
实现如下:
template<class T>
class unique_lock
{
T* tp_{};
public:
unique_lock(T& t) noexcept: tp_(&t)
{
t.lock();
}
void unlock() noexcept
{
tp_->unlock();
}
void release() noexcept
{
tp_ = nullptr;
}
~unique_lock()
{
if (tp_)
tp_->unlock();
}
};
void run_task(mutex& m, task_queue& q)
{
unique_lock l{m};
if (q.empty())
return;
auto task = q.top();
q.pop();
l.unlock();
l.release();
task();
}
注意这个 unique_lock 实现只是一个为了完成功能的最简版本,稍后会完善它。
unique_lock
上一节的结尾实现了一个 unique_lock,但实际上它称不上是 lock,因为它没有 lock 成员函数。
原因是指针只能表达是否存在锁,而不能额外表达是否上锁,因此为了实现该功能,需要使用一个 bool 储存锁的状态。
一个基本完善的 unique_lock 如下:
template<class T>
class unique_lock
{
T& t_{};
bool locked_{};
public:
unique_lock(T& t) noexcept: t_(t_), locked_(true)
{
t.lock();
}
unique_lock(T& t, defer_lock_t) noexcept: t_(t)
{
}
unique_lock(T& t, adopt_lock_t) noexcept: t_(t), locked_(true)
{
}
void lock() noexcept
{
assert(!locked_);
t.lock();
}
bool try_lock() noexcept requires requires{ t.try_lock(); }
{
assert(!locked_);
return t.try_lock();
}
void unlock() noexcept
{
assert(locked_);
t.unlock();
}
void release() noexcept
{
locked_ = false;
}
~unique_lock()
{
if (locked_)
t.unlock();
}
};
作为一个守卫,这个实现已经完整并且合格了。但有时候,用户可能想要延迟绑定锁,而在当前实现中,类储存的是锁的引用,引用没有默认值,因此实现延迟绑定需要将锁的引用改为锁的指针:
template<class T>
class unique_lock
{
T* tp_{};
bool locked_{};
public:
unique_lock() noexcept = default;
unique_lock(T& t) noexcept: tp_(&t), locked_(true)
{
t.lock();
}
unique_lock(T& t, defer_lock_t) noexcept: tp_(&t)
{
}
unique_lock(T& t, adopt_lock_t) noexcept: tp_(&t), locked_(true)
{
}
void lock() noexcept
{
assert(tp_ && !locked_);
tp_->lock();
}
bool try_lock() noexcept requires requires{ tp_->try_lock(); }
{
assert(tp_ && !locked_);
return tp_->try_lock();
}
void unlock() noexcept
{
assert(tp_ && locked_);
tp_->unlock();
}
void release() noexcept
{
locked_ = false;
}
~unique_lock()
{
if (locked_)
tp_->unlock();
}
};
除了使用指针引用锁外,还有一个变化是增加了默认构造函数,毕竟延迟绑定锁的前提是支持构造时不绑定锁。
在此基础上,可以添加 set_lock(T& t)、set_lock(T& t, defer_lock_t)、set_lock(T& t, adopt_lock_t) 函数来实现延迟绑定锁。
善于思考的读者可能已经发现了,set_lock 的三个重载实际上就等于构造函数对应的版本,因此,实际上不需要单独实现 set_lock,只需要实现移动赋值(由于 unique_lock 独占锁,并依此析构,所以不存在复制赋值):
template<class T>
class unique_lock
{
T* tp_{};
bool locked_{};
public:
...
friend void swap(unique_lock& lhs, unique_lock& rhs) noexcept
{
std::swap(lhs.tp_, rhs.tp_);
std::swap(lhs.locked_, rhs.locked_);
}
unique_lock& unique_lock(unique_lock&& rhs) noexcept
{
if (locked_)
unlock();
swap(*this, rhs);
return *this;
}
unique_lock(unique_lock&& rhs) noexcept
{
swap(*this, rhs);
}
...
};
注意,锁的类的移动赋值必须先将 *this 解锁,这是为了防止 *this 被意外的延迟解锁从而阻塞或者死锁,但一般来说,调用该重载时,*this 应该处于不持有锁的状态。
既然实现了移动赋值,那么实现一个移动构造也是理所应当。
到现在,不难发现,unique_lock 居然也是句柄类!
allocate_guard
allocate_guard 这个词可能很多人没听过,不过,实际上它非常常见而且经验丰富的人大概率已经独自发明过了。
它最常见的使用场景是和布置 new 配合;在标准库中,一些支持 emplace 操作的容器使用它。
现在考虑设计一个简化的 shared_ptr,它不追求和 std::shared_ptr 一致,但它拥有类似的功能,解决相同的问题,原型如下:
template<class T>
class shared_ptr
{
struct heap_node_
{
std::size_t counter_{};
alignas(T) std::byte buffer_[sizeof(T)]{};
};
heap_node_* p_{};
};
由于是简化模型,因此不考虑支持分配器,不考虑支持弱引用,不考虑线程安全的计数,不支持别名使用。
现在设计它的构造函数:理应支持默认构造,移动构造;并且应该避免 make_shared,因此它需要支持从 T&&,const T& 构造,以及支持 emplace 构造避免多余的复制和移动,得到如下代码:
template<class T>
class shared_ptr
{
...
shared_ptr() = default;
template<class U>
void emplace_(U&& u) // 为了消除构造函数递归调用的歧义,设此辅助函数
{
p_ = new heap_node_{}; // #1
p_->counter = 1uz;
new (std::addressof(p_->buffer_)) T(std::forward<U&&>(u)); // #2
}
public:
template<class U>
shared_ptr(U&& u)
{
emplace_(std::forward<U&&>(u));
}
shared_ptr(T&& t)
{
emplace_(std::move(u));
}
shared_ptr(const T& t): shared_ptr(t)
{
emplace_(u);
}
friend void swap(shared_ptr& lhs, shared_ptr& rhs) noexcept
{
std::swap(lhs.p_, rhs.p_);
}
shared_ptr(shared_ptr&& rhs) noexcept
{
swap(*this, rhs);
}
shared_ptr(const shared_ptr& rhs) const noexcept
{
if (!p_)
return;
++p_->counter;
}
~shared_ptr()
{
if (!p_)
return;
--p_->counter;
if (p_->counter)
return;
static_cast<T*>(std::addressof(p_->buffer_))->~T();
delete(p_);
}
};
这段代码实际上是错误的,因为如果 #2 处调用 T 的构造函数抛出异常,之前分配的 p_ 就会泄漏。
因此,通常的改进方式如下:
template<class U>
void emplace_(U&& u)
{
p_ = new heap_node_{};
p_->counter = 1uz;
try {
new (std::addressof(p_->buffer_)) T(std::forward<U&&>(u));
} catch(...)
{
delete(p_);
throw;
}
}
但目前来说,catch(...) 和 throw; 会阻止编译器生成最优的代码,为了解决这个问题,可以用以下代码替代:
template<class T>
class allocate_guard
{
T* p_{};
public:
allocate_guard(): p_(new T()) {}
T* get() noexcept
{
return p_;
}
void release()
{
p_ = nullptr;
}
~allocate_guard()
{
delete(p_);
}
};
template<class U>
void emplace_(U&& u)
{
allocate_guard<heap_node_> g{};
p_ = g.get();
p_->counter = 1uz;
new (std::addressof(p_->buffer_)) T(std::forward<U&&>(u)); // #2
g.release();
}
此时,在 #2 抛出异常后,allocate_guard 就会保护 p_,不发生泄漏,并且不使用 catch(...) 和 throw;。
实际上之前文章中介绍过的 std::uninitialized_copy 函数也可以使用相同的手法代替,这里留给读者做思考题。在我实现的 basic_json 中,就同时用到了这两种手法。
认真思考的读者可能已经发现:标准库的 std::unique_ptr 有相同的函数,实际上就是 allocate_guard!
是的,std::unique_ptr 就是 allocate_guard:
template<class U>
void emplace_(U&& u)
{
auto g{std::make_unique<T>()};
p_ = g.get();
p_->counter = 1uz;
new (std::addressof(p_->buffer_)) T(std::forward<U&&>(u)); // #2
g.release();
}
libc++ 的 std::shared_ptr 的构造函数就如此使用 std::unique_ptr。
基于此,可以扩展该 allocate_guard,使其变得更通用:
template<class T, class A>
class allocate_guard
{
using pointer = std::allocator_traits<A>::pointer;
A a_{};
pointer p_{};
public:
allocate_guard()
{
p_ = std::allocator_traits<A>::allocate(a_, 1);
}
allocate_guard(A const& a) noexcept :a_(a) {
p_ = std::allocator_traits<A>::allocate(a_, 1);
}
pointer get() noexcept
{
return p_;
}
void release()
{
p_ = pointer{};
}
~allocate_guard()
{
std::allocator_traits<A>::deallocate(a_, p_, 1);
}
};
仔细思考的读者可能已经回忆到了,之前我的文章中讲过的 vector_base 与之非常类似,这不是巧合。
unique_ptr
读到这里,相信读者一定彻底学会了 unique_ptr,因此这一节讨论的是一些非常细致,微妙的问题。
上述各种守卫以及不完整版 unique_lock,对比 vector_base、std::unique_ptr 以及完整版 unique_lock有什么区别?
最关键的是,后者是句柄类,而前者不是。我故意将前者全部写为无法默认构造/默认构造分配内存的形式,因为这在它们的应用场景中是最简洁的。
大部分持有单一资源的守卫都可以通过扩充变为句柄类,相对的,std::uninitialized_copy 的等价物就不持有单一所有权,因此它无论如何不可能变为句柄类,这实际上对应了我前文所述:
尽量简化句柄类的构造,让句柄类只构造自己该有的东西,例如,请让文件对象和数据库连接对象独立构造(并且支持移动)。
文件和数据库连接当然是单一所有权的。
成为句柄类后,将拥有守卫一般不具有的轻量的默认构造,移动构造和移动赋值。这些新具有的函数使句柄类能够作为容器的元素,能够作为结构体的子对象。但同时不要忘记,unique_lock,vector_base 仍然是守卫。
std::unique_ptr 实际上应该回归它的本职工作,即作为守卫,而不是句柄类使用,因为 std::unique_ptr 并没有有意义的构造函数,它不过是无条件的储存指针而已,通常,我们需要的是特殊化,专用化的句柄类,例如 vector_base,或者某一个 file 类,而不是 std::unique_ptr。std::unique_ptr 没有有意义的构造函数使其必须配合 std::make_unique 使用,这是一种劣化。LLVM 的代码就使用 std::make_unique 编写,但让 std::unique_ptr 拥有一个和 std::make_unique 等效的构造函数,才更符合 RAII,对用户来说更加轻松。