1. 程式人生 > >深入理解並行程式設計-分割和同步設計(二)

深入理解並行程式設計-分割和同步設計(二)

原文連結    作者:paul    譯者:謝寶友,魯陽,陳渝

雙端佇列是一種元素可以從兩端插入或刪除的資料結構[Knu73]。據說實現一種基於鎖的允許在雙端佇列的兩端進行併發操作的方法非常困難[Gro07]。本節將展示一種分割設計策略,能實現合理且簡單的解決方案,請看下面的小節中的三種通用方法。


1.1. 右手鎖和左手鎖

帶有左手鎖和右手鎖的雙端佇列1

圖1.1:帶有左手鎖和右手鎖的雙端佇列

右手鎖和左手鎖是一種看起來很直接的辦法,為左手端的入列操作加一個左手鎖,為右手端的出列操作加一個右手鎖,如圖1.1所示。但是,這種辦法的問題是當佇列中的元素不足四個時,兩個鎖的範圍會發生重疊。這種重疊是由於移除任何一個元素不僅只影響元素本身,還要影響它左邊和右邊相鄰的元素。這種範圍在圖中被塗上了顏色,藍色表示左手鎖的範圍,紅色表示右手鎖的範圍,紫色表示重疊的範圍。雖然建立這樣一種演算法是可能的,但是至少要小心5種特殊情況,尤其是在佇列另一端的併發活動會讓佇列隨時可能從一種特殊情況變為另一種特殊情況。所以最好還是考慮考慮其他解決方案。

1.2. 複合雙端佇列

複合雙端佇列

圖1.2:複合雙端佇列

圖1.2是一種強制鎖範圍不重疊的辦法。兩個單獨的雙端佇列串聯在一起,每個佇列用自己的鎖保護。這意味著資料偶爾會從一個雙端佇列跑到另一個雙端佇列。此時必須同時持有兩把鎖。為避免死鎖,可以使用一種簡單的鎖層級關係,比如,在獲取右手鎖前先獲取左手鎖。這比在同一雙端佇列上用兩把鎖簡單的多,因為我們可以無條件地讓左邊的入列元素進入左手佇列,右邊的入列元素進入右手佇列。主要的複雜度來源於從空佇列中出列,在這種情況下必須: 1. 如果持有右手鎖,釋放並獲取左手鎖,重新檢查佇列是否仍然為空。 2. 獲取右手鎖。 3. 重新平衡穿過兩個佇列的元素。 4. 移除指定的元素。 5. 釋放兩把鎖。

小問題1.1: 在複合雙端佇列實現中,如果佇列在釋放和獲取鎖時變的不為空,那麼該怎麼辦? 重新平衡操作可能會將某個元素在兩個佇列間來回移動,這不僅浪費時間,而且想要獲得最佳效能,還需針對工作負荷進行不斷微調。雖然這在一些情況下可能是最佳方案了,但是帶著更大的雄心,我們還將繼續探索其它演算法。

1.3. 雜湊雙端佇列

雜湊永遠是分割一個數據結構的最簡單和最有效的方法。可以根據元素在佇列中的位置為每個元素分配一個序號,然後以此對雙端佇列進行雜湊,這樣第一個從左邊進入空佇列的元素編號為0,第一個從右邊進入空佇列的元素編號為1。其他從左邊進入只有一個元素的佇列的元素則遞減的編號(-1,-2,-3,…),而其他從右邊進入只有一個元素的佇列的元素則遞增的編號(2,3,4,…)。關鍵的是,實際上不用真正的為元素編號,元素的序號暗含在它在佇列中的位置中。

雜湊雙端佇列

圖1.3:雜湊雙端佇列

我們用一個鎖保護左手的下標,用另一個鎖保護右手的下標,再各用一個鎖保護對應的雜湊連結串列。圖1.3顯示了四個雜湊連結串列的資料結構。注意到鎖的範圍沒有重疊,為了避免死鎖,只在獲取連結串列鎖之前獲取下標鎖,每種型別的鎖(下標或者連結串列)每次從不獲取超過一個。

每個雜湊連結串列都是一個雙端佇列,在這裡的例子中,每個連結串列都holds every fourth element。圖1.4中最上面的部分是“R1”元素從右邊入隊後的狀態,右手的下標增加,用來引用雜湊連結串列2。圖中中間部分是又有三個元素從右邊入隊。正如您見到的那樣,下標回到了它們初始的狀態,但是每個雜湊佇列現在是非空的了。圖中下方部分是另外三個元素從左邊入隊,而另外一個元素從右邊入隊後的狀態。

插入後的雜湊雙端佇列1

圖1.4:插入後的雜湊雙端佇列

從圖1.4中最後一個狀態可以看出,左出隊操作將返回元素“L-2”,讓左手下標指向雜湊鏈2,此時該連結串列只剩下“R2”。在這種狀態下,併發的左入隊操作和右入隊操作可能會導致鎖競爭,但這種鎖競爭發生的可能性可以通過使用更大的雜湊表來降低。

12個元素的雜湊雙端佇列

圖1.5:12個元素的雜湊雙端佇列

圖1.5顯示了12個元素如何組成一個有4個並行雜湊桶的雙端佇列。

 struct pdeq {

     spinlock_t llock;

     int lidx;

     spinlock_t rlock;

     int ridx;

     struct deq bkt[DEQ_N_BKTS];

 };

圖1.6:基於鎖的並行雙端佇列資料結構
圖1.6顯示了對應的C語言資料結構,假設已有struct deq來提供帶有鎖的雙端佇列實現。這個資料結構包括第2行的左手鎖,第3行的左手下標,第4行的右手鎖,第5行的右手下標,以及第6行的雜湊後的基於簡單鎖實現的雙端佇列陣列。高效能的實現當然還會使用填充或者是特殊對齊指令來避免false sharing(http://en.wikipedia.org/wiki/False_sharing)。

 struct element *pdeq_dequeue_l(struct pdeq *d)
 {
 struct element *e;
 int i;
 spin_lock(&d->llock);
 i = moveright(d->lidx);
 e = deq_dequeue_l(&d->bkt[i]);
 if (e != NULL)
 d->lidx = i;
 spin_unlock(&d->llock);
 return e;
 }

 void pdeq_enqueue_l(struct element *e, struct pdeq *d)
 {
 int i;
 spin_lock(&d->llock);
 i = d->lidx;
 deq_enqueue_l(e, &d->bkt[i]);
 d->lidx = moveleft(d->lidx);
 spin_unlock(&d->llock);
 }

 struct element *pdeq_dequeue_r(struct pdeq *d)
 {  struct element *e;
 int i;
 spin_lock(&d->rlock);
 i = moveleft(d->ridx);
 e = deq_dequeue_r(&d->bkt[i]);
 if (e != NULL)
 d->ridx = i;
 spin_unlock(&d->rlock);
 return e;
 }

 void pdeq_enqueue_r(struct element *e, struct pdeq *d)
 {
 int i;
 spin_lock(&d->rlock);
 i = d->ridx;
 deq_enqueue_r(e, &d->bkt[i]);
 d->ridx = moveright(d->lidx);
 spin_unlock(&d->rlock);
 }

圖1.7:基於鎖的並行雙端佇列實現程式碼
圖1.7顯示了入隊和出隊函式。討論將集中在左手操作上,因為右手的操作都是源於左手操作。
第1-13行是pdeq_dequeue_l()函式,從左邊出隊,如果成功返回一個元素,如果失敗返回NULL。第6行獲取左手自旋鎖,第7行計算要出隊的下標。第8行讓元素出隊,如果第9行發現結果不為NULL,第10行記錄新的左手下標。不管結果為何,第11行釋放鎖,最後如果曾經有一個元素,第12行返回這個元素,否則返回NULL。

第15-24行是pdeq_enqueue_l()函式,從左邊入隊一個特定元素。第19行獲取左手鎖,第20行pick up左手下標。第21行讓元素從左邊入隊,進入一個以左手下標標記的雙端佇列。第22行更新左手下標,最後第23行釋放鎖。

和之前提到的一樣,右手操作完全是對對應的左手操作的模擬。

小問題1.2:雜湊過的雙端佇列是一種好的解決方法嗎?如果對,為什麼對?如果不對,為什麼不對?

1.4. 再次回到複合雙端佇列

本節再次回到複合雙端佇列,準備使用一種重新平衡機制來將非空佇列中的所有元素移動到空佇列中。

小問題1.3:讓所有元素進入空的佇列?這種腦殘的方法是哪門子最優方案啊???

相比上一節提出的雜湊式實現,複合式實現建立在對既不使用鎖也不使用原子操作的雙端佇列的順序實現上。

 struct list_head *pdeq_dequeue_l(struct pdeq *d)

 {

 struct list_head *e;

 int i;

 spin_lock(&d->llock);

 e = deq_dequeue_l(&d->ldeq);

 if (e == NULL) {

 spin_lock(&d->rlock);

 e = deq_dequeue_l(&d->rdeq);

 list_splice_init(&d->rdeq.chain, &d->ldeq.chain);

 spin_unlock(&d->rlock);

 }

 spin_unlock(&d->llock);

 return e;

 }

 struct list_head *pdeq_dequeue_r(struct pdeq *d)

 {

 struct list_head *e;

 int i;

 spin_lock(&d->rlock);

 e = deq_dequeue_r(&d->rdeq);

 if (e == NULL) {

 spin_unlock(&d->rlock);

 spin_lock(&d->llock);

 spin_lock(&d->rlock);

 e = deq_dequeue_r(&d->rdeq);

 if (e == NULL) {

 e = deq_dequeue_r(&d->ldeq);

 list_splice_init(&d->ldeq.chain, &d->rdeq.chain);

 }

 spin_unlock(&d->llock);

 }

 spin_unlock(&d->rlock);

 return e;

 }

 void pdeq_enqueue_l(struct list_head *e, struct pdeq *d)

 {

 int i;

 spin_lock(&d->llock);

 deq_enqueue_l(e, &d->ldeq);

 spin_unlock(&d->llock);

 }

 void pdeq_enqueue_r(struct list_head *e, struct pdeq *d)

 {

 int i;

 spin_lock(&d->rlock);

 deq_enqueue_r(e, &d->rdeq);

 spin_unlock(&d->rlock);

 }

圖1.8:複合並行雙端佇列的實現程式碼

圖1.8展示了這個實現。和雜湊式實現不同,複合式實現是非對稱的,所以我們必須單獨考慮pdeq_dequeue_l()和pdeq_dequeue_r()的實現。

小問題1.4:為什麼複合並行雙端佇列的實現不能是對稱的?

圖1.8第1-16行是pdeq_dequeue_l()的實現。第6行獲取左手鎖,第14行釋放。第7行嘗試從雙端佇列的左端左出列一個元素,如果成功,跳過第8-13行,直接返回該元素。否則,第9行獲取右手鎖,第10行從佇列右端左出列一個元素,第11行將右手佇列中剩餘元素移至左手佇列,第12行釋放右手鎖。如果有的話,最後將返回第10行出列的元素。

圖1.8第18-38行是pdeq_dequeue_r()的實現。和之前一樣,第23行獲取右手鎖(第36行釋放),第24行嘗試從右手佇列右出列一個元素,如果成功,跳過第24-35行,直接返回該元素。但是如果第25行發現沒有元素可以出列,那麼第26行釋放右手鎖,第27-28行以恰當的順序獲取左手鎖和右手鎖。然後第29行再一次嘗試從右手佇列右出列一個元素,如果第30行發現第二次嘗試也失敗了,第31行從左手佇列(假設只有一個元素)右出列一個元素,第32行將左手佇列中的剩餘元素移至右手佇列。最後,第34行釋放左手鎖。

小問題1.5:為什麼圖5.11中第29行的重試右出列操作是必須的?

小問題1.6:可以肯定的是,左手鎖必須在某些時刻是可用的!!!那麼,為什麼圖5.11中第26行的無條件釋放右手鎖是必須的?

圖1.8中第40-47行是pdeq_enqueue_l()的實現。第44行獲取左手自旋鎖,第45行將元素左入列到左手佇列,最後第46行釋放自旋鎖。pdeq_enqueue_r(圖中第49-56)的實現和此類似。

1.5. 關於雙端佇列的討論

複合式實現在某種程度上比第1.3節所描述的雜湊式實現複雜,但是仍然屬於比較簡單的。當然,更智慧的重新平衡機制可能更加複雜,但是這裡展現的簡單機制和另一種軟體[DCW+11]相比,已經執行的很好了,即使和使用硬體輔助的演算法[DLM+10]相比也是如此。不過,從這種機制中我們最好也只能獲得2x的擴充套件能力,因為最多隻能有兩個執行緒併發地持有出列的鎖。 關鍵點在於從共享佇列中入列或者出列的巨大開銷。

1.6. 關於分割問題示例的討論

深入理解並行程式設計-分割和同步設計(一)第1.1節的小問題中,關於哲學家就餐問題最優解法的答案是“水平”並行化或者“資料並行化”的極佳例子。在這個例子中,同步開銷接近於0(或者就是)。相反,雙端佇列的實現是“垂直並行化”或者“管道”的極佳例子,因為資料從一個執行緒轉移到另一個執行緒。管道需要密切的合作,因此需要更多的工作來獲得某種程度的效率。

小問題1.7:串聯雙端佇列比雜湊雙端佇列執行快兩倍,即使我將雜湊表大小增加到非常大也是如此。為什麼會這樣?

小問題1.8:有沒有一種更好的方法,能併發地處理雙端佇列?

這兩個例子顯示了分割在並行演算法上的巨大威力。但是,這些例子還渴求更多和更好的並行程式設計準則,下一篇文章將討論此話題。