聲明:
這篇文章關於記憶體模型的部分主要翻譯自Understand the Impact of Low-Lock Techniques in Multithreaded Apps,此外增加了無鎖編程(即Lock-Free)的內容,發布這篇文章的目的在於為以後描述.NET多線程並發/並行編程做底層基礎。理解記憶體模型對於多線程編程有著莫大的益處,尤其是對於Lock-Freedom。
關於Lock-Free的優缺點,可以參考關於無鎖編程。具體的解釋可以參考《Java並發編程實踐》,《Intel Threading Building Blocks. Outfitting C++ for Multi-core Processor Parallelism》,《Concurrent programming without locks》。
預備知識
任何支援多線程的系統都需要一個規範來描述多線程互動時如何精確的訪問記憶體資料狀態,我們稱其為記憶體模型(Memory Model)。最簡單的模型就是圖1所展示的序列一致性記憶體模型(Sequential Consistency Memory Model)。在這個模型中,記憶體獨立於任何使用它們的進程(線程)。記憶體通過記憶體控制器串連到每個線程,而記憶體控制器則會反饋每個線程的讀寫請求。來自單線程的讀寫請求會精確的按照線程的指定次序到達記憶體,然而它們也可能會和其他線程的讀寫請求按照一個未指定的方式交錯進行。
圖1 Sequential Consistency Memory Model
在圖1所示的例子中,Value變數需要初始化,而Inited標誌則用來表明其是否已被初始化。Value和Inited首先都被設定為0。線程1首先初始化Value(Value被設定成5),然後設定Inited為1表明Value已被初始化了。線程2把這些值讀進寄存器Rv和Ri。在一個依賴於順序的(sequential)程式中,不可能會有當Ri為1(說明Value已被初始化了)時,Rv還是0(Value未初始化)的情況發生。這歸因於Sequential Consistency Memory Model。圖2中的表格枚舉了序列一致性記憶體所允許的所有6種合法排列,從中我們可以看出Ri和Rv最終的值是什麼。沒有一種情況當Ri非零時,Rv為零。
圖2 Possible Permutations of Memory Requests
如前面的例子所示,序列一致性是很容易想到的一種模型。依賴於順序的程式的一些概念都可以在其上應用。坦白說,它也很自然的在單一處理器機器上獲得實現。大多數程式員都對這種記憶體模型相當熟悉。不幸的是,對於真正的多處理器機器,通過記憶體硬體來高效的實現這種模型,非常受限制。並且,沒有商業多處理器機器符合這種模型。
典型的多處理器機器記憶體系統看起來很像圖3。每一個處理器都有一個相關的非常快速的小型緩衝來記憶最近訪問的資料。除此之外,所有從處理器到記憶體的寫操作都會有一個緩衝區,以便處理器可以在資料刷到以及緩衝前可以繼續下一個指令。我們可以在圖3看到每個處理器至少會有一級或更多級的緩衝。這些緩衝每一級都會比前一級容量更大,但是速度更慢。最終資料會到達記憶體,這樣資料就可以被其他處理器共用。這種架構會加速處理器,而且也意味著不再會有單一記憶體視圖。現在一個處理器會在各級緩衝針對特定記憶體位置儲存一個值,同時其他處理器也緩衝了相同記憶體位置的資料,但是這個資料可能是到期的老的資料。這就可能導致處理器的緩衝內容不一致。
圖3 Realistic Memory System
上述情況肯定不符合需要。如果我們拿它來用於線程間通訊鐵定引發問題。但是如果我們重新整理每一個線程的快取資料來作為通訊機制的一部分,系統就會可靠了。這樣的系統是高效的,因為保持緩衝同步的開銷只會線上程使用記憶體通訊時才會產生,這隻占所有記憶體訪問的很小的一部分。
圖3所示的就是真實的記憶體系統,它並不符合序列一致性記憶體模型。因此我們就要建立一種記憶體模型來解決這樣的記憶體系統所帶來的問題。我們要做的就是讓處理器互相“看到”所有移動記憶體存取。
圖4 Initial Memory State
舉個例子,考慮下Inited-Value樣本運行在4所示的多處理器機器上會發生什麼。注意,這裡的緩衝是簡化的。在這個版本的系統裡,主記憶體內的兩個變數初始值都是0。處理器1的緩衝現在是空的,而處理器2則已經讀取Value的值(Inited還沒讀)到了它的本機快取。如果我們以前讀取過程式的其他資料,而Value又緊挨著這個資料的話,就有可能發生上述情況。
圖5 Incoherent Caches
如果兩個處理器均運行該程式的話,狀態變化就5所示。不要考慮圖5了,聯絡圖4,現在我們把在主記憶體的資料Value設定成5,Inited則設定1。然而,由於緩衝的原因,處理器2的內容不是我們所期望看到的。當處理器2讀取Inited時,它沒有在緩衝中發現Inited的值,然後它看到其在主記憶體的值(Inited = 1)。當處理器2讀取Value時,它在緩衝中發現其值為到期的0。這其實是處理器2在早期讀取的Value。這跟運行在序列一致性記憶體模型上的下列代碼有著相同的行為:
CacheTemp = InitedRv = ValueRi = CacheTemp
寫操作跟讀操作類似。
這種技術有三個優點。首先,它相對簡單,不需要精確的詳細描述硬體細節。其次,它可以讓原始碼回退到一個很簡單易懂的風格。緩衝讀取可以比其真正需要時才讀取更早的把值載入到一個臨時變數。緩衝寫入則比寫入最終位置要晚。最後,它可以應用於編譯器最佳化中。
很明顯,這種任意移動記憶體訪問的能力會導致混亂。所以所有的實際記憶體模型有以下三個基本原則:
- 當線程隔離運行時,行為不會改變。它的意思是,指定線程到指定位置的讀或寫操作不會通過相同的線程到相同位置的寫操作。
- 讀操作在獲得鎖時,資料不能移動。
- 寫操作在釋放鎖時,資料不能移動。
這就是鎖協議實際的意義。這個協議確保了當持有相關鎖時,所有的線程共用,讀/寫記憶體訪問。
有了這三個原則就使得所有遵循鎖協議的程式在任何記憶體模型都有相同的行為。這是一個極有價值的屬性。沒有這些關於編譯器或記憶體系統能重新排序讀寫的必要思考,我們要寫一個正確的並發程式將會非常困難。
遵循鎖協議的程式不必思考這些。一旦你不想遵循鎖協議,那麼就必須詳細說明和考慮硬體或編譯器的讀寫變換。
較寬鬆的模型:ECMA
你現在已經明白序列一致性記憶體模型非常受限,因為它不允許任何互動的讀寫操作。在Section 12, Partition I of the .NET Framework ECMA standard描述了一個較寬鬆的模型。這個模型把記憶體訪問操作劃分成原始記憶體訪問和那些特別標記為“volatile”的訪問。Volatile記憶體訪問不能建立,刪除或移動。原始記憶體訪問不止受限於三個基本原則,同時也要遵循以下兩個原則:
- 在volatile讀前,讀操作和寫操作都不能移動資料。
- 在volatile寫後,讀操作和寫操作都不能移動資料。
這給了編譯器和硬體相當的最佳化自由。它們只需要關心通過鎖和Volatile訪問的邊界格式。對於沒有使用鎖或Volatile的程式片段,可以做任意的合法最佳化。這也意味著記憶體系統只需要在鎖或Volatile訪問時做相關的昂貴緩衝失效,然後重新整理即可。這個模型非常高效,但是需要程式在使用Low-Lock技術時,遵循鎖協議或顯式標記volatile。
健壯模型 1: x86 行為
不幸的是,正確遵循鎖協議的程式還有更多例外。當設計基於x86架構的多處理器系統時,設計者需要一個記憶體模型來使得大多數程式可以正常工作,同時也允許硬體合理有效。規範需要單一處理器寫操作相對其他寫操作保持有序,而讀操作則不受限。
不幸的是,如果讀操作不受限,寫操作有序的保證等於什麼也不做。因此,x86架構沒有提供比ECMA模型更強的保證。
然而,我相信,x86實際實現的記憶體模型和文檔上描述的有些微不同。因為在我的實驗中正確預測行為這個模型從未失敗,而且它跟公開已知的硬體如何工作完全一致,但是卻不是官方規範。新的處理器可能會打破該規範。在這個模型中,除了三個基本記憶體模型規則,這些規則也起作用:
- A write can only move later in time.
- A write cannot move past another write from the same thread.
- A write cannot move past a read from the same thread to the same location.
- A read can only move by going later in time to stay after a write to keep from breaking rule 3 as that write moves later in time.
這個模型對於帶有寫緩衝隊列和窺探讀操作的系統很有效。寫資料到記憶體時,不是立即寫到記憶體,而是按序放入隊列裡。這些寫操作或許會延遲,但是卻保持了有序。高效率的讀操作不會移動資料,除非允許窺探寫操作隊列。每一個讀操作都會窺探寫緩衝區來查看是否處理器最近寫進了要讀取的值,如果發現就會使用寫緩衝區的值。因為邏輯上講寫緩衝區的寫操作只在實體在刷到主記憶體時才會發生,高效率的讀取操作也會延遲到那時才會擷取其值。規則4特別允許了這種行為。
健壯模型 2: .NET Framework 2.0
儘管.NET Framework的ECMA模型規範並不是那麼健壯,但是運行於x86機器上的.NET Framework 1.x運行時的實現模型卻跟x86模型非常接近(源於JIT或JIT編譯器最佳化)。在版本2.0時,這個模型在Intel IA-64處理器上卻遇到了問題。那些依賴於x86實現的客戶程式在類似IA-64平台上運行時會有問題。結果就導致了.NET Framework 2.0運行時記憶體模型,它的規則如下:
- All the rules that are contained in the ECMA model, in particular the three fundamental memory model rules as well as the ECMA rules for volatile.
- Reads and writes cannot be introduced.
- A read can only be removed if it is adjacent to another read to the same location from the same thread. A write can only be removed if it is adjacent to another write to the same location from the same thread. Rule 5 can be used to make reads or writes adjacent before applying this rule.
- Writes cannot move past other writes from the same thread.
- Reads can only move earlier in time, but never past a write to the same memory location from the same thread.
同x86模型一樣的是寫操作被嚴格限制了,不一樣的則是讀操作可以移動資料和可以被消除。因為重新在記憶體擷取值和在low-lock代碼記憶體中可以被改變,所以有了規則2.最後的規則似乎是多餘的,但是如果允許通過寫到相同位置就會改變要讀取的值,這也就改變了序列行為。如果讀取的值真正被使用了,這就會發生。這個規則更有技術性,而且它被特別添加進來是為了使得通用的延遲初始化模式在該模型中合法。
Lock-Free
對於編寫lock-free代碼來說,上面的描述並沒有詳細的涉及,下面我們來描述一下其規則:
- Data dependence among loads and stores is never violated.
- All stores have release semantics, i.e. no load or store may move after one.
- All volatile loads are acquire, i.e. no load or store may move before one.
- No loads and stores may ever cross a full-barrier (e.g. Thread.MemoryBarrier, lock acquire, Interlocked.Exchange, Interlocked.CompareExchange, etc.).
- Loads and stores to the heap may never be introduced.
- Loads and stores may only be deleted when coalescing adjacent loads and stores from/to the same location.
注意從定義來看,非易失性載入不需要請求擁有任何一種與其關聯的記憶體屏障。所以載入可以被自由重新排序,寫操作也可以在其後移動資料(由於規則2)。在這個模型裡,如果你真的需要規則4所提供的完全記憶體屏障的話,就可以防止緊跟在易失性載入後重新排序。沒有屏障,指令就會重新排序。