通过mutex 的封装整理一些常见的知识点

174 阅读7分钟

通过mutex的封装整理一些常见的知识点

mutex的简单封装

根据RAII的特性简单封装了个mutex。所谓RAII(Resource Acquisition Is Initialization),资源获取即初始化即使用局部对象来管理资源。所谓的资源,我的理解是一切需要创建并且销毁的系统资源,包括内存,套接字,锁等等,局部对象指存储在栈上的对象。这里很好的利用了局部对象自动销毁的特性来管理资源的生命周期。 总结来说步骤一般分为一下:

  1. 把对资源的操作封装在一个类中
  2. 在构造函数内初始化资源
  3. 在析构函数内销毁资源
  4. 在局部作用域内声明一个该对象的类
#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好多了)。这里我们略微扩散的点:

  1. RAII思想:资源获取即初始化;
  2. 类的最小暴露:拷贝,赋值,移动等构造函数的限制;
  3. 类调用逻辑限制:通过代理类限制类调用逻辑;
  4. explicit的用法:当构造函数只有单一入参时推荐使用,防止意想不到的隐式转换;
  5. friend友元类的用法:当我们希望类的一些私有函数只对某些类调用时使用,慎用,会破坏类的封装性;