資料整理: jserv
在〈藉由 spinlock 的調整,改善多核處理器建立 TCP 連線效能〉一文,spinlock 被拆解為 percpu 並予以分析,但未涵蓋 spinlock 本身的效能和可擴展能力 (scalability) 議題,本文繼續探討。
本文啟發自 dog250 的文章
開始探討主題前,先說明本文的術語:
cpu0
和 cpu1
一類的寫法;示意如下圖:
出處: Intel® Performance Counter Monitor - A Better Way to Measure CPU Utilization
假設在實驗環境中中央處理器擁有 16 核,其中每個處理器 (processor) 封裝著有 2 個實體核,也就是說,每個處理器具備兩個獨立 (private) cache 的核,佈局如下:
由於系統實作的可擴展能力考量,目前大多數中央處理器架構在 cache coherence 處理機制大致可分以下兩種:
理解這些,特別是理解點對點 unicast 方式的 one by one 處理時延會隨著核數的增加而線性增加為後面的內容埋下了伏筆。
Linux 核心中存在多種 spinlock (自旋鎖) 被大規模使用,最初是 wild spinlock,再來採納 ticket spinlock。
一如 "wild" 在漢語的意思「粗暴、未經馴化」,這種 spinlock 非常簡單,就由一個整數構成:
typedef struct {
volatile unsigned int lock;
} spinlock_t;
其上鎖和解鎖操作如下:
void spin_lock(spinlock_t *lock) {
while (lock->lock == 1)
/* spin-wait */ ;
}
void spin_unlock(spinlock_t *lock) {
lock->lock = 0;
}
然而在這貌似簡單的程式背後,卻猶如野生環境中動物間頻繁廝殺的競爭,為何呢?
根據上面的中央處理器佈局圖,我們可知各處理器核之間的 processor affinity (趨向的程度) 不同,特定中央處理器核的 cache line 更新同步到不同核的 cache line 的時間亦不同,這就意味著等待 spinlock 的中央處理器中哪個核先察覺到 lock->lock
的變化,該核就能優先獲得鎖。
這將大幅損害等鎖者之間的公平性。這就好比一群人不排隊上火車,體格好的乘客總是優先登車一樣。
為了解決這個公平性問題,ticket spinlock 登場,後者相較於 wild spinlock 的設計,新增一個紀錄等鎖期間單調遞增的 ticket 欄位,確保先到者先獲得鎖,從而保證公平性。
以下是 Linux ticket spinlock 的簡化實作程式碼,為了行文便利,以下的程式碼中所有的遞增 (如 i++
) 操作均為 atomic (有如原子般不可切割),避免鎖中鎖。
struct spinlock_t {
/* 上鎖者抽到的號碼 */
int my_ticket;
/* 目前的叫號數值 */
int curr_ticket;
}
這段程式碼展現標準的佇列,類似銀行叫號系統,每個辦理業務的抽取一個號碼牌,然後按照號碼的先後,循序進行服務。
void spin_lock(spinlock_t *lock) {
int my_ticket;
/* 依序拿到自己的 ticket 號碼 */
my_ticket = lock->my_ticket++;
while (my_ticket != lock->curr_ticket)
/* spin-wait */ ;
}
void spin_unlock(spinlock_t *lock)
{
/* 呼喚下一位 */
lock->curr_ticket++;
}
若僅是閱讀程式碼,確實沒看到什麼可議之處,但只要深究就會發現潛藏的危機。以下是從 cache 的角度,分析上述 spinlock 的執行過程,搭配下列程式碼來進行實驗:
void demo()
{
spin_lock(&g_lock);
g_var1 ++;
g_var2 --;
g_var3 = g_var1 + g_var2;
spin_unlock(&g_lock)
}
各步驟如下:
步驟 1: CPU0 申請鎖,持有本地 ticket 到申請者的 cache
步驟 2: 執行鎖定區域指令的同時,其它 CPU 核企圖獲取鎖而自旋
步驟 3:CPU0 釋放鎖
我們可發現,步驟 3 過於複雜。目前許多中央處理器實作偏好用點對點 unicast 的方式來更新 cache line (因為 broadcast 方式對匯流排頻寬有要求,隨著中央處理器核數的增加,實作複雜度會提高,也限制整體擴展能力),因此步驟 3 裡頭更新每個 cache line 是個循序執行的過程,若是 write invalidate 方式,就需要更多的存取指令,這些對於理解 Linux ticket spinlock 的可擴展能力至關重要!
顯然隨著處理器核數的增加,步驟 3 對於 spinlock 請求增加的執行時間會線性增長,最終,當 spinlock 的請求量達到一定規模時,多核中央處理器非但無法提高效能,反而由於 cache line 更新的時間過久,反過來損害效能。
注意,wild spinlock 同樣存在這個問題,wild spinlock 背後的 cache coherence 過程和 ticket spinlock 完全一致,只是 ticket spinlock 嚴格規定誰將下一個獲得鎖,而 wild spinlock 並不能。
我們已定性地描述 Linux ticket spinlock 的實作會有什麼問題,為定量地衡量這種實作會帶來哪些具體的後果,需要簡述 Linux ticket spinlock (此後簡稱 Linux spinlock) 對應到經典的 Markov chain 模型。
為闡釋 Linux spinlock 的具體效能表現和帶來的衝擊,必然要建立一個模型,本模型結合排隊理論,可精確描述和預測 Linux spinlock 行為。
考慮以下圖示:
這是個 Markov chain,其中一共有
簡化起見,我們忽略掉 spinlock 到達的細節,假設在中央處理器單一核上平均每隔
註:以上敘述對照參考文獻 Non-scalable locks are dangerous 的 Section 3.2,原文是 rate,剛好是
,翻成「頻率」較易懂。
現在考慮釋放鎖的過程導致的狀態轉換。
先理解狀態 E
,另一是 unlock 操作中消耗的時間,我們把這部分時間設為 R
,現在的問題就變成根據上述資訊,求出 R 的表達式。
討論中有個假設,即我們設想實驗的平台對 cache coherence 中 update 的管理方式是點對點、循序的 unicast 方式,而非 broadcast 的方式。假設處理一個核的 update 時間為 c
,那麼在 k
個核同時自旋等鎖的條件下,所有的中央處理器核的 cache line 全部更新的時間就是 spin_lock
自旋狀態返回。因此我們可知:
以上關於 spin_unlock
的頻率,即
有了上面基本的結論,搭配下面一個穩態 Markov chain 狀態轉換率守恆的基本原則,即可導出模型本身:
假設 P0, P1, P2, …, Pn 是系統處在這
這是一個遞推的開始,我們可求出
有了這個式子,我們便可求出任意時刻系統中處在自旋等鎖狀態的 CPU 總量:
模型最終在於解釋歷史紀錄和預測未來表現,在上面的基礎上,我們導出一個加速比的概念,即在
隨著
除了影響擴展能力,cache line 的操作超出預料有著巨大影響!
再看
這個式子是限制 spinlock 擴展能力的根源。注意這裡的
然後我們來看一下
曲線的走勢:
回到我們的模型,
假設我們的 critical section 範圍確實很小,那麼
和
問題已經很明確,如何迴避顯然已至關重要。
既然問題出在共用變數上自旋導致的 cache coherence 開銷過大,那改成在私有變數上自旋即可,這顯然是一個
為了做到這一點,需要重新設計 spinlock 的資料結構。本節所描述的新的 spinlock 正是 MCS spinlock,可參照 MCS locks and qspinlocks 一文。
讓我們從程式碼的角度來看為何 MCS spinlock 可解決 cache coherence 導致擴展能力受限的問題。
mcs spinlock 在 Linux 的實作在 kernel/locking/mcs_spinlock.h
檔案,不過直到 4.14 版,Linux 並沒有哪個子系統採用這個新的 spinlock,不管怎樣,既然程式碼在手,我們便可自行運用。
和 ticket spinlock 的描述一致:
struct mcs_spinlock {
struct mcs_spinlock *next; // 類似 ticket,競爭持有鎖的佇列
int locked; /* 1 if lock acquired */ // 本地的自旋變數,不再是全域
};
可見其特點在於在自旋變數納入佇列實體本身。在 mcs spinlock 中,一個 mcs_spinlock 結構體的實例 (instance) 就是個佇列實體,它擁有自己的自旋變數 locked
,即它只需要不斷查驗自身的 locked
變數即可。
/* 自身的佇列節點 node 需要自行在他處初始化,它將被排到 lock 指示的等鎖佇列中 */
void mcs_spin_lock(struct mcs_spinlock **lock, struct mcs_spinlock *node)
{
struct mcs_spinlock *prev;
/* Init node */
node->locked = 0;
node->next = NULL;
// (atomic) 執行 *lock = node 並返回原来的 *lock
prev = xchg(lock, node);
if (likely(prev == NULL))
return;
// (atomic) 執行 prev->next = node;
// 這相當於一個排入佇列的操作。記為(*)
WRITE_ONCE(prev->next, node);
while (0 == &node->locked)
/* spin-wait */ ;
}
/* 自己要傳入一個排入到 lock 的佇列中自身的節點物件 node
* 解鎖操作即是 node 移出佇列並且主動將佇列中下一個物件的 locked 設定為 1
*/
void mcs_spin_unlock(struct mcs_spinlock **lock, struct mcs_spinlock *node) {
// (atomic) 持有 node 的下一個節點。
struct mcs_spinlock *next = READ_ONCE(node->next);
if (likely(!next)) {
// 再次確認,若是 NULL 則返回,說明自身是最後一個,無後續動作
if (likely(cmpxchg_release(lock, node, NULL) == node))
return;
// 否則說明在 if 判斷 next 為 NULL 和 cmpxchg 之間有 node 插入,随即等待其 mcs_spin_lock 呼叫完成,即上面 mcs_spin_lock 中的 (*) 那句完成以後
while (!(next = READ_ONCE(node->next)))
cpu_relax_lowlatency();
}
// (atomic) 執行 next->locked = 1;
arch_mcs_spin_unlock_contended(&next->locked);
}
如你所見,mcs_spin_unlock 操作僅操作 next 的 locked
欄位,後者屬於在某個 CPU 上自旋等待的 mcs spinlock 物件,不會觸發全域的 cache coherence 刷新!
上述的 mcs spinlock 實作之所以可解決因 cache coherence 而導致的開銷隨著處理器核數的增加線性增加的問題,就是因為一個簡單的動作,即傳球代替搶球。 ticket spinlock 的問題在於,既然已知誰是下一個持有鎖的等待者,為什麼不直接將控制權交給它呢?為什麼還要有一個全域盲搶的動作呢?
從前述程式碼列表中,也許你已發現 Linux 的 MCS spinlock 的實作議題:它的 API 改變,這意味著你無法對核心既有 ticket spinlock 進行無縫轉換。我們期望形如下方的函式:
void mcs_spin_lock(struct mcs_spinlock *lock);
void mcs_spin_unlock(struct mcs_spinlock *lock);
由於同個中央處理器核同時僅能等待一個 spinlock,這件事簡單,可如下實作:
struct mcs_spinlock_node {
struct mcs_spinlock_node *next;
int locked; /* 1 if lock acquired */
};
struct mcs_spinlock {
struct mcs_spinlock_node nodes[NR_CPUS];
struct mcs_spinlock_node *tail;
};
void mcs_spin_lock(struct mcs_spinlock *lock)
{
struct mcs_spinlock_node *local;
struct mcs_spinlock_node *prev;
int cpu = smp_processor_id();
local = &(lock->nodes[cpu]);
local->locked = 0;
local->next = NULL;
prev = xchg(&lock->tail, local);
if (prev == NULL) return;
WRITE_ONCE(prev->next, local);
while (!local->locked);
}
void mcs_spin_unlock(struct mcs_spinlock *lock)
{
struct mcs_spinlock_node *local;
struct mcs_spinlock_node *next;
int cpu = smp_processor_id();
local = &(lock->nodes[cpu]);
next = READ_ONCE(local->next);
if (likely(!next)) {
if (cmpxchg_release(&lock->tail, local, NULL) == local)
return;
while (!(next = READ_ONCE(local->next)));
}
local->next->locked = 1;
}
是不是更清楚?至此完成 spinlock 的討論。
針對 spinlock 改進策略的另一種體現,由阿里巴巴集團提交: aliworkqueue: Adaptive lock integration on multi-core platform
Wire-latency(RC delay) dominate modern computer performance, conventional serialized works cause cache line ping-pong seriously, the process spend lots of time and power to complete.specially on multi-core platform.
However if the serialized works are sent to one core and executed ONLY when contention happens, that can save much time and power, because all shared data are located in private cache of one core.
We call the mechanism as Adaptive Lock Integration.
(ali workqueue)
參照解說: 介绍 aliworkqueue
Aliworkqueue 除了考慮到 spinlock 本身加鎖解鎖對 cache coherence 的影響,還考慮 spinlock 保護的 critical section 操作的共享資料讀寫對 cache coherence 的影響,如此一來,本文上述的 Markov chain 模型就要進行修訂,因為 critical section 的操作時間不再視作常數