欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

熟悉的味道——从Java单例写到C++单例

程序员文章站 2022-10-24 11:50:53
从Java思考至C++,关于如何实现正确的单例类(涉及内存屏障等概念)。 ......

设计模式中,单例模式是常见的一种。单例模式需要满足以下两个条件:

  • 保证一个类只能创建一个示例;
  • 提供对该实例的全局访问点。

关于单例最经典的问题就是dcl(double-checked lock),今天就此问题展开叙述。

1 java单例

1.1 通用的写法

public class singleton {

    private singleton() {}
    public static singleton instance = new singleton();
}

最简单的做法就是如上写法,在类被加载的时候就初始化其静态变量instance。因为jls(java language specification)中规定一个类只会被初始化一次,因此这样就可以实现一个朴实的单例类。

1.2 延迟加载单例

需求是多变的,部分人可能因为单例类的资源负担较重,想要其在需要的时候再进行初始化(lazy initialization)。这样也简单,加把锁就满足你的需求。

public class singleton {

    private singleton() {}
    private static singleton instance = null;
    public static synchronized singleton getinstance() {
        if (null == instance) {
            instance = new singleton();
        }
        return instance;
    }
}

但是挑剔的人又提出了需求,引入synchronized之后,多线程访问单例,会因为synchronized导致访问串行化,这性能上很不好看。程序员于是只能去进行优化,想到了dcl,于是bug就被引入了。

public class singleton {

    private singleton() {}
    private static singleton instance = null;
    public static singleton getinstance() {
        if (null == instance) {
            synchronized (singleton.class) {
                if (null == instance) {
                    instance = new singleton();
                }
            }
        }
        return instance;
    }
}

这是一段看上去很优美的代码,程序员在判断instance为空,加锁进行赋值时,还贴心的考虑到了可能存在多个线程同时判断instance是否为空的情况。如果加锁后发现instance被快一步的线程赋值了,那么我就直接返回此instance。
但是计算机的套路太多了,bug就出现在instance = new singleton()这一句代码上。这一句并不是原子操作,细分下去实际上可以被拆分为以下三个步骤:

  • 分配singleton对象的内存空间;
  • 初始化singleton对象(完成一些field赋值操作,本文为了篇幅,没填充field);
  • instance指向分配的内存空间。

假如计算机严格的按照这个方式执行,那么dcl没错。可是cpu为了效率,代码可能会被乱序执行,假如线程a的指令被乱序为:

  • 分配singleton对象的内存空间;
  • instance指向分配的内存空间;
  • 初始化singleton对象。

线程b假如在执行到第二步的时候拿到了instance对象(此时并未初始化),并且快速的传给应用使用,那么一些美好的事情即将发生(至于是什么,我当然是不晓得的)。

1.3 正确的dcl

2000年,一群致力于java高性能开发者聚集在一起,发表了著名的文章 the "double-checked locking is broken" declaration
文章中讨论了关于dcl的各种尝试,比如使用threadlocal(虽然在老版本的代码中,可能效率会较低,但是为了正确性这是可以忍受的)。不过文章的最后,大家最欣赏的办法就是使用volatile修饰instance对象(加入内存屏障)。

public class singleton {

    private singleton() {}
    private static volatile singleton instance = null;
    public static singleton getinstance() {
        if (null == instance) {
            synchronized (singleton.class) {
                if (null == instance) {
                    instance = new singleton();
                }
            }
        }
        return instance;
    }
}

奏效的原因在于,对volatile对象的写操作不能被重排序到之前对volatile对象的读写操作之前,对volatile对象的读操作不能被重排序到之后对volatile的读写操作之后(如果对这段翻译不甚理解,可以见下面贴着的原文,其实大概的意思就是告诉cpu别重排序了)。当然,享受福利就需要付出一些代价,就是升级jdk至1.5及之后的版本(估计总是会有人守着历史版本,就像锁死在三体的百度,坚持xp优于win10的人们,我真的很respect)。

jdk5 and later extends the semantics for volatile so that the system will not allow a write of a volatile to be reordered with respect to any previous read or write, and a read of a volatile cannot be reordered with respect to any following read or write.

2 c++单例

2.1 加入内存屏障

让我感兴趣的是,在the "double-checked locking is broken一文中,各位大佬列举了关于c++如何正确实现dcl的做法(加入内存屏障),并且他们热心的贴出了代码,如下所示:

// c++ implementation with explicit memory barriers
// should work on any platform, including dec alphas
// from "patterns for concurrent and distributed objects",
// by doug schmidt
template <class type, class lock> type *
singleton<type, lock>::instance (void) {
    // first check
    type* tmp = instance_;
    // insert the cpu-specific memory barrier instruction
    // to synchronize the cache lines on multi-processor.
    asm ("memorybarrier");
    if (tmp == 0) {
        // ensure serialization (guard
        // constructor acquires lock_).
        guard<lock> guard (lock_);
        // double check.
        tmp = instance_;
        if (tmp == 0) {
                tmp = new type;
                // insert the cpu-specific memory barrier instruction
                // to synchronize the cache lines on multi-processor.
                asm ("memorybarrier");
                instance_ = tmp;
        }
    return tmp;
}

如果跟java的代码进行比较,会发现和java的dcl基本类似,只不过有两处显式插入了内存屏障(即asm处)。此外,此段代码使用了rcu(read-copy-update),即先获取需要读取或者创建的数值(此处就是tmp),在检测tmp是合法的数值之后,最后更新到instance_变量中去。通过rcu保证了在创建以及赋值的过程中,不会干扰到别的线程(假使失败,那么也不会污染instance_数值,不过在c++编程中,new失败了不如直接爆炸吧,这样死的明明白白一点)。

关于rcu的用法,可以参见*的rcu大讨论

不过以上方法看似美好,但是在不同的平台上,内存屏障的添加方式也是存在差异,这就导致了你没法写出可移植的代码,甚至是依赖于特定编译器和特定环境的代码。这里就存在一个疑问,c++ 中也含有volatile,是否可以参考java的代码,使用volatile添加内存屏障。答案很悲伤,c++ 的volatile和java的volatile存在区别,无法照搬(关于此区别,可能在后续会写文章进行讲解)。

2.2 找一个类似于volatile的帮手

c++ 11中引入了std::atomic以及std::memory_order,有了标准库的定义,我们就能够写出在绝大多数平台下可移植的c++代码。此处,我们要了解一个概念,std::atomic的默认数值是std::memory_order_seq_cst,这是一个永远安全却又代价昂贵的内存屏障。其作用是在所有cpu(或者核心)中,保持严格的代码线性执行顺序。

std::atomic<singleton*> singleton::m_instance;
std::mutex singleton::m_mutex;

singleton* singleton::getinstance() {
    singleton* tmp = m_instance.load();
    if (tmp == nullptr) {
        std::lock_guard<std::mutex> lock(m_mutex);
        tmp = m_instance.load();
        if (tmp == nullptr) {
            tmp = new singleton;
            m_instance.store(tmp);
        }
    }
    return tmp;
}

使用std::atomic,利用编译器提供的库,现在dcl就能够保证代码的线性执行顺序,代码在多个平台上也能够保证通用性(假如不能保证,我们还能对编译器要求再多吗)。
然而c++开发者向来以追求性能为己任,还能快一点,再快一点吗?当然能了,这段代码存在的问题恰恰就是全部代码都是顺序执行,我们只需要对部分执行代码做出顺序保证即可。

2.3 精确控制顺序

std::atomic<singleton*> singleton::m_instance;
std::mutex singleton::m_mutex;

singleton* singleton::getinstance() {
    singleton* tmp = m_instance.load(std::memory_order_relaxed);
    // 保证在获取单例指针时,其他线程如果在创建单例对象
    // 这些操作,包括创建singleton对象以及其成员变量的初始化,对于当前线程都是可见的
    std::atomic_thread_fence(std::memory_order_acquire);
    if (tmp == nullptr) {
        std::lock_guard<std::mutex> lock(m_mutex);
        tmp = m_instance.load(std::memory_order_relaxed);
        if (tmp == nullptr) {
            tmp = new singleton;
            // 保证后续语句,存储tmp指针时,new singleton已经执行结束
            // 最重要的是,tmp的创建以及singleton的内部初始化操作,对于其他线程均是可见的
            std::atomic_thread_fence(std::memory_order_release);
            m_instance.store(tmp, std::memory_order_relaxed);
        }
    }
    return tmp;
}

我们对代码进行了修改,此处使用atomic_thread_fence实现关键位置的内存屏障。每次获取或存储变量m_instance,均使用memory_order_relaxed,保证单例的指针操作时原子操作。那么,存在的疑问就在于指针所指向的内容是不是跟随着原子操作,被同步过来了呢?
答案不是。指针是原子操作,但是指针的内容并未保证同步,因此我们需要使用std::atomic_thread_fence(std::memory_order_acquire)以及std::atomic_thread_fence(std::memory_order_release)去保证指针存放的内容被正确的同步了(详见代码中的注释内容)。

关于std::atomic_thread_fence,这个确实很晦涩,知乎上wangyongcong
给出的例子,我觉得很是恰当,以下摘抄过来。

thread a thread b
release-fence atomic-load
atomic-store acquire-fence

存在线程a与b,a上的release-fence之后的atomic-store操作,如果对b的atomic-load操作可见,那么a的release-fence与b的atomic-load同步(sync-with);另一方面,b的atomic-load操作后跟acquire-fence,如果线程a的atomic-store所做出的的修改对该atomic-load可见,那么a的atomic-store与b的acquire-fence同步。
a的release-fence与b的acquire-fence构成了一个同步点。在时间轴上,如果a的release-fence在b的acquire-fence钱,那么a在release-fence之间的所有操作,也都在b的acquire-fence之前,同时也将在b的acquire-fence的后继操作之前。简单点,就是release-fence之前的所有store,对acquire-fence之后都是可见的。

补充:
1.上面所提到的atomic load/store均是指对同一个atmoic变量操作;
2.fence必须跟atomic变量共同作用才能起到一致性作用,离开atomic变量,那么无效。

通过使用std::atmoic以及std::atomic_thread_fence,大大的缩小了单例的同步范围,这样也就让cpu有了更大的*去实现优化(指令乱序本身就是为了提升处理效率,为了性能,我们还是得捏着鼻子忍受这件事情)。

2.4 站在巨人的肩膀上

下面介绍一个单例的正确做法,那就是寄托于pthread库已实现安全的单例。下面这段代码摘抄于陈硕的muduo代码中(省略了部分代码),他对此做法的解释是,如果pthread_once都不能保证单例的线程安全,那么我们的代码就让他崩溃吧(总不能让我们再去修改pthread库吧)。

template<typename t>
class singleton : noncopyable
{
 public:
  // 对于c++开发者,使用delete以及boost::noncopyable真的是一个好习惯
  singleton() = delete;
  ~singleton() = delete;

  static t& instance()
  {
    pthread_once(&ponce_, &singleton::init);
    assert(value_ != null);
    return *value_;
  }

 private:
  static pthread_once_t ponce_;
  static t*             value_;
};

2.5 让我们时髦一些

虽然嘴上说着时髦,其实概念很老旧,那就是借助于c++ 11中的local static去正确的实现单例(其本质上,是让编译器去帮助我们完成那些复杂的同步操作,保证我们的代码尽可能的less)。以下是c++ 11关于local static的描述(翻译过来就是在多线程调用的情况下,local static只会被初始化一次)。

if control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization.

于是,我们的代码终于可以精简到一个很完美的地步(比java的版本还要易于理解,不过需要注意,这要求我们的编译器支持c++ 11,或者说使用gcc 4.0以上版本,虽然gcc 4.0及以上的若干版本不完美支持c++ 11,但是它支持local static特性):

t& singleton()
{
    static t instance;
    return instance;
}

这个写法太完美了,不过我还是不喜欢延迟加载,更倾向于在main函数执行之前就初始化静态单例变量(java稍简单,一个声明语句就可以,c++需要在类外执行初始化)。

class singleton: boost::noncopyable
{
private:
    static singleton instance;
public:
    singleton() = delete;
    ~singleton() = delete;
public:
    static singleton& getinstance() {
        return instance;
    }
}

// 类外进行初始化操作
singleton singleton::instance;

ps:
如果您觉得我的文章对您有帮助,请关注我的微信公众号,谢谢!
熟悉的味道——从Java单例写到C++单例