Concurrency-with-Modern-Cpp学习笔记 - 单例模式性能比较

单例模式:线程安全的初始化

开始研究之前,说明一下:我个人并不提倡使用单例模式。

对于单例模式的看法

我只在案例研究中使用单例模式,因为它是以线程安全的方式,初始化变量的典型例子。先来了解一下单例模式的几个严重缺点:

  • 单例是一个经过乔装打扮的全局变量。因此,测试起来非常困难,因为它依赖于全局的状态。
  • 通过MySingleton::getInstance()可以在函数中使用单例,不过函数接口不会说明内部使用了单例,并隐式依赖于对单例。
  • 若将静态对象xy放在单独的源文件中,并且这些对象的构造方式相互依赖,因为无法保证先初始化哪个静态对象,将陷入静态初始化混乱顺序的情况。这里要注意的是,单例对象是静态对象
  • 单例模式是惰性创建对象,但不管理对象的销毁。如果不销毁不需要的东西,那就会造成内存泄漏。
  • 试想一下,当子类化单例化,可能实现吗?这意味着什么?
  • 想要实现一个线程安全且快速的单例,非常具有挑战性。

关于单例模式的详细讨论,请参阅Wikipedia中有关单例模式的文章。

我想在开始讨论单例的线程安全初始化前,先说点别的。

双重检查的锁定模式

双重检查锁定模式是用线程安全的方式,初始化单例的经典方法。听起来像是最佳实践或模式之类的方法,但更像是一种反模式。它假设传统实现中有相关的保障机制,而Java、C#或C++内存模型不再提供这种保障。这样,创建单例是原子操作就是一个错误的假设,这样看起来是线程安全的解决方案并不安全。

什么是双重检查锁定模式?实现线程安全单例的,首先会想到用锁来保护单例的初始化过程。

std::mutex myMutex;
class MySingleton
{
public:
    static MySingleton &getInstance()
    {
        std::lock_guard<mutex> myLock(myMutex);
        if (!instance) instance = new MySingleton(); //7
        return *instance;
    }
private:
    MySingleton() = default;
    ~MySingleton() = default;
    MySingleton(const MySingleton &) = delete;
    MySingleton &operator= (const MySingleton &) = delete;
    static MySingleton *instance;
};

MySingleton *MySingleton::instance = nullptr;

程序有毛病么?有毛病:是因为性能损失太大;没毛病:是因为实现的确线程安全。第7行的锁会对单例的每次访问进行保护,这也适用于读取。不过,构造MySingleton之后,就没有必要读取了。这里双重检查锁定模式就发挥了其作用,再看一下getInstance函数。

static MySingleton& getInstance() {
  if (!instance) { // check
    lock_guard<mutex> myLock(myMutex); // lock
    if (!instance) instance = new MySingleton(); // check
  }
  return *instance;
}

第2行没有使用锁,而是使用指针比较。如果得到一个空指针,则申请锁的单例(第3行)。因为,可能有另一个线程也在初始化单例,并且到达了第2行或第3行,所以需要额外的指针在第4行进行比较。顾名思义,其中两次是检查,一次是锁定。

牛B不?牛。线程安全?不安全。

问题出在哪里?第4行中的instance= new MySingleton()至少包含三个步骤:

  1. MySingleton分配内存。
  2. 初始化MySingleton对象。
  3. 引用完全初始化的MySingleton对象。

能看出问在哪了么?

​ C++运行时不能保证这些步骤按顺序执行。例如,处理器可能会将步骤重新排序为序列1、3和2。因此,在第一步中分配内存,在第二步中实例引用一个非初始化的单例。如果此时另一个线程t2试图访问该单例对象并进行指针比较,则比较成功。其结果是线程t2引用了一个非初始化的单例,并且程序行为未定义。

性能测试

​ 我要测试访问单例对象的开销。对引用测试时,使用了一个单例对象,连续访问4000万次。当然,第一个访问的线程会初始化单例对象,四个线程的访问是并发进行的。我只对性能数字感兴趣,因此我汇总了这四个线程的执行时间。使用一个带范围(Meyers Singleton)的静态变量、一个锁std::lock_guard、函数std::call_oncestd::once_flag以及具有顺序一致和获取-释放语义的原子变量进行性能测试。

展示各种多线程实现的性能数字前,先来看一下串行的代码。C++03标准中,getInstance方法线程不安全。

// singletonSingleThreaded.cpp

#include <chrono>
#include <iostream>

constexpr auto tenMill = 10000000;

class MySingLeton
{
public:
    static MySingLeton &getInstance()
    {
        static MySingLeton instance;
        volatile int dummy{};
        return instance;
    }
private:
    MySingLeton() = default;
    ~MySingLeton() = default;
    MySingLeton(const MySingLeton &) = delete;
    MySingLeton &operator=(const MySingLeton &) = delete;

};

int main()
{
    constexpr auto fourtyMill = 4 * tenMill;
    const auto begin = std::chrono::system_clock::now();
    for (size_t i = 0; i <= fourtyMill; ++i)
    {
        MySingLeton::getInstance();
    }
    const auto end = std::chrono::system_clock::now() - begin;
    std::cout << std::chrono::duration<double>(end).count() << std::endl;

}

在这里插入图片描述

使用volatile声明变量dummy

当我用最高级别的优化选项来编译程序时,编译器删除了第30行中的MySingleton::getInstance(),因为调用不调用都没有效果,我得到了非常快的执行,但结果错误的性能数字。通过使用volatile声明变量dummy(第12行),明确告诉编译器不允许优化第30行中的MySingleton::getInstance()调用。

C++11中,Meyers单例已经线程安全了。

线程安全的Meyers单例

C++11标准中,保证以线程安全的方式初始化具有作用域的静态变量。Meyers单例使用就是有作用域的静态变量,这样就成了!剩下要做的工作,就是为多线程用例重写Meyers单例。

多线程中的Meyers单例

// singletonMeyers.cpp

#include <chrono>
#include <iostream>
#include <future>

constexpr auto tenMill = 10000000;

class MySingLeton
{
public:
    static MySingLeton &getInstance()
    {
        static MySingLeton instance;
        volatile int dummy{};
        return instance;
    }
private:
    MySingLeton() = default;
    ~MySingLeton() = default;
    MySingLeton(const MySingLeton &) = delete;
    MySingLeton &operator=(const MySingLeton &) = delete;

};

std::chrono::duration<double> getTime()
{

    auto begin = std::chrono::system_clock::now();
    for (size_t i = 0; i <= tenMill; ++i)
    {
        MySingLeton::getInstance();
    }
    return std::chrono::system_clock::now() - begin;
}

int main()
{
    auto fut1 = std::async(std::launch::async, getTime);
    auto fut2 = std::async(std::launch::async, getTime);
    auto fut3 = std::async(std::launch::async, getTime);
    auto fut4 = std::async(std::launch::async, getTime);
    const auto total = fut1.get() + fut2.get() + fut3.get() + fut4.get();
    std::cout << total.count() << std::endl;
}

在这里插入图片描述

函数getTime中使用单例对象(第24 - 32行),函数由第36 - 39行中的四个promise来执行,相关future的结果汇总在第41行。

我们来看看最直观的方式——锁。

std::lock_guard

std::lock_guard中的互斥量,保证了能以线程安全的方式初始化单例对象。

// singletonLock.cpp

#include <chrono>
#include <iostream>
#include <future>
#include <mutex>

constexpr auto tenMill = 10000000;

std::mutex myMutex;

class MySingleton
{
public:
    static MySingleton &getInstance()
    {
        std::lock_guard<std::mutex> myLock(myMutex);
        if (!instance)
        {
            instance = new MySingleton();
        }
        volatile int dummy{};
        return *instance;
    }
private:
    MySingleton() = default;
    ~MySingleton() = default;
    MySingleton(const MySingleton &) = delete;
    MySingleton &operator=(const MySingleton &) = delete;

    static MySingleton *instance;
};

MySingleton *MySingleton::instance = nullptr;

std::chrono::duration<double> getTime()
{

    auto begin = std::chrono::system_clock::now();
    for (size_t i = 0; i <= tenMill; ++i)
    {
        MySingleton::getInstance();
    }
    return std::chrono::system_clock::now() - begin;

}

int main()
{

    auto fut1 = std::async(std::launch::async, getTime);
    auto fut2 = std::async(std::launch::async, getTime);
    auto fut3 = std::async(std::launch::async, getTime);
    auto fut4 = std::async(std::launch::async, getTime);

    const auto total = fut1.get() + fut2.get() + fut3.get() + fut4.get();

    std::cout << total.count() << std::endl;

}

这种方式非常的慢。

在这里插入图片描述

线程安全单例模式的下一个场景,基于多线程库,并结合std::call_oncestd::once_flag

使用std::once_flag的std::call_once

std::call_oncestd::once_flag可以一起使用,以线程安全的方式执行可调用对象。

// singletonCallOnce.cpp

#include <chrono>
#include <iostream>
#include <future>
#include <mutex>
#include <thread>

constexpr auto tenMill = 10000000;

class MySingleton
{
public:
    static MySingleton &getInstance()
    {
        std::call_once(initInstanceFlag, &MySingleton::initSingleton);
        volatile int dummy{};
        return *instance;
    }
private:
    MySingleton() = default;
    ~MySingleton() = default;
    MySingleton(const MySingleton &) = delete;
    MySingleton &operator=(const MySingleton &) = delete;

    static MySingleton *instance;
    static std::once_flag initInstanceFlag;

    static void initSingleton()
    {
        instance = new MySingleton;
    }
};

MySingleton *MySingleton::instance = nullptr;
std::once_flag MySingleton::initInstanceFlag;

std::chrono::duration<double> getTime()
{

    auto begin = std::chrono::system_clock::now();
    for (size_t i = 0; i <= tenMill; ++i)
    {
        MySingleton::getInstance();
    }
    return std::chrono::system_clock::now() - begin;

}

int main()
{

    auto fut1 = std::async(std::launch::async, getTime);
    auto fut2 = std::async(std::launch::async, getTime);
    auto fut3 = std::async(std::launch::async, getTime);
    auto fut4 = std::async(std::launch::async, getTime);

    const auto total = fut1.get() + fut2.get() + fut3.get() + fut4.get();

    std::cout << total.count() << std::endl;

}

在这里插入图片描述

继续使用原子变量来实现线程安全的单例。

原子变量

使用原子变量,让实现变得更具有挑战性,我甚至可以为原子操作指定内存序。基于前面提到的双重检查锁定模式,实现了以下两个线程安全的单例。

顺序一致语义

第一个实现中,使用了原子操作,但没有显式地指定内存序,所以默认是顺序一致的。

// singletonSequentialConsistency.cpp

#include <chrono>
#include <iostream>
#include <future>
#include <mutex>
#include <thread>

constexpr auto tenMill = 10000000;

class MySingleton
{
public:
    static MySingleton &getInstance()
    {
        MySingleton *sin = instance.load();
        if (!sin)
        {
            std::lock_guard<std::mutex>myLock(myMutex);
            sin = instance.load(std::memory_order_relaxed);
            if (!sin)
            {
                sin = new MySingleton();
                instance.store(sin);
            }
        }
        volatile int dummy{};
        return *instance;
    }
private:
    MySingleton() = default;
    ~MySingleton() = default;
    MySingleton(const MySingleton &) = delete;
    MySingleton &operator=(const MySingleton &) = delete;

    static std::atomic<MySingleton *> instance;
    static std::mutex myMutex;
};


std::atomic<MySingleton *> MySingleton::instance;
std::mutex MySingleton::myMutex;

std::chrono::duration<double> getTime()
{

    auto begin = std::chrono::system_clock::now();
    for (size_t i = 0; i <= tenMill; ++i)
    {
        MySingleton::getInstance();
    }
    return std::chrono::system_clock::now() - begin;

}

int main()
{

    auto fut1 = std::async(std::launch::async, getTime);
    auto fut2 = std::async(std::launch::async, getTime);
    auto fut3 = std::async(std::launch::async, getTime);
    auto fut4 = std::async(std::launch::async, getTime);

    const auto total = fut1.get() + fut2.get() + fut3.get() + fut4.get();

    std::cout << total.count() << std::endl;

}

在这里插入图片描述

与双重检查锁定模式不同,由于原子操作的默认是顺序一致的,现在可以保证第19行中的sin = new MySingleton()出现在第20行instance.store(sin)之前。看一下第17行:sin = instance.load(std::memory_order_relax),因为另一个线程可能会在第14行第一个load和第16行锁的使用之间,介入并更改instance的值,所以这里的load是必要的。

我们进一步的对程序进行优化。

获取-释放语义

仔细看看之前使用原子实现单例模式的线程安全实现。第14行中单例的加载(或读取)是一个获取操作,第20行中存储(或写入)是一个释放操作。这两种操作都发生在同一个原子上,所以不需要顺序一致。C++11标准保证释放与获取操作在同一原子上同步,并建立顺序约束。也就是,释放操作之后,不能移动之前的所有读和写操作,并且在获取操作之前不能移动之后的所有读和写操作。

这些都是实现线程安全单例的最低保证。

// singletonAcquireRelease.cpp

#include <chrono>
#include <iostream>
#include <future>
#include <mutex>
#include <thread>

constexpr auto tenMill = 10000000;

class MySingleton
{
public:
    static MySingleton &getInstance()
    {
        MySingleton *sin = instance.load(std::memory_order_acquire);
        if (!sin)
        {
            std::lock_guard<std::mutex>myLock(myMutex);
            sin = instance.load(std::memory_order_release);
            if (!sin)
            {
                sin = new MySingleton();
                instance.store(sin);
            }
        }
        volatile int dummy{};
        return *instance;
    }
private:
    MySingleton() = default;
    ~MySingleton() = default;
    MySingleton(const MySingleton &) = delete;
    MySingleton &operator=(const MySingleton &) = delete;

    static std::atomic<MySingleton *> instance;
    static std::mutex myMutex;
};


std::atomic<MySingleton *> MySingleton::instance;
std::mutex MySingleton::myMutex;

std::chrono::duration<double> getTime()
{

    auto begin = std::chrono::system_clock::now();
    for (size_t i = 0; i <= tenMill; ++i)
    {
        MySingleton::getInstance();
    }
    return std::chrono::system_clock::now() - begin;

}

int main()
{

    auto fut1 = std::async(std::launch::async, getTime);
    auto fut2 = std::async(std::launch::async, getTime);
    auto fut3 = std::async(std::launch::async, getTime);
    auto fut4 = std::async(std::launch::async, getTime);

    const auto total = fut1.get() + fut2.get() + fut3.get() + fut4.get();

    std::cout << total.count() << std::endl;

}

获取-释放语义与顺序一致内存序有相似的性能。

在这里插入图片描述

各种线程安全单例实现的性能表现总结

​ 数字很明确,Meyers 单例模式是最快的。它不仅是最快的,也是最容易实现的。如预期的那样,Meyers单例模式比原子模式快两倍。锁的量级最重,所以最慢。std::call_once在Windows上比在Linux上慢得多。

操作系统(编译器)单线程Meyers单例std::lock_guardstd::call_once顺序一致获取-释放语义
Linux(GCC)0.1500.29536.971.611.711.72
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值