qt 5编程入门(第2版)_《C++并发编程实战第2版》第六章:设计基于锁的并发数据结构(1/3)...
本章主要內容
- 設計并發(fā)數(shù)據(jù)結構的含義
- 設計指南
- 并發(fā)數(shù)據(jù)結構的示例實現(xiàn)
在上一章中我們了解了底層原子操作和內存模型。本章我們先把底層的細節(jié)放一放(盡管在第7章我們將需要它們),探討一下數(shù)據(jù)結構。
為編程問題選擇數(shù)據(jù)結構可能是整個解決方案的關鍵部分,并行程序也不例外。如果一個數(shù)據(jù)結構需要被多個線程訪問,要么它完全不可變,因此數(shù)據(jù)永遠不會變化,并且沒有必要同步,要么程序必須設計成確保變動在線程間被正確的同步。一種選擇是使用獨立的互斥鎖以及外部加鎖來保護數(shù)據(jù),比如使用我們在第3和第4章討論的技術,另一種選擇是設計自身支持并發(fā)訪問的數(shù)據(jù)結構。
當設計并發(fā)數(shù)據(jù)結構時,可以使用前面章節(jié)提及的應用于多線程程序的基礎構件塊,比如:互斥鎖和條件變量。實際上,你已經看過了幾個示例,這些示例展示了如何組合這些構建塊來編寫對多線程并發(fā)訪問安全的數(shù)據(jù)結構。
在本章中,我們將從為并發(fā)設計數(shù)據(jù)結構的一些通用指南開始。然后,我們將使用鎖和條件變量的基本構建塊,并在轉向更復雜的數(shù)據(jù)結構之前重新討論這些基本數(shù)據(jù)結構的設計。在第7章中,我們將看到如何回歸基礎,并使用第5章中描述的原子操作來構建無鎖的數(shù)據(jù)結構。
好了!言歸正傳,讓我們來看一下設計并發(fā)數(shù)據(jù)結構都需要什么。
6.1 并發(fā)設計的含義
從基本層面上講,為并發(fā)設計數(shù)據(jù)結構意味著,多個線程可以并發(fā)的訪問這個數(shù)據(jù)結構,不管線程執(zhí)行的是相同還是不同的操作,并且每一個線程都能看到數(shù)據(jù)結構的前后一致的視圖。沒有數(shù)據(jù)丟失或者損壞,所有的不變量都被支持,且沒有有問題的競爭條件。這樣的數(shù)據(jù)結構,稱之為線程安全(thread-safe)的數(shù)據(jù)結構。通常情況下,一個數(shù)據(jù)結構只對特殊類型的并發(fā)訪問是安全的。也許有可能讓多個線程并發(fā)地對數(shù)據(jù)結構執(zhí)行一種類型的操作,而另一種操作則需要由單個線程獨占訪問?;蛘?#xff0c;如果多個線程正在執(zhí)行不同的操作,那它們并發(fā)訪問數(shù)據(jù)結構可能是安全的,但多個線程執(zhí)行相同的操作就會有問題。
然而,真正為并發(fā)而設計遠不止這些:真正的設計意味著要為線程提供并發(fā)訪問數(shù)據(jù)結構的機會。從本質上講,互斥鎖提供了互斥:一次只能有一個線程獲得互斥鎖?;コ怄i保護數(shù)據(jù)結構是通過顯式地阻止對它所保護數(shù)據(jù)的真正并發(fā)訪問來實現(xiàn)的。
這稱為串行化(serialzation):線程輪流訪問被互斥鎖保護的數(shù)據(jù)。它們必須串行而非并發(fā)的訪問它。因此,必須仔細考慮數(shù)據(jù)結構的設計,使得能夠真正的并發(fā)訪問。雖然有些數(shù)據(jù)結構比其他數(shù)據(jù)結構具有更大的并發(fā)范圍,但在所有情況下,其思想都是相同的:受保護的區(qū)域越小,串行化的操作就越少,并發(fā)的潛力也就越大。
在進行數(shù)據(jù)結構的設計之前,讓我們快速的瀏覽一下并發(fā)設計的指南。
6.1.1 設計并發(fā)數(shù)據(jù)結構的指南
之前提過,當設計并訪問的數(shù)據(jù)結構時,需要考慮兩個方面:確保訪問安全以及允許真正的并發(fā)訪問。在第3章中,已經介紹了如何使數(shù)據(jù)結構是線程安全的基礎知識:
- 確保沒有線程能夠看到數(shù)據(jù)結構的不變量被另一個線程破壞的狀態(tài)。
- 通過提供完整操作的函數(shù),而非一個個操作步驟的函數(shù)來小心避免接口固有的競爭條件。
- 注意數(shù)據(jù)結構在有異常時的行為,從而確保不變量不會被破壞。
- 通過限制鎖的范圍以及避免嵌套鎖,將死鎖的概率降到最低。
在思考這些細節(jié)之前,想想要對數(shù)據(jù)結構的用戶施加什么約束也是很重要的;如果線程通過一個特定的函數(shù)對數(shù)據(jù)結構進行訪問,其他線程能安全調用哪些函數(shù)?
這是一個需要考慮的關鍵問題,通常,構造函數(shù)和析構函數(shù)需要互斥地訪問數(shù)據(jù)結構,但是需要由用戶確保它們不會在構造函數(shù)完成之前或者析構函數(shù)開始以后被訪問。如果數(shù)據(jù)結構支持賦值操作,swap()或拷貝構造,作為數(shù)據(jù)結構的設計者,你需要決定這些操作與其他操作并發(fā)調用是否安全,或者它們是否要求用戶確保獨占訪問,盡管大多數(shù)用于操作數(shù)據(jù)結構的函數(shù)可以從多個線程并發(fā)地調用而沒有任何問題。
第二個需要考慮的方面是允許真正的并發(fā)訪問。在這個方面我沒法提供太多的指南。相反,作為一個數(shù)據(jù)結構的設計者,需要問自己以下問題:
- 是否可以限制鎖的作用范圍,以允許操作的某些部分在鎖外執(zhí)行?
- 數(shù)據(jù)結構不同部分能否被不同的互斥鎖保護?
- 所有的操作需要同一級別的保護嗎?
- 是否可以對數(shù)據(jù)結構進行簡單的修改,以增加并發(fā)訪問的機會,并且不影響操作語義?
所有這些問題都基于一個思想:如何最小化必須的串行操作,并且使得真實的并發(fā)最大化?就數(shù)據(jù)結構而言,允許多線程并發(fā)的只讀訪問,而修改線程必須互斥訪問的情況很普遍。這是通過使用像std::shared_mutex這樣的結構來支持的。類似地,你很快就會看到,在串行線程嘗試執(zhí)行相同操作的同時,數(shù)據(jù)結構支持執(zhí)行不同操作的線程并發(fā)地訪問也很普遍。
最簡單的線程安全數(shù)據(jù)結構,通常使用互斥鎖來保護數(shù)據(jù)。盡管這樣做存在一些問題,但就像你在第3章中看到的,確保一次只有一個線程訪問數(shù)據(jù)結構相對比較簡單。為了讓你更容易設計線程安全的數(shù)據(jù)結構,我們將在本章繼續(xù)研究這種基于鎖的數(shù)據(jù)結構,并將無鎖并發(fā)數(shù)據(jù)結構的設計留到第7章討論。
6.2 基于鎖的并發(fā)數(shù)據(jù)結構
設計基于鎖的并發(fā)數(shù)據(jù)結構,都是為了確保在訪問數(shù)據(jù)時鎖住正確的互斥鎖,并且持有鎖的時間最短。對于只有一個互斥鎖的數(shù)據(jù)結構來說,這很困難。你需要確保數(shù)據(jù)不能在互斥鎖的保護之外被訪問,并且接口中沒有固有的競爭條件,就如第3章中看到的那樣。如果使用不同的互斥鎖來保護數(shù)據(jù)結構中不同的部分,問題會進一步惡化,如果操作需要鎖住多個互斥鎖時,現(xiàn)在也可能產生死鎖。所以相比單一互斥鎖的設計,使用多個互斥鎖的數(shù)據(jù)結構需要更加小心。
在本節(jié)中,你將應用6.1.1節(jié)中的指南來設計一些簡單的數(shù)據(jù)結構,通過使用互斥鎖來保護數(shù)據(jù)。在每個例子中,都是在確保數(shù)據(jù)結構保持線程安全的前提下,找出更大并發(fā)的機會。
我們先來看看第3章中棧的實現(xiàn),它是最簡單的數(shù)據(jù)結構,且只使用了一個互斥鎖。那么它是線程安全的嗎?它離真正的并發(fā)訪問有多遠呢?
6.2.1 使用鎖的線程安全棧
下面的清單復制了第3章中線程安全的棧。目的是編寫一個類似于std::stack<>的線程安全的數(shù)據(jù)結構,它支持將數(shù)據(jù)項推入棧中并再次彈出它們。
我們依次看下每條指南以及它們是如何應用在這里。
首先,如你所見,基本的線程安全是通過使用互斥鎖m上的鎖保護每個成員函數(shù)提供的。這將確保在任何時候只有一個線程在訪問數(shù)據(jù),因此只要每個成員函數(shù)保持不變量,就沒有線程能看到被破壞的不變量。
其次,在empty()和pop()成員函數(shù)之間有潛在的競爭條件,不過代碼會在pop()函數(shù)持有鎖的時候,顯式的查詢棧是否為空,所以這里的競爭條件沒有問題。通過直接返回彈出的數(shù)據(jù)項作為調用pop()的一部分,避免了分離的top()和pop()成員函數(shù)(std::stack<>類似)之間潛在的競爭條件。
然后,棧中也有一些潛在拋異常的地方。對互斥鎖上鎖可能會拋出異常,但這種情況不僅極其罕見的(這意味著互斥鎖有問題,或者缺乏系統(tǒng)資源),而且它是每個成員函數(shù)的第一個操作。由于沒有數(shù)據(jù)被修改,所以是安全的。解鎖互斥鎖不會失敗,所以總是安全的,并且使用std::lock_guard<>確保了互斥鎖不會一直處于上鎖的狀態(tài)。
對data.push()①的調用可能會拋出一個異常,只要拷貝/移動數(shù)據(jù)值拋出一個異常,或者可分配的內存不足。不管是哪種情況,std::stack<>都能保證是安全的,所以也沒有問題。
在第一個重載的pop()中,代碼本身可能會拋出一個empty_stack的異常②,但由于什么都沒有修改,所以是安全的。創(chuàng)建res③可能會拋出一個異常,有幾個方面的原因:對std::make_shared的調用,可能因為無法為新對象以及引用計數(shù)需要的內部數(shù)據(jù)分配出足夠的內存而拋出異常,或者在拷貝/移動到新分配內存的時候,返回的數(shù)據(jù)項的拷貝構造或移動構造函數(shù)可能拋出異常。兩種情況下,C++運行庫和標準庫會確保沒有內存泄露,并且新創(chuàng)建的對象(如果有的話)會被正確的銷毀。因為仍然沒有對棧進行任何修改,所以也不會有問題。調用data.pop()④保證不會拋出異常,隨后是返回結果,所以這個重載的pop()函數(shù)是異常安全的。
第二個重載的pop()類似,不過這次是在拷貝賦值或移動賦值時可能拋出異常⑤,而不是在構造新對象和std::shared_ptr實例時。再次,直到調用data.pop()⑥(pop仍然保證不會拋出異常)前,沒有修改數(shù)據(jù)結構,所以這個函數(shù)也是異常安全的。
最后,empty()不會修改任何數(shù)據(jù),所以也是異常安全的。
這里有幾個可能導致死鎖的機會,因為你在持有鎖的時候調用了用戶代碼:數(shù)據(jù)項上的拷貝構造或移動構造(①,③)和拷貝賦值或移動賦值操作⑤,也可能是用戶自定義的new操作符。如果這些函數(shù)或者調用了棧上的成員函數(shù)(而棧正在插入或移除數(shù)據(jù)項),或者需要任何類型的鎖,而在調用棧成員函數(shù)時又持有了另一把鎖,那么就有可能出現(xiàn)死鎖。但明智的做法是要求棧的用戶負責確保這一點;你不能期望在不拷貝或不為它分配內存的情況下將數(shù)據(jù)項添加到?;驈臈V袆h除。
由于所有成員函數(shù)都使用std::lock_guard<>保護數(shù)據(jù),所以不管多少線程調用棧成員函數(shù)都是安全的。唯一不安全的成員函數(shù)是構造函數(shù)和析構函數(shù),但這不是問題;對象只能被構造一次,也只能被銷毀一次。調用一個不完全構造的對象或是部分銷毀的對象的成員函數(shù)永遠都不可取,不管并發(fā)與否。因此,用戶必須確保其他線程直到棧完全構造才能訪問它,,并且必須確保在棧對象銷毀前,所有線程都已經停止訪問棧。
盡管多個線程并發(fā)調用成員函數(shù)是安全的,但由于使用了鎖,每次只有一個線程在棧數(shù)據(jù)結構中做一些工作。線程的串行化會潛在的限制應用程序的性能,因為這里會有嚴重的鎖爭用:當一個線程在等待鎖時,它沒有做任何有用的工作。同樣,棧也沒有提供什么方法等待添加一個數(shù)據(jù)項,所以如果線程需要等待時,它必須周期性地調用empty()或pop(),并且捕獲empty_stack異常。如果這種場景是必須的,那這種棧實現(xiàn)就是個糟糕的選擇,因為等待線程要么消耗寶貴的資源去檢查數(shù)據(jù),要么要求用戶編寫外部等待和通知的代碼(例如,使用條件變量),這就使內部上鎖沒有必要,因而造成浪費。第4章中的隊列展示了一種使用數(shù)據(jù)結構內部的條件變量將這種等待合并到數(shù)據(jù)結構本身的方法,接下來我們看一下這個。
6.2.2使用鎖和條件變量的線程安全隊列
清單6.2復制了第4章中的線程安全隊列,就像棧是仿照std::stack<>一樣,這個隊列也是仿照了std::queue<>。再次,接口不同于標準容器適配器,因為實現(xiàn)的數(shù)據(jù)結構需要支持多線程并發(fā)訪問。
除了在push()①中調用data_cond.notify_one(),以及wait_and_pop()②③外,清單6.2中隊列的實現(xiàn)與6.1清單中的棧類似。兩個重載的try_pop()幾乎和清單6.1中一樣,只是在隊列為空時不拋異常,取而代之返回一個bool值表示是否檢索到值或者一個NULL指針(對應返回指針的重載版本)如果沒有值可以檢索的話。這也是實現(xiàn)棧的一個有效方式。如果排除wait_and_pop()函數(shù),對棧的分析在這里也同樣適用。
新的wait_and_pop()函數(shù)解決了在棧中碰到的等待隊列條目的問題;比起持續(xù)調用empty(),等待線程調用wait_and_pop()函數(shù)并且數(shù)據(jù)結構使用條件變量來處理等待。對data_cond.wait()的調用,直到隊列中至少有一個元素時才會返回,所以不用擔心會出現(xiàn)空隊列的情況,并且數(shù)據(jù)仍然被互斥鎖保護。因此,這些函數(shù)不會添加任何新的競爭條件或死鎖的可能性,并且將支持不變量。
在異常安全性方面有一個細微的變化,當一個條目被推入隊列時,如果有多個線程在等待,那么只有一個線程會被data_cond.notify_one()喚醒。但是,如果這個線程在wait_and_pop()中拋出一個異常,比如當構造新的std::shared_ptr<>對象④時,那么沒有其他線程被喚醒。這種情況不可接受,調用可以替換成data_cond.notify_all(),它將喚醒所有的工作線程,代價就是大多線程發(fā)現(xiàn)隊列依舊是空時,重新進入休眠狀態(tài)。第二種替代方案是,有異常拋出的時,讓wait_and_pop()函數(shù)調用notify_one(),從而讓另一個線程可以去嘗試檢索存儲的值。第三種替代方案是,將std::shared_ptr<>的初始化過程移到push()中,并且存儲std::shared_ptr<>實例,而不是直接使用數(shù)據(jù)值。將std::shared_ptr<>從內部std::queue<>中拷出不會拋出異常,這樣wait_and_pop()又是安全的了。下面的程序清單,就是基于這種思路修改的。
通過std::shared_ptr<>持有數(shù)據(jù)的影響比較直接:通過引用變量來接收新值的pop函數(shù)現(xiàn)在必須對存儲的指針解引用①②;并且,在返回給調用者前,返回std::shared_ptr<>實例的pop函數(shù)可以從隊列中檢索它③④。
通過std::shared_ptr<>持有數(shù)據(jù)還有個好處:在push()⑤中分配新實例可以在鎖外面完成,而在清單6.2中,只能在pop()持有鎖時完成。因為內存分配是個典型的代價高昂的操作,這有利于隊列的性能,因為它減少了持有互斥鎖的時間,并允許其他線程同時在隊列上執(zhí)行操作。
如同棧示例,使用互斥鎖來保護整個數(shù)據(jù)結構限制了該隊列的并發(fā)支持;盡管在不同的成員函數(shù)中,隊列上可能阻塞多個線程,但一次只能有一個線程開展工作。但是部分限制來自于實現(xiàn)中使用了std::queue<>;通過使用標準容器,你現(xiàn)在可以決定數(shù)據(jù)項是否受保護。通過控制數(shù)據(jù)結構的實現(xiàn)細節(jié),你可以提供更細粒度的鎖從而實現(xiàn)更高級別的并發(fā)。
6.2.3使用細粒度鎖和條件變量的線程安全隊列
在清單6.2和6.3中,有一個受保護的數(shù)據(jù)項(data_queue)和一個互斥鎖。為了使用細粒度鎖,需要查看隊列內部的組成部分,并將一個互斥鎖與每個不同的數(shù)據(jù)項關聯(lián)起來。
最簡單的隊列是單鏈表,如圖6.1所示。隊列包含一個頭指針,指向鏈表中的第一個項,然后每一項指向下一項。從隊列中刪除數(shù)據(jù)項,是用指向下一項的指針替換頭指針,然后將之前頭指針的數(shù)據(jù)返回。
數(shù)據(jù)項從隊列的另一端添加到隊列。為了做到這點,隊列還有一個tail指針,它指向鏈表中的最后一項。新節(jié)點的添加是通過改變最后一項的next指針,讓它指向新的節(jié)點,然后更新tail指針指向這個新的數(shù)據(jù)項。當鏈表為空時,頭/尾指針都為NULL。
圖6.1 用單鏈表表示的隊列下面的清單顯示了這個隊列的簡單實現(xiàn),它基于清單6.2中隊列接口的簡化版本;只有一個try_pop()函數(shù),沒有wait_and_pop(),因為這個隊列只支持單線程使用。
首先,注意清單6.4中使用了std::unique_ptr<node>來管理節(jié)點,因為這能保證當不再需要它們的時候,它們(以及它們引用的數(shù)據(jù))會自動刪除,而不必使用顯式的delete。這個所有權鏈的管理從head開始,tail是指向最后一個節(jié)點的裸指針,因為它需要引用std::unique_ptr<node>已經擁有的節(jié)點。
雖然這個實現(xiàn)在單線程環(huán)境工作的很好,但當在多線程下嘗試使用細粒度鎖時,有幾個事情會帶來麻煩。因為在給定的實現(xiàn)中有兩個數(shù)據(jù)項(head①和tail②);原則上可以使用兩個互斥鎖來分別保護頭和尾指針,但這樣做會有幾個問題。
最明顯的問題就是push()可能同時修改head⑤和tail⑥,所以它必須鎖住兩個互斥鎖。盡管很不幸,但這倒不算是太大的問題,因為鎖住兩個互斥鎖是可能的。關鍵的問題是push()和pop()都能訪問next指針指向的節(jié)點:push()更新tail->next④,然后try_pop()讀取head->next③。如果隊列中只有一個元素,那么head==tail,所以head->next和tail->next是同一個對象,并且這個對象需要保護。由于不同時讀取head和tail的話,沒法區(qū)分它們是否是同一個對象,你現(xiàn)在必須在push()和try_pop()中鎖住同一個鎖,所以,也沒比以前好多少。那有什么辦法擺脫這個困境嗎?
總結
以上是生活随笔為你收集整理的qt 5编程入门(第2版)_《C++并发编程实战第2版》第六章:设计基于锁的并发数据结构(1/3)...的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: everything便携版和安装版区别_
- 下一篇: C++PrimerPlus学习——第七章