iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Software Development

打造 OS Kernel:從 OS in 1,000 Lines 到 xv6系列 第 27 篇

Day 27|多核心 Kernel 為什麼需要 Lock?親手製造一個 Race Condition

  • 分享至 

  • xImage
  •  

三個行程各加 200 次,預期答案是 600。
今天故意把核心中的「讀取、加一、寫回」拆開,再用鎖保護同一段操作,觀察結果為什麼不同。

這不是修改正式的 PID 或排程計數器,而是另開一個專門用來犯錯的教學欄位。
實驗只在 QEMU 中執行,不接觸實體硬體或正式服務。

問題出在操作序列

假設兩個 CPU 都讀到 10,各自加一後都寫回 11,實際完成兩次請求,結果卻只增加一次。
每次 load 與 store 都可能正確,錯的是整段 read-modify-write 沒有被當成不可分割的操作。

我們使用原子 load/store 避免以未定義的 C data race 作為教學基礎,但刻意不使用 atomic fetch-add。
分開的原子讀寫不會自動讓整段增量變成原子操作。

加一個限於測試的系統呼叫

沿用固定版本,kernel/syscall.h 的編號目前到 22,在其末端新增:

#define SYS_raceadd 23

在 kernel/syscall.c 加入 extern uint64 sys_raceadd(void);,並在 syscalls[] 初始化清單內加入 [SYS_raceadd] = sys_raceadd,。
在 user/user.h 加入 int raceadd(int);,在 user/usys.pl 末端加入 entry("raceadd");。
如果你自己的分支已使用 23,必須改用尚未使用的編號,不能覆蓋既有呼叫。

在 kernel/sysproc.c 加入:

static uint race_value;
static struct spinlock race_lock;

uint64
sys_raceadd(void)
{
  int op;
  argint(0, &op);
  if (op == 0) {
    // Test-only: call before workers exist, never during a run.
    initlock(&race_lock, "race-demo");
    __atomic_store_n(&race_value, 0, __ATOMIC_RELAXED);
    return 0;
  }
  if (op == 1) {
    uint old = __atomic_load_n(&race_value, __ATOMIC_RELAXED);
    yield();
    __atomic_store_n(&race_value, old + 1, __ATOMIC_RELAXED);
    return 0;
  }
  if (op == 2) {
    acquire(&race_lock);
    uint old = __atomic_load_n(&race_value, __ATOMIC_RELAXED);
    __atomic_store_n(&race_value, old + 1, __ATOMIC_RELAXED);
    release(&race_lock);
    return 0;
  }
  if (op == 3)
    return __atomic_load_n(&race_value, __ATOMIC_RELAXED);
  return -1;
}

未加鎖版本刻意加入 yield(),放大交錯機會,甚至單核心也可能重現遺失更新。
它不是自然工作負載的效能模型,不能用兩版耗時推算 spinlock 成本。
加鎖版本絕對不能把這個 yield 搬進 critical section,持有不相關 spinlock 時不能睡眠或交出 CPU。

建立相同數量的請求

新增 user/racetest.c,並將 $U/_racetest\ 加入 Makefile 的 UPROGS:

#include "kernel/types.h"
#include "user/user.h"

int
main(int argc, char **argv)
{
  if (argc != 2 || (atoi(argv[1]) != 1 && atoi(argv[1]) != 2)) {
    fprintf(2, "usage: racetest 1|2\n");
    exit(1);
  }
  int mode = atoi(argv[1]);
  raceadd(0);
  int made = 0;
  for (int k = 0; k < 3; k++) {
    int pid = fork();
    if (pid < 0)
      break;
    if (pid == 0) {
      for (int i = 0; i < 200; i++)
        if (raceadd(mode) < 0)
          exit(1);
      exit(0);
    }
    made++;
  }
  int failed = made != 3;
  for (int k = 0; k < made; k++) {
    int status;
    if (wait(&status) < 0 || status != 0)
      failed = 1;
  }
  int got = raceadd(3);
  printf("mode=%d expected=%d actual=%d\n", mode, made * 200, got);
  exit(failed || (mode == 2 && got != made * 200));
}

在 30-days-os-kernel/examples/xv6-riscv/ 執行 make TOOLPREFIX=riscv64-linux-gnu- CPUS=3 qemu。
於 xv6 Shell 依序執行 racetest 1 與 racetest 2,不要同時在背景執行兩份測試。

未加鎖版本可能少於 600,加鎖版本應等於 600。
未加鎖偶爾碰巧得到正確數值,也不能證明它正確,只代表那一次交錯沒有暴露問題。

這個 API 為什麼不能直接留在正式系統?

op 0 會重設全域資料並重新初始化鎖,只能在沒有工作者使用它時執行。
目前沒有權限或測試工作階段管理,其他行程也能呼叫,這是隔離教學環境的明確限制。
要移到非教學系統,必須移除這個介面,或設計完整生命週期與存取控制。

原子操作與鎖都只是工具,真正要維護的是「每個完成的增量都恰好計入一次」的不變條件。
Day 28 回到正式觀察功能,替統計資料設計較清楚的輸出介面。

本日新增 racetest.c,修改 sysproc.c、syscall.c、syscall.h、user.h、usys.pl 與 Makefile。

day27: demonstrate lost updates and protect the critical section

參考資料


上一篇
Day 26|追蹤一次完整 System Call:getpid() 怎麼來回 User 與 Kernel?
下一篇
Day 28|第一次擴充 xv6 API:實作自己的 pstat System Call
系列文
打造 OS Kernel:從 OS in 1,000 Lines 到 xv6 共 29 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言