The Battle with Context-Switch
Zig によるメモリ所有権の話
先日、Zig で構造の可視化を目的に実装した OS において、プロセス切り替え時のメモリ所有権の整合を保つための設計を補足する文書です
プロセス固有のポインタ
各プロセスは、実行中のレジスタ状態を保持する Context 構造体を内部に持ちます。これは プロセス固有の所有領域であり、別のプロセスが直接変更してはなりません。これに関してはカーネルも例外ではなく、詳細を後述します
pub const Context = struct {
ra: u64, // x1 - Return address
sp: u64, // x2 - Stack pointer
s0: u64, // x8 - Saved register / frame pointer
s1: u64, // x9 - Saved register
s2: u64, // x18 - Saved register
s3: u64, // x19 - Saved register
s4: u64, // x20 - Saved register
s5: u64, // x21 - Saved register
s6: u64, // x22 - Saved register
s7: u64, // x23 - Saved register
s8: u64, // x24 - Saved register
s9: u64, // x25 - Saved register
s10: u64, // x26 - Saved register
s11: u64, // x27 - Saved register
};
プロセス
各 Process はこの Context を所有し、現在のレジスタスナップショット(Saved registers と SP)を保持します
pub const Process = struct {
pid: PID, // Process identifier
state: ProcessState, // Current state
context: Context, // Saved register state
user_frame: ?*trap.TrapFrame, // User mode registers (null for kernel processes)
stack: []u8, // Kernel stack
name: [config.Process.NAME_LENGTH]u8, // Process name
parent: ?*Process, // Parent process
exit_code: i32, // Exit status for zombies
is_kernel: bool, // Kernel-only process flag
cwd: [config.Process.CWD_LENGTH]u8, // Current working directory
cwd_len: usize, // CWD string length
page_table_ppn: u64, // Root page table PPN (0 = use kernel PT)
// Memory management
heap_start: u64, // Start of heap (brk)
heap_end: u64, // Current heap end
// Scheduler queue link
next: ?*Process, // Next in ready/wait queue
}
一般に SP(スタックポインタ)はアーキテクチャによって定義されるレジスタですが、その指すメモリ領域(スタック)は OS によって実装されます。本実装では、プロセス生成時にカーネル空間へスタックを割り当て、そのポインタを各プロセスに紐付けます
生成タイミング・配置 (どのアドレス空間に置くか)・個数 (プロセス/スレッドごとの本数)・サイズ (確保ページ数) といった資源パラメータの管理に加え、当該領域の所有・排他・寿命といった所有権の規約は、すべてプログラマが明示的に設計・実装します
所有権の境界
ユーザープロセス自身が push/pop 等で SP を操作するのは許容されますが、その状態をカーネルが任意のタイミングで書き換えることはないという契約を前提にします。当然ながらポインタが存在するので簡単に書き換える事は可能であります
(A プロセスの実行中にカーネルが A の context を改変すれば、A はそれを検知できません)
したがって、コンテキスト操作に関する責務は次のように整理しています:
- コンテキストを「所有」するのは各プロセスのみ
- スケジューラは「文脈の差し替え」を指示するのみ
- コンテキストポインタを参照するのは切り替えの瞬間のみ
- 「書き換え」はアセンブリルーチンのみ
これによって、コンテキストは「プロセスが最後に CPU を離れた状態」を記録するものであり、唯一の書き込み責務はカーネルのアセンブリレベルのスイッチルーチンである事が約束されます
スケジューラの責務
スケジューラは、現在のプロセスから次のプロセスへポインタを切り替える操作だけを行います。その内部では、所有権の移動が一時的に生じますが、それは「CPU がどの Context を現在参照しているか」を切り替えるだけです
pub fn schedule(make_current_runnable: bool) ?*Process {
if (current_process) |proc| {
if (proc == &idle_process and ready_queue_head == null) {
return proc;
}
if (make_current_runnable and proc.state == .RUNNING and proc != &idle_process) {
makeRunnable(proc);
}
if (dequeueRunnable()) |next| {
contextSwitch(proc, next);
return next;
} else {
contextSwitch(proc, &idle_process);
return &idle_process;
}
}
if (dequeueRunnable()) |next| {
makeProcessCurrent(next);
return next;
}
makeProcessCurrent(&idle_process);
return &idle_process;
}
プロセス切り替え
最後に contextSwitch は、Zig 側では宣言のみで、実体はアセンブリで実装します。Zig から見れば「現在のコンテキストを他の所有者に譲渡し、次の所有者が保持するレジスタセットを CPU に載せ替えた上で、差し替えられた return address に制御を返す関数」という意味になります
切り替えの直前で副作用を持ちますが、必ずここでスケジューラー、およびプロセスの状態と、レジスタセットの整合を保つため同一関数に閉じ、書き換えのみをアセンブリに委譲します。誰の目にも危険なメモリ上に唯一のポインタは、ここでしか取らないという話です
fn contextSwitch(current: *Process, next: *Process) void {
makeProcessCurrent(next);
switchAddressSpace(next);
context_switch(¤t.context, &next.context);
}
最後に
Rust では unsafe によって危険地帯を明確に出来ますが、Zig では書き方によっては全てが危険地帯となるため、プログラマが明確に責務を割り当て設計し、危険な領域を閉じる必要があります
いつどこで、誰が何を持っているかを見える構造のままにしておく事で、無駄に糖衣せず、危険な事であると理解し、安全にコントロールする事が可能になります
Discussion