文章列表
AI 写作

C++ 单例:一行 static 背后的初始化保证

语法早就存在,C++11 新增的是并发初始化的可移植保证

很多 C++ 单例实现最后都缩成这样:

static T& Instance() {
    // C++11 guarantees thread-safe initialization of function-local static objects.
    static T instance;
    return instance;
}

它看起来像是把对象藏进函数里,再用一个引用把对象取出来。真正值得讲的却不是单例模式本身,而是这段代码同时碰到了 C++ 的存储期、作用域、引用、延迟初始化,以及 C++11 才补齐的并发语义。

先纠正一个叫法:上下文里说的“C98”应当是“C++98”。如果严格说 C 语言,C98 并不存在这个版本;更重要的是,C 语言没有 C++ 的类、引用和成员函数,因此 T& 这套语法根本不是 C 代码。

本文目录

两个 static 不是一回事

完整的单例类通常写成这样:

class Singleton {
public:
    static Singleton& Instance() {
        static Singleton instance;
        return instance;
    }

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

private:
    Singleton() = default;
    ~Singleton() = default;
};

外层的 static 出现在类成员函数声明里,表示 Instance 是静态成员函数。它没有 this 指针,可以直接用 Singleton::Instance() 调用。

内层的 static 修饰的是函数体里的局部变量 instance。它仍然只在这段函数体内可见,但存储期不是“每次进入函数就新建一次”的自动存储期,而是静态存储期:程序运行期间一直保留同一个对象。它的初始化时机是控制流第一次经过这条声明,而不是每次调用函数都重新构造。

所以这里的“单例”来自几个条件的叠加:

  • instance 的名字被局限在 Instance 函数里,外部不能直接拿到另一个同名对象;
  • static 让它在多次调用之间保留同一个对象;
  • 构造函数私有,外部不能随意 Singleton s
  • 删除拷贝构造和拷贝赋值,避免通过复制制造第二个对象;
  • 返回 Singleton&,把同一个对象的引用交给调用者,不返回副本。

最后两项是 C++11 写法。C++98 没有 = delete= default,只能把拷贝构造、赋值运算符和构造函数声明在 private 区域而不定义它们。

T& 到底做了什么

T& 可以从右往左读成“一个引用,引用的对象类型是 T”。因此:

static T& Instance()

表示 Instance 返回的是 T 对象的引用,不是一个 T 对象。

return instance;

这里返回的是局部名字 instance 所指向的那个静态对象。因为它的生命周期覆盖整个程序运行期,函数返回后引用仍然有效;如果它是普通局部变量,这样返回引用就会变成悬空引用。

返回引用还有两个直接效果:调用者拿到的是原对象,可以修改它;同时没有额外的对象复制。若只允许读取,可以把接口写成 const T&,但这不会自动让 T 内部的其他共享状态具备并发安全性。

第一次调用时,编译器实际要解决什么

把调用过程拆成三个场景最清楚。

第一次调用时,控制流到达:

static T instance;

如果对象还没有完成初始化,就调用 T 的默认构造函数。构造完成后执行 return instance

之后再次调用时,instance 已经初始化完成,构造函数不会再次执行,代码直接返回同一个对象。

两个线程同时第一次调用时,问题就出现了:它们都可能同时发现对象“还没有初始化”。如果没有额外同步,可能发生两次构造、一个线程读到半构造对象,或者初始化标记先于对象内容对另一个线程可见。

实现通常会为局部 static 隐藏一个 guard 状态。下面只是帮助理解的伪代码,不是标准要求的真实 ABI:

if (!guard_is_complete()) {
    lock_guard_for_this_static();

    if (!guard_is_complete()) {
        construct(instance);
        mark_guard_complete();
    }

    unlock_guard_for_this_static();
}

return instance;

关键不在于有没有一个叫 guard 的变量,而在于“检查、构造、标记完成”这一整段必须由实现用符合标准的同步机制保护。初始化完成前,竞争线程要等待;初始化完成后,它们才能拿到这个对象。

C++98 为什么不能把它当成线程安全

这里有一个容易误解的地方:C++98 不是不能写这段代码。局部 static 的语法和“只初始化一次”的顺序语义在 C++98 就已经存在。

差别在于,C++98 只描述了单线程抽象机里的执行过程,没有 C++11 那套标准化的多线程执行、数据竞争和内存可见性规则。它没有承诺两个线程同时第一次经过声明时,必须由一个线程完成初始化、其他线程等待。

因此,C++98 下这段代码的正确说法是:单线程时可以实现懒加载单例;多线程下是否安全,取决于编译器、运行库和平台实现,不能从 C++98 标准本身推出保证。

下面这种 C++98 版本尤其不能因为“判断指针再分配”就变安全:

static T* instance = 0;

if (instance == 0) {
    instance = new T;
}
return *instance;

两个线程可能同时通过 instance == 0,分别创建两个 T。即使再加一个“先检查、加锁、锁内再检查”的双重检查结构,C++98 也缺少足够的内存模型来保证发布指针时,另一个线程一定能按正确顺序看到完整对象。volatile 不是线程同步工具,也不能修复这个问题。

C++98 项目如果确实需要这个保证,只能把同步交给标准之外的平台或库,例如 POSIX 的一次性初始化、Windows 同步原语,或当时可用的线程库;也可以让所有访问都经过一把互斥锁。那是“用外部同步补上保证”,不是 C++98 的这行 static 自己提供了保证。

C++11 改变的是语义,不是这行语法

C++11 的关键变化不是发明了:

static T instance;

这条声明早就能写。C++11 把并发初始化规则正式写进了语言标准:函数局部、具有静态存储期的变量,在控制流第一次经过声明时进行动态初始化;如果多个线程并发进入,而变量正在初始化,其他执行应等待初始化完成。

这就是注释里“thread-safe initialization”的准确含义。它保证的是初始化阶段:T 的构造函数只成功执行一次,竞争线程不会越过未完成的构造直接使用对象。

它不保证下面的业务代码天然安全:

Singleton::Instance().append(data);  // 多线程修改同一个对象,仍可能有数据竞争

如果 append 修改共享容器,仍然需要在 T 内部使用互斥锁、原子变量或其他正确的并发设计。单例只解决了“对象何时、如何只构造一次”,没有解决“对象内部的每个操作如何并发执行”。

C++11 写法还有几个边界

第一,static T instance; 需要 T 可以默认构造,并且构造函数和析构函数在这里可访问。若构造函数抛出异常,这次初始化不算完成;下一次控制流再次到达声明时,会重新尝试初始化,而不是留下一个半成品。

第二,初始化函数不能递归地再次进入同一局部 static 的初始化过程。比如 T 的构造函数又调用了 Instance(),就进入了标准明确禁止的递归初始化场景,不能把它当成普通的重入。

第三,instance 如果真的被构造,就会在程序结束阶段析构。这个特性通常比手写 new 后故意泄漏更健康,但也意味着它可能依赖其他全局对象的析构顺序。单例的构造函数里最好不要偷偷依赖一堆跨文件的全局状态。

最后,单例的“全局唯一”主要是语言层面的一个对象。跨动态库、插件或不同运行时边界时,是否真的是整个进程唯一,还要看链接和加载边界;不能仅凭类名就假定所有模块共享同一个实例。

结尾:记住这句区分

C++98 与 C++11 都能写局部 static 单例;C++11 才把并发初始化时“只初始化一次、其他线程等待”的保证写进标准。

所以看到这段代码时,最好分三层检查:static 让对象活得久,函数作用域让入口收得窄,C++11 的语言规则才让第一次并发初始化有了可移植保证。至于对象构造完成以后,里面的数据能不能被多个线程同时改,那是另一道题。

参考资料

写作附记

原始提示词

$blog-writer C++ 单例,详细解释下,这个原理,语法层面怎么支持的 static T &Instance() { // C++11 guarantees thread-safe initialization of function-local static objects. static T instance; return instance; },为什么 C98 不能这样做,为什么C11可以这样做。

评论区将在滚动到此处后加载。