Erlang運行時中的無鎖隊列及其在異步線程中的應用


本文首先介紹 Erlang 運行時中需要使用無鎖隊列的場合,然后介紹無鎖隊列的基本原理及會遇到的問題,接下來介紹 Erlang 運行時中如何通過“線程進度”機制解決無鎖隊列的問題,並介紹 Erlang 運行時中提供的一個通用無鎖隊列的實現及其在 ERTS 異步線程池中的應用。

無鎖隊列在 ERTS 中的應用場合

為了提升 Erlang 運行時在多核/眾核處理器上的 scalability,Erlang 運行時使用了大量無鎖數據結構,無鎖隊列(lock-free queue)就是其中廣泛使用的一種並發數據結構。其中最重要的應用應該是 Erlang 運行時從 ERTS 5.9 版本(對應 Erlang/OTP R15B)開始引入的“delayed deallocation”(延遲釋放)特性。在大規模的多線程程序中,如果通過加鎖的方式保護是用一個內存分配器,那么在內存分配和釋放的應用中一定會引起大量的爭用,這種爭用對於 scalability 的損害是非常大的。因此在這種情況下,最直觀的解決方案是每一個線程都使用自己的內存分配器實例。這種方案的確解決了多個線程爭用分配器分配內存的問題,但是釋放內存依然還有問題。內存分配了就要釋放,在多線程程序中,一個生產者線程分配的數據對象很有可能會傳遞到其他線程去處理,其他消費者線程用完了數據之后,那么數據對象應該由誰來釋放呢?如果由消費者釋放的話,消費者為了訪問生產者的內存分配器實例,不得不使用鎖,因此又出現了使用鎖的問題。此外,如果生產者和消費者不在同一個處理器核心上,甚至在不同的 NUMA 節點上,那么釋放內存還會造成 cache 失效以及產生 NUMA 跨節點流量的問題,使得釋放內存的開銷增大,影響消費者的處理能力。因此數據對象最好是能夠在生產者這一端釋放。生產者可以選擇合適的時機釋放內存,既能避免上述問題,還能保證自己的 latency 水平。

這種誰分配誰負責釋放的機制稱為“延遲釋放”。ERTS 在實現延遲釋放的時候采用的也是類似於“消息傳遞”的方式:消費者將要釋放的內存的指針通過一個無鎖的隊列發送到分配這個指針的線程。ERTS 中,調度器特定的(scheduler specific)的內存分配器以及調度器特有的內存預分配器都使用了這種無鎖隊列來傳遞釋放內存塊的任務。實際上這兩種分配器使用的無鎖隊列的代碼都差不多。

此外,ERTS 5.9 還引入了一個通用的、多對一的(many-to-one)無鎖隊列。多對一的意思是說可以有多個生產者並發地向隊列中 enqueue 數據,而只能有一個消費者從隊列中 dequeue 數據,當然,生產者和消費者可以並發地操作這個無鎖隊列。由於在 ERTS 中可以使用這種多對一生產者消費者模型的場合非常多,所以 ERTS 未來會越來越多地使用這個通用隊列。目前(本文編寫的時候 ERTS 版本為 5.10.3,對應 Erlang/OTP R1602),在 ERTS 中只有一些作業調度和異步線程池使用了通用隊列。

內存分配器使用的無鎖隊列和通用無鎖隊列的相同點在於:兩者都是多對一的。兩者的區別在於:前者對於入隊的順序並不敏感,因為入隊的內容都表示要釋放的內存塊,釋放的順序並不重要,因此前者在並發 enqueue 數據的時候,插入的數據會出現在隊列的尾端或尾端附近的位置,不要求一定出現在尾端,因此支持更高的並發度;而后者則保證 enqueue 數據的時候數據一定插在尾端后面,從而確保了公平性。

本文下面會以通用無鎖隊列為例介紹這個無鎖隊列的工作原理,並且討論通用無鎖隊列在異步線程池中的應用。由於內存分配器中使用無鎖隊列更多的和內存分配器本身相關,所以為了不偏題本文主要討論通用無鎖隊列的實現原理,應用部分主要討論通用無鎖隊列在異步線程池中應用的具體實例。有了這個基礎之后,就好理解無鎖隊列在其他場合的應用。其他場合無鎖隊列的應用就可以在介紹其他組件的文章中順便提及。

無鎖隊列的基本原理以及“線程進度”機制的支持

無鎖隊列

本節介紹無鎖隊列的基本原理,並介紹之前一篇文章介紹的“線程進度”機制到底是怎樣利用的。

這里的無鎖隊列如下圖所示,是以鏈表的形式實現的,每一個節點表示一個元素。節點通過 next 指針鏈接到下一個節點,節點內部通過 data 指針指向節點表示的實際數據。最后一個節點的 next 指針設置為 NULL。隊列數據結構本身維護一個指向第一個節點的 head 指針以及一個指向最后一個節點的 tail 指針。

先看 enqueue 一個節點。如果是單線程的隊列,那么很簡單,只需要分配一個節點,設置好新節點的數據指針和next指針,然后將 tail 指針指向的節點的 next 指針修改為新節點,最后將 tail 指針向后挪一位指向新的節點。

在多線程的環境中,enqueue 一個節點也需要更新這兩個指針,不過我們需要采用原子的 CAS 指令。 首先將新節點插入最后一個節點的 next,這一步的操作看下面的偽代碼:

1 ilast = tail;
2 while (1) {
3     last = ilast;
4     itmp = CAS(&last->next, this, NULL);
5     if (itmp == NULL) 
6         break;
7     ilast = itmp
8 }

首先讀取 tail 指針當前快照 放在 ilast 中,也就是可以先賭一把,認為 ilast 指向的是最后一個節點。然后進入 while 循環,通過一個 CAS 操作判定之前的賭注是否正確。這個 CAS 操作的意義是:在一個原子操作中,將 last->next 指針的值和 NULL 比較,如果相等的話,就把 last->next 替換為 this,this 表示指向新節點的指針,否則不動 last->next。在讀這種使用了原子操作的代碼的時候,要注意 CAS 調用的參數順序和返回值。這里的 CAS 操作實際上對應的是 ERTS 中的 erts_atomic_cmpxchg_mb 調用,而這個調用遵循的是 Intel 平台 cmpxchg 指令的格式,所以這個 CAS 操作的參數順序是:第一個參數表示原子變量,第二個表示要設置的值,第三個表示要比較的值。返回值的規定是:如果成功,則返回老的值,如果失敗,則返回原來的值。而其他有些原子庫或一些書或文章中使用的 CAS 操作的參數中第二和第三個參數可能是和這個 CAS 相反的,而且有的返回值通過 1(true)和 0(false)分別表示成功和失敗。

繼續回到這段代碼。如果 CAS 操作成功了,也就是返回的 itmp 等於新設置的值 NULL,那么退出循環。如果不成功,itmp 就等於 last->next,然后 ilast = itmp,也就是說下次嘗試 CAS 的時候向后移動了一個節點。想象一下這個場景,當很多線程在並發 enqueue 隊列的時候,由於一次只能有一個線程成功地完成 CAS 操作,其他線程都要重試,因此看上去好像是很多線程在追逐終點一樣。直到最后大家都插入了自己的節點。

下一步的操作是要更新 tail 指針,偽代碼如下:

 1 while (1) {
 2     if (this->next != NULL) {
 3         ilast = tail
 4         return ilast;
 5     }
 6     itmp = CAS(&tail, this, ilast);
 7     if (ilast == itmp)
 8         return this;
 9     ilast = itmp;
10 }

進入 while 循環之后,首先判斷 this->next 是否為 NULL,如果不是,說明當前線程在插入完節點之后還有其他並發線程在我之后也插入了新的節點,那么我就不用負責更新 tail 指針了,等別人更新好了,在這些並發線程中必然有一個插入的節點是最后一個,因此必然有一個線程能夠更新 tail 指針。那個線程就是發現 this->next 等於 NULL 的線程,這個線程進入第 6 行的 CAS 操作。這個 CAS 操作比較 tail 和 ilast 的值。還記得 ilast 指針指向誰嗎?根據前面插入節點的代碼,這個 ilast 指向 this 之前的那個節點。如果是單線程的話,這個 CAS 操作一步就成功了,將 tail 從 this 之前的那個節點挪到 this。而多線程並發的情況下,執行到第 6 行的時候 tail 不太可能恰好指向 this 之前的那個節點,所以第 9 行將 ilast 更新為 tail 的老值,下一次進入循環的時候,必然有一個線程能成功地在 CAS 操作中將 tail 從隨便什么老值設置為對應的 this。想象一下這樣的場景:tail 指針也在多個並發線程的共同努力下不停地向后跳動。如果並發 enqueue 都完成了操作,那么 tail 必然最終指向最后一個節點。如果有很多並發的 enqueue 在同時操作,那么實際的 tail 指針會在這些 enqueue 操作開始之前時候的位置以及當前最后一個節點之間的某個位置

下面討論 dequeue 的操作。由於這個隊列是M對1的,所以 dequeue 端只有一個線程操作,原則上應該很簡單,只要修改 head 指針即可,對吧?事實上,dequeue 操作才是麻煩之處,因為 dequeue 涉及到資源釋放的問題。如果過早釋放資源,那么有可能釋放正在被使用的資源;如果在dequeue的時候釋放資源,那么會出現之前提到的釋放“外地”資源的問題。因此,最合適的方式應該是將資源釋放延遲到“適當”的實際。下面的“線程進度機制”的作用就是確定適當的時機。

這里不得不提一下傳統教科書上(例如“The Art of Multiprocessor Programming”[1])的無鎖隊列。教科書上介紹的一般是論文[2]提出的 M&S 無鎖隊列,同樣這個隊列的也是命名於其兩個發明者。M&S 無鎖隊列是大部分操作系統以及程序語言運行時中使用的無鎖隊列。M&S 無鎖隊列的 enqueue 操作和 dequeue 操作都支持多線程並發訪問。M&S 無鎖隊列的 enqueue 操作和上面介紹的基本思想差不多,也是分兩步修改兩個指針。但是 dequeue 操作會檢查 head 指針和 tail 指針的碰撞,並且在碰撞發生的情況下“幫助”修改 tail 指針。判斷隊列為空的條件就是看 head 指向的節點的 next 是否為 NULL。

M&S 無鎖隊列的 enqueue 線程和 dequeue 線程都會修改 tail 指針,顯然在多線程程序中,特別是涉及處理器核心數特別多的程序中應該避免這種情況。為了將兩種操作分離開,ERTS 無鎖隊列將 enqueue 操作會操作的數據放在一條 cache 線中,將 dequeue 操作會操作的數據放在另一條 cache 線中。那么這個 dequeue 操作簡化的偽代碼是這樣的:

1 inext = head->next;
2 if (inext == NULL)
3     return NULL;
4 head = inext;
5 return head;

先判斷 head 指向節點的 next 是否為 NULL,即是否最后一節點,如果是的話,說明隊列為空。如果不是的話,head 向后移動一個位置,然后返回 head 指向的節點。

那么返回(即 dequeue)的節點什么時候才能安全釋放呢?考慮下面的情形:

圖中有一個線程 A,速度很慢。圖中的 tail 指針的狀態是 A 剛進入 enqueue 操作時看到的狀態。線程 A 帶着新節點 new 在慢悠悠地一個接一個地找 next 指針為 NULL 的節點,可是由於 A 太慢了,很多其他並發線程已經插入了很多節點在后面。更糟糕的是,消費者 dequeue 的速度也已經超越了 A 掃描的速度,圖中灰色節點都是已經 dequeue 出去的節點,這意味着 A 在掃描邏輯上已經不存在的節點。如果這些節點在被 dequeue 之后就被釋放了,而 A 還在操作這些節點,例如修改 next 指針或其他操作,那么會引起內存訪問的異常,導致 Erlang 虛擬機崩潰。

從 ERTS 5.9 開始,Erlang 引入了“線程進度”機制,用於判定可以安全釋放節點的時機。

線程進度機制

年初的時候我寫過一篇博文[3]分析 ERTS 5.9 新引入的線程進度(thread progress)機制的實現原理。但是當時我並沒有弄明白 ERTS 中的無鎖算法是怎樣利用線程進度機制的,無鎖隊列 ErtsThrQ_t 數據結構也非常復雜,包含了一些和線程進度相關的字段,API 也包含一些和線程進度相關的接口。后來這一塊就擱置了,直到 RELEASE 項目發布了 WP2 的報告 D2.3,里面介紹了線程進度機制和延遲釋放相關的原理。可是看了文檔寫得還是有點抽象,后來直接給 Rickard Green 大神發電郵討論了一下,在大神的點撥下豁然開朗,所以也就有了這篇博文,算是彌補了一大遺憾。

單純把線程進度機制抽取出來,這個機制的作用是跟蹤 ERTS 中所有受管線程(managed thread)的進度值。就所有線程而言,ERTS 還有一個全局的進度值。所有受管線程都需要在特定的點調用 erts_thr_progress_update() 更新自己的進度。如果自己是 leader,還需要調用 erts_thr_progress_leader_update() 更新全局進度。下面是進度值的規則(從[3]中摘出的比較重要的規則):

  • 在受管的線程集合中,有且只有一個線程是 leader 線程
  • 每一個受管線程都在固定的位置更新自己的進度值
  • leader 線程除了更新自己的進度值之外,還要更新全局進度值
  • 所有受管線程的進度值和全局進度值初始化為 0
  • 線程運行到更新點的時候更新自己的進度值,但是這個進度值不超過全局進度值 +1
  • leader 線程更新完了自己的進度值之后,要檢查所有受管線程的進度值是否都達到了全局進度值 +1,如果達到了,則更新全局進度值 +1

每一個受管的線程都有責任在固定點更新進度,比如調度器線程會在前一個 Erlang 進程調度出之后下一個 Erlang 進程調度入之前、進入睡眠之前以及喚醒的時候會更新進度。受管線程也就是可以保證以一定的頻度更新進度的線程,對於這一類線程,我們了解這些線程的全部工作流程,知道這些線程一定不會無故掛起,而且一定會調用進度更新函數。調度器就是這樣的線程。而異步線程池中的異步線程會調用外部的驅動代碼,所以執行行為是不可控的,可能會延遲很長的時間不更新進度,因此異步線程就不屬於受管線程了。

下面看一個具體的例子,這個例子展示了幾個線程更新進度的理想情況(即沒有任何受管線程在睡眠)。圖中的橫軸表示時間軸,上下一共 4 根時間軸是對齊的。第一根軸表示全局的進度值變化,下面 3 根軸分別表示 3 個受管線程 T1、T2 和 T3 的進度更新情況:

下面 3 根軸上的一個 tick 表示線程調用一次 erts_thr_progress_update()。所有的進度值初始都為 0,其中 T1 為 leader 線程。T3 線程的速度最快,因此調用進度更新的間隔也最小。T3 首先將進度更新為1,然后 T1 和 T2 依次更新。然后由於 leader 還沒有更新,所以全局進度依然為 0,所以 T3 第二次更新的時候依然為 1。接下來,leader T1 更新,將全局進度更新為 1。之后的更新以此類推。從中可以看出,跑得快的線程雖然調用更新更頻繁,但是如果全局進度沒有更新,也不會更新進度值。而全局進度在更新的時候,要掃描所有線程的進度是否都更新了。所以從這里可以隱約看出,全局進度是在保證什么東西。 

那么線程進度是在保證什么呢?線程進度是要保證兩件事情的判定:

  • 受管線程從一個任意狀態返回到一個固定的已知狀態。
  • 線程執行了一次完整的內存屏障。

關於第一件事情,所謂的已知狀態就是調用 erts_thr_progress_update() 時的狀態,這個函數是線程進度機制提供的函數,所以線程進度機制必然知道線程在干什么了,那么也就說明在這個狀態下保證線程沒有在干其他任何事情。比如說,線程在調用這個函數之前正在訪問一個數據結構,那么調用這個函數的時候,說明線程一定完成了對之前的數據結構的訪問。假定有一個線程通過 erts_thr_progress_later() 調用請求下一個線程,那么當這個線程調用 erts_thr_progress_has_reached_this() 返回 true 的時候,說明所有受管線程至少更新了進度一次。當然,線程可以請求 ERTS 在到達全局進度的時候將自己喚醒,然后進入睡眠狀態,而不是反復查詢是否到達下一個進度。

上面的描述還是比較抽象,那么線程進度機制對於我們的無鎖隊列到底有什么用途呢?我們之前說了無鎖隊列的一個問題,就是要判定在什么時候能夠真正將節點的內存釋放,關鍵問題在於我們不知道 enqueue 什么時候結束執行。有了線程進度機制就好辦了,線程產生進度的時候說明 enqueue 一定完成了一次執行,那么只要等待下一次全局進度更新的時候,我們就可以判定所有的線程都至少完成了一次 enqueue 操作。

如下圖所示,這個圖其實是上面那個圖的真實應用。圖中有 3 個生產者在調用 enqueue 操作(通過圖中時間軸上的小盒子表示),我們從觀察者 T4 的角度來看這些並發的 enqueue 操作:

T1 仍然是 leader 線程。假設在全局進度 1 和 2 之間的某個時間點,即圖中用虛線標出的時間點,在這個時間點我們可以看到 3 個生產者都在調用 enqueue,這時的 tail 指針是在一個范圍內變化。但是如果此時我們取當前 tail 的一個快照,然后調用一次 erts_thr_progress_later(),那么在到達下一次全局進度(即 3)的時候,我們可以保證在調用 erts_thr_progress_later() 時刻的所有 enqueue 都完成了執行並返回了,所以可以認為在到達下一次全局進度的時候,tail 快照之前的所有節點都是可以安全釋放的。看這個設計多巧妙:下一次全局進度剛好是 3 而不是 2,如果是 2 的話,T2 的那個 enqueue 操作還沒有完成執行。

因此,我們就這樣通過線程進度機制判定了哪些節點是可以安全釋放的。無鎖隊列的用戶應該負責將這些要釋放的節點發送給原來分配這些節點的線程,讓原來的線程負責釋放。

另外提一下線程進度機制實現上的優化。線程進度機制之所以需要選擇一個 leader 線程的原因,是為了讓這個線程負責更新全局進度值。假設沒有 leader 線程,而是用一個原子變量(計數器)表示全局進度,那么這個原子變量一定會成為爭用的瓶頸。但是如果這樣設計:每個普通線程在自己的 cache 線上更新自己的進度數據,當然更新的時候只讀取全局進度值,leader 線程在更新的時候只讀所有線程的進度值,然后再更新自己的 cache 線上的全局進度值。在這種設計下:線程自己更新的時候,如果全局進度沒有更新,那么只需要更新自己的 cache 線,不會產生 cache 通信;如果全局進度有更新,那么會產生一次全局進度值所在的 cache 線到每一個線程所在 cache 的廣播流量。leader 線程在更新全局進度值的時候,只需要通過 cache 總線從每一個其他線程的 cache 線收集一次數據。可以看出,線程進度機制的 cache 通信模式是非常高效的。

ERTS 5.10(對應 Erlang/OTP R16)起線程進度機制有所改進,改進了對非受管線程的管理,這是為了重寫 port 任務調度而做的改進。增加了允許非受管線程延遲全局進度的功能。目前我對 port 任務調度了解不多,所以這一塊也就不妄作評論了。

好了,理論說得夠多了,下面開始看代碼。

ERTS 中的通用無鎖隊列

下面是無鎖隊列的結構體:

 1 struct ErtsThrQ_t_ {
 2     /*
 3      * This structure needs to be cache line aligned for best
 4      * performance.
 5      */
 6     union {
 7         /* Modified by threads enqueuing */
 8         ErtsThrQTail_t data;
 9         char align__[ERTS_ALC_CACHE_LINE_ALIGN_SIZE(sizeof(ErtsThrQTail_t))];
10     } tail;
11     /*
12      * Everything below this point is *only* accessed by the
13      * thread dequeuing.
14      */
15     struct {
16         erts_atomic_t head;
17         ErtsThrQLive_t live;
18         ErtsThrQElement_t *first;
19         ErtsThrQElement_t *unref_end;
20         int clean_reached_head_count;
21         struct {
22             int automatic;
23             ErtsThrQElement_t *start;
24             ErtsThrQElement_t *end;
25         } deq_fini;
26         struct {
27 #ifdef ERTS_SMP
28             ErtsThrPrgrVal thr_progress;
29             int thr_progress_reached;
30 #endif
31             int um_refc_ix;
32             ErtsThrQElement_t *unref_end;
33         } next;
34         int used_marker;
35         void *arg;
36         void (*notify)(void *);
37     } head;
38     struct {
39         int finalizing;
40         ErtsThrQLive_t live;
41         void *blk;
42     } q;
43 };

這個結構體主要分為三大塊:tail、head 和 q。

tail 是一個聯合體,里面有用的數據部分是 ErtsThrQTail_t 類型的 data,表示和 tail 指針相關的操作,所有的 enqueue 操作都只會操作 ErtsThrQTail_t 中的數據。tail 聯合體的另一部分是用來 cache 對齊的,字節數等於 cache 線大小的倍數,而且是大於 ErtsThrQTail_t 字節數的最小值。這種 cache 對齊方式在 ERTS 代碼中非常常見。后面會詳細介紹 ErtsThrQTail_t 中的字段。

接下來是 head,所有的 dequeue 操作只會操作 head 中的數據。tail 和 head 占用的是不同的 cache 線,所以可以保證 enqueue 線程和 dequeue 線程不會在 cache 上互相干擾。head 結構體中字段不少,大多都和線程進度機制有關:

  • head:是一個原子變量,指向邏輯上的隊頭
  • live:隊列中元素的生存期,長期或短期,會影響內存分配/釋放的策略。如果是長期對象,則使用普通的內存分配器。如果是短期對象,那么分配和釋放操作頻繁,因此會使用短生存期對象的專用
  • first:指向隊列中第一個元素
  • unref_end:表示unreferenced end pointer,指向最后一個未被其他線程引用的元素,也就是說,在這個指針指向的元素之前的元素都是可以安全釋放的
  • clean_reached_head_count:清理該釋放的元素時使用的計數器。當 first 碰到 head 的時候記錄一次。(具體意圖我暫不明了
  • deq_fini:dequeue finilization相關的數據。automatic 表示是否在 dequeue 的時候自動釋放節點。如果希望延遲釋放的話一般都會把這個字段設置為 0。start 和 end 表示目前需要釋放的一段節點的頭和尾。延遲釋放的時候,應該把這個數據發送給要負責釋放內存的線程
  • next:表示到達下一個全局的線程進度時相關的數據。thr_progress 表示在等待下次進度的具體進度值;thr_progress_reached 表示是否到達了下次的進度值;um_refc_ix 和非受管線程有關(具體操作的是 tail 中的數據)
  • used_marker:是否使用 marker,marker即哨兵元素,當隊列為空的時候用來占位
  • arg,nofity:用於通知新的元素插入,一般都是調用 notify 喚醒線程。

接下來是 q,q 結構體中的字段描述的是和隊列本身相關的數據。finalizing 表示是否正在銷毀整個隊列。live 表示整個隊列的生存期,ERTS 中用來發送消息的隊列一般都是長期隊列。blk 是指向整個隊列數據結構所占內存塊的指針,銷毀隊列的時候會用到。

下面是 tail 部分的結構體定義:

 1 typedef struct {
 2     ErtsThrQElement_t marker;
 3     erts_atomic_t last;
 4     erts_atomic_t um_refc[2];
 5     erts_atomic32_t um_refc_ix;
 6     ErtsThrQLive_t live;
 7 #ifdef ERTS_SMP
 8     erts_atomic32_t thr_prgr_clean_scheduled;
 9 #endif
10     void *arg;
11     void (*notify)(void *);
12 } ErtsThrQTail_t;

marker 是哨兵元素。last (應該)指向隊列中的最后一個元素,也就是前面討論無鎖隊列原理時說到的 tail 指針。

um_refc[2]、um_refc_ix 和非受管線程有關。前面可以看出來,線程進度機制的主要目的是為了判定一個數據結構是否被其他線程引用了。傳統的引用計數方式在眾核多線程環境中會因為 cache 一致性而引起嚴重的 cache 通信過重問題,因此才會通過受管線程更新自身進度的方式來簡介管理數據的引用。而由於非受管線程無法保證進度更新,因此非受管線程的管理仍然通過傳統的引用計數的方式。這里的 um_refc 指的就是 unmanaged reference count,um_refc_ix 就是索引 um_refc 數組的 index。由於非受管線程對這種機制使用的頻率遠比受管線程低,所以這種低效的方式也不會造成太大的性能問題(因此我們也不詳細討論非受管線程相關的內容)。

接下來是 live,表示元素的生存期,和 head 里面的 live 是一樣的,只不過這個 live 專門由 enqueue 線程在分配內存的時候訪問。

thr_prgr_clean_scheduled 的意義目前不明了,在代碼中也沒有使用到。

下面的 arg 和 notify 和 head 中對應的是一樣的。

知道了這些數據結構中各個字段的作用之后,我們就好看懂 API 具體實現的代碼了。下面簡單介紹一下 ERTS 無鎖隊列提供的幾個重要 API:

  • erts_thr_q_initialize():根據提供的參數初始化無鎖隊列。
  • erts_thr_q_destroy():銷毀隊列。
  • erts_thr_q_clean():清理隊列。逐個釋放從 head.first 到 head.unref_end 之間的元素,根據 automatic 的設置,直接釋放元素或將要釋放的元素放在一個待釋放元素的鏈表中。根據線程進度的情況,向后挪動 head.unref_end 指針。如果挪不動 head.unref_end,而且確實有元素需要釋放,那么返回 ERTS_THR_Q_NEED_THR_PRGR 狀態,告訴調用者 需要調用無鎖隊列 API erts_thr_q_need_thr_progress() 獲得下一步的全局進度值,然后調用 erts_thr_progress_wakeup() 請求喚醒,然后沒事的話就睡眠。這個調用返回的是和無鎖隊列有關的狀態,除了之前提到的 ERTS_THR_Q_NEED_THR_PRGR 之外,還有 ERTS_THR_Q_CLEAN,表示隊列是“干凈”的,即沒有需要清理的垃圾,看上去就像單線程的普通隊列那樣。還有一個狀態 ERTS_THR_Q_DIRTY,表示隊列中存在已經 dequeue 但是還沒有釋放的元素,不過不需要等待下一次的全局進度就可以安全釋放。clean 操作一次不會釋放全部的元素,而是執行指定數目的釋放操作。
  • erts_thr_q_inspect():快速判斷當前隊列的狀態。
  • erts_thr_q_prepare_enqueue():參見下一條。
  • erts_thr_q_enqueue_prepared():如果要在某個專門的線程中分配元素,那么可以調用前一條分配好元素,然后調用這一條傳入分配好的元素並 enqueue。
  • erts_thr_q_enqueue():enqueue 操作,會自動分配元素。
  • erts_thr_q_dequeue():dequeue 操作,返回隊頭的元素,不釋放元素,但是會調用 clean 操作。如果隊列設置了 head.deq_fini.automatic 為 1,說明調用者要求當場就釋放元素,dequeue 操作會讓 clean 一次釋放 ERTS_THR_Q_MAX_DEQUEUE_CLEAN_OPS 個元素。而如果設置的 head.deq_fini.automatic 為 0,那么說明不要求當場釋放元素,而是把要釋放的元素鏈接在另一個鏈表中,那么就會讓 clean 一次釋放 ERTS_THR_Q_MAX_SCHED_CLEAN_OPS 個元素。這兩個常量明顯第一個應該比第二個小。在 ERTS 中前一個設置為 3,后一個設置為 50。
  • erts_thr_q_get_finalize_dequeue_data():獲得前面所說的要釋放的元素的鏈表。
  • erts_thr_q_append_finalize_dequeue_data():將兩個上述鏈表合並為一個。
  • erts_thr_q_finalize_dequeue():調用這條 API 對上述鏈表進行操作,完成真正的釋放工作。顯然,這條 API 應該在分配元素的線程中調用。
  • erts_thr_q_need_thr_progress():參見之前 erts_thr_q_clean() 條目的描述。

相信有了上面對每一條 API 具體需求的說明之后,代碼讀起來就應該沒問題了。由於代碼實在太多,所以這里就不細品了。 有興趣的讀者如果在讀代碼過程中有具體的問題可以找我討論。

無鎖隊列在 ERTS 異步線程池中的應用

異步線程是 ERTS 中訪問 I/O 使用的線程,目前文件操作使用到了異步線程池。Erlang 進程要做文件操作的時候,會請求操作文件的 port 驅動程序,port 驅動程序將文件訪問的操作通過無鎖隊列以消息傳遞的方式發送給異步線程池中的某一個異步線程。異步線程通過 dequeue 操作取出 async 任務之后以同步的方式執行具體的操作,因此異步線程可能會阻塞。操作結束之后,異步線程將操作結果放在另一個無鎖隊列中。Erlang 調度器會時不時地檢查自己是否有 aux 任務要執行。如果在這個無鎖隊列中 dequeue 出了異步線程投遞進來的結果,那么 Erlang 調度器就會把這個結果以消息的方式發回給原來發出請求的 Erlang 進程。Erlang 中的文件操作基本上就是這么個原理。

 

實在是寫累了,以上過程的具體代碼分析就待續吧。。。如果太長的話,也許就放到另外一篇里面了

小結

Erlang 運行時可以說是高並發多線程程序的寶庫,里面充滿了各種極致的優化,任何一個細節都可能值得慢慢品味。

總結起來,ERTS 針對多核/眾核的優化原則都是一些基本常識,比如說爭用的資源要分散開,盡量避免對資源的爭用;cache 線要注意避免偽共享;為了避免對 latency 造成影響,能延遲的事情盡量延遲,然后放在恰當的時機解決;盡量使用細粒度的同步,甚至使用無鎖的數據結構。

RELEASE 項目是歐盟的一個針對 Erlang 在眾核處理器或大型分布式系統上各種優化的項目。項目參與者包括 Ericsson AB 以及一些在 Erlang 上做過不少工作的大學,當然還少不了 Erlang Solutions 公司。貌似各種 Erlang 的用戶會議,還有 Erlang workshop 基本上都是這些學校和公司的人在活動。

RELEASE 項目的 Work Package 2 (WP2) 部分的目標是“improving the Erlang Virtual Machine (VM) by re-examining its runtime system architecture, identifying possible bottlenecks that affect its performance, scalability and responsiveness, designing and implementing improvements and changes to its components and, whenever improvements without major changes are not possible, proposing alternative mechanisms able to eliminate these bottlenecks”。目前已經發布的報告有 D2.1、D2.2 和 D2.3。D2.1 和 scalability 分析有關。D2.2 主要涉及到 ERTS 中 ETS 的 scalability 優化,在 Erlang 中支持高效的 DTrace/SystemTap 所做的工作,以及在 Blue Gene/Q 巨型機上移植 Erlang 的工作。D2.3 則是關於 Erlang 虛擬機中各種 scalability 優化,包括本文介紹的高效的線程進度機制和內存延遲釋放。D2.4 還沒有發布。這些報告可以在這里下載到,讀這些 scalability 相關優化的報告真是一件令人激動的事情,不僅適合專門搞 Erlang 的同學們,還適合所有搞大型並發系統的同學們借鑒。

我覺得 Erlang 就像一個大型的寶藏一樣,從大的架構到小的細節,里面處處充滿了值得我們學習品味的地方。

 

 

[1] Herlihy, M. and Shavit, N. (2008). The Art of Multiprocessor Programming. Morgan Kaufmann.

[2] Michael, M. M. and Scott, M. L. (1996). Simple, fast, and practical non-blocking and blocking concurrent queue algorithms. In Proceedings of the fifteenth annual ACM symposium on Principles of distributed computing, PODC ’96, pages 267–275, New York, NY, USA. ACM.

[3] Erlang運行時源碼分析之——線程進度機制. http://www.cnblogs.com/zhengsyao/archive/2013/01/27/erts_thread_progress.html

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM