使用C++无锁编程实现多线程下的单例模式
贺志国
2023.8.1
在多线程环境下创建一个类的单例对象,要比单线程环境下要复杂很多。下面介绍在多线程环境下实现单例模式的几种方法。
一、尺寸较小的类单例对象创建
如果待创建的单例类SingletonForMultithread
内包含的成员变量较少,整个类占用的内存空间较小,则可使用局部静态变量来创建单例对象。C++ 11标准保证在多线程环境下,在第一个线程未完成静态对象的构建前,其他线程必须等待其完成,因此是线程安全的。如果类的尺寸较大,静态变量存储栈区无法容纳该类的单例对象,则禁止使用该方法。例如:64位Linux系统默认栈的最大空间为8 MB,64位Windows系统默认栈的最大空间为1 MB,当待创建的单例对象尺寸接近或超过上述栈的默认存储空间时,如使用该方法创建则会导致程序崩溃。示例代码如下所示:
class SmallSingletonForMultithread {
public:
static SmallSingletonForMultithread& GetInstance() {
static SmallSingletonForMultithread instance;
return instance;
}
private:
SmallSingletonForMultithread() = default;
~SmallSingletonForMultithread() = default;
SmallSingletonForMultithread(const SmallSingletonForMultithread&) = delete;
SmallSingletonForMultithread& operator=(const SmallSingletonForMultithread&) = delete;
SmallSingletonForMultithread(SmallSingletonForMultithread&&) = delete;
SmallSingletonForMultithread& operator=(SmallSingletonForMultithread&&) = delete;
};
二、尺寸较大的类单例对象创建(使用无锁编程来实现)
在实际工作中,由于某些单例类的尺寸较大,静态变量存储栈区无法容纳该单例对象,因此无法使用上述方法来创建单例对象,这时需要使用new
在堆区动态创建单例对象。为了避免多线程环境下对于单例对象的抢夺,可使用C++无锁编程来实现。在程序结束时使用atexit(DestoryInstance)
来删除单例对象,示例代码如下所示:
#include <atomic>
#include <cassert>
#include <memory>
#include <thread>
#include <vector>
namespace {
constexpr size_t kThreadNum = 2000;
}
class SingletonForMultithread {
public:
static SingletonForMultithread* GetInstance() {
if (!instance_.load(std::memory_order_acquire)) {
auto* new_ptr = new SingletonForMultithread;
SingletonForMultithread* old_ptr = nullptr;
if (!instance_.compare_exchange_strong(old_ptr, new_ptr,
std::memory_order_release,
std::memory_order_relaxed)) {
// If the CAS operation fails, another thread has created a singleton
// object, and it's necessary to delete the temporary object created by
// the current thread.
delete new_ptr;
new_ptr = nullptr;
}
// When the program exits, the function to delete the singleton object
// is called.
atexit(DestoryInstance);
}
return instance_.load(std::memory_order_relaxed);
}
static void DestoryInstance() {
if (instance_.load(std::memory_order_acquire)) {
auto* old_ptr = instance_.load(std::memory_order_relaxed);
SingletonForMultithread* new_ptr = nullptr;
if (instance_.compare_exchange_strong(old_ptr, new_ptr,
std::memory_order_release,
std::memory_order_relaxed)) {
// If the CAS operation succeeds, the current thread obtains the
// original object and can safely delete it.
delete old_ptr;
old_ptr = nullptr;
}
}
}
private:
SingletonForMultithread() = default;
~SingletonForMultithread() = default;
SingletonForMultithread(const SingletonForMultithread&) = delete;
SingletonForMultithread& operator=(const SingletonForMultithread&) = delete;
SingletonForMultithread(SingletonForMultithread&&) = delete;
SingletonForMultithread& operator=(SingletonForMultithread&&) = delete;
private:
static std::atomic<SingletonForMultithread*> instance_;
};
// Static member variable initialization
std::atomic<SingletonForMultithread*> SingletonForMultithread::instance_;
int main() {
std::vector<std::thread> customers;
for (size_t i = 0; i < kThreadNum; ++i) {
customers.emplace_back(&SingletonForMultithread::GetInstance);
}
for (auto& cutomer : customers) {
cutomer.join();
}
auto* singleton = SingletonForMultithread::GetInstance();
assert(singleton != nullptr);
// singleton->DestoryInstance();
return 0;
}
编译指令如下:
g++ -g -Wall -std=c++14 -O3 singleton_test.cpp -pthread -o singleton_test
三、尺寸较大的类单例对象创建(使用std::unique_ptr<T>
和std::call_once
实现)
C++ 11还支持使用std::call_once
函数来确保单例对象的构造只发生一次,注意本示例中使用了智能指针std::unique_ptr<T>
来管理单例对象的生命周期,示例代码如下:
#include <cassert>
#include <memory>
#include <mutex>
class SingletonForMultithread {
public:
~SingletonForMultithread() = default;
static SingletonForMultithread* GetInstance() {
static std::unique_ptr<SingletonForMultithread> instance;
static std::once_flag only_once;
std::call_once(only_once,
[]() { instance.reset(new (std::nothrow) SingletonForMultithread); });
return instance.get();
}
private:
SingletonForMultithread() = default;
SingletonForMultithread(const SingletonForMultithread&) = delete;
SingletonForMultithread& operator=(const SingletonForMultithread&) = delete;
SingletonForMultithread(SingletonForMultithread&&) = delete;
SingletonForMultithread& operator=(SingletonForMultithread&&) = delete;
};
int main() {
auto* singleton = SingletonForMultithread::GetInstance();
assert(singleton != nullptr);
return 0;
}
但我在Ubuntu 20.04
系统上使用GCC 9.4.0
似乎无法正常完成任务,会抛出异常,产生core dump
,原因暂不详。
8月10日补充说明:
经同事路遥提示,出现core dump的根本原因是链接pthread
库的指令不正确,我最开始使用的编译指令是:
g++ -g -Wall -std=c++14 -O3 singleton_test.cpp -lpthread -o singleton_test
以上命令中,-lpthread
的使用不正确,具体原因参见该博客:编译时-pthread和-lpthread之间的区别。正确的编译指令如下:
g++ -g -Wall -std=c++14 -O3 singleton_test.cpp -pthread -o singleton_test
使用上述命令编译生成的程序能够正常运行。
四、尺寸较大的类单例对象创建(使用std::unique_ptr<T>
和std::atomic_flag
实现)
还可使用std::atomic_flag
替换std::call_once
来完成任务,基本思想如下:首先定义一个静态的无锁标志变量std::atomic_flag start_flag
,并将其初始值设置为ATOMIC_FLAG_INIT
。第一次调用start_flag.test_and_set(std::memory_order_relaxed)
函数时,由于start_flag
的状态是ATOMIC_FLAG_INIT
,该函数返回false
,于是可调用instance.reset(new SingletonForMultithread)
创建单例对象。第二次直至第N次调用start_flag.test_and_set(std::memory_order_relaxed)
函数时,因为start_flag
的状态已被设置,该函数返回true
,创建单例对象的语句instance.reset(new SingletonForMultithread)
永远不会被再次执行,这就达到了只创建一次的目的。同时,因为使用静态的智能指针变量std::unique_ptr<SingletonForMultithread> instance
来管理单例对象,于是不再需要显式地回收内存,只要程序结束,静态变量自动清除,智能指针对象instance
会在其析构函数中释放内存。
由于new
运算符创建单例对象可能耗时较长,为了避免其他线程在单例对象创建到一半的过程中读取到不完整的对象,导致未定义的行为,我们使用另一个原子变量std::atomic<bool> finished
来确保创建动作已正确完成,不选用另一个无锁标志变量std::atomic_flag
的原因是,该类在C++ 20标准前未提供单独的测试函数test
。finished.store(true, std::memory_order_release);
与while (!finished.load(std::memory_order_acquire))
的内存顺序,实现了synchronizes-with
与happens-before
关系,保证在while (!finished.load(std::memory_order_acquire))
成功时,instance.reset(new SingletonForMultithread);
必定执行完毕,单例对象的创建是完整的。
完整的示例代码如下:
#include <atomic>
#include <cassert>
#include <memory>
#include <mutex>
#include <thread>
#include <vector>
using namespace std::chrono_literals;
namespace {
constexpr size_t kThreadNum = 2000;
}
class SingletonForMultithread {
public:
~SingletonForMultithread() = default;
static SingletonForMultithread* GetInstance() {
static std::unique_ptr<SingletonForMultithread> instance;
static std::atomic_flag start_flag = ATOMIC_FLAG_INIT;
static std::atomic<bool> finished(false);
if (!finished.load(std::memory_order_acquire)) {
// 'start_flag.test_and_set' is a completed or incomplete atomic
// operation. It is a one-time read-modify-write operation. If it
// completes, the modified value must have been written to the memory.
// Because its result does not need to be synchronized with other threads,
// the simplest memory order is used: memory_order_relaxed
if (!start_flag.test_and_set(std::memory_order_relaxed)) {
// The object created by the `new` operator may be relatively large and
// time-consuming, therefore another atomic variable 'finished' is used
// to ensure that other threads read a fully constructed singleton
// object. Do not consider using another `std::atomic_flag`. Because it
// doesn't provide a separate `test` function before the C++20 standard.
instance.reset(new (std::nothrow) SingletonForMultithread);
finished.store(true, std::memory_order_release);
} else {
// Wait in a loop until the singleton object is fully created, using
// `std::this_thread::yield()` to save CPU resources.
while (!finished.load(std::memory_order_acquire)) {
std::this_thread::yield();
}
}
}
return instance.get();
}
private:
SingletonForMultithread() {
// Simulate a constructor that takes a relative long time.
std::this_thread::sleep_for(10ms);
}
SingletonForMultithread(const SingletonForMultithread&) = delete;
SingletonForMultithread& operator=(const SingletonForMultithread&) = delete;
SingletonForMultithread(SingletonForMultithread&&) = delete;
SingletonForMultithread& operator=(SingletonForMultithread&&) = delete;
};
int main() {
std::vector<std::thread> customers;
for (size_t i = 0; i < kThreadNum; ++i) {
customers.emplace_back(&SingletonForMultithread::GetInstance);
}
for (auto& cutomer : customers) {
cutomer.join();
}
auto* singleton = SingletonForMultithread::GetInstance();
assert(singleton != nullptr);
return 0;
}
原子变量之间的内存顺序示意图如下:
测试代码中,我们特意在构造函数中添加了让当前线程休眠10 ms的代码:std::this_thread::sleep_for(10ms);
, 以此来模拟一个耗时较长的构造过程。另外,通过2000个线程来同时访问SingletonForMultithread::GetInstance()
,通过下图的调试界面可看出,在SingletonForMultithread()
比较漫长的构建过程中,确实有多个线程闯入,这时等待构建过程完成的代码:
while (!finished.load(std::memory_order_acquire)) {
std::this_thread::yield();
}
确实发挥出了应有的拦截作用,整个过程是线程安全的。
SingletonForMultithread* GetInstance()
函数在ARM 64平台下使用GCC 9.4带-O3优化选项生成的汇编代码如下:
SingletonForMultithread::GetInstance():
push {r4, r5, r6, lr}
ldr r4, .L35
ldrb r5, [r4] @ zero_extendqisi2
bl __sync_synchronize
tst r5, #1
beq .L31
.L10:
ldrb r5, [r4, #8] @ zero_extendqisi2
bl __sync_synchronize
cmp r5, #0
beq .L32
.L19:
ldr r0, [r4, #4]
pop {r4, r5, r6, pc}
.L31:
mov r0, r4
bl __cxa_guard_acquire
cmp r0, #0
beq .L10
ldr r2, .L35+4
ldr r1, .L35+8
add r0, r4, #4
bl __aeabi_atexit
mov r0, r4
bl __cxa_guard_release
ldrb r5, [r4, #8] @ zero_extendqisi2
bl __sync_synchronize
cmp r5, #0
bne .L19
.L32:
mov r6, r4
ldrb r3, [r6, #12]! @ zero_extendqisi2
.L13:
lsl r1, r3, #24
asr r1, r1, #24
mov r2, #1
mov r0, r6
mov r5, r3
bl __sync_val_compare_and_swap_1
and r2, r5, #255
mov r3, r0
and r1, r3, #255
cmp r1, r2
bne .L13
cmp r2, #0
bne .L14
b .L33
.L34:
bl __gthrw_sched_yield()
.L14:
ldrb r5, [r4, #8] @ zero_extendqisi2
bl __sync_synchronize
cmp r5, #0
beq .L34
ldr r0, [r4, #4]
pop {r4, r5, r6, pc}
.L33:
ldr r1, .L35+12
mov r0, #1
bl operator new(unsigned int, std::nothrow_t const&)
subs r5, r0, #0
beq .L18
bl SingletonForMultithread::SingletonForMultithread() [complete object constructor]
.L18:
ldr r0, [r4, #4]
str r5, [r4, #4]
cmp r0, #0
beq .L17
mov r1, #1
bl operator delete(void*, unsigned int)
.L17:
bl __sync_synchronize
mov r3, #1
strb r3, [r4, #8]
ldr r0, [r4, #4]
pop {r4, r5, r6, pc}
ldr r1, .L35+12
mov r0, r5
bl operator delete(void*, std::nothrow_t const&)
bl __cxa_end_cleanup
.L35:
.word .LANCHOR0
.word __dso_handle
.word _ZNSt10unique_ptrI23SingletonForMultithreadSt14default_deleteIS0_EED1Ev
.word _ZSt7nothrow
说明:r0-r15是ARMv7 32位架构下的通用寄存器,每个寄存器的空间为4字节(32位),其中r13又称为sp(栈顶指针),r14 又称为lr(链接寄存器,即当前调用结束后的返回地址),r15又称为pc(程序计数器);在ARMv8 64位架构下,通用寄存器为x0-x30,每个寄存器的空间为8字节(64位),w0-w30分别表示对应的x0-x30的低32位寄存器。在ARMv8 64位架构下,x0 = r0, x1 = r1, …, x15 = r15。
上述代码首先从.L35加载内容到寄存器r4,再将其转存到寄存器r5, 然后加载 r5 寄存器中的一个字节,这个字节表示单例对象是否已经被创建。然后使用 __sync_synchronize 函数进行同步,确保前面的加载操作完成。接下来,代码通过比较 r5 是否为 1(已创建)来决定是继续执行还是等待。如果已经创建,则直接跳到 .L19,否则继续执行。在 .L31 标签处,代码调用 __cxa_guard_acquire 函数来获取锁,确保只有一个线程可以进入临界区。如果没有获取到锁,则跳回 .L10,继续尝试获取锁。在获取到锁之后,代码进行一系列操作,包括调用 __aeabi_atexit 函数注册析构函数,调用 __cxa_guard_release 函数释放锁,并将单例对象的地址存储在 r0 中。然后,代码再次加载 r5 寄存器中的一个字节,进行同步,并检查 r5 是否为 0。如果为 0,则跳转到 .L32 标签处,继续尝试获取锁。否则,继续执行。在 .L32 标签处,代码使用 __sync_val_compare_and_swap_1 函数来进行原子比较和交换操作,以确保只有一个线程可以成功创建单例对象。之后,代码进行一系列操作,包括调用构造函数和析构函数,分配和释放内存等。最后,代码进行一系列的清理操作,并将单例对象的地址存储在 r0 中,最终返回该地址。
编译命令如下:
g++ -g -Wall -std=c++14 -O3 singleton_test.cpp -pthread -o singleton_test
如果使用CMake编译,配置文件CMakeLists.txt
如下:
cmake_minimum_required(VERSION 3.0.0)
project(singleton_test VERSION 0.1.0)
set(CMAKE_CXX_STANDARD 14)
# If the debug option is not given, the program will not have debugging information.
SET(CMAKE_BUILD_TYPE "Debug")
add_executable(${PROJECT_NAME} ${PROJECT_NAME}.cpp)
find_package(Threads REQUIRED)
set(THREADS_PREFER_PTHREAD_FLAG ON)
# target_link_libraries(${PROJECT_NAME} ${CMAKE_THREAD_LIBS_INIT})
target_link_libraries(${PROJECT_NAME} Threads::Threads)
include(CTest)
enable_testing()
set(CPACK_PROJECT_NAME ${PROJECT_NAME})
set(CPACK_PROJECT_VERSION ${PROJECT_VERSION})
include(CPack)
编译指令为:
# Do it only once
mkdir build
cd build
cmake .. && make