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