《C++11併發編程》讀書筆記

1-4章

初始的C++標準在1998年發佈,13年後,C++標準委員會給語言本身,以及標準庫,帶來了一次重大的變革。

新C++標準(也被稱爲C++11或C++0x)在2011年發佈,帶來一系列的變革讓C++編程更加簡單和高效。

C++標準第一次承認多線程在語言中的存在,並在標準庫中爲多線程提供組件。這意味着使用C++編寫與平臺無關的多線程程序成爲可能,也爲可移植性提供了強有力的保證

讀者們,有兩個問題可以先問一下自己:

1)什麼要使用併發?

2)什麼情況下不能使用併發?

多進程併發:在類似於Erlang的編程環境中,將進程作爲併發的基本構造塊。

C++標準並未對進程間通信提供任何原生支持,所以使用多進程的方式實現,這會依賴與平臺相關的API。因此,本書只關注使用多線程的併發,並且在此之後所提到“併發”,均假設爲多線程來實現。

回答上面的問題:

1)什麼要使用併發?

主要原因有兩個:關注點分離(SOC)和性能。事實上,它們應該是使用併發的唯一原因;如果你觀察得足夠仔細,所有因素都可以歸結到其中的一個原因。

2)什麼情況下不能使用併發?

知道何時不使用併發與知道何時使用它一樣重要。基本上,不使用併發的唯一原因就是,收益比不上成本。使用併發的代碼在很多情況下難以理解,因此編寫和維護的多線程代碼就會產生直接的腦力成本,同時額外的複雜性也可能引起更多的錯誤。除非潛在的性能增益足夠大或關注點分離地足夠清晰,能抵消所需的額外的開發時間以及與維護多線程代碼相關的額外成本(代碼正確的前提下);否則,別用併發。

同樣地,性能增益可能會小於預期;因爲操作系統需要分配內核相關資源和堆棧空間,所以在啓動線程時存在固有的開銷,然後才能把新線程加入調度器中,所有這一切都需要時間。如果在線程上的任務完成得很快,那麼任務實際執行的時間要比啓動線程的時間小很多,這就會導致應用程序的整體性能還不如直接使用“產生線程”的方式。

此外,線程是有限的資源。如果讓太多的線程同時運行,則會消耗很多操作系統資源,從而使得操作系統整體上運行得更加緩慢。不僅如此,因爲每個線程都需要一個獨立的堆棧空間,所以運行太多的線程也會耗盡進程的可用內存或地址空間。對於一個可用地址空間爲4GB(32bit)的平坦架構的進程來說,這的確是個問題:如果每個線程都有一個1MB的堆棧(很多系統都會這樣分配),那麼4096個線程將會用盡所有地址空間,不會給代碼、靜態數據或者堆數據留有任何空間。即便64位(或者更大)的系統不存在這種直接的地址空間限制,但其他資源有限:如果你運行了太多的線程,最終也是出會問題的。儘管線程池(參見第9章)可以用來限制線程的數量,但這也並不是什麼靈丹妙藥,它也有自己的問題。

當客戶端/服務器(C/S)應用在服務器端爲每一個鏈接啓動一個獨立的線程,對於少量的鏈接是可以正常工作的,但當同樣的技術用於需要處理大量鏈接的高需求服務器時,也會因爲線程太多而耗盡系統資源。在這種場景下,使用線程池可以對性能產生優化(參見第9章)。

最後,運行越多的線程,操作系統就需要做越多的上下文切換,每一次切換都需要耗費本可以花在有價值工作上的時間。所以在某些時候,增加一個額外的線程實際上會降低,而非提高應用程序的整體性能。爲此,如果你試圖得到系統的最佳性能,可以考慮使用硬件併發(或不用),並調整運行線程的數量。

爲性能而使用併發就像所有其他優化策略一樣:它擁有大幅度提高應用性能的潛力,但它也可能使代碼複雜化,使其更難理解,並更容易出錯。因此,只有應用中具有顯著增益潛力的性能關鍵部分,才值得併發化。當然,如果性能收益的潛力僅次於設計清晰或關注點分離,可能也值得使用多線程設計。

C++多線程歷史

C++98(1998)標準不承認線程的存在,並且各種語言要素的操作效果都以順序抽象機的形式編寫。不僅如此,內存模型也沒有正式定義,所以在C++98標準下,沒辦法在缺少編譯器相關擴展的情況下編寫多線程應用程序。

當然,編譯器供應商可以自由地向語言添加擴展,添加C語言中流行的多線程API———POSIX標準中的C標準和Microsoft Windows API中的那些———這就使得很多C++編譯器供應商通過各種平臺相關擴展來支持多線程。這種編譯器支持一般受限於只能使用平臺相關的C語言API,並且該C++運行庫(例如,異常處理機制的代碼)能在多線程情況下正常工作。因爲編譯器和處理器的實際表現很不錯了,所以在少數編譯器供應商提供正式的多線程感知內存模型之前,程序員們已經編寫了大量的C++多線程程序了。

由於不滿足於使用平臺相關的C語言API來處理多線程,C++程序員們希望使用的類庫能提供面向對象的多線程工具。像MFC這樣的應用框架,如同Boost和ACE這樣的已積累了多組類的通用C++類庫,這些類封裝了底層的平臺相關API,並提供用來簡化任務的高級多線程工具。各種類和庫在細節方面差異很大,但在啓動新線程的方面,總體構造卻大同小異。一個爲許多C++類和庫共有的設計,同時也是爲程序員提供很大便利的設計,也就是使用帶鎖的獲取資源即初始化(RAII, Resource Acquisition Is Initialization)的習慣,來確保當退出相關作用域時互斥元解鎖。

C++11支持併發

只有在C++11標準下,才能編寫不依賴平臺擴展的多線程代碼。

新標準中不僅有了一個全新的線程感知內存模型,C++標準庫也擴展了:包含了用於管理線程(參見第2章)、保護共享數據(參見第3章)、線程間同步操作(參見第4章),以及低級原子操作(參見第5章)的各種類。

新的C++標準直接支持原子操作,允許程序員通過定義語義的方式編寫高效的代碼,從而無需瞭解與平臺相關的彙編指令。這對於試圖編寫高效、可移植代碼的程序員們來說是一個好消息;編譯器不僅可以搞定具體平臺,還可以編寫優化器來解釋操作語義,從而讓程序整體得到更好的優化。

雖然C++線程庫爲多線程和併發處理提供了較全面的工具,但在某些平臺上提供額外的工具。爲了方便地訪問那些工具的同時,又使用標準C++線程庫,在C++線程庫中提供一個native_handle()成員函數,允許通過使用平臺相關API直接操作底層實現。就其本質而言,任何使用native_handle()執行的操作都是完全依賴於平臺的,這超出了本書(同時也是標準C++庫本身)的範圍。

線程啓動

使用C++線程庫啓動線程,可以歸結爲構造std::thread對象:

等待線程完成

只能對一個線程使用一次join();一旦已經使用過join(),std::thread對象就不能再次加入了,當對其使用joinable()時,將返回false。但是, 也有一個例外, 就是當除了第一個參數以外的其他參數都有默認值的時候, explicit關鍵字依然有效, 此時, 當調用構造函數時只傳入一個參數, 等效於只有一個參數的類構造函數。

向線程函數傳遞參數

期望傳遞一個引用,但整個對象被複制了。當線程更新一個引用傳遞的數據結構時,這種情況就可能發生,比如:

void update_data_for_widget(widget_id w,widget_data& data); // 1
void oops_again(widget_id w)
{
  widget_data data;
  std::thread t(update_data_for_widget,w,data); // 2
  display_status();
  t.join();
  process_widget_data(data); // 3
}

雖然update_data_for_widget①的第二個參數期待傳入一個引用,但是std::thread的構造函數②並不知曉;構造函數無視函數期待的參數類型,並盲目的拷貝已提供的變量。當線程調用update_data_for_widget函數時,傳遞給函數的參數是data變量內部拷貝的引用,而非數據本身的引用。因此,當線程結束時,內部拷貝數據將會在數據更新階段被銷燬,且process_widget_data將會接收到沒有修改的data變量③。對於熟悉std::bind的開發者來說,問題的解決辦法是顯而易見的:可以使用std::ref將參數轉換成引用的形式,從而可將線程的調用改爲以下形式:

std::thread t(update_data_for_widget,w,std::ref(data));

如果你熟悉std::bind,就應該不會對以上述傳參的形式感到奇怪,因爲std::thread構造函數和std::bind的操作都在標準庫中定義好了,可以傳遞一個成員函數指針作爲線程函數,並提供一個合適的對象指針作爲第一個參數:

class X
{
public:
  void do_lengthy_work();
};
X my_x;
std::thread t(&X::do_lengthy_work,&my_x); // 1

這段代碼中,新線程將my_x.do_lengthy_work()作爲線程函數;my_x的地址①作爲指針對象提供給函數。也可以爲成員函數提供參數:std::thread構造函數的第三個參數就是成員函數的第一個參數,以此類推(代碼如下,譯者自加)。

class X
{
public:
  void do_lengthy_work(int);
};
X my_x;
int num(0);
std::thread t(&X::do_lengthy_work, &my_x, num);

有趣的是,提供的參數可以移動,但不能拷貝"移動"是指:原始對象中的數據轉移給另一對象,而轉移的這些數據就不再在原始對象中保存了(譯者:比較像在文本編輯的"剪切"操作)。std::unique_ptr就是這樣一種類型(譯者:C++11中的智能指針),這種類型爲動態分配的對象提供內存自動管理機制(譯者:類似垃圾回收)。同一時間內,只允許一個std::unique_ptr實現指向一個給定對象,並且當這個實現銷燬時,指向的對象也將被刪除。移動構造函數(move constructor)和移動賦值操作符(move assignment operator)允許一個對象在多個std::unique_ptr實現中傳遞(有關"移動"的更多內容,請參考附錄A的A.1.1節)。使用"移動"轉移原對象後,就會留下一個空指針(NULL)。移動操作可以將對象轉換成可接受的類型,例如:函數參數或函數返回的類型。當原對象是一個臨時變量時,自動進行移動操作,但當原對象是一個命名變量,那麼轉移的時候就需要使用std::move()進行顯示移動。下面的代碼展示了std::move的用法,展示了std::move是如何轉移一個動態對象到一個線程中去的:

void process_big_object(std::unique_ptr<big_object>);

std::unique_ptr<big_object> p(new big_object);
p->prepare_data(42);
std::thread t(process_big_object,std::move(p));

std::thread實例不像std::unique_ptr那樣能佔有一個動態對象的所有權,但是它能佔有其他資源:每個實例都負責管理一個執行線程。執行線程的所有權可以在多個std::thread實例中互相轉移,這是依賴於std::thread實例的可移動不可複製性。不可複製保性證了在同一時間點,一個std::thread實例只能關聯一個執行線程;可移動性使得程序員可以自己決定,哪個實例擁有實際執行線程的所有權。

轉移線程所有權

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章