从C++的单例模式到任务队列的构建

C++的单例模式到任务队列

什么是单例模式?

单例模式是设计模式中的一种,它隶属于创建型设计模式,作用是让使用者能保证该类只会存在一个实例,同时对外提供一个访问该实例的全局节点。

单例解决了两个问题:

  1. 保证一个类只有一个实例

    它的运作机制是这样的:如果创建了一个对象,同时过一会决定再创建一个对象则使用者会获得之前创建的对象而不是一个新的对象,换言之——也就是保证了一个类只会存在一个实例

    为什么会有人想要只有一个实例?最常见的原因是为了控制某些共享资源(例如数据库、任务队列或者文件)的访问权限。

    同时还要注意,普通构造函数一定无法完成这个任务,因为构造函数的设定使得它每次使用必须返回一个新对象,因此需要一些特殊处理,暂时按下不表,后文详细介绍。

  2. 为该实例提供了一个全局访问节点

    从前面的描述看起来,单例与全局变量似乎起到了一个作用,但是全局变量的使用具用极强的安全隐患,因为任何代码都有可能覆盖掉那个全局变量的内容,它只是保证了名字不变,但内容不一定始终如一,同时全局变量也无法保证这个类只有一个实例(请牢记:不要把安全隐患暴露给使用者,而应尽可能从源头遏制相关问题)

    单例虽然也允许程序在任何地方访问特定对象,但它可以保护该实例不被其他代码覆盖。

    另外:没有一个代码编写者希望解决同一个问题的代码分散在程序各处的,把它们放到同一个类中是一个特别好的方法,尤其是如果还有其他代码已经依赖于这个类的时候。

编写单例模式的解决方案

所有单例的实现一定不会跳脱出以下两个相同的步骤:

  1. 将默认构造函数设为私有, 防止其他对象使用单例类的new运算符
  2. 新建一个静态构建方法作为构造函数。 该函数会“偷偷”调用上述被设置为私有的构造函数来创建对象,并将其保存在一个静态成员变量中。 此后所有对于该函数的调用都将返回这一缓存对象。(只要能够访问单例类, 那就可以调用单例类的静态方法进而获得单例实例对象)

编写单例模式的具体步骤

  1. 在类中添加一个私有静态成员变量用于保存单例实例

  2. 声明一个公有静态构建方法用于获取单例实例

  3. 在静态方法中实现"延迟初始化"。 该方法会在首次被调用时创建一个新对象, 并将其存储在静态成员变量中,此后该方法每次被调用时都返回该实例

  4. 将类的构造函数设为私有。 类的静态方法仍能调用构造函数, 但是其他对象不能调用

  5. 检查客户端代码,将对单例的构造函数的调用替换为对其静态构建方法的调用

使用C++实现单例模式

上文有提到,单例模式应禁止掉涉及一个类会产生新对象的构造函数,在C++中,会涉及到一个类多对象操作的函数有如下几个:

  • 构造函数:创建一个新的对象
  • 拷贝构造函数:根据已有对象拷贝出一个新的对象
  • 拷贝赋值操作符重载函数:对于=的重载,用于两个对象之间的赋值操作

为了把一个类的实例化多个对象的路封死,可以进行如下处理:

  1. 构造函数私有化,使得只有类内部才能调用,并通过一定方式保证只会调用一次
    • 考虑到使用者在类外部不能使用构造函数,所以在类内部创建的这个唯一的对象必须是静态的,这样就可以仅通过类名来进行访问,同时为了不破坏封装,一般会选择将这个静态对象的访问权限设置为私有
    • C++中类的静态成员变量只有其静态成员函数才可以访问,因此给这个单例类提供一个公有的静态函数来访问得到这个静态的单例对象
  2. 拷贝构造函数私有化(private\protect)或者禁用(使用=delete后缀)
  3. 拷贝赋值操作符重载函数私有化(private\protect)或者禁用(使用=delete后缀),从单例的语义上讲这个函数已经毫无意义,因此类中不再提供这样一个函数,所以也将它一并处理掉

单例模式的类的模版——雏形

根据上述信息,我们给出单例模式的第一版代码:

  • 将相关函数禁用的写法:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    // 定义一个单例模式的类
    class Singleton {
    public:
        // = delete 代表函数禁用, 也可以将其访问权限设置为私有
        Singleton(const Singleton &obj) = delete;
    
        Singleton &operator=(const Singleton &obj) = delete;
    
        static Singleton *getInstance();
    
    private:
        Singleton() = default;
    
        static Singleton *m_obj;
    };
    
  • 将相关函数设置为私有化的写法:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    // 定义一个单例模式的类
    class Singleton {
    public:
        static Singleton *getInstance();
    
    private:
        // = default 代表使用默认的实现
        Singleton() = default;
    
        Singleton(const Singleton &obj) = default;
    
        Singleton &operator=(const Singleton &obj) = default;
    
        static Singleton *m_obj;
    };
    

以上就是一个单例模式的C++实现的基本雏形,首先,它有两种方案,一个是将构造函数禁用,一个是将构造函数私有化,这里要注意一点如果选用禁用构造函数的方案的话不能把所有构造函数都禁用,因为我们还是需要一个实例。

接下来,我们要注意一个细节,无论哪种方案目前有一个函数都还没有去实现,那就是getInstance()方法,通过这个方法可以获得这个类的唯一实例,同时因为要操作静态成员变量,因此这个函数也是静态成员函数,可以直接通过类名进行访问。

具体到getInstance()的实现,这里需要先引入两个概念,懒汉模式饿汉模式:

  • 懒汉模式:懒汉比较懒,不考虑场景,在类加载的时候就立即进行实例化,这样就得到了一个唯一的可用对象
  • 饿汉模式:饿汉则比较乖巧,在类加载的时候(不饿)不去创建这个唯一的实例,而是在需要使用的时候(饿了)再进行实例化

从这个两个概念我们很容易看出来,懒汉模式比较简单直接,只要程序开始运行就会有这个唯一的类,而饿汉只有只有用到的时候才去创建,因此懒汉模式更适合全局要使用的对象(比如服务器的数据库连接池),而饿汉更适合局部使用的对象,同时仅在需要的时候才去创建,会更节省相应资源,这也是饿汉模式的一个优点。接下来我们看一下懒汉模式以及饿汉模式应如何编写相关代码。

单例模式的类的模版——饿汉模式

饿汉模式实现代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 懒汉模式
// 定义一个单例模式的类
class Singleton {
public:
    // = delete 代表函数禁用, 也可以将其访问权限设置为私有
    Singleton(const Singleton &obj) = delete;

    Singleton &operator=(const Singleton &obj) = delete;

    static Singleton *getInstance() {
        return m_obj;
    }

private:
    Singleton() = default;

    static Singleton *m_obj;
};

Singleton *Singleton::m_obj = new Singleton;

可以看到,饿汉模式的代码非常简单,只需要直接返回私有成员变量m_obj即可,这是因为在第20行m_obj成员变量已经被定义好了,因此每次返回的都是这个唯一的成员变量。

这里注意一下,C++中类的静态成员变量在使用之前必须在类的外部进行初始化才可以使用

单例模式的类的模版——懒汉模式

有了上面的经验,我们很容易给出饿汉模式的第一版代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 饿汉模式
// 定义一个单例模式的类
class Singleton {
public:
    // = delete 代表函数禁用, 也可以将其访问权限设置为私有
    Singleton(const Singleton &obj) = delete;

    Singleton &operator=(const Singleton &obj) = delete;

    static Singleton *getInstance() {
        if (m_obj == nullptr) {
            m_obj = new Singleton;
        }
        return m_obj;
    }

private:
    Singleton() = default;

    static Singleton *m_obj;
};
Singleton *Singleton::m_obj = nullptr;

也就是在getInstance()方法中对私有成员变量进行一个判断,如果是空指针那么就说明还没有使用过它,需要创建,而如果不是空指针则说明已经使用过,直接返回私有成员变量即可。

这在单线程下自然是没有问题,接下来我们进一步思考,这个实现是多线程安全的实现方式吗,很显然不是,如果有多个线程同时执行这个函数,则每一个线程都会判断m_obj是空指针,也就每个线程都会创建一个新的实例对象,换言之,在第一次使用这个单例对象时,有多少个线程同时调用这个方法就会生成多少个实例对象。这明显与预期不符。

同时我们考虑一下之前的懒汉模式有没有线程安全问题,很显然没有,因为每个线程在使用之前就已经生成了唯一的实例对象,也就不会产生新的对象。接下来我们看一下要如何修改饿汉模式的代码使它成为线程安全的实现。

双重检查锁定

想要像饿汉模式一样没有线程安全问题,就需要保证对象只有一个,解决它最常用的办法是互斥锁,通过将创建单例对象的代码使用互斥锁锁住就可以保证线程安全,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 饿汉模式
// 定义一个单例模式的类
class Singleton {
public:
    // = delete 代表函数禁用, 也可以将其访问权限设置为私有
    Singleton(const Singleton &obj) = delete;

    Singleton &operator=(const Singleton &obj) = delete;

    void test_print() {
        cout << "test success" << endl;
    }

    static Singleton *getInstance() {
        m_mutex.lock();
        if (m_obj == nullptr) {
            m_obj = new Singleton;
        }
        m_mutex.unlock();
        return m_obj;
    }

private:
    Singleton() = default;

    static mutex m_mutex;
    static Singleton *m_obj;
};

Singleton *Singleton::m_obj = nullptr;
mutex Singleton::m_mutex;

看似解决了,但上面代码的15~19行被互斥锁锁住了,这就意味着无论多少个线程,只要想获得这个对象,就只能串行执行,这执行效率相比较于饿汉模式差了十万八千里,因此需要对它进行优化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 饿汉模式
// 定义一个单例模式的类
class Singleton {
public:
    // = delete 代表函数禁用, 也可以将其访问权限设置为私有
    Singleton(const Singleton &obj) = delete;

    Singleton &operator=(const Singleton &obj) = delete;

    void test_print() {
        cout << "test success" << endl;
    }

    static Singleton *getInstance() {
        if (m_obj == nullptr) {
            m_mutex.lock();
            if (m_obj == nullptr) {
                m_obj = new Singleton;
            }
            m_mutex.unlock();
        }

        return m_obj;
    }

private:
    Singleton() = default;

    static mutex m_mutex;
    static Singleton *m_obj;
};

Singleton *Singleton::m_obj = nullptr;
mutex Singleton::m_mutex;

改进的思路就是在加锁、解锁的代码块外层再添加一个空指针判断(第15行),这样当单例实例被创建出来之后,访问这个对象的线程就不会再执行加锁和解锁操作了(只要有了单例类的实例对象,限行就解除了)

注意⚠️:对于第一次创建单例对象的时候线程之间还是具有竞争关系,被互斥锁阻塞,但相比较于上面每一次都会阻塞的代码已经优化了很多。

上面这种通过两个嵌套的 if 来判断单例对象是否为空的操作就叫做双重检查锁定。

双重检查锁定的问题

虽然双重检查锁定看似解决了所有问题,但其实它仍然存在一些潜在危险,我们来看一下下面这个情况。

对于语句m_obj = new Singleton,它正常的机器指令如下:

  1. 分配内存用于保存Singleton对象
  2. 在分配的内存中构造一个Singleton对象(初始化内存)
  3. 使用m_obj指针指向分配的内存

但考虑到这是多条机器指令,在执行过程中这几条机器指令可能会被重新排列,重新排列后顺序可能会变成如下这种顺序:

  1. 分配内存用于保存Singleton对象
  2. 使用m_obj指针指向分配的内存
  3. 在分配的内存中构造一个Singleton对象(初始化内存)

这样重新排列并不会影响到单线程的执行结果,但在多线程中就会存在问题,如果线程 A 按照第二种顺序执行机器指令,执行完前两步之后失去 CPU 时间片被挂起了,此时线程 B 在第 3 行处进行指针判断的时候m_obj指针是不为空的,但这个指针指向的内存却没有被初始化,最后线程 B 使用了一个没有被初始化的单例对象,这就导致出问题了(出现这种情况是概率问题,需要反复的大量测试问题才可能会出现)。

这里使用C++11中引入的原子变量atomic可以实现一种更加安全的懒汉模式的单例类,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 饿汉模式
// 定义一个单例模式的类
class Singleton {
public:
    // = delete 代表函数禁用, 也可以将其访问权限设置为私有
    Singleton(const Singleton &obj) = delete;

    Singleton &operator=(const Singleton &obj) = delete;

    static Singleton *getInstance() {
        auto m_obj = m_atomic.load();
        if (m_obj == nullptr) {
            lock_guard<mutex> locker(m_mutex);	// 使用一种更便捷的方式来加锁解锁
            m_obj = m_atomic.load();
            if (m_obj == nullptr) {
                m_obj = new Singleton;
                m_atomic.store(m_obj);
            }
        }

        return m_obj;
    }

private:
    Singleton() = default;

    static atomic<Singleton *> m_atomic;
    static mutex m_mutex;
};

在C++11中,使用原子变量atomicstore()方法来存储单例对象,使用load()方法来加载单例对象,在原子变量中这两个函数在处理指令时默认的原子顺序是memory_order_seq_cst(顺序原子操作 - sequentially consistent),使用顺序约束原子操作库,整个函数执行都将保证顺序执行,并且不会出现数据竞态(data races),不足之处就是这样子的实现相比较于懒汉模式的单例执行效率会低一些

静态局部对象

在明白了,以上所有的基本原理,我们其实可以使用一种更简单的方法来实现C++中懒汉模式的单例,那就是使用局部静态变量,这种实现更简单同时也不会出现线程安全问题,对应代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 饿汉模式
// 定义一个单例模式的类
class Singleton {
public:
    // = delete 代表函数禁用, 也可以将其访问权限设置为私有
    Singleton(const Singleton &obj) = delete;

    Singleton &operator=(const Singleton &obj) = delete;

    static Singleton *getInstance() {
        static Singleton m_obj;
        return &m_obj;
    }

private:
    Singleton() = default;
};

在程序第11行使用了一个局部静态变量即可替代之前复杂的写法,这个静态局部对象就是这个类唯一的单例,这种方式也是线程安全的,因为在C++11的标准中有明确规定该操作在编译的时候由编译器保证:

如果指令逻辑进入一个未被初始化的声明变量,所有并发执行应当等待该变量完成初始化

一个基于单例模式的任务队列

接下来对这个单例模式进行一下实践,假设我们需要一个任务队列来接受需要完成任务并定时的处理,同时这个任务队列是全局共享的,那么使用单例模式来设计该任务队列就合情合理。

我们可以预先设计好这个队列的一些属性和方法如下:

  1. 属性:
    • 存储任务的容器,因为任务是先进先出的(FIFO),因此可以选用STL中的队列(queue)
    • 互斥锁,多线程访问该队列时应保护队列中的数据的线程安全
  2. 方法:
    • 队列判空操作
    • 向队列中添加一个任务
    • 从队列中取出一个任务
    • 从队列中删除一个任务

我们还是使用饿汉模式来完成这个任务队列,代码如下:

首先我们预先准备一组任务类,假设有两种任务分别为A和B,代码实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Task {
public:
    virtual void work() = 0;
    virtual std::string get_attribute() = 0;
};

class Task_A : public Task {
public:
    void work() override {
        std::cout << "已经执行完成任务A" << std::endl;
    }
    std::string get_attribute() override {
        return "Task A";
    }
};

class Task_B : public Task {
public:
    void work() override {
        std::cout << "已经执行完成任务B" << std::endl;
    }
    std::string get_attribute() override {
        return "Task B";
    }
};

任务队列代码实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

class TaskQueue {
public:
    TaskQueue(const TaskQueue &t) = delete;

    TaskQueue &operator=(const TaskQueue &t) = delete;

    static TaskQueue *getTaskQueue() {
        static TaskQueue taskQueue;
        return &taskQueue;
    }

    // 队列判空
    bool empty() {
        std::lock_guard<std::mutex> locker(m_mutex);
        return m_queue.empty();
    }

    // 添加任务
    void push_task(Task *task) {
        std::lock_guard<std::mutex> locker(m_mutex);
        m_queue.push(task);
    }

    // 删除任务
    bool pop_task() {
        std::lock_guard<std::mutex> locker(m_mutex);
        if (m_queue.empty()) {
            return false;
        }
        m_queue.pop();
        return true;
    }

    // 获取队头任务
    Task *get_task() {
        std::lock_guard<std::mutex> locker(m_mutex);
        if (m_queue.empty()) {
            return nullptr;
        }
        return m_queue.front();
    }

private:
    TaskQueue() = default;

    std::queue<Task *> m_queue;
    std::mutex m_mutex;
};

最终测试代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
int main() {
    // 生产者:基数次生产A任务,偶数次生产B任务
    std::thread t1([]() {
        auto q = TaskQueue::getTaskQueue();
        for (int i = 1; i <= 10; ++i) {
            if (i & 1) {
                q->push_task(new Task_A);
            } else {
                q->push_task(new Task_B);
            }
            cout << "+++push task: " << i << ", threadID: "
                 << this_thread::get_id() << endl;
            this_thread::sleep_for(chrono::milliseconds(500));
        }
    });
    // 消费者
    std::thread t2([]() {
        auto q = TaskQueue::getTaskQueue();
        while (!q->empty()) {
            auto task = q->get_task();
            if (task) {
                task->work();
                q->pop_task();
            }
            this_thread::sleep_for(chrono::seconds(1));
        }
    });
    t1.join();
    t2.join();
    return 0;
}

代码执行结果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
push task: 1, threadID: 0x16f987000
已经执行完成任务A
push task: 2, threadID: 0x16f987000
已经执行完成任务B
push task: 3, threadID: 0x16f987000
push task: 4, threadID: 0x16f987000
已经执行完成任务A
push task: 5, threadID: 0x16f987000
push task: 6, threadID: 0x16f987000
已经执行完成任务B
push task: 7, threadID: 0x16f987000
push task: 8, threadID: 0x16f987000
已经执行完成任务A
push task: 9, threadID: 0x16f987000
push task: 10, threadID: 0x16f987000
已经执行完成任务B
已经执行完成任务A
已经执行完成任务B
已经执行完成任务A
已经执行完成任务B
  • 正常情况的任务队列的work函数肯定要复杂很多,这里用文本输出替代
  • 任务队列中的互斥锁保护的是队列的数据(也就是任务),之前单例中说的线程安全是保障一个单例对象只会被创建一次,是不一样的
0%