|
miniOS
x86_64 hobby kernel with SMP, VFS, and POSIX process model
|
The miniOS scheduler is a preemptive, round-robin scheduler responsible for managing threads and switching between them. It forms the core of the operating system's multitasking capabilities.
The context switch is the mechanism for saving the state of the currently running thread and restoring the state of the next thread.
Every process has its own PML4 (vmm_new_address_space(), include/miniOS/mm/vmm.h), allocated once when the task is created (sched_create_user_task()) and stored in struct thread.pml4_phys (or thread_group_t.pml4_phys for clone threads — see below; resolved via the THREAD_PML4(t) macro). The kernel high half (canonical PML4 indices 256–511: kernel image, heap, DMA bounce buffer, GOP framebuffer, LAPIC/IOAPIC MMIO) is copied by value into every new PML4 so kernel mappings are identical regardless of which process's table is loaded in CR3 — all processes share the same underlying PDPT/PD/PT chain for that half, so kernel-side growth (e.g. the heap mapping a new page) is automatically visible everywhere without re-syncing. Only the low half (indices 0–255: user code/stack/heap/mmap) differs per process.
sched_schedule() reloads CR3 with the next thread's THREAD_PML4(next) only when it differs from the currently-loaded value — this skips the reload (and TLB flush) when switching between clone threads in the same thread_group_t, which already share one PML4. A kernel-only thread (idle) has pml4_phys == 0 and is skipped entirely; whatever CR3 was already loaded is left alone, since idle never touches user memory.
sched_fork() gives the child its own address space and copies the parent's user stack content directly (both sides are independently mapped, so no VA sharing/swapping is needed). Code/data/BSS/heap and mmap-region pages are shared via fork_cow_share_page() (src/kernel/sched/sched.c):
Every shared frame is refcounted (pmm_ref_frame()/pmm_unref_frame(), src/kernel/mm/pmm.c) — a frame is only actually returned to the free pool when its count reaches zero, so a parent and any number of forked children (or further generations) can share one physical frame safely; whichever side writes first peels off its own private copy, and process exit (vmm_free_address_space()) or execve() correctly drops just that process's share.
Because the parent's own address space is never touched by the child's existence, the child is THREAD_RUNNABLE immediately after sched_fork() returns — no deferred activation, no THREAD_BLOCKED-until-waitpid() dance. This matches real POSIX fork() semantics (parent and child genuinely run concurrently) and also fixed a documented known limitation (no true concurrent process execution).
sched_exit_current() calls vmm_free_address_space(pml4_phys) once (for a single-threaded process) or when the last member of a thread_group_t exits (shared address space). That walks the process's own low half, pmm_unref_frame()-ing every mapped page — private pages are freed, still-shared CoW pages are just decremented, leaving any sibling process's reference intact — then frees the intermediate PDPT/PD/PT frames and the PML4 itself.
sys_execve() no longer special-cases "is this a fork child" at all: it just tears down (pmm_unref_frame()) and rebuilds this process's own PML4, since CoW-shared frames are safely refcounted regardless of lineage.
Execve failure safety: the old image is unmapped before the new ELF is loaded (otherwise already-present pages at the same fixed VAs would be silently kept instead of overwritten). If elf_load() then fails — truncated read, corrupt ELF, a disk I/O error — there is no valid old image left to return an error into; sys_execve() kills the process via sched_exit_current() instead of resuming execution on unmapped or half-written memory.
clone(CLONE_VM|CLONE_FILES|CLONE_THREAD, child_stack, ..., child_tid, tls) creates a new thread that shares the caller's address space. The kernel path is SYS_clone → sched_clone().
On the first clone() call the parent thread's per-thread resources are migrated into a heap-allocated thread_group_t (from a pool of THREAD_GROUP_MAX static slots):
| Field | Shared resource |
|---|---|
| *fd_table | Open file descriptors (heap-allocated, VFS_MAX_FDS entries) |
| brk / mmap_next / *mmap_regions | Heap and mmap state (mmap_regions heap-allocated) |
| cwd[] | Current working directory |
All threads in the group access these via the THREAD_FDT(t), THREAD_BRK(t), etc. macros, which route through t->tg when non-NULL and fall back to t->fd_table / fields for single-threaded processes. Every VFS operation that takes a file descriptor must use THREAD_FDT(t)[fd], not t->fd_table[fd] directly; bypassing it makes clone threads see an empty private fd table.
sched_clone() sets the child's ctx.fs_base from:
fork_child_trampoline restores FS.base via wrfsbase before sysretq so the child enters userspace with the correct TLS pointer.
sched_clone() wires child->ctx.rip = fork_child_trampoline — the same trampoline used by sched_fork(). The child inherits the parent's saved user registers (saved_user_rip, etc.) so it returns from clone() at the same instruction as the parent, but with RAX = 0 and RSP = child_stack.
On exit the child calls _exit() → sched_exit_current(). If CLONE_CHILD_CLEARTID was set, the kernel writes 0 to clear_child_tid and does a futex(WAKE) on that address before marking the thread DEAD, allowing the joining thread to wait with futex(WAIT, clear_child_tid, saved_tid).
THREAD_STOPPED is a distinct scheduler state (separate from THREAD_WAITING/ THREAD_BLOCKED/THREAD_FUTEX_WAIT) for a thread paused by SIGSTOP or SIGTSTP.
SYS_futex (nr 202) implements FUTEX_WAIT and FUTEX_WAKE (with the FUTEX_PRIVATE flag silently stripped — matching is always by raw virtual address, and since a futex only makes sense between threads sharing an address space — either clone threads in one thread_group_t, or MAP_SHARED, which miniOS doesn't implement yet — the private/shared distinction doesn't currently change any behavior).
FUTEX_WAIT: if *uaddr != val returns -EAGAIN immediately; otherwise sets ft->state = THREAD_FUTEX_WAIT and ft->futex_uaddr = uaddr, then loops on sched_yield() until state changes.
FUTEX_WAKE: scans the thread pool for threads in THREAD_FUTEX_WAIT with matching futex_uaddr, sets up to val of them to THREAD_RUNNABLE.
sched_exit_current() follows this protocol to prevent scheduler races:
Step 5 is essential. Without it, a LAPIC timer or sched-kick IPI can fire between steps 4 and 6 on the dying thread's kernel stack. sched_schedule() would be called from the ISR (with the dead thread as old), save the dead thread's context, and switch to another thread. The dead thread's own sched_schedule() call would then never run — but on some paths the interrupt's saved interrupt frame (pushed by the CPU when the ISR fired) would remain on the dead thread's kernel stack and never be popped, potentially causing a GP if the thread were ever resumed (which it is not, but the invariant keeps the path predictable and testable).
sched_yield() maintains the same invariant: it disables IRQs with cli before calling sched_schedule() and re-enables with sti on return.
Each CPU has an independent run queue stored in its cpu_t struct. The scheduler provides three mechanisms:
sched_tick() dequeues exclusively from the calling CPU's run queue. The idle thread is a permanent sentinel in each queue (never dequeued). queue_depth counts only non-idle RUNNABLE threads and serves as the load signal.
sched_balance_enqueue() is called when a new thread is created via sched_create_thread(). It scans all g_cpus[] entries for the minimum queue_depth and tail-inserts the new thread there. If the target CPU is not the calling CPU, a scheduler-kick IPI (vector 50) is sent via lapic_send_ipi() to wake the target from its hlt instruction.
When a CPU's run queue empties and its idle thread runs, sched_work_steal() is called with interrupts enabled. It:
The threshold > 1 ensures a victim CPU always retains at least one real thread, preventing ping-pong migration.