Skip to content

Process Management

kazah-png edited this page Aug 25, 2026 · 8 revisions

Process Management

A preemptive, weighted round-robin scheduler over a fixed table of 512 process slots, with copy-on-write fork, in-place execve, POSIX-subset signals, real threads sharing an address space, and job control. On a multi-core machine each CPU runs its own scheduler over the same table — see SMP.

See also: Architecture, Memory-Management, Syscalls, Userspace, SMP, Shell

Process states

State Value Meaning
PROC_PARKED 0 Not runnable — a retired kernel thread; the scheduler skips it
PROC_RUN 1 Runnable or running
PROC_ZOMBIE 2 Exited, awaiting reap
PROC_BLOCKED 3 Blocked in kwait(), sleep(), read(), accept(), futex_wait()
PROC_STOPPED 4 Job-control stopped by SIGTSTP/SIGSTOP, parked until SIGCONT

Scheduler

  • Every task carries a sched_weight (1–64) and a sched_quantum countdown.
  • The PIT fires at 1000 Hz. Each tick decrements the current task's quantum; at zero the scheduler picks the next runnable task and stamps it a fresh quantum equal to its weight.
  • A high-weight task therefore runs several consecutive ticks and receives a proportionally larger share. The compositor gets SCHED_WEIGHT_GUI (4) so the desktop stays responsive while background jobs at weight 1 run.
  • nice <weight> <file> spawns a job at a chosen weight; renice <pid> <weight> changes a running one. Both are kernel-shell commands.
  • sched_cpu pins a task to a specific core. This is the entire mutual-exclusion argument for AP scheduling: no other core will even consider a task pinned elsewhere.

The idle desktop does not busy-poll. When nothing is happening — no key queued, no mouse button, pointer unmoved, no drag/resize/menu active — the compositor sleep(5)s instead of spinning, handing its slice to background work. Any activity skips the sleep, so responsiveness is unchanged.

Context switching

NyxOS has no dedicated switch() in its hot path. A context switch happens inside the timer-interrupt handler, irq_common (kernel/core/isr_stubs.asm): every switch is an interrupt return in which the scheduler changes which stack the iretq unwinds. There is a switch_context routine in kernel/core/switch.asm, but it is currently unused — a leftover of the cooperative model retired in v5.7.9 (its own comment records that it was once broken: it pushed 14 registers and popped 15).

The flow, per timer tick

  1. The PIT fires (1000 Hz); the CPU pushes the interrupt frame and enters irq_common.
  2. SAVE_REGS pushes all 15 general-purpose registers onto the outgoing task's kernel stack — first thing, before any register is touched.
  3. The handler switches CR3 to the kernel PML4 so C code can run, calls irq_handler (the device ISR) and irq_eoi, then records the outgoing stack: [gs:CPU_SAVED_RSP] = rsp.
  4. It calls irq_scheduler_tick() (kernel/proc/process.c), which picks the next runnable task under weighted round-robin and writes two per-CPU slots: CPU_NEXT_RSP (the incoming task's saved stack) and CPU_NEXT_CR3 (its address space).
  5. Back in assembly: mov rsp, [gs:CPU_NEXT_RSP], then mov cr3, [gs:CPU_NEXT_CR3] (both validated first — a non-canonical or unaligned CR3 halts with a serial B3 marker rather than triple-faulting).
  6. RESTORE_REGS pops the 15 GPRs off the incoming task's stack, and iretq returns into it.

The switch is, in essence: swap RSP, swap CR3, pop the registers the new task saved on its last interrupt, iretq. Saved state lives on each task's own kernel stack; process_t.stack merely remembers where that stack pointer was.

Registers saved and restored

SAVE_REGS/RESTORE_REGS cover all 15 GPRs, saved in this order:

RAX RBX RCX RDX RSI RDI RBP  R8 R9 R10 R11 R12 R13 R14 R15

The CPU's interrupt frame supplies the other five, restored by iretq:

RIP  CS  RFLAGS  RSP  SS

SAVE_REGS also issues cld on entry, because ring 3 may have left RFLAGS.DF set.

FPU / SSE state (since v6.4.342)

The kernel itself is still built -mno-sse -mno-mmx -mno-sse2 and never touches those registers, but user programs may use SSE, so the FPU/SSE state (x87 + XMM + MXCSR) is now saved and restored across a context switch — closing the long-standing gap (#40) that would otherwise corrupt an SSE-using program the moment another task ran.

The save is lazy and per-core: each cpu_info tracks an fpu_owner, and a task's area is fxsaved only when a different task is switched in. Each task's process_t.fpu_area (512 bytes + 16 for alignment — fxsave/fxrstor need a 16-byte-aligned pointer) starts from g_fpu_initial, a clean fninit'd template captured once at boot, so no XMM state leaks between tasks. A freed task is dropped as any core's fpu_owner so it is never fxsaved into after reap.

What is not saved

State Why not
DS / ES / FS Ignored in long mode
GS base Deliberately left per-CPU — there is no swapgs on this path. irq_common can enter from a ring-0 task and iretq into a different ring-3 task, so the GS base must stay this core's. If userspace ever gets a GS base (TLS), this must become a real swapgs with paired swaps

CR3 is part of the switch

Each user process has its own PML4; kernel threads run on the kernel PML4. The resume CR3 depends on where the task was interrupted, read from the saved frame's CS (frame index 18):

  • Interrupted in ring 3 → resume on the process's own CR3.
  • Interrupted in ring 0 (mid-syscall, even one merely busy-polling) → resume on the kernel CR3, because -mcmodel=large kernel code lives at low link addresses only mapped there. The syscall switches back to the user CR3 itself before it returns.

Note

That ring check is a real bug fix, not a nicety. A userspace program busy-polling the network inside a syscall used to resume on its user CR3 and #PF on the unmapped low kernel .text — an intermittent fault under IRQ load.

Per-process kernel stack

sched_target() also repoints TSS.RSP0 (where a ring-3 → ring-0 entry lands) and kernel_rsp (the syscall instruction's stack) at the incoming process's own kernel stack. This is what makes a syscall re-entrant across a context switch: a process blocked mid-syscall keeps its frame on its private stack instead of a single shared one, which is precisely what lets waitpid, sleep and accept truly block rather than poll.

The per-CPU handoff slots

CPU_SAVED_RSP, CPU_NEXT_RSP and CPU_NEXT_CR3 are per-CPU (reached through GS, whose base is the higher-half alias so they resolve under a user CR3 too). They were global once — which was exactly wrong the moment a second core began scheduling. See SMP.

Warning

The canonical origin story: this path once used RAX/RBX as scratch for the CR3 switch before SAVE_REGS, so an interrupted task resumed with a corrupted RBX = KERNEL_BASE and, if it computed a jump from it, landed at KERNEL_BASE+3#UD. Moving SAVE_REGS to the very top of irq_common is what killed the intermittent GUI/keyboard crash and unblocked the preemptive scheduler.

Kernel threads

Created with create_process(name, entry, arg) or create_kernel_thread_on_cpu(name, entry, cpu). They run in ring 0 on the kernel PML4. The compositor is one of these.

mtdemo is the scheduler's self-test: it spins up two demo threads at different weights, heartbeats for a few seconds, then retires them. Measured over a 400 ms sample, a weight-3 thread counted 99.1M against a weight-1 thread's 34.3M — a ratio of 2.89 against a nominal 3.0.

User processes

Ring 3, each with its own PML4. A process is created by spawn_user_path() (loads an ELF as a scheduler-managed process) or by execve().

Path Behaviour
exec <file> Foreground. Spawns the child, blocks the shell in kwait(), prints the exit code. The desktop keeps recompositing at ~16 fps while the child runs preemptively, so full-screen TUIs render live in the GUI window
spawn <file> Background. Returns immediately; the scheduler time-slices the child while the shell stays interactive. ps lists it with the shell as PPID
bare name The kernel shell auto-execs /name.elf if it is not a builtin, with argv forwarded

execve()

Replaces the caller's image in place: same pid, same open fds, same cwd, new address space. The ELF is loaded into a fresh page directory by elf_load_image, then the old one is freed (refcount-aware, so COW-shared frames survive). argv and envp are threaded onto the new stack in SysV layout — crt0 reads argc from [rsp] and argv from rsp+8, and sets environ from envp, so a child inherits its parent's environment.

fork()

SYS_FORK clones the calling process copy-on-write. The mechanics are in Memory-Management; the visible contract is the POSIX one — the child gets 0, the parent gets the child's pid, and both continue round-robin.

Exit and reaping

exit() marks the task PROC_ZOMBIE and wakes anything waiting on it. The address space and kernel stacks are freed later by reap_zombies() from a safe context — a process cannot free the stack it is currently running on. A parent collects the exit status with waitpid(); orphans are reaped by the background task.

close_proc_fds() runs at reap, closing anything the process left open, so a crashed or careless program cannot leak VFS handles.

Threads

clone(fn, stack_top, arg, CLONE_VM) creates a real thread: a new task that shares the caller's address space instead of getting fork's copy. Without CLONE_VM the call fails — use fork() for a separate address space.

Three pieces of shared state make the thread model coherent:

Shared Mechanism
Address space CLONE_VM — no page-table copy
Heap and mmap VMAs tgid names the thread-group leader; every sbrk/mmap/VMA lookup defers to it via tg_leader(), so malloc from any thread is seen by all
File descriptors One fd table per thread group

Synchronisation is a futex:

futex_wait(&addr, val);   // sleep while *addr == val — returns immediately if it already differs
futex_wake(&addr, n);     // wake up to n waiters, returns how many

The compare-and-sleep is what makes a lock race-free. user/threads.c is the worked example.

A thread must finish with exit(); it never "returns" anywhere. Thread groups stay pinned to one core, because they share page tables.

Blocking

Primitive Blocks until
kwait(pid) / waitpid A child exits (pid <= 0 waits for any)
kwait_all() All children have exited — the shell's wait
sleep(ms) / nanosleep wake_tick arrives; the scheduler's per-tick wait-queue pass wakes the sleeper
read() Data is available on a pipe, socket or the keyboard
accept() A client connects
poll() An fd becomes ready, or the timeout elapses
futex_wait() futex_wake, or the value changes

All of them park the task with the classic cli-check-then-sti; hlt pattern, which makes check-then-sleep atomic against the wakeup — otherwise a wakeup arriving between the two would be lost.

Every process has its own kernel stack for syscalls. This is what makes blocking real: without it a second process entering the kernel would trample the first one's parked frame, and waitpid had to poll instead of sleeping.

Signals

A POSIX subset in kernel/proc/signal.c. Signals are posted as a bit in sig_pending and delivered at return-to-ring-3 by signal_dispatch.

Disposition Effect
SIG_DFL Terminates the process with exit status 128 + signo
SIG_IGN Dropped silently
User handler The interrupted frame is saved, a trampoline is pushed onto the user stack, and the handler is entered with the signal number in RDI; it returns through SYS_SIGRETURN, which restores the context

sigprocmask(how, set, oldset) reads and changes the blocked-signal mask — the primitive behind sigsetjmp/siglongjmp in libc.

CPU exceptions become catchable signals. A ring-3 page fault, divide error or illegal instruction is turned into SIGSEGV/SIGFPE/SIGILL and delivered to the faulting process. With a handler installed the program recovers; without one it dies with status 128 + signo. A crashing user program no longer panics the kernel — user/fault.c and user/segv.c exercise this deliberately.

alarm(seconds) schedules SIGALRM for later; it returns whatever was left on any previous alarm.

Job control

setfg(pid) names the terminal foreground process, so keyboard-generated signals target the running job rather than the shell:

  • Ctrl-CSIGINT to the foreground process; a blocking read() returns -EINTR
  • Ctrl-ZSIGTSTP; the process enters PROC_STOPPED

The shell points setfg at a job while it runs in the foreground and back at itself afterwards (setfg(0) clears it). jobs lists background and stopped jobs, fg resumes one in the foreground, bg resumes one in the background, and waitpid reports a stop through WIFSTOPPED(status) / WSTOPSIG(status) when passed WUNTRACED.

Concurrency safety

Two different mechanisms, for two different problems:

  • preempt_disable/preempt_enable stops a context switch on the local core. The count itself is per-CPU. This is the right tool for single-core re-entrancy — a shell command touching the VFS on the compositor thread being preempted by a process doing the same.
  • Spinlocks are what actually work across cores. The physical allocator, kernel heap, slab caches, VFS node pool, mount table, keyboard input path, network stack and EXT2 driver each sit behind one, taken with interrupts disabled.

get_current_process() resolves per-CPU. It used to answer with the BSP's task for anything running on an application processor, which was the root cause of two separate multi-core hangs.

Inspecting processes

Where What
ps Process table — pid, ppid, state, CPU time, command (kernel builtin and ring-3 ps.elf)
top Live auto-refreshing monitor (ring 3, full-screen TUI)
/proc/<pid>/status Name, Pid, PPid, State
/proc/<pid>/cmdline Full command line
/proc/<pid>/maps Mapped memory regions
jobs The shell's background and stopped jobs

See also

External resources

Clone this wiki locally