運行時鎖定正確性驗證器 【ChatGPT】

鎖類

該驗證器操作的基本對象是“鎖”的“類”。

“鎖”的“類”是一組邏輯上相同的鎖,即使這些鎖可能有多個(可能有成千上萬個)實例化。例如,inode結構中的鎖是一個類,而每個inode都有自己的該鎖類的實例化。

驗證器跟蹤鎖類的“使用狀態”,並跟蹤不同鎖類之間的依賴關係。鎖的使用狀態指示鎖在IRQ上下文中的使用方式,而鎖的依賴關係可以理解爲鎖的順序,其中L1 -> L2表示任務在持有L1的同時嘗試獲取L2。從lockdep的角度來看,這兩個鎖(L1和L2)不一定相關;這種依賴只是表示順序曾經發生過。驗證器不斷努力證明鎖的使用和依賴關係是正確的,否則驗證器將在不正確時發出警告。

鎖類的行爲是由其實例共同構成的:當鎖類的第一個實例在啓動後被使用時,該類被註冊,然後所有(隨後的)實例將被映射到該類,因此它們的使用和依賴將有助於該類的使用和依賴。鎖類不會在鎖實例消失時消失,但如果鎖類的內存空間(靜態或動態)被回收,它可以被移除,例如當模塊被卸載或工作隊列被銷燬時。

STATE

驗證器跟蹤鎖類的使用歷史,並將使用分爲(4個使用 * n個STATE + 1)類別:

其中4個使用可以是:

  • '在STATE上曾經持有'
  • '在STATE上曾經以讀鎖持有'
  • '在啓用STATE下曾經持有'
  • '在啓用STATE下曾經以讀鎖持有'

n個STATE編碼在kernel/locking/lockdep_states.h中,目前包括:

  • hardirq
  • softirq

最後1個類別是:

  • '曾經使用' [ == !未使用 ]

當違反鎖定規則時,這些使用位將顯示在鎖定錯誤消息中,用大括號括起來,共有2 * n個STATE位。一個人爲的例子:

modprobe/2287 正在嘗試獲取鎖:
(&sio_locks[i].lock){-.-.}, at: [<c02867fd>] mutex_lock+0x21/0x24

但任務已經持有鎖:
(&sio_locks[i].lock){-.-.}, at: [<c02867fd>] mutex_lock+0x21/0x24

對於給定的鎖,從左到右的位位置指示了鎖和讀鎖(如果存在)在上述每個n個STATE中的使用情況,每個位位置顯示的字符表示:

  • '.': 在禁用IRQ且不在IRQ上下文中獲取
  • '-': 在IRQ上下文中獲取
  • '+': 在啓用IRQ的情況下獲取
  • '?': 在啓用IRQ的情況下在IRQ上下文中獲取

示例:

(&sio_locks[i].lock){-.-.}, at: [<c02867fd>] mutex_lock+0x21/0x24
                     ||||
                     ||| \-> softirq disabled and not in softirq context
                     || \--> acquired in softirq context
                     | \---> hardirq disabled and not in hardirq context
                      \----> acquired in hardirq context

對於給定的狀態,鎖是否在該狀態上曾經被獲取以及該狀態是否已啓用會產生四種可能的情況,如下表所示。位字符能夠指示在報告時間點上鎖的確切情況。

irq已啓用 irq已禁用
曾在irq中 '?' '-'
從未在irq中 '+' '.'

字符'-'表示irq已禁用,因爲否則將顯示字符'?'。對於'+'也可以應用類似的推斷。

未使用的鎖(例如互斥鎖)不會成爲錯誤的原因。

單鎖狀態規則:

鎖是irq安全的意味着它曾在irq上下文中使用,而鎖是irq不安全的意味着它曾在啓用irq的情況下被獲取。

一個softirq不安全的鎖類也自動是hardirq不安全的。以下狀態必須是互斥的:對於任何鎖類,基於其使用情況只允許設置其中的一個:

<hardirq安全> 或 <hardirq不安全>
<softirq安全> 或 <softirq不安全>

這是因爲如果一個鎖可以在irq上下文中使用(irq安全),那麼它就不能在啓用irq的情況下被獲取(irq不安全)。否則,可能會發生死鎖。例如,在獲取此鎖後但在釋放之前,如果上下文被中斷,這個鎖將被嘗試獲取兩次,這會導致死鎖,稱爲鎖遞歸死鎖。

驗證器檢測並報告違反這些單鎖狀態規則的鎖使用。

多鎖依賴規則:

同一鎖類不得被獲取兩次,因爲這可能導致鎖遞歸死鎖。

此外,兩個鎖不得以相反的順序獲取:

<L1> -> <L2>
<L2> -> <L1>

因爲這可能導致死鎖,稱爲鎖倒置死鎖,嘗試獲取這兩個鎖形成一個循環,這可能導致兩個上下文永久等待對方。驗證器將在任意複雜度中找到這樣的依賴循環,即在獲取鎖的操作之間可能存在任何其他鎖定序列;驗證器仍然會找出這些鎖是否可以以循環方式獲取。

此外,以下基於使用的鎖依賴關係在任何兩個鎖類之間都是不允許的:

<hardirq安全>   ->  <hardirq不安全>
<softirq安全>   ->  <softirq不安全>

第一個規則來自於這樣一個事實:一個hardirq安全的鎖可以被hardirq上下文獲取,中斷一個hardirq不安全的鎖,因此可能導致鎖倒置死鎖。同樣,一個softirq安全的鎖可以被softirq上下文獲取,中斷一個softirq不安全的鎖。

以上規則適用於內核中發生的任何鎖定序列:在獲取新鎖時,驗證器會檢查新鎖與任何已持有鎖之間是否存在違反規則的情況。

當鎖類改變其狀態時,上述依賴規則的以下方面將被強制執行:

  • 如果發現一個新的hardirq安全鎖,我們將檢查它是否在過去獲取過任何hardirq不安全的鎖。
  • 如果發現一個新的softirq安全鎖,我們將檢查它是否在過去獲取過任何softirq不安全的鎖。
  • 如果發現一個新的hardirq不安全鎖,我們將檢查是否有任何hardirq安全鎖在過去獲取過它。
  • 如果發現一個新的softirq不安全鎖,我們將檢查是否有任何softirq安全鎖在過去獲取過它。

(同樣,我們也會在這些檢查中假設中斷上下文可能中斷任何irq不安全或hardirq不安全的鎖,這可能導致鎖倒置死鎖,即使該鎖情景在實踐中尚未觸發。)

例外:導致嵌套鎖定的嵌套數據依賴

在一些情況下,Linux內核會獲取同一鎖類的多個實例。這種情況通常發生在同一類型的對象中存在某種層次結構時。在這些情況下,兩個對象之間存在固有的“自然”順序(由層次結構的屬性定義),內核在每個對象上以固定的順序獲取鎖。

導致“嵌套鎖定”的對象層次結構的一個例子是“整個磁盤”塊設備對象和“分區”塊設備對象;分區是整個設備的一部分,只要始終將整個磁盤鎖作爲高級鎖獲取分區鎖,鎖定順序就是完全正確的。驗證器不會自動檢測到這種自然順序,因爲順序背後的鎖定規則是不固定的。

爲了向驗證器介紹這種正確的使用模型,添加了各種鎖定原語的新版本,允許您指定“嵌套級別”。例如,對於塊設備互斥鎖的調用如下所示:

enum bdev_bd_mutex_lock_class
{
     BD_MUTEX_NORMAL,
     BD_MUTEX_WHOLE,
     BD_MUTEX_PARTITION
};

mutex_lock_nested(&bdev->bd_contains->bd_mutex, BD_MUTEX_PARTITION);

在這種情況下,對已知是分區的bdev對象進行鎖定。驗證器將以嵌套方式獲取的鎖視爲驗證目的的一個單獨(子)類。

注意:在更改代碼以使用_nested()原語時,務必小心並仔細檢查層次結構是否正確映射;否則可能會出現錯誤的陽性或陰性結果。

註釋

有兩種結構可用於註釋和檢查是否必須持有某些鎖:lockdep_assert_held*(&lock)lockdep_*pin_lock(&lock)

顧名思義,lockdep_assert_held*宏系列斷言在某個時間點持有特定鎖(否則會生成WARN())。這種註釋在整個內核中廣泛使用,例如kernel/sched/core.c:

void update_rq_clock(struct rq *rq)
{
      s64 delta;

      lockdep_assert_held(&rq->lock);
      [...]
}

在這裏,持有rq->lock是安全地更新rq的時鐘所必需的。

另一組宏是lockdep_*pin_lock(),目前只用於rq->lock。儘管這些註釋的採用範圍有限,但如果感興趣的鎖“意外”解鎖,這些註釋會生成WARN()。這對於具有回調代碼的調試非常有幫助,其中一個上層層次認爲鎖仍然被持有,而下層層次認爲它可能會釋放並重新獲取鎖(“無意”引入競爭)。lockdep_pin_lock()返回一個“struct pin_cookie”,然後lockdep_unpin_lock()使用它來檢查沒有人篡改鎖,例如kernel/sched/sched.h:

static inline void rq_pin_lock(struct rq *rq, struct rq_flags *rf)
{
      rf->cookie = lockdep_pin_lock(&rq->lock);
      [...]
}

static inline void rq_unpin_lock(struct rq *rq, struct rq_flags *rf)
{
      [...]
      lockdep_unpin_lock(&rq->lock, rf->cookie);
}

儘管關於鎖定要求的註釋可能提供有用的信息,但註釋執行的運行時檢查在調試鎖定問題時是非常寶貴的,並且在檢查代碼時具有相同的詳細信息級別。在懷疑時,始終優先使用註釋!

100%正確性的證明

驗證器在數學上實現了完美的“閉合”(鎖定正確性的證明),對於內核生命週期中至少發生一次的每個簡單的、獨立的單任務鎖定序列,驗證器都能以100%的確定性證明,這些鎖定序列的任何組合和時機都不會導致任何鎖相關的死鎖1

即使複雜的多CPU和多任務鎖定場景在實踐中不必發生,也可以證明死鎖:只需至少一次觸發“簡單”單任務鎖定依賴即可(在任何時間、在任何任務/上下文中),驗證器就能夠證明正確性。(例如,通常需要超過3個CPU和非常不太可能的任務、irq上下文和時機組合才能發生的複雜死鎖,在普通、輕負載的單CPU系統上也可以檢測到!)

這極大地簡化了內核鎖定相關的質量保證工作:在質量保證期間需要做的是儘可能觸發內核中儘可能多的“簡單”單任務鎖定依賴,至少一次,以證明鎖定正確性 - 而不是必須觸發每個可能的CPU之間的所有可能的鎖定交互組合,再加上每種可能的hardirq和softirq嵌套場景(這在實踐中是不可能的)。

性能:

上述規則需要大量的運行時檢查。如果我們對每個鎖的獲取和每個irqs-enable事件都進行檢查,那麼系統幾乎無法使用,因爲檢查的複雜度是O(N^2),所以即使只有幾百個鎖類,我們每個事件都需要進行數萬次的檢查。

這個問題通過僅對任何給定的“鎖定場景”(相繼獲取的唯一鎖序列)進行一次檢查來解決。維護一個簡單的持有鎖的堆棧,並計算一個輕量級的64位哈希值,該哈希對於每個鎖鏈是唯一的。當第一次驗證鏈時,哈希值被放入哈希表中,該哈希表可以以無鎖的方式進行檢查。如果以後再次出現鎖鏈,哈希表會告訴我們不需要再次驗證該鏈。

故障排除:

驗證器跟蹤最多MAX_LOCKDEP_KEYS個鎖類。超過這個數字將觸發以下lockdep警告:

(DEBUG_LOCKS_WARN_ON(id >= MAX_LOCKDEP_KEYS))

默認情況下,MAX_LOCKDEP_KEYS目前設置爲8191,典型的桌面系統鎖類少於1000個,因此這個警告通常是由於鎖類泄漏或未正確初始化鎖引起的。以下兩個問題進行了說明:

  1. 在運行驗證器時重複加載和卸載模塊會導致鎖類泄漏。問題在於每次加載模塊都會爲該模塊的鎖創建一組新的鎖類,但卸載模塊不會刪除舊的類(請參見下面關於爲什麼重用鎖類的討論)。因此,如果該模塊反覆加載和卸載,鎖類的數量最終會達到最大值。

  2. 使用諸如數組之類的結構,其中包含大量未顯式初始化的鎖。例如,一個具有8192個桶的哈希表,其中每個桶都有自己的spinlock_t,將消耗8192個鎖類,除非每個自旋鎖在運行時顯式初始化,例如使用run-time spin_lock_init(),而不是使用編譯時初始化器,如__SPIN_LOCK_UNLOCKED()。未正確初始化每個桶的自旋鎖將導致鎖類溢出。相比之下,調用spin_lock_init()對每個鎖的循環將把所有8192個鎖放入單個鎖類中。

    這個故事的寓意是,你應該始終顯式初始化你的鎖。

有人可能會認爲驗證器應該被修改以允許鎖類的重用。然而,如果你有這種想法,首先審查代碼並仔細考慮所需的更改,要記住要刪除的鎖類可能已經鏈接到鎖依賴圖中。這樣做比說起來更難。

當然,如果你的鎖類用完了,下一步要做的就是找到有問題的鎖類。首先,以下命令可以給出當前使用的鎖類數量以及最大值:

grep "lock-classes" /proc/lockdep_stats

這個命令在一臺普通系統上產生以下輸出:

lock-classes: 748 [max: 8191]

如果分配的數量(上面的748)隨着時間的推移不斷增加,那麼很可能存在泄漏。以下命令可用於識別泄漏的鎖類:

grep "BD" /proc/lockdep

運行該命令並保存輸出,然後與稍後運行該命令的輸出進行比較,以識別泄漏者。這個輸出也可以幫助你找到遺漏運行時鎖初始化的情況。

遞歸讀鎖:

整個文檔的其餘部分試圖證明某種類型的循環等價於死鎖可能性。

有三種類型的鎖定器:寫入者(即獨佔鎖定器,如spin_lock()或write_lock())、非遞歸讀取者(即共享鎖定器,如down_read())和遞歸讀取者(遞歸共享鎖定器,如rcu_read_lock())。在文檔的其餘部分,我們使用這些鎖定器的以下符號:

  • W或E:代表寫入者(獨佔鎖定器)。r:代表非遞歸讀取者。R:代表遞歸讀取者。S:代表所有讀取者(非遞歸+遞歸),因爲兩者都是共享鎖定器。N:代表寫入者和非遞歸讀取者,因爲兩者都不是遞歸的。

顯然,N是“r或W”,S是“r或R”。

遞歸讀取者,正如其名稱所示,是允許在另一個相同鎖實例的讀取者的臨界區內獲取鎖的鎖定器,換句話說,允許同一鎖實例的嵌套讀取端臨界區。

而非遞歸讀取者在嘗試在另一個相同鎖實例的讀取者的臨界區內獲取鎖時會導致自死鎖。

遞歸讀取者和非遞歸讀取者之間的區別在於:遞歸讀取者只會被當前的寫入鎖持有者阻塞,而非遞歸讀取者可能會被寫入鎖的等待者阻塞。考慮以下例子:

任務A:                 任務B:

read_lock(X);
                        write_lock(X);
read_lock_2(X);

任務A通過read_lock()首先在X上獲取讀取者(無論是遞歸的還是非遞歸的)。當任務B嘗試在X上獲取寫入者時,它將被阻塞併成爲X的寫入者的等待者。現在如果read_lock_2()是遞歸讀取者,任務A將會取得進展,因爲寫入者的等待者不會阻塞遞歸讀取者,因此不會發生死鎖。然而,如果read_lock_2()是非遞歸讀取者,它將被寫入者的等待者B阻塞,並導致自死鎖。

相同鎖實例的讀取者/寫入者的阻塞條件:

簡單來說,有四種阻塞條件:

  1. 寫入者阻塞其他寫入者。

  2. 讀取者阻塞寫入者。

  3. 寫入者阻塞遞歸讀取者和非遞歸讀取者。

  4. 讀取者(遞歸或非遞歸)不會阻塞其他遞歸讀取者,但可能會阻塞非遞歸讀取者(因爲可能存在共存的寫入者等待者)

阻塞條件矩陣,Y表示行阻塞列,N表示相反。

image

(W:寫入者,r:非遞歸讀取者,R:遞歸讀取者)

遞歸讀鎖不會被遞歸地獲取。與非遞歸讀鎖不同,遞歸讀鎖只會被當前寫入鎖持有者阻塞,而不會被寫入鎖的等待者阻塞,例如:

任務A:                 任務B:

read_lock(X);

                        write_lock(X);

read_lock(X);

對於遞歸讀鎖來說,上述情況不會導致死鎖,因爲當任務B等待鎖X時,第二個read_lock()不需要等待,因爲它是遞歸讀鎖。然而,如果read_lock()是非遞歸讀鎖,那麼上述情況就會導致死鎖,因爲即使任務B無法獲取鎖,但它可以阻塞任務A中的第二個read_lock()。

請注意,一個鎖可以是寫鎖(獨佔鎖)、非遞歸讀鎖(非遞歸共享鎖)或遞歸讀鎖(遞歸共享鎖),具體取決於用於獲取它的鎖操作(更具體地說,是lock_acquire()的“read”參數的值)。換句話說,單個鎖實例具有三種不同的獲取方式:獨佔、非遞歸讀和遞歸讀。

簡而言之,我們將寫鎖和非遞歸讀鎖稱爲“非遞歸”鎖,將遞歸讀鎖稱爲“遞歸”鎖。

遞歸鎖不會相互阻塞,而非遞歸鎖會(即使是兩個非遞歸讀鎖也是如此)。非遞歸鎖可以阻塞相應的遞歸鎖,反之亦然。

涉及遞歸鎖的死鎖案例如下:

任務A:                 任務B:

read_lock(X);
                        read_lock(Y);
write_lock(Y);
                        write_lock(X);

任務A正在等待任務B對Y進行read_unlock(),而任務B正在等待任務A對X進行read_unlock()。

依賴類型和強依賴路徑:

鎖依賴記錄了一對鎖的獲取順序,由於有3種類型的鎖,理論上有9種鎖依賴類型,但我們可以證明只有4種鎖依賴類型足以用於死鎖檢測。

對於每種鎖依賴:

L1 -> L2

這意味着在運行時,lockdep看到了L1在同一上下文中先於L2被持有。在死鎖檢測中,我們關心的是當L1被持有時是否會被L2阻塞,換句話說,是否存在一個鎖L3,L1阻塞L3並且L2被L3阻塞。因此,我們只關心1)L1阻塞了什麼以及2)什麼阻塞了L2。因此,我們可以將遞歸讀者和非遞歸讀者合併爲L1(因爲它們阻塞相同類型),並且我們可以將寫者和非遞歸讀者合併爲L2(因爲它們被相同類型阻塞)。

通過上述簡化組合,鎖依賴圖中有4種依賴邊的類型:

  1. -(ER)->:
    獨佔寫者到遞歸讀者的依賴,"X -(ER)-> Y"表示X -> Y,X是寫者,Y是遞歸讀者。

  2. -(EN)->:
    獨佔寫者到非遞歸鎖的依賴,"X -(EN)-> Y"表示X -> Y,X是寫者,Y要麼是寫者要麼是非遞歸讀者。

  3. -(SR)->:
    共享讀者到遞歸讀者的依賴,"X -(SR)-> Y"表示X -> Y,X是讀者(遞歸或非遞歸),Y是遞歸讀者。

  4. -(SN)->:
    共享讀者到非遞歸鎖的依賴,"X -(SN)-> Y"表示X -> Y,X是讀者(遞歸或非遞歸),Y要麼是寫者要麼是非遞歸讀者。

需要注意的是,給定兩個鎖,它們之間可能存在多個依賴關係,例如:

任務A:

read_lock(X);
write_lock(Y);
...

任務B:

write_lock(X);
write_lock(Y);

在依賴圖中,我們既有X -(SN)-> Y,又有X -(EN)-> Y。

我們使用-(xN)->表示既是-(EN)->又是-(SN)->的邊,對於-(Ex)->、-(xR)->和-(Sx)->同理。

“路徑”是圖中一系列連接的依賴邊。我們定義“強”路徑,表示路徑中每個依賴都是強依賴,即路徑中不存在兩個相鄰的依賴邊是-(xR)->和-(Sx)->。換句話說,“強”路徑是指通過鎖依賴從一個鎖到另一個鎖的路徑,如果路徑中存在X -> Y -> Z(其中X、Y、Z是鎖),並且從X到Y的路徑是通過-(SR)->或-(ER)->依賴,那麼從Y到Z的路徑就不能是通過-(SN)->或-(SR)->依賴。

我們將在下一節看到爲什麼路徑被稱爲“強”。

遞歸讀者死鎖檢測:

我們現在證明兩個結論:

引理1:

如果存在一個封閉的強路徑(即強環路),那麼存在一組鎖定序列會導致死鎖。也就是說,強環路足以用於死鎖檢測。

引理2:

如果不存在封閉的強路徑(即強環路),那麼不存在一組鎖定序列會導致死鎖。也就是說,強環路是死鎖檢測的必要條件。

有了這兩個引理,我們可以輕鬆地說,封閉的強路徑既是足夠的也是必要的死鎖條件,因此封閉的強路徑等同於死鎖可能性。由於封閉的強路徑代表了可能導致死鎖的依賴鏈,所以我們稱之爲“強”,考慮到存在不會導致死鎖的依賴環。

充分性的證明(引理1):

假設我們有一個強環路:

L1 -> L2 ... -> Ln -> L1

這意味着我們有依賴關係:

L1 -> L2
L2 -> L3
...
Ln-1 -> Ln
Ln -> L1

我們現在可以構造一組導致死鎖的鎖定序列:

首先讓一個CPU/任務獲取L1在L1 -> L2中,然後另一個獲取L2在L2 -> L3中,依此類推。這樣,所有Lx在Lx -> Lx+1中都被不同的CPU/任務持有。

然後因爲我們有L1 -> L2,所以持有者將在L1 -> L2中獲取L2,然而由於L2已經被另一個CPU/任務持有,再加上L1 -> L2和L2 -> L3不是-(xR)->和-(Sx)->(強依賴的定義),這意味着要麼L1 -> L2中的L2是非遞歸鎖(被任何人阻塞),要麼L2 -> L3中的L2是寫者(阻塞任何人),因此L1的持有者無法獲取L2,必須等待L2的持有者釋放。

此外,我們可以得出類似的結論:L2的持有者必須等待L3的持有者釋放,依此類推。我們現在可以證明Lx的持有者必須等待Lx+1的持有者釋放,注意到Ln+1是L1,所以我們有一個循環等待的情景,沒有人能夠取得進展,因此發生了死鎖。

必要性的證明(引理2):

引理2等價於:如果存在死鎖情景,那麼依賴圖中必定存在一個強環路。

根據維基百科1,如果存在死鎖,那麼必定存在循環等待的情景,意味着有N個CPU/任務,其中CPU/任務P1正在等待被P2持有的鎖,P2正在等待被P3持有的鎖,...,Pn正在等待被P1持有的鎖。讓我們將Px正在等待的鎖命名爲Lx,因此由於P1正在等待L1並持有Ln,所以我們在依賴圖中會有Ln -> L1。類似地,我們有L1 -> L2,L2 -> L3,...,Ln-1 -> Ln,這意味着我們有一個環路:

Ln -> L1 -> L2 -> ... -> Ln

現在讓我們證明這個環路是強的:

對於鎖Lx,Px貢獻了依賴Lx-1 -> Lx,Px+1貢獻了依賴Lx -> Lx+1,由於Px正在等待Px+1釋放Lx,因此不可能出現Px+1上的Lx是讀者且Px上的Lx是遞歸讀者,因爲讀者(無論遞歸或非遞歸)不會阻塞遞歸讀者,因此Lx-1 -> Lx和Lx -> Lx+1不可能是-(xR)-> -(Sx)->對,對於環路中的任何鎖來說都是如此,因此這個環路是強的。

參考資料:

1: https://en.wikipedia.org/wiki/Deadlock [2]: Shibu, K. (2009). Intro To Embedded Systems (1st ed.). Tata McGraw-Hill

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章