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

2022-10-14,,,

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

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

关于单例最经典的问题就是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的用法,可以参见stackoverflow的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++单例.doc》

下载本文的Word格式文档,以方便收藏与打印。