鍍金池/ 教程/ C/ 3.3 保護共享數(shù)據(jù)的替代設(shè)施
3.4 本章總結(jié)
6.3 基于鎖設(shè)計更加復(fù)雜的數(shù)據(jù)結(jié)構(gòu)
6.1 為并發(fā)設(shè)計的意義何在?
5.2 <code>C++</code>中的原子操作和原子類型
A.7 自動推導(dǎo)變量類型
2.1 線程管理的基礎(chǔ)
8.5 在實踐中設(shè)計并發(fā)代碼
2.4 運行時決定線程數(shù)量
2.2 向線程函數(shù)傳遞參數(shù)
第4章 同步并發(fā)操作
2.3 轉(zhuǎn)移線程所有權(quán)
8.3 為多線程性能設(shè)計數(shù)據(jù)結(jié)構(gòu)
6.4 本章總結(jié)
7.3 對于設(shè)計無鎖數(shù)據(jù)結(jié)構(gòu)的指導(dǎo)建議
關(guān)于這本書
A.1 右值引用
2.6 本章總結(jié)
D.2 &lt;condition_variable&gt;頭文件
A.6 變參模板
6.2 基于鎖的并發(fā)數(shù)據(jù)結(jié)構(gòu)
4.5 本章總結(jié)
A.9 本章總結(jié)
前言
第10章 多線程程序的測試和調(diào)試
5.4 本章總結(jié)
第9章 高級線程管理
5.1 內(nèi)存模型基礎(chǔ)
2.5 識別線程
第1章 你好,C++的并發(fā)世界!
1.2 為什么使用并發(fā)?
A.5 Lambda函數(shù)
第2章 線程管理
4.3 限定等待時間
D.3 &lt;atomic&gt;頭文件
10.2 定位并發(fā)錯誤的技術(shù)
附錄B 并發(fā)庫的簡單比較
5.3 同步操作和強制排序
A.8 線程本地變量
第8章 并發(fā)代碼設(shè)計
3.3 保護共享數(shù)據(jù)的替代設(shè)施
附錄D C++線程庫參考
第7章 無鎖并發(fā)數(shù)據(jù)結(jié)構(gòu)設(shè)計
D.7 &lt;thread&gt;頭文件
D.1 &lt;chrono&gt;頭文件
4.1 等待一個事件或其他條件
A.3 默認函數(shù)
附錄A 對<code>C++</code>11語言特性的簡要介紹
第6章 基于鎖的并發(fā)數(shù)據(jù)結(jié)構(gòu)設(shè)計
封面圖片介紹
7.2 無鎖數(shù)據(jù)結(jié)構(gòu)的例子
8.6 本章總結(jié)
8.1 線程間劃分工作的技術(shù)
4.2 使用期望等待一次性事件
8.4 設(shè)計并發(fā)代碼的注意事項
D.5 &lt;mutex&gt;頭文件
3.1 共享數(shù)據(jù)帶來的問題
資源
9.3 本章總結(jié)
10.3 本章總結(jié)
10.1 與并發(fā)相關(guān)的錯誤類型
D.4 &lt;future&gt;頭文件
3.2 使用互斥量保護共享數(shù)據(jù)
9.1 線程池
1.1 何謂并發(fā)
9.2 中斷線程
4.4 使用同步操作簡化代碼
A.2 刪除函數(shù)
1.3 C++中的并發(fā)和多線程
1.4 開始入門
第5章 C++內(nèi)存模型和原子類型操作
消息傳遞框架與完整的ATM示例
8.2 影響并發(fā)代碼性能的因素
7.1 定義和意義
D.6 &lt;ratio&gt;頭文件
A.4 常量表達式函數(shù)
7.4 本章總結(jié)
1.5 本章總結(jié)
第3章 線程間共享數(shù)據(jù)

3.3 保護共享數(shù)據(jù)的替代設(shè)施

互斥量是最通用的機制,但其并非保護共享數(shù)據(jù)的唯一方式。這里有很多替代方式可以在特定情況下,提供更加合適的保護。

一個特別極端(但十分常見)的情況就是,共享數(shù)據(jù)在并發(fā)訪問和初始化時(都需要保護),但是之后需要進行隱式同步。這可能是因為數(shù)據(jù)作為只讀方式創(chuàng)建,所以沒有同步問題;或者因為必要的保護作為對數(shù)據(jù)操作的一部分,所以隱式的執(zhí)行。任何情況下,數(shù)據(jù)初始化后鎖住一個互斥量,純粹是為了保護其初始化過程(這是沒有必要的),并且這會給性能帶來不必要的沖擊。出于以上的原因,C++標準提供了一種純粹保護共享數(shù)據(jù)初始化過程的機制。

3.3.1 保護共享數(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_flagstd::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::mutexstd::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)生競爭。

3.3.2 保護很少更新的數(shù)據(jù)結(jié)構(gòu)

試想,為了將域名解析為其相關(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()。

3.3.3 嵌套鎖

當(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.