【多執行緒那些事兒】如何使用C++寫一個執行緒安全的單例模式?

2022-10-20 15:00:11

如何寫一個執行緒安全的單例模式?

單例模式的簡單實現

單例模式大概是流傳最為廣泛的設計模式之一了。一份簡單的實現程式碼大概是下面這個樣子的:

class singleton
{
public:
	static singleton* instance()
	{
		if (inst_ != nullptr) { 
			inst_ = new singleton();
		}
		return inst_;
	}
private:
	singleton(){}
	static singleton* inst_;
};

singleton* singleton::inst_ = nullptr;

這份程式碼在單執行緒的環境下是完全沒有問題的,但到了多執行緒的世界裡,情況就有一點不同了。考慮以下執行順序:

  1. 執行緒1執行完if (inst_ != nullptr)之後,掛起了;
  2. 執行緒2執行instance函數:由於inst_還未被賦值,程式會inst_ = new singleton()語句;
  3. 執行緒1恢復,inst_ = new singleton()語句再次被執行,單例控制程式碼被多次建立。

所以,這樣的實現是執行緒不安全的。

有問題的雙重檢測鎖

解決多執行緒的問題,最常用的方法就是加鎖唄。於是很容易就可以得到以下的實現版本:

class singleton
{
public:
	static singleton* instance()
	{
		guard<mutex> lock{ mut_ };
		if (inst_ != nullptr) {
			inst_ = new singleton();
		}
		return inst_;
	}
private:
	singleton(){}
	static singleton* inst_;
	static mutex mut_;
};

singleton* singleton::inst_ = nullptr;
mutex singleton::mut_;

這樣問題是解決了,但效能上就不那麼另人滿意,畢竟每一次使用instance都多了一次加鎖和解鎖的開銷。更關鍵的是,這個鎖也不是每次都需要啊!實際我們只有在建立單例範例的時候才需要加鎖,之後使用的時候是完全不需要鎖的。於是,有人提出了一種雙重檢測鎖的寫法:

...
	static singleton* instance()
	{
		if (inst_ != nullptr) {
			guard<mutex> lock{ mut_ };
			if (inst_ != nullptr) {
				inst_ = new singleton();
			}
		}
		return inst_;
	}
...

我們先判斷一下inst_是否已經初始化了,如果沒有,再進行加鎖初始化流程。這樣,雖然程式碼看上去有點怪異,但好像確實達到了只在建立單例時才引入鎖開銷的目的。不過遺憾的是,這個方法是有問題的。Scott Meyers 和 Andrei Alexandrescu 兩位大神在C++ and the Perils of Double-Checked Locking 一文中對這個問題進行了非常詳細地討論,我們在這兒只作一個簡單的說明,問題出在:

	inst_ = new singleton();

這一行。這句程式碼不是原子的,它通常分為以下三步:

  1. 呼叫operator new為singleton物件分配記憶體空間;
  2. 在分配好的記憶體空間上呼叫singleton的建構函式;
  3. 將分配的記憶體空間地址賦值給inst_。

如果程式能嚴格按照1-->2-->3的步驟執行程式碼,那麼上述方法沒有問題,但實際情況並非如此。編譯器對指令的優化重排、CPU指令的亂序執行(具體範例可參考《【多執行緒那些事兒】多執行緒的執行順序如你預期嗎?》)都有可能使步驟3執行早於步驟2。考慮以下的執行順序:

  1. 執行緒1按步驟1-->3-->2的順序執行,且在執行完步驟1,3之後被掛起了;
  2. 執行緒2執行instance函數獲取單例控制程式碼,進行進一步操作。

由於inst_線上程1中已經被賦值,所以線上程2中可以獲取到一個非空的inst_範例,並繼續進行操作。但實際上單例對像的建立還沒有完成,此時進行任何的操作都是未定義的。

現代C++中的解決方法

在現代C++中,我們可以通過以下幾種方法來實現一個即執行緒安全、又高效的單例模式。

使用現代C++中的記憶體順序限制

現代C++規定了6種記憶體執行順序。合理的利用記憶體順序限制,即可避免程式碼指令重排。一個可行的實現如下:

class singleton {
public:
	static singleton* instance()
	{
		singleton* ptr = inst_.load(memory_order_acquire);
		if (ptr == nullptr) {
			lock_guard<mutex> lock{ mut_ };
			ptr = inst_.load(memory_order_relaxed);
			if (ptr == nullptr) {
				ptr = new singleton();
				inst_.store(ptr, memory_order_release);
			}
		}
	
		return inst_;
	}
private:
	singleton(){};
	static mutex mut_;
	static atomic<singleton*> inst_;
};

mutex singleton::mut_;
atomic<singleton*> singleton::inst_;

來看一下組合程式碼:

可以看到,編譯器幫我們插入了必要的語句來保證指令的執行順序。

使用現代C++中的call_once方法

call_once也是現代C++中引入的新特性,它可以保證某個函數只被執行一次。使用call_once的程式碼實現如下:

class singleton
{
public:
	static singleton* instance()
	{
		if (inst_ != nullptr) {
			call_once(flag_, create_instance);
		}
		return inst_;
	}
private:
	singleton(){}
	static void create_instance()
	{
		inst_ = new singleton();
	}
	static singleton* inst_;
	static once_flag flag_;
};

singleton* singleton::inst_ = nullptr;
once_flag singleton::flag_;

來看一下組合程式碼:

可以看到,程式最終呼叫了__gthrw_pthread_once來保證函數只被執行一次。

使用靜態區域性變數

現在C++對變數的初始化順序有如下規定:

If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization.

所以我們可以簡單的使用一個靜態區域性變數來實現執行緒安全的單例模式:

class singleton
{
public:
	static singleton* instance()
	{
		static singleton inst_;
		return &inst_;
	}
private:
	singleton(){}
};

來看一下組合程式碼:

可以看到,編譯器已經自動幫我們插入了相關的程式碼,來保證靜態區域性變數初始化的多執行緒安全性。

全文完。