Files
danos/docs/scheduling.md
T

4.7 KiB

Scheduling

The scheduler turns danos from a linear "boot then halt" kernel into a running multitasking system. It's fixed-priority preemptive: the highest-priority ready task always runs, and tasks at the same priority take turns. That model is chosen for real-time — it's predictable (you can reason about which task runs when) and its decisions are O(1), unlike a fair-share scheduler.

The scheduler proper (src/sched.zig) is generic; the context switch and new-task stack setup are architecture-specific (src/arch/x86_64/, see arch).

Tasks

A task is a kernel thread: ring-0 code with its own 16 KiB stack (allocated from the heap). A task struct holds its saved stack pointer, priority, state, and a ready-queue link. The currently-running kernel context (kmain) registers itself as task 0, so there's always something to switch from.

The context switch

Switching tasks means swapping stacks. switch_context(old, new) (in isr.s) saves the callee-saved registers on the current stack, stores the stack pointer into the old task, loads the new task's stack pointer, restores its callee-saved registers, and rets — landing wherever the new task was last suspended. Only callee-saved registers are handled explicitly: to the compiler this looks like a normal function call, so it already preserves the caller-saved ones itself (this is the SysV convention doing the work).

A freshly spawned task has never run, so there's nothing to restore. Its stack is faked to look as if it had just called switch_context: init_task_stack lays down a return address pointing at task_trampoline and zeroed callee-saved slots (smuggling the entry function in via the r15 slot). When first switched to, the ret lands in the trampoline, which enables interrupts and calls the entry.

Two ways to switch, one flag discipline

schedule() — pick the best task and switch — runs from two places:

  • yield() — a task voluntarily gives up the CPU.
  • tick() — the 1000 Hz timer preempts the running task. This is what lets a task that never yields still share the CPU.

The subtlety in mixing them is the interrupt flag (IF). The rule: switch_context is always entered with interrupts disabled — naturally so inside the timer ISR, and explicitly (cli) in yield. Then every task ends up with interrupts enabled again through whichever path resumes it:

  • a task suspended in yield re-enables them (sti) right after schedule returns;
  • a task suspended mid-ISR resumes through the interrupt return (iretq), which restores the RFLAGS it had when it was preempted (IF set);
  • a brand-new task enables them in the trampoline.

One related detail: the timer interrupt is acknowledged (EOI) before its handler runs, so a handler that switches tasks and doesn't return promptly can't stall the LAPIC from delivering the next tick.

Priority selection, in O(1)

Ready tasks live in a FIFO queue per priority level (8 levels), plus a bitmap with one bit per non-empty level. Picking the next task is: find the highest set bit (one instruction), take the front of that level's queue. No list walking, no scanning — the decision cost is constant regardless of how many tasks exist, which is what a real-time scheduler needs.

  • Highest priority wins. A ready high-priority task always runs before a lower-priority one.
  • Round-robin within a level. When a task is descheduled it goes to the back of its level's queue, so equal-priority tasks share the CPU fairly.

Verifying it

Two tests (see testing.md) prove the two guarantees:

  • sched spawns three tasks that busy-loop without ever yielding. They all make progress — which can only happen if the timer is preempting between them and the context switch is correct (nothing yields voluntarily).
  • priority (with preemption off, for determinism) spawns tasks at three priorities; they run and exit highest-priority first — [6, 4, 2].

What's next (not done here)

  • Blocking and sleep. Right now a task can only yield or exit; it can't wait for a condition or a duration. sleep(ms) (on the calibrated clock) and blocking come next, and are what a real-time task really needs.
  • Priority inheritance. Once tasks block on shared resources (locks, IPC), danos will need it to bound priority inversion — a real-time requirement.
  • Task exit / a reaper. exit currently leaks the task's stack; nothing frees finished tasks' memory yet.
  • Per-address-space tasks. Today all tasks share the kernel address space. User processes will each get their own, switching page tables (CR3) on the context switch.