通过mutex的封装整理一些常见的知识点
mutex的简单封装
根据RAII的特性简单封装了个mutex。所谓RAII(Resource Acquisition Is Initialization),资源获取即初始化即使用局部对象来管理资源。所谓的资源,我的理解是一切需要创建并且销毁的系统资源,包括内存,套接字,锁等等,局部对象指存储在栈上的对象。这里很好的利用了局部对象自动销毁的特性来管理资源的生命周期。 总结来说步骤一般分为一下:
- 把对资源的操作封装在一个类中
- 在构造函数内初始化资源
- 在析构函数内销毁资源
- 在局部作用域内声明一个该对象的类
#ifndef __DXY_BASE_MUTEX_H__
#define __DXY_BASE_MUTEX_H__
#include <pthread.h>
namespace dxy {
class Mutex {
public:
Mutex() {
// 在构造函数内初始化资源
pthread_mutex_init(&mutex_, NULL);
}
~Mutex() {
// 在析构函数内销毁资源
pthread_mutex_destroy(&mutex_);
}
void lock() {
pthread_mutex_lock(&mutex_);
}
void unLock() {
pthread_mutex_unlock(&mutex_);
}
private:
pthread_mutex_t mutex_;
};
}
#endif
用例
include "mutex.h"
int main() {
{
// 在这个局部作用域结束之后,锁的资源会自动回收
Mutex mutex;
mutex.lock();
// ... 访问临界资源
mutex.unlock();
}
return 0;
}
这样我们就把锁资源封装在Mutex内了,由Mutex对象的生命周期来管理锁的生命周期
Mutex的进阶封装
我们在使用锁的时候,往往遵循的应该是哪里lock,就应该在哪里unlock掉,这也是一组对立的操作,所以我们可以把资源这个定义扩散到一切有成对操作的对象上,所以我们在使用时可以再利用RAII的思想进行封装:
namespace dxy {
class MutexGuard {
public:
MutexGuard(Mutex &mutex) : mutex_(mutex) {
// 在构造函数占用资源
mutex_.lock();
}
~MutexGuard() {
// 在析构函数释放资源
mutex_.unLock();
}
private:
Mutex &mutex_;
};
}
用例:
#include "mutex.h"
int main() {
{
dxy::Mutex mutex;
dxy::MutexGuard look(mutex);
//.. 访问临界资源
// 在局部作用域退栈的时候,先销毁的是lock,后销毁的是mutex
}
return 0;
}
Mutex封装的一些思考
上文我们封装的Mutex其实是从单纯功能意义上的封装(即刚好够用),然而有些细节我们没有考虑到。 从常规上来说,我们在封装类的时候,应该谨慎的选择要暴露的类的特性,因为我们无法知道外部是如何来使用我们这个类的,因此在设计类的时候要遵守的规则是只暴露我们想暴露的功能,尽可能减少不必要的暴露:
方法一:可以使用比如在类的注释上详细说明该类的使用方法,以及禁止的使用方法(当然这要保证每一个程序员都会认真的看注释)
方法二:采用assert,当出现了我们不希望的逻辑时,我们就抛出异常,从而将问题暴露出来,当然这非常暴力,而且不靠谱,只有当程序运行到对应逻辑,问题才能暴露出来,而这往往可能产生很大的影响;
方法三:在编译的时候,就让编译器可以检测出,这样做是非法的,我们不希望用户用这样的方式来使用该类。这种方法其实是最优选的,在编译阶段我们就找到了问题所在,而且是一个强制限制外部模块调用类的行为;
Mutex 的创建
回到上面简单的Mutex代码上,我们知道,如果我们没有声明对应的构造函数,C++会帮生成默认构造函数,拷贝构造函数,赋值函数。而这些默认的赋值函数采用的都是浅拷贝,这对于我们在析构函数中去释放资源这种方式来说,是一种巨大的灾难。比如我们的Mutex实例如果被复制一份,那么mutex_资源在原来的实例以及复制出来的实例在被回收时,均会对同一个mutex_进行销毁,造成重复释放。因此我们在编写类的时候,我们应该将C++默认帮我们生成的这些函数都屏蔽掉,除非我们真的需要它。这样才能达到类的最小暴露。 为了直接从源头上限制Mutex被用来拷贝赋值以及移动,我们可以直接将拷贝构造函数,赋值函数,移动构造函数等设置为private,或者通过C++11提供的delete特性删除函数
#define DXY_DISALLOW_ASSIGN(ClassName) \
void operator = (const ClassName&) = delete;
#define DXY_DISALLOW_COPY_AND_ASSIGN(ClassName) \
ClassName(const ClassName&) = delete; \
DXY_DISALLOW_ASSIGN(ClassName)
#define DXY_DISALLOW_MOVE_CONS(ClassName) \
ClassName(const ClassName&&) = delete;
class Mutex {
public:
Mutex() {
pthread_mutex_init(&mutex_, NULL);
}
~Mutex() {
pthread_mutex_destroy(&mutex_);
}
private:
void lock() {
pthread_mutex_lock(&mutex_);
}
void unLock() {
pthread_mutex_unlock(&mutex_);
}
private:
pthread_mutex_t mutex_;
DXY_DISALLOW_COPY_AND_ASSIGN(Mutex)
DXY_DISALLOW_MOVE_CONS(Mutex)
};
当然,按照面向对象的方法,我们也可以封装一个noncopyable 以及nonmoveable 的基类来实现
class noncopyable {
public:
noncopyable(const noncopyable&) = delete;
void operator = (const noncopyable&) = delete;
protected:
// 接口类
// 构造函数和析构函数放在protected,可以防止被实例化,
// 也就是该类只能被继承
noncopyable() = default;
~noncopyable() = default;
};
class nonmoveable {
public:
nonmoveable(const nonmoveable&) = delete;
protected:
nonmoveable() = default;
~nonmoveable() = default;
};
class Mutex : private noncopyable, nonmoveable {
public:
Mutex() {
pthread_mutex_init(&mutex_, NULL);
}
~Mutex() {
pthread_mutex_destroy(&mutex_);
}
void lock() {
pthread_mutex_lock(&mutex_);
}
void unLock() {
pthread_mutex_unlock(&mutex_);
}
private:
pthread_mutex_t mutex_;
};
Mutex 调用顺序
我们可以看到Mutex其实是存在一个使用顺序的,先初始化,锁,解锁,再销毁锁,由于我们把lock和unlock声明为public,因此这对操作是有可能出现调用了lock,但是没有调用unlock的情况的,这样如果直接销毁锁,那么就会造成异常情况,可能导致死锁,因此我们宁愿在出现这种情况的时候直接抛出异常,及时发现问题,也不希望我们的线程就死锁在那,导致将问题隐藏的更深。所以我们其实可以做一个flag来标志锁的状态
class Mutex : private noncopyable, nonmoveable {
public:
Mutex() :hold_(0) {
pthread_mutex_init(&mutex_, NULL);
}
~Mutex() {
assert(hold_ == 0);
pthread_mutex_destroy(&mutex_);
}
void lock() {
pthread_mutex_lock(&mutex_);
assignHold();
}
void unLock() {
unassignHold();
pthread_mutex_unlock(&mutex_);
}
private:
void unassignHold() {
hold_ = 0;
}
void assignHold() {
hold_ = 1;
}
private:
pthread_mutex_t mutex_;
int hold_;
// DXY_DISALLOW_COPY_AND_ASSIGN(Mutex)
// DXY_DISALLOW_MOVE_CONS(Mutex)
};
因为我们在大部分场景下,Mutex都是推荐搭配MutexGuard来使用的,而本身MutexGuard已经限制死了对Mutex的行为,我们也可以根据方法三的原则,我们把lock和unlock配置为private,这样完全限制了外部访问该接口,然后将MutexGuard声明为它的Mutex的友元类(当然这也牺牲了Mutex的封装性),这样只有MutexGuard可以访问到Mutex的private成员函数
class MutexGuard;
class Mutex : private noncopyable, nonmoveable {
public:
Mutex() :hold_(0) {
pthread_mutex_init(&mutex_, NULL);
}
~Mutex() {
assert(hold_ == 0);
pthread_mutex_destroy(&mutex_);
}
private:
void lock() {
pthread_mutex_lock(&mutex_);
assignHold();
}
void unLock() {
unassignHold();
pthread_mutex_unlock(&mutex_);
}
friend MutexGuard;
private:
void unassignHold() {
hold_ = 0;
}
void assignHold() {
hold_ = 1;
}
private:
pthread_mutex_t mutex_;
int hold_;
// DXY_DISALLOW_COPY_AND_ASSIGN(Mutex)
// DXY_DISALLOW_MOVE_CONS(Mutex)
};
但是采用这种方法,可能就限制Mutex的灵活性,由于Mutex的运用比较广泛,因此不太建议把接口限制死;
MutexGuard的构造
对于MutexGuard来看,和Mutex同样,我们在析构函数内有定义操作,那么我们就要很小心的对待拷贝函数,赋值函数,移动构造函数,因此我们也需要屏蔽掉。 同时MutexGuard的构造函数是单个入参,这种情况就有可能出现隐式转换,因此需要用explicit来限制这种行为。使得dxy::MutexGuard lock() 这种语义不明的方式被限制
class MutexGuard : private noncopyable, nonmoveable {
public:
explicit MutexGuard(Mutex &mutex) : mutex_(mutex) {
mutex_.lock();
}
~MutexGuard() {
mutex_.unLock();
}
private:
Mutex &mutex_;
};
总结
以上就算是把Mutex最基本的功能的封装思路了,如果要工程化,可能还要考虑封装condition的情况,以及一些线程辅助定位的判断接口,同时也可以考虑声明一些编译器的特性,从而增加更多的编译检查,帮我们在编译阶段就发现问题(这点听说Clang做的比GCC好多了)。这里我们略微扩散的点:
- RAII思想:资源获取即初始化;
- 类的最小暴露:拷贝,赋值,移动等构造函数的限制;
- 类调用逻辑限制:通过代理类限制类调用逻辑;
- explicit的用法:当构造函数只有单一入参时推荐使用,防止意想不到的隐式转换;
- friend友元类的用法:当我们希望类的一些私有函数只对某些类调用时使用,慎用,会破坏类的封装性;