三個行程各加 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。
未加鎖偶爾碰巧得到正確數值,也不能證明它正確,只代表那一次交錯沒有暴露問題。
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