-
-
Notifications
You must be signed in to change notification settings - Fork 5
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
| 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
|
- Every task carries a
sched_weight(1–64) and asched_quantumcountdown. - 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_cpupins 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.
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 PIT fires (1000 Hz); the CPU pushes the interrupt frame and enters
irq_common. -
SAVE_REGSpushes all 15 general-purpose registers onto the outgoing task's kernel stack — first thing, before any register is touched. - The handler switches
CR3to the kernel PML4 so C code can run, callsirq_handler(the device ISR) andirq_eoi, then records the outgoing stack:[gs:CPU_SAVED_RSP] = rsp. - 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) andCPU_NEXT_CR3(its address space). - Back in assembly:
mov rsp, [gs:CPU_NEXT_RSP], thenmov cr3, [gs:CPU_NEXT_CR3](both validated first — a non-canonical or unaligned CR3 halts with a serialB3marker rather than triple-faulting). -
RESTORE_REGSpops the 15 GPRs off the incoming task's stack, andiretqreturns 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.
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.
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.
| 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 |
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=largekernel 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.
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.
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.
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.
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 |
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.
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() 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.
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 manyThe 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.
| 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.
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.
setfg(pid) names the terminal foreground process, so keyboard-generated signals target the running job rather than the shell:
-
Ctrl-C →
SIGINTto the foreground process; a blockingread()returns-EINTR -
Ctrl-Z →
SIGTSTP; the process entersPROC_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.
Two different mechanisms, for two different problems:
-
preempt_disable/preempt_enablestops 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.
| 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 |
-
Kernel-Data-Structures -
process_tfield by field - Memory-Management - copy-on-write and address spaces
- SMP - per-core scheduling and locking
- Syscalls - the process and signal interface
- POSIX.1-2017 - System Interfaces - The Open Group
- Futexes Are Tricky - Ulrich Drepper
NyxOS v6.4.363 · GPL v2 · GitHub · uselessalter on Discord · nyxos@inbox.lv
NyxOS Wiki
Getting started
Kernel
Storage & network
Graphics & apps
Userspace
HOWTO
- HOWTO-Add-a-system-call
- HOWTO-Write-a-userspace-program
- HOWTO-Add-a-shell-command
- HOWTO-Add-a-GUI-application
Reference
- Syscall-Reference
- Command-Reference
- Hardware-Reference
- Format-Reference
- Kernel-Data-Structures
- Source-Tree-Reference
Project