互斥量是最通用的機制,但其并非保護共享數(shù)據(jù)的唯一方式。這里有很多替代方式可以在特定情況下,提供更加合適的保護。
一個特別極端(但十分常見)的情況就是,共享數(shù)據(jù)在并發(fā)訪問和初始化時(都需要保護),但是之后需要進行隱式同步。這可能是因為數(shù)據(jù)作為只讀方式創(chuàng)建,所以沒有同步問題;或者因為必要的保護作為對數(shù)據(jù)操作的一部分,所以隱式的執(zhí)行。任何情況下,數(shù)據(jù)初始化后鎖住一個互斥量,純粹是為了保護其初始化過程(這是沒有必要的),并且這會給性能帶來不必要的沖擊。出于以上的原因,C++
標準提供了一種純粹保護共享數(shù)據(jù)初始化過程的機制。
假設(shè)你與一個共享源,構(gòu)建代價很昂貴,可能它會打開一個數(shù)據(jù)庫連接或分配出很多的內(nèi)存。
延遲初始化(Lazy initialization)在單線程代碼很常見——每一個操作都需要先對源進行檢查,為了了解數(shù)據(jù)是否被初始化,然后在其使用前決定,數(shù)據(jù)是否需要初始化:
std::shared_ptr<some_resource> resource_ptr;
void foo()
{
if(!resource_ptr)
{
resource_ptr.reset(new some_resource); // 1
}
resource_ptr->do_something();
}
當(dāng)共享數(shù)據(jù)對于并發(fā)訪問是安全的,①是轉(zhuǎn)為多線程代碼時,需要保護的,但是下面天真的轉(zhuǎn)換會使得線程資源產(chǎn)生不必要的序列化。這是因為每個線程必須等待互斥量,為了確定數(shù)據(jù)源已經(jīng)初始化了。
清單 3.11 使用一個互斥量的延遲初始化(線程安全)過程
std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo()
{
std::unique_lock<std::mutex> lk(resource_mutex); // 所有線程在此序列化
if(!resource_ptr)
{
resource_ptr.reset(new some_resource); // 只有初始化過程需要保護
}
lk.unlock();
resource_ptr->do_something();
}
這段代碼相當(dāng)常見了,也足夠表現(xiàn)出沒必要的線程化問題,很多人能想出更好的一些的辦法來做這件事,包括聲名狼藉的雙重檢查鎖模式:
void undefined_behaviour_with_double_checked_locking()
{
if(!resource_ptr) // 1
{
std::lock_guard<std::mutex> lk(resource_mutex);
if(!resource_ptr) // 2
{
resource_ptr.reset(new some_resource); // 3
}
}
resource_ptr->do_something(); // 4
}
指針第一次讀取數(shù)據(jù)不需要獲取鎖①,并且只有在指針為NULL時才需要獲取鎖。然后,當(dāng)獲取鎖之后,指針會被再次檢查一遍② (這就是雙重檢查的部分),避免另一的線程在第一次檢查后再做初始化,并且讓當(dāng)前線程獲取鎖。
這個模式為什么聲名狼藉呢?因為這里有潛在的條件競爭,未被鎖保護的讀取操作①沒有與其他線程里被鎖保護的寫入操作③進行同步。因此就會產(chǎn)生條件競爭,這個條件競爭不僅覆蓋指針本身,還會影響到其指向的對象;即使一個線程知道另一個線程完成對指針進行寫入,它可能沒有看到新創(chuàng)建的some_resource實例,然后調(diào)用do_something()④后,得到不正確的結(jié)果。這個例子是在一種典型的條件競爭——數(shù)據(jù)競爭,C++
標準中這就會被指定為“未定義行為”。這種競爭肯定是可以避免的??梢蚤喿x第5章,那里有更多對內(nèi)存模型的討論,包括數(shù)據(jù)競爭的構(gòu)成。
C++標準委員會也認為條件競爭的處理很重要,所以C++標準庫提供了std::once_flag
和std::call_once
來處理這種情況。比起鎖住互斥量,并顯式的檢查指針,每個線程只需要使用std::call_once
,在std::call_once
的結(jié)束時,就能安全的知道指針已經(jīng)被其他的線程初始化了。使用std::call_once
比顯式使用互斥量消耗的資源更少,特別是當(dāng)初始化完成后。下面的例子展示了與清單3.11中的同樣的操作,這里使用了std::call_once
。在這種情況下,初始化通過調(diào)用函數(shù)完成,同樣這樣操作使用類中的函數(shù)操作符來實現(xiàn)同樣很簡單。如同大多數(shù)在標準庫中的函數(shù)一樣,或作為函數(shù)被調(diào)用,或作為參數(shù)被傳遞,std::call_once
可以和任何函數(shù)或可調(diào)用對象一起使用。
std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag; // 1
void init_resource()
{
resource_ptr.reset(new some_resource);
}
void foo()
{
std::call_once(resource_flag,init_resource); // 可以完整的進行一次初始化
resource_ptr->do_something();
}
在這個例子中,std::once_flag
①和初始化好的數(shù)據(jù)都是命名空間區(qū)域的對象,但是std::call_once()
可僅作為延遲初始化的類型成員,如同下面的例子一樣:
清單3.12 使用std::call_once
作為類成員的延遲初始化(線程安全)
class X
{
private:
connection_info connection_details;
connection_handle connection;
std::once_flag connection_init_flag;
void open_connection()
{
connection=connection_manager.open(connection_details);
}
public:
X(connection_info const& connection_details_):
connection_details(connection_details_)
{}
void send_data(data_packet const& data) // 1
{
std::call_once(connection_init_flag,&X::open_connection,this); // 2
connection.send_data(data);
}
data_packet receive_data() // 3
{
std::call_once(connection_init_flag,&X::open_connection,this); // 2
return connection.receive_data();
}
};
例子中第一個調(diào)用send_data()①或receive_data()③的線程完成初始化過程。使用成員函數(shù)open_connection()去初始化數(shù)據(jù),也需要將this指針傳進去。和其在在標準庫中的函數(shù)一樣,其接受可調(diào)用對象,比如std::thread
的構(gòu)造函數(shù)和std::bind()
,通過向std::call_once()
②傳遞一個額外的參數(shù)來完成這個操作。
值得注意的是,std::mutex
和std::one_flag
的實例就不能拷貝和移動,所以當(dāng)你使用它們作為類成員函數(shù),如果你需要用到他們,你就得顯示定義這些特殊的成員函數(shù)。
還有一種情形的初始化過程中潛存著條件競爭:其中一個局部變量被聲明為static類型。這種變量的在聲明后就已經(jīng)完成初始化;對于多線程調(diào)用的函數(shù),這就意味著這里有條件競爭——搶著去定義這個變量。在很多在前C++11編譯器(譯者:不支持C++11標準的編譯器),在實踐過程中,這樣的條件競爭是確實存在的,因為在多線程中,每個線程都認為他們是第一個初始化這個變量線程;或一個線程對變量進行初始化,而另外一個線程要使用這個變量時,初始化過程還沒完成。在C++11標準中,這些問題都被解決了:初始化及定義完全在一個線程中發(fā)生,并且沒有其他線程可在初始化完成前對其進行處理,條件競爭終止于初始化階段,這樣比在之后再去處理好的多。在只需要一個全局實例情況下,這里提供一個std::call_once
的替代方案
class my_class;
my_class& get_my_class_instance()
{
static my_class instance; // 線程安全的初始化過程
return instance;
}
多線程可以安全的調(diào)用get_my_class_instance()①函數(shù),不用為數(shù)據(jù)競爭而擔(dān)心。
對于很少有更新的數(shù)據(jù)結(jié)構(gòu)來說,只在初始化時保護數(shù)據(jù)。在大多數(shù)情況下,這種數(shù)據(jù)結(jié)構(gòu)是只讀的,并且多線程對其并發(fā)的讀取也是很愉快的,不過一旦數(shù)據(jù)結(jié)構(gòu)需要更新,就會產(chǎn)生競爭。
試想,為了將域名解析為其相關(guān)IP地址,我們在緩存中的存放了一張DNS入口表。通常,給定DNS數(shù)目在很長的一段時間內(nèi)保持不變。雖然,在用戶訪問不同網(wǎng)站時,新的入口可能會被添加到表中,但是這些數(shù)據(jù)可能在其生命周期內(nèi)保持不變。所以定期檢查緩存中入口的有效性,就變的十分重要了;但是,這也需要一次更新,也許這次更新只是對一些細節(jié)做了改動。
雖然更新頻度很低,但更新也有可能發(fā)生,并且當(dāng)這個可緩存被多個線程訪問,這個緩存就需要處于更新狀態(tài)時得到保護,這也為了確保每個線程讀到都是有效數(shù)據(jù)。
沒有使用專用數(shù)據(jù)結(jié)構(gòu)時,這種方式是符合預(yù)期,并且為并發(fā)更新和讀取特別設(shè)計的(更多的例子在第6和第7章中介紹)。這樣的更新要求線程獨占數(shù)據(jù)結(jié)構(gòu)的訪問權(quán),直到其完成更新操作。當(dāng)更新完成,數(shù)據(jù)結(jié)構(gòu)對于并發(fā)多線程訪問又會是安全的。使用std::mutex
來保護數(shù)據(jù)結(jié)構(gòu),顯的有些反應(yīng)過度(因為在沒有發(fā)生修改時,它將削減并發(fā)讀取數(shù)據(jù)的可能性)。這里需要另一種不同的互斥量,這種互斥量常被稱為“讀者-作者鎖”,因為其允許兩種不同的使用方式:一個“作者”線程獨占訪問和共享訪問,讓多個“讀者”線程并發(fā)訪問。
雖然這樣互斥量的標準提案已經(jīng)交給標準委員會,但是C++
標準庫依舊不會提供這樣的互斥量[3]。因為建議沒有被采納,這個例子在本節(jié)中使用的是Boost庫提供的實現(xiàn)(Boost采納了這個建議)。你將在第8章中看到,這種鎖的也不能包治百病,其性能依賴于參與其中的處理器數(shù)量,同樣也與讀者和作者線程的負載有關(guān)。為了確保增加復(fù)雜度后還能獲得性能收益,目標系統(tǒng)上的代碼性能就很重要。
比起使用std::mutex
實例進行同步,不如使用boost::shared_mutex
來做同步。對于更新操作,可以使用std::lock_guard<boost::shared_mutex>
和std::unique_lock<boost::shared_mutex>
上鎖。作為std::mutex
的替代方案,與std::mutex
所做的一樣,這就能保證更新線程的獨占訪問。因為其他線程不需要去修改數(shù)據(jù)結(jié)構(gòu),所以其可以使用boost::shared_lock<boost::shared_mutex>
獲取訪問權(quán)。這與使用std::unique_lock
一樣,除非多線程要在同時獲取同一個boost::shared_mutex
上有共享鎖。唯一的限制:當(dāng)任一線程擁有一個共享鎖時,這個線程就會嘗試獲取一個獨占鎖,直到其他線程放棄他們的鎖;同樣的,當(dāng)任一線程擁有一個獨占鎖時,其他線程就無法獲得共享鎖或獨占鎖,直到第一個線程放棄其擁有的鎖。
如同之前描述的那樣,下面的代碼清單展示了一個簡單的DNS緩存,使用std::map
持有緩存數(shù)據(jù),使用boost::shared_mutex
進行保護。
清單3.13 使用boost::shared_mutex
對數(shù)據(jù)結(jié)構(gòu)進行保護
#include <map>
#include <string>
#include <mutex>
#include <boost/thread/shared_mutex.hpp>
class dns_entry;
class dns_cache
{
std::map<std::string,dns_entry> entries;
mutable boost::shared_mutex entry_mutex;
public:
dns_entry find_entry(std::string const& domain) const
{
boost::shared_lock<boost::shared_mutex> lk(entry_mutex); // 1
std::map<std::string,dns_entry>::const_iterator const it=
entries.find(domain);
return (it==entries.end())?dns_entry():it->second;
}
void update_or_add_entry(std::string const& domain,
dns_entry const& dns_details)
{
std::lock_guard<boost::shared_mutex> lk(entry_mutex); // 2
entries[domain]=dns_details;
}
};
清單3.13中,find_entry()使用boost::shared_lock<>
來保護共享和只讀權(quán)限①;這就使得多線程可以同時調(diào)用find_entry(),且不會出錯。另一方面,update_or_add_entry()使用std::lock_guard<>
實例,當(dāng)表格需要更新時②,為其提供獨占訪問權(quán)限;update_or_add_entry()函數(shù)調(diào)用時,獨占鎖會阻止其他線程對數(shù)據(jù)結(jié)構(gòu)進行修改,并且阻止線程調(diào)用find_entry()。
當(dāng)一個線程已經(jīng)獲取一個std::mutex
時(已經(jīng)上鎖),并對其再次上鎖,這個操作就是錯誤的,并且繼續(xù)嘗試這樣做的話,就會產(chǎn)生未定義行為。然而,在某些情況下,一個線程嘗試獲取同一個互斥量多次,而沒有對其進行一次釋放是可以的。之所以可以,是因為C++
標準庫提供了std::recursive_mutex
類。其功能與std::mutex
類似,除了你可以從同一線程的單個實例上獲取多個鎖?;コ饬挎i住其他線程前,你必須釋放你擁有的所有鎖,所以當(dāng)你調(diào)用lock()三次時,你也必須調(diào)用unlock()三次。正確使用std::lock_guard<std::recursive_mutex>
和std::unique_lock<std::recursive_mutex>
可以幫你處理這些問題。
大多數(shù)情況下,當(dāng)你需要嵌套鎖時,就要對你的設(shè)計進行改動。嵌套鎖一般用在可并發(fā)訪問的類上,所以其擁互斥量保護其成員數(shù)據(jù)。每個公共成員函數(shù)都會對互斥量上鎖,然后完成對應(yīng)的功能,之后再解鎖互斥量。不過,有時成員函數(shù)會調(diào)用另一個成員函數(shù),這種情況下,第二個成員函數(shù)也會試圖鎖住互斥量,這就會導(dǎo)致未定義行為的發(fā)生。“變通的”解決方案會將互斥量轉(zhuǎn)為嵌套鎖,第二個成員函數(shù)就能成功的進行上鎖,并且函數(shù)能繼續(xù)執(zhí)行。
但是,這樣的使用方式是不推薦的,因為其過于草率,并且不合理。特別是,當(dāng)鎖被持有時,對應(yīng)類的不變量通常正在被修改。這意味著,當(dāng)不變量正在改變的時候,第二個成員函數(shù)還需要繼續(xù)執(zhí)行。一個比較好的方式是,從中提取出一個函數(shù)作為類的私有成員,并且讓其他成員函數(shù)都對其進行調(diào)用,這個私有成員函數(shù)不會對互斥量進行上鎖(在調(diào)用前必須獲得鎖)。然后,你仔細考慮一下,在這種情況調(diào)用新函數(shù)時,數(shù)據(jù)的狀態(tài)。
[3] Howard E. Hinnant, “Multithreading API for C++0X—A Layered Approach,” C++ Standards Committee Paper N2094, http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2006/n2094.html.