本系列是 The art of multipropcessor programming 的讀書筆記,在原版圖書的基礎上,結合 OpenJDK 11 以上的版本的程式碼進行理解和實現。並根據個人的查資料以及理解的經歷,給各位想更深入理解的人分享一些個人的資料
硬體基礎
處理器和執行緒(processors and threads)
多處理器(multiprocessor)包括多個硬體處理器,每個都能執行一個順序程式。當討論多處理器架構的時候,基本的時間單位是指令週期(cycle):即處理器提取和執行一條指令需要的時間。
執行緒是一個順序程式,是一個軟體抽象。上下文切換(context switch)指的是處理器可以執行一個執行緒一段時間之後去執行另一個執行緒。處理器可以因為各種原因撤銷一個執行緒或者從排程中刪除該執行緒:
- 執行緒發出了一個記憶體請求,而該請求需要一段時間才能完成
- 執行緒已經執行了足夠長的時間,該讓別的執行緒執行了。
當執行緒被從排程中刪除時,他可能重新在另一個處理器上執行。
互連線(interconnect)
目前常見的三種伺服器基本互聯結構:
- SMP(symmetric multiprocessing,對稱多處理)
- NUMA(nonuniform memory access,非一致記憶體訪問)
SMP 指多個 CPU 對稱工作,無主次或從屬關係。各 CPU 共享相同的實體記憶體。每個 CPU 訪問記憶體中的任何地址所需時間是相同的,因此 SMP 也被稱為一致儲存器訪問結構(即 UMA:Uniform Memory Access)。一般 SMP 架構中,CPU 和記憶體之間存在快取記憶體。並且,處理器和主存都有用來負責傳送和監聽匯流排上廣播資訊的匯流排控制單元(bus controller)。整體結構如下圖所示:
這種結構最為容易實現,但是隨著處理器的增多,匯流排並不能擴充套件導致匯流排終將過載。
在 NUMA 系統結構中,與 SMP 相反,一系列節點通過點對點網路互相連線,有點像一個小型的區域網,每個節點包含若干個處理器和本地記憶體。一個節點的本地儲存對於其他節點也是可以訪問的,當然,訪問自己的本地記憶體要快於訪問其他節點的記憶體。網路比匯流排複雜,需要更加複雜的協議,但是帶來了擴充套件性。如下圖所示:
從程式設計師的角度看,無論底層是 SMP 還是 NUMA,互連線都是有限的資源。寫程式碼的時候,要考慮這一點避免使用過多的互聯線資源。
記憶體(memory)
所有處理器共享記憶體,通常會被抽象成為一個很大的“字”(words)陣列,陣列下標即為地址(address)。字長度和平臺相關,現在多為 64 位,地址的最大長度也是這麼長。64 位能表示的記憶體就已經很大了。
處理器訪問記憶體的流程,簡單概括包括:
- 處理器通過給記憶體傳送一個包含要讀取的地址的訊息,來獲取記憶體上對應地址的值
- 處理器通過給記憶體傳送一個包含要寫入的地址和值的訊息,資料寫入後,記憶體回覆一個確認訊息。
快取記憶體(Cache)
快取命中率
如果處理器一直直接從記憶體中讀取,處理器直接訪問記憶體消耗時間很長,可能需要幾百個指令週期,這樣效率會很低。一般需要引入若干個快取記憶體(Cache):與處理器緊挨著的小型儲存器,位於處理器和記憶體之間。
當需要讀取一個地址的值時,訪問快取記憶體看是否存在:存在代表命中(hit),直接讀取。不存在被稱為缺失(miss)。同樣的,如果需要寫一個值到一個地址,這個地址在快取中存在也就不需要訪問記憶體了。
我們一般比較關心快取記憶體中命中的請求比例,也就是快取命中率
區域性性與快取行
大部分程式都表現出較高的區域性性(locality):
- 如果處理器讀或寫一個記憶體地址,那麼它很可能很快還會讀或寫同一個地址。
- 如果處理器讀或寫一個記憶體地址,那麼它很可能很快還會讀或寫附近的地址。
針對區域性性,快取記憶體一般會一次操作不止一個字,而是一組臨近的字,稱為快取行。
多級快取記憶體
現代處理器中一般不止一級快取,而是多級快取,從離處理器最近到最遠分別是 L1 Cache,L2 Cache 和 L3 Cache:
- L1 Cache 通常和處理器位於同一個晶片,離處理器最近,訪問僅需要 1~3 個指令週期
- L2 Cache 通常和處理器位於同一個晶片,處於邊緩位置,訪問需要通過更遠的銅線,甚至更多的電路,從而增加了延時,一般在 8 ~ 11 個指令週期左右
- L3 Cache L1/L2 為每個處理器私有的,這樣導致對於很多相同的資料,也只能每個處理器獨有的快取各儲存一份。所以需要考慮引入一個所有處理器共用的快取,這就是 L3 快取。L3 快取的材質以及佈線都和 L1/L2 不同,需要更長的時間訪問,一般在 20 ~ 25 個指令週期左右
快取記憶體記憶體有限,在同一時刻只有一部分記憶體單元被放置在快取記憶體中,因此我們需要快取替換策略。如果替換策略可以替換任何快取行,則該快取記憶體是全相聯(fully associative)的。相反,如果只能替換一個特定的快取行,他就是直接對映(direct mapped)的。如果取其折中,即允許使用一組大小為 k 的集合中任一快取行來替換,則稱為k 級組相聯(k-way set associative)的。
一致性(coherence)
當一個處理器訪問另一個處理器已經裝載入快取記憶體的主存地址的時候,就會發生共享(sharing,或者稱為爭用 contention)。需要考慮快取一致性的問題,因為如果一個處理器要更新共享的快取行,則另一個處理器的副本需要作廢以免讀取到過期的值。
MESI 快取一致性協議,快取行存在以下四種狀態:
- Modified:快取行被修改,最終一定會被寫回入主存,在此之前其他處理器不能再快取這個快取行。
- Exclusive:快取行還未被修改,但是其他的處理器不能將這個快取行載入快取
- Shared:快取行未被修改,其他處理器可以載入這個快取行到快取
- Invalid:快取行中沒有有意義的資料
舉例:假設處理器和主存由匯流排連線,如圖所示:
a) 處理器 A 從地址 a 讀取資料,將資料存入他的快取記憶體並置為 Exclusive
b) 處理器 B 從地址 a 讀取資料,處理器 A 檢測到地址衝突,響應快取中 a 地址的資料,之後, 地址 a 的資料被 A 和 B 以 Shared 狀態裝入快取
c) 處理器 B 對於 a 進行寫操作,狀態修改為 Modified,並廣播提醒 A(所有其他已經將該資料裝入快取的處理器),狀態置為 Invalid。
d) 隨後 A 還需要訪問 a,它會廣播這個請求,B 將修改過的資料發到 A 和主存上,並且置兩個副本狀態為 Shared。
當處理器訪問邏輯上不同的資料,但是這些資料恰好處於同一記憶體行,這種情況被稱為錯誤共享(false sharing)
自旋(Spinning)
自旋即:某個處理器不斷地檢查記憶體中的某個字,等待另一個處理器改變它。
對於具有快取記憶體的 SMP 或者 NUMA 系統結構,自旋僅消耗非常少的資源。根據上面我們對於 MESI 的介紹,第一次讀取地址時,會產生一個快取記憶體缺失,將該地址的內容載入到快取塊中。此後,只要資料沒有改變,處理器僅從快取記憶體讀取資料,不需要佔用互連線。當這個地址被修改時,處理器也會接收到 Invalid 並且重新請求這個資料並獲取到修改。
為何 TTASLock 要優於 TASLock。
通過之前的分析,我們可以知道, TASLock 的每次 LOCKED.compareAndSet(this, false, true)
的時候,都會產生修改訊號,佔用互連線頻寬。while 迴圈每次都執行,會產生大量修改訊號。但是 TTASLock 的 LOCKED.get(this)
僅僅是一次本地自旋。所以 TTASLock 要比 TASLock 效能快得多。