Featured image of post 關於 Mutex、Semaphore 與 Spinlock

關於 Mutex、Semaphore 與 Spinlock

關於 Mutex、Semaphore 與 Spinlock

起源

今年年初在準備一些嵌入式系統相關的面試時,又遇到了 MutexSemaphoreSpinlock 這三個東西,其實之前在面一些大廠時就有被問過,像是某薯條公司或是某水果公司,但當時沒什麼準備,所以答得也不是很好,所以趁著準備面試時再來複習一下,有修過作業系統的人對這三個名詞應該不陌生,只要碰到 RTOS、Linux Kernel 或是多執行緒程式之後多少都會遇到

其實如果沒有特別去記,大多人的印象都是:

  • 「Mutex 不就是鎖嗎?」
  • 「Semaphore 好像也是鎖?」
  • 「那 Spinlock 又是什麼鬼,為什麼已經有 Mutex 還要再多一個 Spinlock?」

而且三個東西印象中都像是在做同一件事情:不要讓大家同時碰同一個東西,所以這篇一樣想用一個超級白話的方式把這三個東西整理起來,以免哪天又忘記 Mutex、Semaphore 跟 Spinlock 到底差在哪裡,就可以快速回來複習一下

所以為什麼會需要這些東西?

我們先不要管 Mutex、Semaphore 跟 Spinlock 這些東西各自在幹嘛,先設想一個情境好了,如果今天寫的是一個超級簡單的 Bare Metal Firmware:

1
2
3
4
5
6
7
main()
{
    讀 Sensor
    控制 Motor
    更新 LED
    ...
}

CPU 就這樣一件事情一件事情照順序做下去對吧,其實很多時候根本不會需要到什麼 Mutex 或 Semaphore,這就是我們在 MCU 上最常見的寫法,也不太會有什麼問題,因為同一個時間基本上就只有現在這段 Code 在執行,可以自由使用系統裡的資源

但如果我們今天需要系統有多個執行緒分別去跑不同的邏輯,開始使用 RTOS,例如 Zephyr、FreeRTOS、ThreadX,那情況就不一樣了

我們假設系統裡面有三個 Thread:

1
2
3
Sensor Thread
Motor Thread
UI Thread

通常 MCU 只有一個 CPU Core,而 RTOS Scheduler 會在不同的 Thread 之間快速切換,例如說系統裡可能同時有用來收集 Sensor 資料的 Sensor Thread、用來控制馬達狀態的 Motor Thread 跟用來顯示裝置狀態的 UI Thread,Scheduler 會讓它們輪流使用 CPU,雖然這些 Thread 並不是真的同時執行,但只要其中一個 Thread 做到一半時切換到另一個 Thread,另一個 Thread 就有機會碰到還在被前一個 Thread 使用的資源,在 Single Core 上,前一個 Thread 雖然已經暫停執行,但資源可能仍處於使用中的狀態,而在 Multicore 上則真的可能發生同一個資源被兩個跑在不同 Core 上的 Thread 同時存取,所以問題就來了:如果兩個 Thread 同時想要操作同一個東西怎麼辦?

例如 Motor Thread 跟 UI Thread 可能同時修改 Motor 的控制狀態,或是 Sensor Thread 跟其他 Thread 同時存取某一塊 Shared Memory,這時候如果沒有適當的保護就會出問題

我們先從一個最簡單的狀況開始,假設有一個很單純的變數:

1
counter++;

看起來只是一行,但 CPU 實際上通常需要先讀取 counter、加一,再把結果寫回 Memory

如果 Thread A 跟 Thread B 都在差不多的時間讀到 counter = 10,它們最後就可能各自寫回 11,明明執行了兩次 counter++,結果卻只增加一次,這個大家在作業系統課程裡應該都有學過,這就是很典型的 Race Condition,而 Mutex、Semaphore、Spinlock 這些東西,本質上就是為了解決這類 Concurrency / Synchronization 問題而存在的

嚴格來說,並不是說「Bare Metal 就絕對不會有這種 Race Condition」,實際上只要有 Interrupt、Multicore 或其他非同步的 Execution Context,其實 Bare Metal 一樣可能碰到 Synchronization 問題,只是說一般在嵌入式韌體開發中,這些搶佔情形比較常出現在開始使用 RTOS、Linux 或其他有 Thread / Scheduler 的系統之後,即使只有單核心 CPU,只要系統存在 Preemption,一個 Thread 在 Critical Section 中被另一個 Thread 搶走 CPU,一樣可能產生 Race Condition,Linux Kernel 的文件也特別指出,即使只有一顆 CPU,Preemption 仍然可以造成類似的 Race Condition(Linux Kernel Documentation — Locking

所以回到 Mutex、Semaphore 跟 Spinlock,這些可以理解為是作業系統為了管理這種搶佔狀況而提供的同步機制,也就是說如果我們今天只是用 Bare Metal,那通常就沒有 RTOS 直接提供的現成 Lock 可以用啦,如果需要就要自己在系統裡去刻出來了

OK,所以知道問題從哪裡來之後,接下來就看看這三種東西到底分別在幹嘛,以及想解決什麼問題吧


Mutex:這個東西現在是我的,你們先不要碰

先從最好理解的 Mutex 開始,Mutex 全名是 Mutual Exclusion,從名字就差不多知道它想做什麼:

同一個時間,只允許一個 Thread 進來

假設 Motor Thread 跟 UI Thread 都可能修改同一顆 Motor 的狀態,我們不希望其中一邊正準備把轉速設成 1000 RPM,另一邊突然把它改成 200 RPM,兩個 Thread 一起修改 Driver State 或 Hardware Register,最後控制結果很容易亂掉而且出現不可預測的隨機狀態,很可怕,這時候 Mutex 就上場了

Thread 在操作 Motor 之前要先取得 Mutex,拿到之後,其他 Thread 就不能進入同一段程式,這段只有 Lock 持有者能執行的區域,就是作業系統課程裡常聽到的 Critical Section

1
2
3
4
5
6
mutex_lock(&motor_mutex);

motor_set_speed(1000);
motor_set_direction(FORWARD);

mutex_unlock(&motor_mutex);

等 Thread A 操作完 Motor 並 Unlock 之後,Thread B 才能取得 Mutex 繼續執行,這樣兩個 Thread 就不會同時碰同一個硬體資源了,Zephyr 對 Mutex 的建議用途也是讓多個 Thread 互斥地存取同一個資源,像是保護一個 Physical Device(Zephyr Project Documentation — Mutexes

當然,Mutex 保護的不一定是硬體,也可以是 Shared Memory、Linked List、File、I2C Bus、SPI Bus、UART 或 Driver State,總之呢,只要某個東西同一時間只能讓一個 Thread 操作,第一個可以想到的通常就是 Mutex

這時候一定有人會好奇說,那如果 Mutex 已經被別人拿走,系統又是怎麼調度 Thread B 呢?

其實做法很直接,RTOS 會把 Thread B 從 Running State 移到 Blocked State,接著讓 Scheduler 從 Ready Queue 裡挑選其他可以執行的 Thread,例如 Sensor Thread,這段期間 Thread B 不會一直檢查 Mutex,也不會繼續消耗 CPU

等 Thread A 呼叫 mutex_unlock(),RTOS 再把 Thread B 移回 Ready State,如果 Thread B 的 Priority 比目前執行中的 Thread 高,就可能直接搶占 CPU,否則就留在 Ready Queue 等待下一次調度,所以這也是 Mutex 跟後面 Spinlock 很重要的一個差別:Mutex 等不到的話,可以睡

一個實際的例子是 Linux Kernel 裡的 Mutex 就屬於 Sleeping Lock,拿不到 Mutex 時,Task 可以 Suspend,讓 CPU 去執行其他工作(Linux Kernel Documentation — Locking


Semaphore:我不是在鎖東西,我是在數「還有幾個」

接下來來說說 Semaphore,中文通常翻作「信號量」,這個名字第一次看到其實不太容易懂,其實可以把它想成一個裝著 Token 的盒子,假設盒子裡有三個 Token,Semaphore Count 就是 3,每當 Thread Take() 一個 Token,Count 就減一;有人 Give() 一個,而且 Count 還沒有到上限時,Count 就加一,所以 Semaphore 最核心的東西其實就是:一個 CounterZephyr Project Documentation — Semaphores

如果 Count 一開始是 3,Thread A、B、C 各自拿走一個之後,Count 就會變成 0,這時 Thread D 再呼叫 Take(),就只能等到有人 Give() 一個回來,這種可以記錄多個 Token 的 Semaphore,就叫做 Counting Semaphore,如果 Count 只有 01,則稱作 Binary Semaphore

看到這邊可能會冒出一個問題:「等等,Binary Semaphore 最大就是 1,那不就跟 Mutex 一樣嗎?」

沒錯,表面看起來真的很像,但兩者想表達的概念不太一樣:

  • Mutex 在意的是 Ownership:這個 Resource 現在是誰的?
  • Semaphore 在意的是 Count / Signal:現在有幾個 Resource,或者發生了幾次 Event?

Mutex 通常有明確的 Owner,也就是哪一個 Thread Lock,就應該由那一個 Thread Unlock,而 Semaphore 則不一定有這種 Ownership 關係,以 FreeRTOS 為例,Mutex 額外具有 Priority Inheritance,而 Binary Semaphore 沒有,因此官方也建議 Resource Mutual Exclusion 與 Synchronization 分別使用不同的 Primitive(FreeRTOS Documentation — Mutexes

這個差別看起來還是有點抽象,所以我們直接來看一個嵌入式很常見的例子吧

假設今天有一顆 Sensor 不斷取樣,並且把資料暫存在自己的 FIFO,當 FIFO 裡的資料到達某個 Threshold,Sensor 就拉起 Interrupt,通知 MCU:「我這邊累積了一些資料,記得來讀取喔」

MCU 進入 ISR 之後,當然可以直接讀 Sensor Register、搬出 FIFO 資料,再做 Filter 與後續運算,但通常不會想這樣做,因為 ISR 最好越短越好,比較常見的做法,是讓 ISR 只負責 Give Semaphore,再由 Sensor Thread 執行真正耗時間的工作:

1
Sensor Interrupt -> ISR -> Give Semaphore -> Sensor Thread -> Read FIFO

概念上,ISR 只做很少的事情,下面是方便理解的通用寫法,實際使用時仍要換成該 RTOS 允許在 ISR 中呼叫的 API:

1
2
3
4
void sensor_isr(void)
{
    semaphore_give(&sensor_sem);
}

平常 Sensor Thread 則停在 semaphore_take() 等待通知:

1
2
3
4
5
6
7
while (1)
{
    semaphore_take(&sensor_sem);

    read_sensor_fifo();
    process_sensor_data();
}

沒有 Interrupt 時,Semaphore Count 是 0,Sensor Thread 會保持在 Blocked State,不需要一直輪詢 Sensor 有沒有資料,當 Interrupt 發生後,ISR 將 Count 從 0 加到 1,等待中的 Sensor Thread 就會被喚醒;它 Take 掉這個 Token,再去讀取 FIFO

這樣分工就很清楚:ISR 負責通知,Thread 負責真正做事情

Zephyr 官方文件也把這列為 ISR Offload Work 的典型方法:ISR 可以透過 Semaphore 等 Kernel Object 去 Signal 一個 Helper Thread,把比較耗時間的工作留到 Thread Context 執行(Zephyr Project Documentation — Interrupts

所以從這個例子就很容易看出 Mutex 與 Semaphore 的語意差別:

  • Mutex 比較像:「廁所有人了,你先不要進來」
  • Semaphore 比較像:「現在有一件事情要處理」
  • Counting Semaphore 則可以表示:「現在累積有三件事情要處理」

這邊還有個細節是,如果使用 Binary Semaphore,它的 Count 只會是 01,假設 Sensor Thread 還沒開始處理,Sensor 又連續觸發了三次 Interrupt,最後的 Count 可能仍然只有 1,也就是說,Binary Semaphore 比較接近「有事情發生了」,而不是精確記錄「事情發生了三次」

如果你的系統需要每一次 Event 都不能遺失,就要考慮使用 Counting Semaphore 並設定足夠的 Count 上限,甚至改用 Queue,Semaphore 可以代表可用的 Resource 數量,也可以代表等待處理的 Event 數量,會比 Mutex 更泛用


Spinlock:反正應該馬上就好了,我站在這邊等

最後來看看 Spinlock 吧,前面說過,Thread 拿不到 Mutex 時可以進入 Blocked State,讓 RTOS 的調度策略去把 CPU 交給其他 Thread,這聽起來很合理對吧,但 Context Switch 其實不是免費的

系統需要先保存目前 Thread 的 Register、Program Counter、Stack Pointer 等 Execution Context,再載入另一個 Thread 的 Context,這些動作當然都有成本,假設 CPU 1 上的 Thread B 正拿著 Lock,但它只剩下 shared_data++ 這種非常短的操作,可能再幾個 CPU Cycle 就會釋放,這時如果 CPU 0 上的 Thread A 立刻 Block、儲存 Context、重新排程,忙了一圈之後,Thread B 搞不好早就把 Lock 放掉了

所以另外一個想法就出現了:

既然我猜它馬上就會用完,那我乾脆站在這裡等

這就是 Spinlock,概念上它會不斷檢查 Lock 是否已經被釋放,也就是直接佔住 CPU 做 Busy Waiting:

1
2
3
4
while (lock_is_taken)
{
    // Busy waiting
}

等待期間 Thread 不會進入 Sleep State,它所在的 CPU Core 也不會去執行其他 Thread,而是一直嘗試取得 Lock,它一直在原地「轉」,所以才叫 Spin Lock

Linux Kernel 對 Spinlock 的描述也是這個概念:拿不到 Spinlock 就持續嘗試;相較之下,拿不到 Mutex 則可以 Suspend,讓 CPU 去做其他事情(Linux Kernel Documentation — Locking

那 Spinlock 不是超級浪費 CPU 嗎?沒錯,所以 Spinlock 有一個非常重要的前提:Critical Section 必須非常短,否則就會讓等待這把 Lock 的 CPU Core 白白浪費在那邊

例如下面這種很快就結束的操作,才適合使用 Spinlock:

1
2
3
4
5
6
spin_lock();

shared_state++;
update_pointer();

spin_unlock();

如果持有 Spinlock 時去 Sleep、執行很慢的 I/O,或等待 Hardware,其他正在等待同一把 Lock 的 CPU Core 就只能一直空轉,除了浪費 CPU,在許多 Kernel Context 中,持有 Spinlock 時 Sleep 本身就是不允許的

所以可以很粗略地記成:

  • 等待時間可能比較長,而且允許 Sleep:考慮 Mutex
  • Critical Section 非常短,而且不能或不適合 Sleep:考慮 Spinlock

這就是 Spinlock 最基本的 Trade-off:拿 CPU Time 換掉 Context Switch 與 Sleep / Wakeup 的 Overhead

看到這邊通常就會覺得,哦,所以 Spinlock 是只會在多核心系統上出現對吧

當然 Spinlock 在多核心系統上特別好理解,假設 CPU 1 正在修改 Shared Data,而 CPU 0 也想修改,CPU 0 即使原地 Spin,也不會妨礙 CPU 1 繼續完成手上的 Critical Section;只要 CPU 1 很快 Unlock,CPU 0 就能接著取得 Lock

不過這邊有個很容易誤會的地方:Spinlock 並不是只有 Multicore 才有意義

Linux Kernel 裡還有 Thread Context、SoftIRQ、Hard IRQ 等不同的 Execution Context,其中有些 Context 根本不能 Sleep,如果 Interrupt Handler 拿不到 Lock,我們不能叫它先睡一下,因此 Mutex 這種 Sleeping Lock 就不適合,反而需要 Spinlock 或其他不能 Sleep 的 Synchronization Primitive,Linux Kernel 也因此提供了 spin_lock_irq()spin_lock_irqsave() 等不同形式(Linux Kernel Documentation — Locking

所以更完整地說,Spinlock 常見的使用條件是:Critical Section 很短,而且目前的 Execution Context 不適合或不能 Sleep,Multicore 只是這個需求最直覺的一種情境,另外,在某些單核心 Linux Kernel Configuration 中,Spinlock 甚至不一定需要真的 Spin,例如系統沒有 SMP,也沒有 Preemption 時,就不存在另一個同時執行的 Task 可以搶走這個 Critical Section;Linux Kernel 會依 Configuration 對 Lock 做不同處理(Linux Kernel Documentation — Locking

補充一下,Linux 的 PREEMPT_RT 會改變 spinlock_t 的實作語意,真正會在所有 Kernel Configuration 中維持 Spinning Lock 語意的是 raw_spinlock_t,這篇先用一般 Spinlock 的概念理解就好(Linux Kernel Documentation — Lock Types


所以 Mutex、Semaphore、Spinlock 到底差在哪?

前面講了一大堆,最後其實可以用三句話快速記:

  • Mutex:「這個東西現在是我的,你們先不要碰」主要用來保護 Motor、I2C Bus、Shared Data、Driver State 或 File 等 Shared Resource,而拿不到時通常可以 Block / Sleep
  • Semaphore:「現在有幾個 Event 要處理?」主要用來做 Synchronization、Signaling 或 Resource Counting,而且不一定有 Owner
  • Spinlock:「它應該馬上就好了,我站在這邊等」主要用來保護非常短的 Critical Section;拿不到時不會 Sleep,而是持續佔用所在的 CPU Core 等待

當然現實世界沒有這麼絕對,不同作業系統對搶佔的實作機制也可能有差異,調度策略可能也不太一樣,但如果只是想快速判斷,可以先從下面三個方向想:

  • 我要保護一個 Resource 嗎?先想到 Mutex
  • 我要通知另一個 Thread「有事情發生」嗎?先想到 Semaphore
  • 我要保護一個非常短的 Critical Section,而且這裡不能 Sleep 嗎?考慮 Spinlock
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
                    要解決什麼問題?
                          |
          +---------------+---------------+
          |                               |
    保護 Shared Resource             Event / Resource Count
          |                               |
          v                               v
        Mutex                         Semaphore
          |
          |
   拿不到 Lock 怎麼辦?
          |
    +-----+------+
    |            |
可以 Sleep     不能/不想 Sleep
    |            |
    v            v
 Mutex        Spinlock

結語

總之大概就是這樣了,Mutex、Semaphore 跟 Spinlock 第一次看的時候,很容易覺得三個東西長得差不多,反正都在避免多個 Execution Context 互相干擾,但真正去看它們想解決的問題,思考當初發明這些東西的人的核心思想,就會覺得還真是精妙啊,各自對應不同 Thread 之間的互動情境

Mutex 比較偏向 Ownership / Mutual Exclusion 強調資源的唯一性,Semaphore 比較偏向 Count / Signaling 更適合當有事件發生時的提醒信號,Spinlock 則是在 Mutual Exclusion 的問題上選擇 Busy Waiting,用 CPU Time 換掉 Context Switch 可能造成的額外成本

這些經典機制會被廣泛運用在不同的作業系統中,而它們背後其實都在處理不同的問題,理解這些機制以及它們背後想解決的問題,也能讓我們在設計系統時靈活運用這些作業系統提供的功能

希望這篇對大家有幫助啦


Reference

Hugo Shih World
使用 Hugo 建立
主題 StackJimmy 設計