鍍金池/ 教程/ C/ 2.1 線程管理的基礎(chǔ)
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 默認(rèn)函數(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ù)

2.1 線程管理的基礎(chǔ)

每個程序至少有一個線程:執(zhí)行main()函數(shù)的線程,其余線程有其各自的入口函數(shù)。線程與原始線程(以main()為入口函數(shù)的線程)同時運行。如同main()函數(shù)執(zhí)行完會退出一樣,當(dāng)線程執(zhí)行完入口函數(shù)后,線程也會退出。在為一個線程創(chuàng)建了一個std::thread對象后,需要等待這個線程結(jié)束;不過,線程需要先進行啟動。下面就來啟動線程。

2.1.1 啟動線程

第1章中,線程在std::thread對象創(chuàng)建(為線程指定任務(wù))時啟動。最簡單的情況下,任務(wù)也會很簡單,通常是無參數(shù)無返回的函數(shù)。這種函數(shù)在其所屬線程上運行,直到函數(shù)執(zhí)行完畢,線程也就結(jié)束了。在一些極端情況下,線程運行時,任務(wù)中的函數(shù)對象需要通過某種通訊機制進行參數(shù)的傳遞,或者執(zhí)行一系列獨立操作;可以通過通訊機制傳遞信號,讓線程停止。線程要做什么,以及什么時候啟動,其實都無關(guān)緊要??傊?,使用C++線程庫啟動線程,可以歸結(jié)為構(gòu)造std::thread對象:

void do_some_work();
std::thread my_thread(do_some_work);

為了讓編譯器識別std::thread類,這個簡單的例子也要包含<thread>頭文件。如同大多數(shù)C++標(biāo)準(zhǔn)庫一樣,std::thread可以用可調(diào)用類型構(gòu)造,將帶有函數(shù)調(diào)用符類型的實例傳入std::thread類中,替換默認(rèn)的構(gòu)造函數(shù)。

class background_task
{
public:
  void operator()() const
  {
    do_something();
    do_something_else();
  }
};

background_task f;
std::thread my_thread(f);

代碼中,提供的函數(shù)對象會復(fù)制到新線程的存儲空間當(dāng)中,函數(shù)對象的執(zhí)行和調(diào)用都在線程的內(nèi)存空間中進行。函數(shù)對象的副本應(yīng)與原始函數(shù)對象保持一致,否則得到的結(jié)果會與我們的期望不同。

有件事需要注意,當(dāng)把函數(shù)對象傳入到線程構(gòu)造函數(shù)中時,需要避免“最令人頭痛的語法解析”(C++’s most vexing parse, 中文簡介)。如果你傳遞了一個臨時變量,而不是一個命名的變量;C++編譯器會將其解析為函數(shù)聲明,而不是類型對象的定義。

例如:

std::thread my_thread(background_task());

這里相當(dāng)與聲明了一個名為my_thread的函數(shù),這個函數(shù)帶有一個參數(shù)(函數(shù)指針指向沒有參數(shù)并返回background_task對象的函數(shù)),返回一個std::thread對象的函數(shù),而非啟動了一個線程。

使用在前面命名函數(shù)對象的方式,或使用多組括號①,或使用新統(tǒng)一的初始化語法②,可以避免這個問題。

如下所示:

std::thread my_thread((background_task()));  // 1
std::thread my_thread{background_task()};    // 2

使用lambda表達式也能避免這個問題。lambda表達式是C++11的一個新特性,它允許使用一個可以捕獲局部變量的局部函數(shù)(可以避免傳遞參數(shù),參見2.2節(jié))。想要具體的了解lambda表達式,可以閱讀附錄A的A.5節(jié)。之前的例子可以改寫為lambda表達式的類型:

std::thread my_thread([]{
  do_something();
  do_something_else();
});

啟動了線程,你需要明確是要等待線程結(jié)束(加入式——參見2.1.2節(jié)),還是讓其自主運行(分離式——參見2.1.3節(jié))。如果std::thread對象銷毀之前還沒有做出決定,程序就會終止(std::thread的析構(gòu)函數(shù)會調(diào)用std::terminate())。因此,即便是有異常存在,也需要確保線程能夠正確的加入(joined)或分離(detached)。2.1.3節(jié)中,會介紹對應(yīng)的方法來處理這兩種情況。需要注意的是,必須在std::thread對象銷毀之前做出決定,否則你的程序?qū)K止(std::thread的析構(gòu)函數(shù)會調(diào)用std::terminate(),這時再去決定會觸發(fā)相應(yīng)異常)。

如果不等待線程,就必須保證線程結(jié)束之前,可訪問的數(shù)據(jù)得有效性。這不是一個新問題——單線程代碼中,對象銷毀之后再去訪問,也會產(chǎn)生未定義行為——不過,線程的生命周期增加了這個問題發(fā)生的幾率。

這種情況很可能發(fā)生在線程還沒結(jié)束,函數(shù)已經(jīng)退出的時候,這時線程函數(shù)還持有函數(shù)局部變量的指針或引用。下面的清單中就展示了這樣的一種情況。

清單2.1 函數(shù)已經(jīng)結(jié)束,線程依舊訪問局部變量

struct func
{
  int& i;
  func(int& i_) : i(i_) {}
  void operator() ()
  {
    for (unsigned j=0 ; j<1000000 ; ++j)
    {
      do_something(i);           // 1. 潛在訪問隱患:懸空引用
    }
  }
};

void oops()
{
  int some_local_state=0;
  func my_func(some_local_state);
  std::thread my_thread(my_func);
  my_thread.detach();          // 2. 不等待線程結(jié)束
}                              // 3. 新線程可能還在運行

這個例子中,已經(jīng)決定不等待線程結(jié)束(使用了detach()②),所以當(dāng)oops()函數(shù)執(zhí)行完成時③,新線程中的函數(shù)可能還在運行。如果線程還在運行,它就會去調(diào)用do_something(i)函數(shù)①,這時就會訪問已經(jīng)銷毀的變量。如同一個單線程程序——允許在函數(shù)完成后繼續(xù)持有局部變量的指針或引用;當(dāng)然,這從來就不是一個好主意——這種情況發(fā)生時,錯誤并不明顯,會使多線程更容易出錯。

處理這種情況的常規(guī)方法:使線程函數(shù)的功能齊全,將數(shù)據(jù)復(fù)制到線程中,而非復(fù)制到共享數(shù)據(jù)中。如果使用一個可調(diào)用的對象作為線程函數(shù),這個對象就會復(fù)制到線程中,而后原始對象就會立即銷毀。但對于對象中包含的指針和引用還需謹(jǐn)慎,例如清單2.1所示。使用一個能訪問局部變量的函數(shù)去創(chuàng)建線程是一個糟糕的主意(除非十分確定線程會在函數(shù)完成前結(jié)束)。此外,可以通過join()函數(shù)來確保線程在函數(shù)完成前結(jié)束。

2.1.2 等待線程完成

如果需要等待線程,相關(guān)的std::thread實例需要使用join()。清單2.1中,將my_thread.detach()替換為my_thread.join(),就可以確保局部變量在線程完成后,才被銷毀。在這種情況下,因為原始線程在其生命周期中并沒有做什么事,使得用一個獨立的線程去執(zhí)行函數(shù)變得收益甚微,但在實際編程中,原始線程要么有自己的工作要做;要么會啟動多個子線程來做一些有用的工作,并等待這些線程結(jié)束。

join()是簡單粗暴的等待線程完成或不等待。當(dāng)你需要對等待中的線程有更靈活的控制時,比如,看一下某個線程是否結(jié)束,或者只等待一段時間(超過時間就判定為超時)。想要做到這些,你需要使用其他機制來完成,比如條件變量和期待(futures),相關(guān)的討論將會在第4章繼續(xù)。調(diào)用join()的行為,還清理了線程相關(guān)的存儲部分,這樣std::thread對象將不再與已經(jīng)完成的線程有任何關(guān)聯(lián)。這意味著,只能對一個線程使用一次join();一旦已經(jīng)使用過join(),std::thread對象就不能再次加入了,當(dāng)對其使用joinable()時,將返回false。

2.1.3 特殊情況下的等待

如前所述,需要對一個還未銷毀的std::thread對象使用join()或detach()。如果想要分離一個線程,可以在線程啟動后,直接使用detach()進行分離。如果打算等待對應(yīng)線程,則需要細心挑選調(diào)用join()的位置。當(dāng)在線程運行之后產(chǎn)生異常,在join()調(diào)用之前拋出,就意味著這次調(diào)用會被跳過。

避免應(yīng)用被拋出的異常所終止,就需要作出一個決定。通常,當(dāng)傾向于在無異常的情況下使用join()時,需要在異常處理過程中調(diào)用join(),從而避免生命周期的問題。下面的程序清單是一個例子。

清單 2.2 等待線程完成

struct func; // 定義在清單2.1中
void f()
{
  int some_local_state=0;
  func my_func(some_local_state);
  std::thread t(my_func);
  try
  {
    do_something_in_current_thread();
  }
  catch(...)
  {
    t.join();  // 1
    throw;
  }
  t.join();  // 2
}

清單2.2中的代碼使用了try/catch塊確保訪問本地狀態(tài)的線程退出后,函數(shù)才結(jié)束。當(dāng)函數(shù)正常退出時,會執(zhí)行到②處;當(dāng)函數(shù)執(zhí)行過程中拋出異常,程序會執(zhí)行到①處。try/catch塊能輕易的捕獲輕量級錯誤,所以這種情況,并非放之四海而皆準(zhǔn)。如需確保線程在函數(shù)之前結(jié)束——查看是否因為線程函數(shù)使用了局部變量的引用,以及其他原因——而后再確定一下程序可能會退出的途徑,無論正常與否,可以提供一個簡潔的機制,來做解決這個問題。

一種方式是使用“資源獲取即初始化方式”(RAII,Resource Acquisition Is Initialization),并且提供一個類,在析構(gòu)函數(shù)中使用join(),如同下面清單中的代碼。看它如何簡化f()函數(shù)。

清單 2.3 使用RAII等待線程完成

class thread_guard
{
  std::thread& t;
public:
  explicit thread_guard(std::thread& t_):
    t(t_)
  {}
  ~thread_guard()
  {
    if(t.joinable()) // 1
    {
      t.join();      // 2
    }
  }
  thread_guard(thread_guard const&)=delete;   // 3
  thread_guard& operator=(thread_guard const&)=delete;
};

struct func; // 定義在清單2.1中

void f()
{
  int some_local_state=0;
  func my_func(some_local_state);
  std::thread t(my_func);
  thread_guard g(t);
  do_something_in_current_thread();
}    // 4

當(dāng)線程執(zhí)行到④處時,局部對象就要被逆序銷毀了。因此,thread_guard對象g是第一個被銷毀的,這時線程在析構(gòu)函數(shù)中被加入②到原始線程中。即使do_something_in_current_thread拋出一個異常,這個銷毀依舊會發(fā)生。

在thread_guard的析構(gòu)函數(shù)的測試中,首先判斷線程是否已加入①,如果沒有會調(diào)用join()②進行加入。這很重要,因為join()只能對給定的對象調(diào)用一次,所以對給已加入的線程再次進行加入操作時,將會導(dǎo)致錯誤。

拷貝構(gòu)造函數(shù)和拷貝賦值操作被標(biāo)記為=delete③,是為了不讓編譯器自動生成它們。直接對一個對象進行拷貝或賦值是危險的,因為這可能會弄丟已經(jīng)加入的線程。通過刪除聲明,任何嘗試給thread_guard對象賦值的操作都會引發(fā)一個編譯錯誤。想要了解刪除函數(shù)的更多知識,請參閱附錄A的A.2節(jié)。

如果不想等待線程結(jié)束,可以分離(_detaching)線程,從而避免_異常安全*(exception-safety)問題。不過,這就打破了線程與std::thread對象的聯(lián)系,即使線程仍然在后臺運行著,分離操作也能確保std::terminate()std::thread對象銷毀才被調(diào)用。

2.1.4 后臺運行線程

使用detach()會讓線程在后臺運行,這就意味著主線程不能與之產(chǎn)生直接交互。也就是說,不會等待這個線程結(jié)束;如果線程分離,那么就不可能有std::thread對象能引用它,分離線程的確在后臺運行,所以分離線程不能被加入。不過C++運行庫保證,當(dāng)線程退出時,相關(guān)資源的能夠正確回收,后臺線程的歸屬和控制C++運行庫都會處理。

通常稱分離線程為守護線程(daemon threads),UNIX中守護線程是指,沒有任何顯式的用戶接口,并在后臺運行的線程。這種線程的特點就是長時間運行;線程的生命周期可能會從某一個應(yīng)用起始到結(jié)束,可能會在后臺監(jiān)視文件系統(tǒng),還有可能對緩存進行清理,亦或?qū)?shù)據(jù)結(jié)構(gòu)進行優(yōu)化。另一方面,分離線程的另一方面只能確定線程什么時候結(jié)束,發(fā)后即忘(fire and forget)的任務(wù)就使用到線程的這種方式。

如2.1.2節(jié)所示,調(diào)用std::thread成員函數(shù)detach()來分離一個線程。之后,相應(yīng)的std::thread對象就與實際執(zhí)行的線程無關(guān)了,并且這個線程也無法加入:

std::thread t(do_background_work);
t.detach();
assert(!t.joinable());

為了從std::thread對象中分離線程(前提是有可進行分離的線程),不能對沒有執(zhí)行線程的std::thread對象使用detach(),也是join()的使用條件,并且要用同樣的方式進行檢查——當(dāng)std::thread對象使用t.joinable()返回的是true,就可以使用t.detach()。

試想如何能讓一個文字處理應(yīng)用同時編輯多個文檔。無論是用戶界面,還是在內(nèi)部應(yīng)用內(nèi)部進行,都有很多的解決方法。雖然,這些窗口看起來是完全獨立的,每個窗口都有自己獨立的菜單選項,但他們卻運行在同一個應(yīng)用實例中。一種內(nèi)部處理方式是,讓每個文檔處理窗口擁有自己的線程;每個線程運行同樣的的代碼,并隔離不同窗口處理的數(shù)據(jù)。如此這般,打開一個文檔就要啟動一個新線程。因為是對獨立的文檔進行操作,所以沒有必要等待其他線程完成。因此,這里就可以讓文檔處理窗口運行在分離的線程上。

下面代碼簡要的展示了這種方法:

清單2.4 使用分離線程去處理其他文檔

void edit_document(std::string const& filename)
{
  open_document_and_display_gui(filename);
  while(!done_editing())
  {
    user_command cmd=get_user_input();
    if(cmd.type==open_new_document)
    {
      std::string const new_name=get_filename_from_user();
      std::thread t(edit_document,new_name);  // 1
      t.detach();  // 2
    }
    else
    {
       process_user_input(cmd);
    }
  }
}

如果用戶選擇打開一個新文檔,需要啟動一個新線程去打開新文檔①,并分離線程②。與當(dāng)前線程做出的操作一樣,新線程只不過是打開另一個文件而已。所以,edit_document函數(shù)可以復(fù)用,通過傳參的形式打開新的文件。

這個例子也展示了傳參啟動線程的方法:不僅可以向std::thread構(gòu)造函數(shù)①傳遞函數(shù)名,還可以傳遞函數(shù)所需的參數(shù)(實參)。C++線程庫的方式也不是很復(fù)雜。當(dāng)然,也有其他方法完成這項功能,比如:使用一個帶有數(shù)據(jù)成員的成員函數(shù),代替一個需要傳參的普通函數(shù)。