1. 程式人生 > >深入理解 Linux 核心---核心同步

深入理解 Linux 核心---核心同步

核心如何為不同的請求提供服務

把核心看作必須滿足兩種請求的侍者:一種來自中斷,另一種來自使用者態程序發出的系統呼叫或異常。前者的優先順序更高。

侍者提供的服務對應於 CPU 處於核心態時所執行的程式碼。如果 CPU 在使用者態執行,則認為侍者處於空閒狀態。

核心搶佔

如果程序正在執行核心函式時,即在核心態執行,允許發生核心切換,這個核心就是搶佔的。

Linux 中的搶佔型別

  • 計劃性程序切換:無論在搶佔核心還是非搶佔核心中,執行在核心態的程序都可以自動放棄 CPU,比如等待資源。
  • 強制性程序切換:搶佔式核心可響應引起程序切換的非同步事件(如喚醒高優先權程序的中斷處理程式)。
  • 所有的程序切換都由 switch_to 完成。

核心搶佔的好處
使核心可搶佔的目的是降低一個程序被另一個執行在核心態程序延遲的風險。

什麼時候禁止搶佔
當被 current_thread_info() 巨集所引用的 thread_info 描述符的 preempt_count 欄位大於 0 時,禁止核心搶佔,存在以下情況:

  1. 核心正在執行中斷服務例程。
  2. 可延遲函式被禁止(當核心正在執行軟中斷或 tasklet 時經常如此)。
  3. 將搶佔計數器設定為正數,顯示禁用核心搶佔。

所以,只有當核心正在執行異常處理程式(尤其時系統呼叫),且核心搶佔沒有被顯式禁用,才可搶佔核心。

核心搶佔相關的巨集、函式
preempt_enable() 巨集遞減搶佔計數器,檢查 TIF_NEED_RESCHED 是否被設定。此時,程序切換請求是掛起的,因此呼叫
preempt_schedule() 函式:

// 檢查 preempt_count 是否為0,以及是否允許本地中斷
if(!current_thread_info->preempt_count && !irqs_disabled()){
	current_thread_info->preempt_count = PREMPT_ACTIVE;
	schedule();   // 選擇另外一個程序執行
	current_thread_info->preempt_count = 0;
}

核心搶佔的時機

  • 結束核心控制路徑時(通常是一箇中斷處理程式)
  • 異常處理程式呼叫 preempt_enable() 重新允許核心搶佔時。
  • 啟用可延遲函式時。

Linux 2.6 允許使用者在編譯核心時通過設定選項決定是否使用核心搶佔。

什麼時候同步是必需的

當計算結果依賴於兩個或兩個以上的交叉核心控制路徑的巢狀方式時,可能出現競爭條件。

臨界區是一段程式碼,在其他的核心控制路徑能進入臨界區前,進入臨界區的核心控制路徑必須執行完這段程式碼。

什麼時候同步是不必要的

  • 中斷處理程式和 tasklet 不必編寫成可重入的函式。
  • 僅被軟中斷和 tasklet 訪問的 每 CPU 變數不需要同步。
  • 僅被一種 tasklet 訪問發資料結構不需要同步。

同步原語

每 CPU 變數

每 CPU 變數,核心變數,每 CPU 變數是一個數組,系統中的每個 CPU 對應陣列的一個元素。

一個 CPU 不應訪問其他 CPU 對應的陣列元素,另外,它可以隨意修改它自己的元素而不用擔心出現競爭條件。

雖然每 CPU 變數為來自不同 CPU 的併發訪問提供包含,但對來自非同步函式(中斷處理程式和可延遲函式)的訪問不提供保護,這需要另外的同步原語。

原子操作

原子操作:將操作一個單個指令執行,中間不能中斷,且避免其他的 CPU 訪問同一儲存單元。

本地中斷的禁止只適用於一個 CPU(系統中的其他 CPU 不受影響)。

原子操作的指令:inc、dec、操作碼字首是 lock 位元組(0xf0)的彙編指令等。

優化和記憶體屏障

優化屏障原語保證編譯程式不會混淆放在原語操作之前的彙編指令和放在原語操作之後的組合語言指令。

Linux 中,優化屏障是 barrier() 巨集,展開為 asm volatile("":::“memory”)。

  • volatile 禁止編譯器把 asm 指令與程式中的其他指令重新組合。
  • memory 強制編譯器假定 RAM 中的所有記憶體單元已經被彙編指令修改,因此,編譯器不能使用存放在 CPU 暫存器中的記憶體單元的值來優化 asm 指令前的程式碼。

記憶體屏障原語確保在原語執行之後的操作執行之前,原語前的操作已經完成。

自旋鎖

當核心控制路徑必須訪問共享資料結構或進入臨界區時,就需要為自己獲取一把“鎖”。

自旋鎖時用來在多處理器環境中工作的一種特殊的鎖。如果核心控制路徑發現鎖被執行在另一個 CPU 上的核心控制路徑 “鎖著”,就會在周圍”旋轉“,反覆執行一條緊湊的迴圈指令(”忙等“),直到鎖被釋放。

等待自旋鎖釋放的程序可能被更高優先順序的程序替代。

spinlock_t

  • slock,自旋鎖的狀態,1:未加鎖;<=0:加鎖。
  • break_slock,程序正在忙等自旋鎖。

具有核心搶佔的 spin_lock 巨集

  1. preempt_disable() 禁用核心搶佔。
  2. _raw_spin_trylock() 對自旋鎖的 slock 欄位執行原子性的測試和設定操作。
movb $0, %al
xchgb %al, slp->slock  // xchg 原子性地交換 8 位暫存器 %al 和 slp->slock
  1. 如果自旋鎖中的舊值是正數,巨集結束:核心控制路徑已經獲得自旋鎖。
  2. 否則,核心控制路徑無法獲得自旋鎖,必須忙等。preempt_enable() 遞減在第 1 步 遞增了的搶佔計數器。忙等期間可被其他程序搶佔。
  3. 如果 break_lock 欄位等於 0,則設定為 1。通過檢測該欄位,擁有鎖且在別的 CPU 上執行的程序就能知道是否有其他程序在等待該鎖。如果程序持有某個自旋鎖的時間過長,該程序可提前釋放鎖。
  4. 執行等待迴圈:
while(spin_is_locked(slp) && slp->break_lock)
	cpu_relax();  // 巨集 cpu_relax() 簡化為一條 pause 彙編指令
  1. 回到第 1 步,再次試圖獲取自旋鎖。

非搶佔式核心中的 spin_lock 巨集
如果在核心編譯時沒有旋轉核心搶佔選項,spin_lock 巨集本質上為:

1: lock; decb slp->slock  // decb 遞減自旋鎖的值,該指令是原子的,因為帶有 lock 字首
   jns 3f  // 檢測符號標誌,如果被清 0,說明自旋鎖被設定為 1(未鎖),從 3 處繼續執行,f:forward
2: pause   // 否則,在 2 處執行緊湊的迴圈,直到自旋鎖出現正值
   cmpb $0, slp->slock
   jle 2b
   jmp 1b  // 然後從 1 處重新執行,檢查是否其他的處理器搶佔了鎖
3: 

spin_unlock 巨集

movb $1, slp->slock

隨後呼叫 preempt_enable()(如果不支援核心搶佔,什麼也不做)。

讀/寫自旋鎖

是為了增加核心的併發能力。

  • 只有沒有核心控制路徑對資料結構修改,讀/寫自旋鎖就允許多個核心控制路徑同時讀同一個資料結構。
  • 如果一個核心控制路徑想對資料結構進行寫操作,必須首先獲得讀/寫鎖的寫鎖,寫鎖授權獨佔訪問這個資源。

讀/寫鎖是一個 rwlock_t 結構,lock 欄位是一個 32 位的欄位,分兩部分:

  • 0~23 位,計數器,對受保護的資料結構併發進行讀操作的核心控制路徑的數目。
  • 第 24 位,“未鎖”標誌欄位,當沒有核心控制路徑在讀或寫時設定該位,否則清 0。

lock 值:

  • 0x01000000:自旋鎖為空(設定了“未鎖”標誌,且無讀者)。
  • 0x00000000:寫者獲得了自旋鎖(“未鎖”標誌清 0,且無讀者)。
  • 0x00ffffff,0x00fffffe 等:一個或多個讀者獲得了自旋鎖(“未鎖”標誌清 0,讀者個數的二進位制補碼在 0~23 位上)。

rwlock_t 結構也包括 break_lock 欄位。

rwlock_init 巨集把讀/寫自旋鎖的 lock 欄位初始化為 0x01000000(“未鎖”),把 break_lock 初始化為 0。

讀自旋鎖

read_lock 巨集,作用於讀/寫自旋鎖的地址 rwlp,與 spin_lock 相似。如果編譯核心時選擇了核心搶佔選項,read_lock 與 spin_lock 只有一點不同:執行 _raw_read_trylock() ,在第 2 步獲得讀/寫自旋鎖。

int _raw_read_trylock(rwlock_t  *lock)
{
	atomic_t *count = (atomic_t *)lock->lock;
	atomic_dec(count);
	if(atomic_read(count) >= 0)
		return 1;
	atomic_inc(count);
	return 0;
}

如果編譯核心時沒有選擇核心搶佔選項,read_lock 巨集產生如下彙編程式碼

	movl $rwlp->lock, %eax
	lock; subl $1, (%eax)    // 將自旋鎖原子減 1,增加讀者個數
	jns 1f                   // 如果遞減操作結果非負,就獲得自旋鎖    
	call __read_lock_failed  // 否則,呼叫 __read_lock_failed()
1: 
// 試圖獲取自旋鎖
__read_lock_failed:
	lock; incl (%eax)   // 原子增加 lock 欄位,以取消 read_lock 巨集執行的遞減操作
1: 	pause   
	cmpl $1, (%eax)     // 迴圈,直到 lock 欄位 >= 0
	js 1b
	lock; decl (%eax)
	js __read_lock_failed
	ret

read_unlock 釋放讀自旋鎖

lock; incl rwlp->lock    // 減少讀者計數

然後呼叫 preempt_enable() 重新啟用核心搶佔。

寫自旋鎖

write_lock 巨集與 spin_lock() 和 read_lock() 相似。如果支援核心搶佔,則禁用核心搶佔並呼叫 _raw_write_trylock() 獲得鎖。

int _raw_write_trylock(rwlock_t *lock)
{
	atomic_t *count = (atomic_t *)lock->lock;
	if(atomic_sub_and_test(0x01000000, count))  // 從讀/寫自旋鎖中減去 0x01000000,清除未上鎖標誌並返回 1
		return 1;
	atomic_add(0x01000000, count);  // 原子地在自旋鎖值上增加 0x0100000,以抵消減操作。
	return 0;                       // 鎖已經被佔用,需重新啟用核心搶佔並開始忙等待
}

write_unlock 巨集,釋放寫鎖

lock; addl $0x0100000, rwlp  // 把 lock 欄位中的“未鎖”標識置位

再呼叫 preempt_enable()

順序鎖

順序鎖與讀/寫鎖相似,只是為寫者賦予了較高的優先順序:讀者讀的時候允許寫者執行。好處是寫者永遠不會等待(除非另一個寫者在寫),缺點是讀者有時需多次讀相同的資料直到獲得有效的副本。

seqlock_t 包括兩個欄位:

  • 型別為 spinlock_t 的 lock 欄位。
  • 整型的 sequence 欄位,是一個順序計數器。每個讀者都必須在讀資料前後兩次讀順序計數器,如果兩次讀到的值不同,說明新的寫者開始寫並增加了順序計數器,讀取的資料無效。

初始化:將 SEQLOCK_UNLOCKED 賦給變數 seqlock_t,或執行 seqlock_init 巨集,將 seqlock_t 初始化為”未上鎖“。

write_seqlock():寫者獲取順序鎖。獲取 seqlock_t 中的自旋鎖,然後使順序計數器加 1。

write_sequnlock():寫者釋放順序鎖。再次增加順序計數器,然後釋放自旋鎖。可保證有寫者寫時,計數器值為奇數,沒有寫者時,計數器值是偶數。

讀者執行下面臨界區程式碼:

unsigned int seq;
do
{
	seq = read_seqbegin(&seqlock);    // 返回順序鎖的當前序號。如果是奇數,或 seq 的值與順序鎖的順序計數器值不匹配,read_deqretry() 返回 1
	/* ... 臨界區 ... */
}while(read_deqretry(&seqlock, seq));

讀者進入臨界區,不必禁用核心搶佔。由於寫者獲取自旋鎖,它進入臨界區時自動禁用核心搶佔。

使用順序鎖的條件

  • 被保護的資料結構不包括被寫者修改和被讀者間接引用的指標。
  • 讀者的臨界區程式碼沒有副作用。

另外:

  • 讀者的臨界區程式碼應該簡短。
  • 寫者不應常獲取順序鎖。

典型例子:保護與系統時間處理相關的資料結構。

讀-拷貝-更新(RCU)

是為了保護在多數情況下被多個 CPU 讀的資料結構而設計的一種同步技術。

RCU 允許多個讀者和寫者併發執行,且不使用鎖,相比讀/寫自旋鎖、順序鎖有更大的優勢。

通過限制 RCP 的範圍,可不使用共享資料結構而實現多個 CPU 同步:

  • RCU 只包含被動態分配並通過引用指標引用的資料結構。
  • 在被 RCU 保護的臨界區中,任何核心控制路徑不能睡眠。

核心控制路徑讀取被 RCU 保護的資料結構流程

  1. rcu_read_lock() 等同於 preempt_disable()。
  2. 讀者間接引用該資料結構指標所對應的記憶體單元,並開始讀該資料結構。讀者在完成讀操作前,不能睡眠。
  3. rcu_read_unlock() 等同於 preempt_enable(),標記臨界區的結束。

核心控制路徑寫被 RCU 保護的資料結構

需要做一些事情防止競爭條件的出現。

  • 寫者要更新資料結構時,間接引用指標並生成整個資料結構的副本。
  • 寫者修改該副本。
  • 修改完畢,寫者改變指向資料結構的指標,使其指向被修改後的副本。修改指標的操作是一個原子操作。需要記憶體屏障保證:只有數結構被修改後,已更新的指標對其他 CPU 才是可見的。如果把自旋鎖與 RCU 結合以禁止寫者的併發執行,就隱含地引入了記憶體屏障。

使用 RCU 技術的困難:寫者修改指標時不能立即釋放資料結構的舊副本。只有 CPU 上所有的讀者都執行完巨集 rcu_read_unlock() 後,才能釋放舊副本。

核心要求每個讀者在執行以下操作前執行 rcu_read_unlock() 巨集:

  • CPU 執行程序切換。
  • CPU 開始在使用者態執行。
  • CPU 執行空迴圈。

對於以上任何情況中,認為 CPU 經過了靜止狀態。

call_rcu() 寫者釋放資料結構的舊副本。

  • 將 rcu_head 描述符的地址和將要呼叫的回撥函式地址作為引數。
  • 把回撥函式和其引數的地址存放在 rcu_head 描述符中,然後把描述符插入回撥函式的每 CPU 連結串列中,核心每經過一個時鐘滴答檢查本地 CPU 是否經歷了一個靜止狀態。
  • 如果所有 CPU 都經歷了靜止狀態,本地 tasklet 執行連結串列中的所有回撥函式。

訊號量

實現了一個加鎖原語,即讓等待者睡眠,直到等待的資源變為空閒。

Linux 提供兩種訊號量:

  • 核心訊號量,由核心控制路徑使用。
  • System V IPC 訊號量,由使用者態程序使用。

核心控制路徑試圖獲取核心訊號量保護的資源時,會被掛起,直到資源被釋放。因此,只有可睡眠的函式才能獲取核心訊號量,中斷處理程式和可延遲函式不能使用核心訊號量。

核心訊號量資料結構:struct semaphore,包含的欄位:

  • count,存放 atomic_t 型別的值。> 0,資源空閒;= 0,訊號量忙,但沒有程序等待;< 0,資源不可用,至少一個程序等待資源。
  • wait,存放等待佇列連結串列的地址。
  • sleepers,標誌,表示是否有一些程序在訊號量上睡眠。

初始化訊號量的幾種情形

  • init_MUTEX() 將 count 欄位設定為 1(資源空閒)
  • init_MUTEX_LOCKED() 將 count 欄位設定為 0。
  • 巨集 DECLARE_MUTEX 和 DECLARE_MUTEX_LOCKED 完成同樣的功能,也靜態分配 semaphore 結構的變數。
  • 將 count 初始化為任意的正整數 n 時,最多有 n 個程序可以併發地訪問該資源。

釋放訊號量

釋放核心訊號量時,呼叫 up() 函式,等價於:

	movl $sem->count, %ecx  
	lock; incl (%ecx)   // 增加 *sem 訊號量 count 欄位的值
	jg 1f  // 如果大於 0,說明沒有程序在等待佇列上睡眠,什麼也不做,否則,呼叫 __up() 喚醒睡眠的程序
	lea %ecx, %eax
	pushl %edx
	pushl %ecx
	call __up   // 從 eax 暫存器接收引數
	popl %ecx
	popl %edx
1: 
__attribute_((regparm(3))) void __up(struct semaphore *sem)
{
	wake_up(&sem->wait);
}

獲取訊號量

呼叫 down() 函式,等價於:

down:
	movl $sem->count, %ecx
	lock; decl (%ecx);  // 減少 *sem 訊號量的 count 值
	jns 1f              // 如果 count >= 0,當前程序獲得資源並繼續正常執行
	lea %ecx, %eax      // 否則,當前程序必須掛起,將一些暫存器內容壓棧後,呼叫 __down()
	pushl %edx
	pushl %ecx
	call __down
	popl %ecx
	popl %edx
1:
// 掛起當前程序,直到訊號量被釋放
__attribute__((regparam(3))) void __down(struct semaphore *sem)
{
	DECLARE_WAITQUEUE(wait, current);
	unsigned long flags;
	current->state = TASK_UNINTERRUPTIBLE;  // 將當前程序的狀態從 TASK_RUNNGING 變為 TASK_UNINTERRUPTIBLE
	spin_lock_irqsave(&sem->wait.lock, flags);   // 在訪問訊號量資料結構的欄位前,獲得保護訊號量等待佇列自旋鎖 sem->wait.lock,並禁止本地中斷
	add_wait_queue_exclusive_locked(&sem->wait, &wait);  // 將程序放入訊號量的等待佇列
	sem->sleepers++;
	for(;;)
	{
		if(!atomic_add_negative(sem->sleepers-1, &sem->count))  // count -= 1
		{
			sem->sleepers = 0;   // 如果 count 欄位 >= 0,將 sleepers 置 0,表示沒有程序在訊號量等待佇列上睡眠
			break;
		}

	    // 如果 count < 0
		sem->seleepers = 1; 
		spin_unlock_irqrestore(&sem->wait.lock, flags);
		schedule();               // 呼叫 schedule() 掛起當前程序
		spin_lock_irqsave(&sem->wait.lock, flags);
		current->state = TASK_UNINTERRUPTTIBLE;
	}
	remove_wait_queue_locked(&sem->wait, &wait);
	wake_up_locked(&sem->wait);   // 試圖喚醒訊號量等待佇列中的另一個程序,並終止保持的訊號量
	spin_unlock_irqrestore(&sem->wait.lock, flags);
	current->state = TASK_RUNNTING;
}

down_trylock() 函式在資源繁忙時,立即返回,而不是讓程序睡眠。

down_interruptible() 廣泛應用於裝置驅動程式中,如果睡眠的程序在獲得需要的資源前被一個訊號喚醒,該函式會增加訊號量的 count 欄位的置並返回 -EINTR,可放棄 I/O 操作。如果正常獲得需要的資源,返回 0。

讀/寫訊號量

類似於讀/寫自旋鎖,不同之處:訊號零再次變為開啟之前,等待程序掛起而不是自旋轉。

只有在核心控制路徑不持有讀訊號量和寫訊號量時,才能獲取寫訊號量。

資料結構為 rw_semaphore

  • count,兩個 16 位計數器。高 16 位以二進位制補碼形式存放非等待寫者程序的總數(0 或 1)和等待的寫核心控制路徑數。低 16 位存放非等待的讀者和寫者總數。
  • wait_list,等待程序的連結串列。連結串列中的每個元素是 rwsem_waiter 結構,包含一個指標和一個標誌,指標指向睡眠程序的描述符,標誌表示程序為讀訊號量還是寫訊號量。
  • wait_lock,自旋鎖,保護等待佇列連結串列和 rw_semaphore 結構。

函式

  • init_rwsem() 初始化 rw_semaphore 結構,把 count 置為 0,wait_lock 置為未鎖,wait_list 置為空連結串列。
  • dwon_read()、down_write() 獲取讀或寫訊號量。
  • up_read()、up_write() 釋放讀或寫訊號量。
  • down_read_trylock()、down_write_trylock() 類似於 down_read()、down_write(),但在訊號量忙的情況下,不阻塞程序。
  • downgrade_write() 自動將寫鎖轉換為讀鎖。

補充原語

為了解決多處理器系統上發生的一種微妙的競爭關係,當程序 A 分配了一個臨時訊號變數,並將其初始化為關閉的 MUTEX,然後將其地址傳遞給程序 B。A 呼叫 down(),打算一旦被喚醒舊撤銷該訊號量,而執行在不同 CPU 上的 B 在該訊號量上呼叫 up(),結果,up() 可能訪問一個不存在的資料結構。

上述現象的原因:up() 和 down() 可在同一訊號量上併發執行。

struct completion 
{
	unsigned int done;   
	wait_queue_head_t wait;
};-

up() 對應 complete()

  • 將 completion 的地址作為引數。
  • 在補充等待佇列的自旋鎖上呼叫 spin_lock_irqsave() 遞增 done 欄位,喚醒在 wait 等待佇列上睡眠的互斥程序.
  • 呼叫 spin_unlock_irqrestore()。

down() 對應 wait_for_completion()

  • 將 completion 的地址作為引數,如果 done > 0,說明 complete() 已在另一個 CPU 上執行,終止。
  • 把 current 作為一個互斥程序加到等待佇列的末尾,將 current 置為 TASK_UNINTERRUPTIBLE 狀態並讓其睡眠。
  • 一旦 current 被喚醒,將其從等待佇列中刪除,如果 done = 0,結束;否則,再次掛起 current。

補充原語和訊號量之間的真正差別:如果使用等待佇列中包含的自旋鎖。

  • 補充原語中,自旋鎖確保 complete() 和 wait_for_completion() 不會併發執行。
  • 訊號量中,自旋鎖用於避免併發執行的 down() 函式弄亂訊號量的資料結構。

禁止本地中斷

當硬體裝置產生了一個 IRQ 訊號,中斷禁止也讓核心控制路徑繼續執行,確保中斷處理程式訪問的資料結構受到保護。然而,禁止本地中斷不保護執行在另一個 CPU 上的中斷處理程式對資料結構的併發訪問,因此需要與自旋鎖結合使用。

local_irq_disable() 使用 cli 彙編指令關閉本地 CPU 上的中斷。cli 清除 eflags 控制暫存器上的 IF 標誌。
local_irq_enable() 使用 sti 彙編指令開啟被關閉的中斷。sti 設定 eflags 控制暫存器上的 IF 標誌。

中斷可以以巢狀方式執行,因此核心在臨界區末尾不能簡單設定 IF 標誌。控制路徑必須儲存先前賦給 IF 標誌的置,並在執行結束時恢復它。

local_irq_save 巨集將 eflags 的內容儲存到一個區域性變數中,然後用 cli 彙編指令將 IF 標誌清 0。
local_irq_restore 巨集在臨界區末尾恢復 eflags 的內容。

禁止和啟用可延遲函式

禁止可延遲函式在一個 CPU 上執行的一種簡單方式是禁止在那個 CPU 上的中斷,使得軟中斷不能非同步開始。

另一種方式是禁止可延遲函式而不禁止中斷,通過操作當前 thread_info 描述符 preempt_count 欄位中存放的軟中斷計數器即可。

如果軟中斷計數器是正數,do_softirq() 函式就不會執行。tasklet 會在軟中斷之前被執行,並將該計數器設定為大於 0 的值。

local_bh_disable 巨集給本地 CPU 的軟中斷計數器加 1。

local_bh_enable() 函式從本地 CPU 的軟中斷計數器中減 1。

  • 如果本地 CPU 的 preempt_count 欄位中硬中斷計數器和軟中斷計數器的值都等於 0,且有掛起的軟終端,就呼叫 do_softirq()。
  • 如果本地 CPU 的 TIF_NEED_RESCHED 標誌被設定,說明程序切換請求時掛起的,呼叫 preempt_schedule()。

對核心資料結構的同步訪問

在自旋鎖、訊號量及中斷禁止之間選擇

只要核心控制路徑獲得自旋鎖(還有讀/寫鎖、順序鎖或 RCU“讀鎖”),就禁用本地中斷或本地軟中斷,自動禁用核心搶佔。

保護異常所訪問的資料結構

最常見的產生同步問題的異常是系統呼叫服務例程,僅由異常訪問的資料結構通常表示一種資源,競爭條件可通過訊號量避免。

保護中斷所訪問的資料結構

如果一個數據結構僅被中斷處理程式的“上半部分”訪問,無需任何同步原語,因為中斷處理程式本身不能同時多次執行。

但是,如果多箇中斷處理程式訪問一個數據結構時,情況有所不同:

  • 單處理器系統中,必須在中斷處理程式的所有臨界區上禁止中斷來避免競爭條件,其他同步原語都不行。因為訊號量能阻塞程序,自旋鎖可能使系統凍結。
  • 多處理器系統中,避免競爭條件最簡單的方法時禁止本地中斷,並獲取保護資料結構的自旋鎖或讀/寫自旋鎖。

保護被可延遲函式所訪問的資料結構

單處理器系統上不存在競爭條件,因為可延遲函式的執行總是在一個 CPU 上序列執行,不需要同步原語。

多處理器系統上存在競爭條件,因為幾個可延遲函式可以併發執行。

  • 由軟中斷訪問的資料結構必須收到保護,通常使用自旋鎖,因為同一個軟中斷可在多個 CPU 上併發執行。
  • 僅由一種 tasklet 訪問的資料結構不需要保護,因為同種 tasklet 不能併發執行。
  • 由幾種 tasklet 訪問,必須對資料結構進行保護。

保護由異常和中斷訪問的資料結構

單處理系統上,

  • 以本地中斷禁止訪問資料結構。
  • 如果資料結構被一種中斷處理程式訪問,中斷處理程式不用禁止本地中斷就可訪問資料結構。

多處理器系統上,

  • 本地中斷禁止必須外加自旋鎖,強制併發的核心控制路徑等待。

有時使用訊號量代替自旋鎖可能更好。

  • 因為中斷處理程式不能被掛起,必須用緊迴圈和 down_trylock() 函式獲得訊號量,在這裡,訊號量的作用與自旋鎖一樣。
  • 系統呼叫服務例程可在訊號量忙時掛起呼叫程序,提高系統併發度。

保護由異常和可延遲函式訪問的資料結構

與異常和中斷處理程式訪問的資料結構處理方式類似。可延遲函式本質上是由中斷的出現啟用的,而可延遲函式執行時不可能產生異常。因此,把本地中斷禁止與自旋鎖結合起來即可。

異常處理程式可用過使用 local_bh_disable() 巨集禁止可延遲函式,而不禁止本地中斷。在每 CPU 上可延遲函式的執行都被序列化,不存在競爭條件。

多處理器系統上,使用自旋鎖可確保任何時候只有一個核心控制路徑訪問資料結構。

保護由中斷和可延遲函式訪問的資料結構

類似於中斷和異常訪問的資料結構。可延遲函式執行期間禁用本地中斷。沒有其他的中斷處理程式訪問資料結構時,中斷處理程式可隨意訪問被可延遲函式訪問的資料結構而不用關中斷。

多處理器系統上,需要自旋鎖禁止對多個 CPU 上資料結構的併發訪問。

保護由異常、中斷和可延遲函式訪問的資料結構

禁止本地中斷和獲取自旋鎖幾乎總是避免競爭條件所必須的,但沒有必要顯式禁止可延遲函式。

避免競爭條件的例項

引用計數器

是一個 atomic_t 計數器,與特定的資源,如記憶體頁、模組或檔案相關。

  • 核心控制路徑開始使用資源,原子減少計數器值。
  • 核心控制路徑用完資源,原子增加計數器值。
  • 原子計數器變為 0,說明資源未被使用,如果必要,釋放該資源。

大核心鎖

粗粒度的自旋鎖,確保每次只有一個程序執行在核心態。

用叫 kernel_sem 的訊號量實現大核心鎖,但比訊號量複雜。

每個程序描述符含有 lock_depth 欄位,允許同一個程序幾次獲得大核心鎖。

  • -1,程序未獲得過鎖。
  • 正數,表示請求了多少次鎖。

lock_kernel() 獲得大核心鎖:

depth = current->lock_depth + 1;
if(depth == 0)
	down(&kernel_sem);
current->lock_depth = depth;

unlock_kernel() 釋放大核心鎖:

if(--current->lock_depth < 0)
	up(&kernel_sem);

持有大核心鎖的程序可呼叫 schedule() 放棄 CPU。

當一個持有大核心鎖的程序被強佔時,schedule() 一定不能釋放訊號量,因為在臨界區內執行程式碼的程序沒有主動觸發程序切換。

為避免被強佔的程序事情大核心鎖,preempt_schedule_irq() 臨時把程序的 lock_depth 欄位設定為 -1,這樣 schedule() 假定被替換的程序不擁有 kernel_sem 訊號量,也就不能釋放它。一旦該程序再次被排程程式選中,preempt_schedule_irq() 函式就恢復 lock_depth 原來的值。

記憶體描述符讀/寫訊號量

mm_struct 型別的記憶體描述符的 mmap_sem 欄位為訊號量。因為幾個輕量級程序之間可以共享一個記憶體描述符,因此訊號量可保護該描述符,以避免可能產生的競爭條件。

這種訊號量為以讀/寫訊號量方式實現,因為一些核心函式,如缺頁異常處理程式只需要掃描記憶體描述符。

slab 快取記憶體連結串列的訊號量

slab 快取記憶體描述符連結串列是通過 cache_chain_sem 訊號量保護的,允許互斥地訪問和修改該連結串列。

索引節點的訊號量

Linux 把磁碟檔案的資訊存放在一種叫做索引節點的記憶體物件中。相應的資料結構包括自己的訊號量,存放在 i_sem 欄位中。

rename() 涉及兩個不同的索引節點,必須採用兩個訊號量。為避免死鎖,訊號量的請求按預先確定的地址順序進行。