為什麼同步難#

核心是個極度多線的環境:多個 CPU 同時執行核心程式碼、可能被中斷打斷、可能在 spinlock 內被 preempt。所以共享資料的存取必須謹慎。本章涵蓋核心提供的全部主流同步原語,並回答「什麼時候用哪個」。

三種上下文#

選同步原語前先搞清楚當前在哪個上下文:

上下文可以睡眠?觸發點
Process contextsyscall 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);

實作上:

  1. wait_event 把當前 task 加到 wq 的等待者鏈
  2. 設 task state 為 TASK_INTERRUPTIBLE(或 _UNINTERRUPTIBLE
  3. 呼叫 schedule() 讓出 CPU
  4. 醒來後檢查條件,仍不成立則繼續睡

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)幾乎沒有競爭成本。

死鎖防範#

幾條鐵則:

  1. 加鎖順序固定:所有 code path 對多個鎖都用同一順序拿
  2. 不持鎖呼叫可能拿其他鎖的函式(特別是 callback、registered hook)
  3. 不持 spinlock 呼叫可能 sleep 的函式
  4. 中斷與 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 nscache line bounce
spinlock~30 nsspin(高峰時極差)
mutex~50 nssleep + wakeup
rwsem (read)~50 ns讀者多時 cache 寫入競爭
RCU read~1 ns0(讀路徑無同步)
RCU write視 grace period100 μ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 變數從根本上避開競爭

讀核心程式碼時看到鎖選擇 ── 思考「他在這裡為什麼選這個?」常常能反推作者對該路徑的延遲假設與爭搶模型。