為什麼同步難#
核心是個極度多線的環境:多個 CPU 同時執行核心程式碼、可能被中斷打斷、可能在 spinlock 內被 preempt。所以共享資料的存取必須謹慎。本章涵蓋核心提供的全部主流同步原語,並回答「什麼時候用哪個」。
三種上下文#
選同步原語前先搞清楚當前在哪個上下文:
| 上下文 | 可以睡眠? | 觸發點 |
|---|---|---|
| Process context | 可 | syscall handler、kthread |
| Atomic context(持 spinlock) | 不可 | spinlock 內、preempt 關閉 |
| Hard interrupt | 不可 | IRQ handler |
| Soft interrupt / tasklet | 不可 | softirq、tasklet、bh |
「不可睡眠」的真正含義:不能呼叫任何可能 schedule 的函式(mutex_lock、kmalloc(GFP_KERNEL)、copy_from_user)。違反會引發 “scheduling while atomic” 警告。
atomic 操作#
最低層的同步是不可分割的單一操作:
atomic_t counter = ATOMIC_INIT(0);
atomic_inc(&counter); // ++
atomic_dec(&counter); // --
atomic_add(5, &counter);
int v = atomic_read(&counter);
atomic_set(&counter, 42);
// 條件操作
if (atomic_dec_and_test(&counter)) { // 減 1 後變 0 才回 true
// 引用計數歸零,可釋放
}
// 64 位
atomic64_t big = ATOMIC64_INIT(0);
// CAS
int old = atomic_cmpxchg(&counter, expected, new_val);這些對應 CPU 的 atomic instruction(x86 lock 前綴、ARM LDXR/STXR)。比 spinlock 快得多,但只能做單一操作 ── 多步驟邏輯仍需鎖。
Memory barrier ── 沒它你以為的順序不存在#
現代 CPU 與編譯器都會重排記憶體存取以提升效能。多核共享資料時,這會破壞同步邏輯。
// CPU 0
data = 42;
ready = 1; // 可能被 reorder 到 data = 42 之前!
// CPU 1
if (ready)
use(data); // 可能讀到尚未寫入的 data
barrier API:
smp_mb(); // 完整:之前的 load/store 都先完成
smp_rmb(); // read barrier
smp_wmb(); // write barrier
smp_load_acquire(&p);
smp_store_release(&p, v);
barrier(); // 純編譯器 barrier,不影響 CPU
實際使用幾乎都包在更高層原語裡(spinlock、RCU、atomic_*_release/_acquire 變體)── 直接寫 barrier 是高手活,多數場景不需要。
不要假設「x86 是強順序 model 所以可以省略」── 即使 x86 也允許 store-load reorder。寫可移植的核心程式碼,必須遵守 memory model 的明確要求。
spinlock:忙等的鎖#
spinlock_t lock;
spin_lock_init(&lock);
spin_lock(&lock);
/* critical section */
spin_unlock(&lock);特性:
- 忙等:拿不到就一直 retry,不會 schedule
- 不睡眠:可以在 atomic context 用
- 持鎖時關 preempt:避免被 preempt 出去後其他人空轉等
- 持鎖時間應極短:因為其他 CPU 在燒 CPU cycle 等
如果共享資料也會被中斷處理碰到,需要「中斷下的 spinlock」變體:
unsigned long flags;
spin_lock_irqsave(&lock, flags);
/* critical section */
spin_unlock_irqrestore(&lock, flags);_irqsave 額外關閉本 CPU 的中斷 ── 否則拿到鎖之後被中斷打斷、中斷處理也想拿同一把鎖 ➡️ 死鎖。
幾種變體:
| 變體 | 何時用 |
|---|---|
spin_lock / _unlock | 不可能被中斷處理碰 |
spin_lock_bh / _unlock_bh | 與 softirq/tasklet 共用資料 |
spin_lock_irq / _unlock_irq | 知道中斷一定是開的 |
spin_lock_irqsave / _irqrestore | 不確定中斷狀態(最安全,最常見) |
Ticket spinlock 與 qspinlock#
實作演進:
- 早期:純 test-and-set,導致大量 cache line bouncing
- Ticket spinlock:先到先服務,公平
- qspinlock(4.x 起):MCS lock 變體,每個等待者在自己的 cache line 上 spin,大幅減少 contention 下的 bus traffic
對 user 完全透明,但對於高 contention 場景有顯著差異。
semaphore#
struct semaphore sem;
sema_init(&sem, 1); // 初始 count = 1,相當於 mutex
down(&sem); // count--, < 0 則睡眠
up(&sem); // count++, 喚醒等待者
down_interruptible(&sem); // 可被訊號中斷
可睡眠 ── 只能在 process context 用。
在現代 Linux 中,semaphore 已經很少用。如果只是 mutex 用途,請用
mutex_t(更高效、有 owner 追蹤、有 deadlock 偵測)。如果是計數信號量,多數場景可用wait_queue+ atomic 變數實作得更明確。
mutex#
DEFINE_MUTEX(my_mutex);
mutex_lock(&my_mutex);
/* critical section */
mutex_unlock(&my_mutex);
mutex_lock_interruptible(&my_mutex); // 可被 SIGKILL 中斷
mutex_trylock(&my_mutex); // 試一下,拿不到立刻回
特性:
- 可睡眠
- 有 owner:debug 時可以查「誰在持有」
- 不可在中斷 context 用
- 內建 adaptive spinning:拿不到鎖時,如果持有者正在跑,先 spin 一會(短暫競爭可避開 schedule)
spin_lock vs mutex 的選擇:
- 持鎖時間極短(< 數百 ns)、可能在中斷脈絡 ➡️
spin_lock - 持鎖時間較長、可能 sleep(如做 I/O、配置記憶體)➡️
mutex
rwlock / rwsem ── 讀寫鎖#
允許多讀者同時、寫者獨佔:
rwlock_t rw; // spinlock 版(不睡眠)
read_lock(&rw);
read_unlock(&rw);
write_lock(&rw);
write_unlock(&rw);
struct rw_semaphore rws; // mutex 版(可睡眠)
init_rwsem(&rws);
down_read(&rws);
up_read(&rws);
down_write(&rws);
up_write(&rws);理論上讀多寫少時更快。實務上:
- 讀寫鎖本身的 cache line 還是會被讀者反覆寫(incrementing reader count)
- 讀者多時,寫者可能餓死
- 多數現代核心程式碼改用 RCU 取代讀寫鎖
RCU ── Read-Copy-Update#
RCU 的核心想法:讀者不需要任何同步。寫者複製出新版本、原子地切換指標、等所有舊讀者離開後再釋放舊版本。
// 全域指標
struct foo __rcu *gp;
// 讀者
rcu_read_lock();
struct foo *p = rcu_dereference(gp);
if (p)
use(p->field);
rcu_read_unlock();
// 寫者
struct foo *new = kmalloc(sizeof(*new), GFP_KERNEL);
*new = *gp; // 複製
new->field = value; // 修改
struct foo *old = gp;
rcu_assign_pointer(gp, new);
synchronize_rcu(); // 等所有舊讀者離開 RCU read-side
kfree(old);關鍵性質:
- 讀路徑零開銷:
rcu_read_lock在多數核心配置下是preempt_disable(),幾乎免費 - 寫路徑相對昂貴:必須等 grace period
- 適合讀極多、寫極少的場景:路由表、namespace、network device list
rcu_read_lock 不是真正的鎖 ── 它只是「告訴 RCU 系統:我正在讀」。多個 CPU 可同時持有。
synchronize_rcu() 阻塞,要做非阻塞的釋放:
call_rcu(&old->rcu_head, free_callback);把釋放排到 RCU callback queue,grace period 結束後核心會自動呼叫。
RCU 是 Linux 最被自豪的同步創新之一。Paul McKenney 寫了一本厚書專講這件事。日常開發不常自己用 RCU 寫資料結構,但讀懂帶
__rcu的核心程式碼很重要 ── 不照規矩存取會炸現代亂序 CPU。
wait queue#
「我要等某個條件成立才繼續」── 用 wait queue:
DECLARE_WAIT_QUEUE_HEAD(wq);
int condition = 0;
// 等待方
wait_event_interruptible(wq, condition);
// 喚醒方
condition = 1;
wake_up_interruptible(&wq);實作上:
wait_event把當前 task 加到 wq 的等待者鏈- 設 task state 為
TASK_INTERRUPTIBLE(或_UNINTERRUPTIBLE) - 呼叫
schedule()讓出 CPU - 醒來後檢查條件,仍不成立則繼續睡
wake_up 把所有 / 第一個等待者改回 TASK_RUNNING,加進 runqueue。
變體:
| 函式 | 區別 |
|---|---|
wait_event(wq, c) | 不可中斷(D 狀態) |
wait_event_interruptible(wq, c) | 可被訊號中斷 |
wait_event_timeout(wq, c, t) | 超時版 |
wait_event_interruptible_timeout | 兩者結合 |
wake_up(wq) | 喚醒所有等待者(exclusive 喚醒一個) |
wake_up_interruptible(wq) | 只喚醒 _interruptible 的等待者 |
Completion ── 一次性等待#
「我等某件事發生一次」── 比 wait queue 更精簡:
DECLARE_COMPLETION(done);
// thread A
do_work_async();
wait_for_completion(&done);
// 完成方
complete(&done);典型用於 kthread 啟動同步、I/O 完成通知。
per-CPU 變數#
避開鎖的最有效方式 ── 每 CPU 自己一份:
DEFINE_PER_CPU(int, my_counter);
// 寫(不用鎖,但仍要關 preempt)
get_cpu_var(my_counter)++;
put_cpu_var(my_counter);
// 讀其他 CPU
int v = per_cpu(my_counter, target_cpu);或更簡潔:
this_cpu_inc(my_counter); // x86 上是單一指令
讀全域時遍歷加總。寫密集(如 packet counter)幾乎沒有競爭成本。
死鎖防範#
幾條鐵則:
- 加鎖順序固定:所有 code path 對多個鎖都用同一順序拿
- 不持鎖呼叫可能拿其他鎖的函式(特別是 callback、registered hook)
- 不持 spinlock 呼叫可能 sleep 的函式
- 中斷與 process context 共享 ➡️ 必用
_irqsave變體
CONFIG_PROVE_LOCKING(lockdep)是核心開發必開的選項:執行期追蹤鎖的取得順序,發現潛在死鎖立刻警告。即使在開發時沒實際發生,只要曾經有「在某個 path A 後 B、在另一個 path B 後 A」就會被抓出來。
dmesg | grep -i lockdep
# possible recursive locking detected
# inconsistent {HARDIRQ-ON-W} -> {IN-HARDIRQ-W} usage效能對比(粗略量級)#
| 原語 | 無爭搶開銷 | 有爭搶代價 |
|---|---|---|
| atomic op | ~10 ns | cache line bounce |
| spinlock | ~30 ns | spin(高峰時極差) |
| mutex | ~50 ns | sleep + wakeup |
| rwsem (read) | ~50 ns | 讀者多時 cache 寫入競爭 |
| RCU read | ~1 ns | 0(讀路徑無同步) |
| RCU write | 視 grace period | 100 μs ~ ms |
| seqlock read | ~5 ns | 高頻寫者下需重試 |
選擇建議#
資料是讀極多、寫極少 → RCU
寫稍微多、可睡眠 → mutex
持鎖極短、可能在中斷 → spin_lock_irqsave
單一 counter → atomic / per-CPU
等條件 → wait_event + wake_up
等一次性事件 → completion
單一指標 publish → smp_store_release / smp_load_acquire小結#
核心同步是個工具箱,不是鐵律:
- atomic 解決計數器與簡單 flag
- spinlock 解決持鎖極短、可能在中斷的場景
- mutex 解決持鎖較長、可睡眠的一般場景
- RCU 解決讀極多寫極少的資料結構(路由表、設定)
- wait queue 解決「等條件」
- per-CPU 變數從根本上避開競爭
讀核心程式碼時看到鎖選擇 ── 思考「他在這裡為什麼選這個?」常常能反推作者對該路徑的延遲假設與爭搶模型。