C++ Nifty Counter(Schwarz计数器)的线程安全缺陷及修复方案
Nifty Counter(Schwarz计数器)实现与初始化竞态分析
参考实现
以下是Nifty Counter(又称Schwarz计数器)的实现代码:
头文件(Stream.h)
// Stream.h #ifndef STREAM_H #define STREAM_H struct Stream { Stream (); ~Stream (); }; extern Stream& stream; // global stream object static struct StreamInitializer { StreamInitializer (); ~StreamInitializer (); } streamInitializer; // static initializer for every translation unit #endif // STREAM_H
实现文件(Stream.cpp)
// Stream.cpp #include "Stream.h" #include <new> // placement new #include <type_traits> // aligned_storage static int nifty_counter; // zero initialized at load time static typename std::aligned_storage<sizeof (Stream), alignof (Stream)>::type stream_buf; // memory for the stream object Stream& stream = reinterpret_cast<Stream&> (stream_buf); Stream::Stream () { // initialize things } Stream::~Stream () { // clean-up } StreamInitializer::StreamInitializer () { if (nifty_counter++ == 0) new (&stream) Stream (); // placement new } StreamInitializer::~StreamInitializer () { if (--nifty_counter == 0) (&stream)->~Stream (); }
问题背景
上述实现看似是线程安全的,但存在例外情况,相关规则来自C++标准关于变量初始化的规定:
所有静态存储期的非局部变量都会作为程序启动的一部分完成初始化,即在
main函数开始执行前完成……
到这里似乎没有问题。
(除非初始化被延迟,详见下文)。
但问题随之出现。
动态初始化是在main函数(针对静态变量)或线程初始函数(针对线程局部变量)的首条语句之前完成,还是被延迟到首条语句之后执行,是由实现定义的。
核心疑问
如果首个Nifty计数器对应的static对象的动态初始化被延迟到main函数的首条语句之后执行,而main函数的首条语句恰好创建了一个线程,且该线程要访问stream对象,要如何保证stream对象已经在主线程完成完整初始化、之后才会被该子线程访问?
上述代码是否存在竞态条件?如果存在该如何修复?是否需要使用读写锁?
补充说明:将计数器改为原子操作并不能解决该问题。即使计数器是原子的,也无法保证原线程上的构造函数已经执行完成后,其他线程才会访问该对象。与具备标准线程安全保障的函数局部静态对象构造不同,通过placement new调用的构造函数没有这类相关保障。
问题解答
1. 是否存在竞态条件?
确实存在明确的竞态条件,触发逻辑如下:
- 若编译器实现将
streamInitializer的动态初始化延迟到main函数首条语句之后执行,而main第一条语句直接创建子线程,子线程会在stream对象完成构造前访问其内存,属于未定义行为。 - 即使将
nifty_counter改为原子类型,仅默认的原子自增操作无法保证Stream构造函数的所有内存写入对其他线程可见;若未配套指定release/acquire内存序,构造完成的状态也无法被其他线程感知。
2. 修复方案
针对该问题,无需使用读写锁,可根据实际场景选择以下方案:
方案一:强制初始化优先级(兼容原有接口)
通过编译器扩展强制streamInitializer的初始化优先级高于程序启动阶段的常规延迟初始化,确保其在main执行前完成构造。
GCC/Clang可通过修饰符实现:
// 修饰streamInitializer,指定初始化优先级为最高档 static struct StreamInitializer { // ... 原有成员 } streamInitializer __attribute__((init_priority(101)));
该方案完全保留原有全局对象直接访问的接口,无额外运行时开销,是Nifty Counter的典型增强实现。
方案二:替换为Meyers单例(现代标准方案)
C++11及后续标准明确规定,函数局部静态变量的初始化是线程安全的,完全可以替代Nifty Counter的所有功能,且无需手动处理内存布局、计数器和析构时机。
改写后代码如下:
// Stream.h #ifndef STREAM_H #define STREAM_H struct Stream { Stream (); ~Stream (); }; // 仅暴露访问函数 Stream& get_stream(); // 宏定义兼容原有`stream`的调用语法,无需修改现有业务代码 #define stream (get_stream()) #endif // STREAM_H
// Stream.cpp #include "Stream.h" Stream& get_stream() { // 标准保障:多线程同时调用时仅会初始化一次 static Stream inst; return inst; } Stream::Stream() { /* 原有初始化逻辑 */ } Stream::~Stream() { /* 原有清理逻辑 */ }
该方案无平台相关扩展依赖,完全由C++标准保障安全性,是目前替代Nifty Counter的首选方案。
方案三:内存序同步(兼容老代码)
若无法修改原有全局接口,可通过带内存序的原子操作同步初始化状态:
StreamInitializer构造完成Stream对象后,以release内存序将原子状态标记为“已初始化”- 所有访问
stream的入口处,先以acquire内存序等待状态标记为已初始化,再允许访问
该方案需要改动所有stream的访问点,运行时开销高于前两种方案,仅适用于无法修改接口的 legacy 代码。
3. 是否需要使用读写锁?
完全不需要。读写锁适用于初始化完成后需要频繁并发读写的长期同步场景,而该问题仅出现在程序启动阶段的一次性初始化竞态,使用读写锁会带来不必要的运行时开销,上述方案均可在零或极低开销下解决问题。
内容的提问来源于stack exchange,提问作者Paul J. Lucas
相关产品推荐
相关产品推荐

