两个 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 的语言规则才让第一次并发初始化有了可移植保证。至于对象构造完成以后,里面的数据能不能被多个线程同时改,那是另一道题。
参考资料
- C++11 工作草案 N3337:6.7 Declaration statement
- 当前 C++ 工作草案:[stmt.dcl]
- WG21 N1875:C++ Threads
- Microsoft:/Zc:threadSafeInit(线程安全的局部 static 初始化)
写作附记
原始提示词
$blog-writer C++ 单例,详细解释下,这个原理,语法层面怎么支持的 static T &Instance() { // C++11 guarantees thread-safe initialization of function-local static objects. static T instance; return instance; },为什么 C98 不能这样做,为什么C11可以这样做。
评论区将在滚动到此处后加载。