sched: fair share by virtual runtime, with nice
The scheduler was xv6's round-robin: run everything RUNNABLE in slot order and yield on every timer tick. Slot order is not fairness, a process alone on a CPU paid a full context switch a hundred times a second to be handed straight back to itself, and there was no way to say that one thing mattered more than another. Every process now carries vruntime, the CPU time it has used divided by its weight, and the scheduler runs the runnable process with the smallest. Fairness and interactivity both fall out of that one rule; nice is a weight, 1.25x per level as UNIX has always had it. A slice is 20 ms shared among the runnable processes, never below 2 ms, and the timer tick preempts only once it is spent. A process waking from a long sleep is brought up to min_vruntime less one period, so it is scheduled promptly without being owed everything it slept through; fork inherits vruntime, so forking does not reset a debt. getpriority/setpriority are real, with Linux's offset of 20 on the return; /proc/<pid>/schedstat and the ctxt_switches lines of status report what the scheduler did. docs/architecture/scheduler.md has the reasoning, including why the run queue is still a scan of ptable. This was written in an earlier session and left uncommitted. Finished here: a signal waking a sleeper goes through the same vruntime floor as wakeup() does, and sched_yield() yields -- it returned 0 doing nothing, harmless while every tick was a yield and a spin of a whole slice once ticks stopped being one. Checked under QEMU on 1 and 4 CPUs: a nice 10 loop beside a nice 0 one gets about a tenth of a shared CPU, and a command typed meanwhile answers in 26 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
This commit is contained in:
parent
b70e36e0a2
commit
a2d987db5f
11 changed files with 666 additions and 35 deletions
|
|
@ -85,6 +85,7 @@ Read in this order if you are new:
|
|||
| [boot.md](boot.md) | Firmware to the first userspace process. The shortest path through the whole system. |
|
||||
| [kernel.md](kernel.md) | Memory, processes, scheduling, traps, signals, SMP. |
|
||||
| [syscalls.md](syscalls.md) | The one table everything is generated from, and how a musl binary runs unmodified. |
|
||||
| [scheduler.md](scheduler.md) | Fair share by virtual runtime, slices, and `nice`. |
|
||||
| [filesystem.md](filesystem.md) | The VFS, the buffer cache, mounts, procfs. |
|
||||
| [schfs.md](schfs.md) | The copy-on-write root filesystem. |
|
||||
| [fat.md](fat.md) | FAT12/16/32 with long names: USB sticks, SD cards, the ESP. |
|
||||
|
|
|
|||
|
|
@ -344,5 +344,7 @@ allocation over 8 MB mapped straight into a neighbouring live mapping.
|
|||
reported up as `-ENOMEM` and that is the whole policy.
|
||||
- **Nested signal delivery is not supported.** `in_signal` guards against
|
||||
re-entering a handler while one is running.
|
||||
- **Scheduling is round-robin with no priorities**, no fairness accounting, and
|
||||
no CPU affinity despite `sched_setaffinity` existing.
|
||||
- **Scheduling is fair-share on one global run queue.** Priorities and
|
||||
fairness accounting exist now ([scheduler.md](scheduler.md)); per-CPU run
|
||||
queues, work stealing and CPU affinity do not, despite `sched_setaffinity`
|
||||
existing.
|
||||
|
|
|
|||
152
docs/architecture/scheduler.md
Normal file
152
docs/architecture/scheduler.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<!-- SPDX-License-Identifier: GPL-2.0-or-later -->
|
||||
# The scheduler
|
||||
|
||||
Every runnable process carries one number, `vruntime`: the CPU time it has
|
||||
consumed, divided by its weight. The scheduler always runs whichever runnable
|
||||
process has the smallest one. That is the whole rule.
|
||||
|
||||
Everything below is either a consequence of that rule or a guard against one
|
||||
of the two ways it goes wrong.
|
||||
|
||||
## What it replaced, and why
|
||||
|
||||
It was xv6's round-robin: walk the process table from end to end, run
|
||||
everything `RUNNABLE` in slot order, and take the CPU back on the next timer
|
||||
tick. Three things were wrong with it on a machine that now runs a shell, a
|
||||
network stack and a display server at once.
|
||||
|
||||
**Slot order is not fairness.** A process that blocked after a microsecond and
|
||||
one that burned its whole slice were treated as equals, because nothing
|
||||
counted what anyone had used. A compile and the shell you were typing into got
|
||||
alternate turns, and the shell felt it.
|
||||
|
||||
**Every tick was a context switch.** `yield()` fired unconditionally on the
|
||||
timer, so a process alone on a CPU paid a full switch — `switchuvm`, a `%cr3`
|
||||
reload, a flushed TLB — a hundred times a second, only to be handed straight
|
||||
back to itself.
|
||||
|
||||
**Nothing could be said to matter more than anything else.** No nice, no
|
||||
priority, nothing for a driver thread that has to answer a device promptly.
|
||||
|
||||
## What falls out of the rule
|
||||
|
||||
- **Fairness**, without anything enforcing it. Running advances your clock, so
|
||||
the process whose clock is furthest behind is by definition the one that has
|
||||
had least CPU, and it is the one that runs next. Equal weights converge on
|
||||
equal shares.
|
||||
|
||||
- **Interactivity**, without a heuristic. A process that spends its life
|
||||
blocked on a keystroke barely advances its vruntime, so when the keystroke
|
||||
arrives it is already at the front of the queue. Older schedulers guessed at
|
||||
"is this interactive?", and that guess was the part that was always either
|
||||
gameable or wrong.
|
||||
|
||||
- **Priority**, as one multiplication. Weight scales how fast your clock runs;
|
||||
a process of twice the weight is charged half the vruntime for the same real
|
||||
time, so it is chosen twice as often. This is the only line in the scheduler
|
||||
where `nice` does anything.
|
||||
|
||||
## The two guards
|
||||
|
||||
### Sleepers do not get to collect a debt
|
||||
|
||||
A process blocked for ten seconds comes back ten seconds of vruntime behind
|
||||
everything else. Left alone it would monopolise the CPU until it caught up —
|
||||
the standard way a naive fair scheduler turns one sleeper into a system-wide
|
||||
stall.
|
||||
|
||||
`min_vruntime` is the floor: it tracks the front of the queue and only ever
|
||||
moves forward. A waking process is pulled up to no worse than
|
||||
`min_vruntime - SCHED_LATENCY_NS`: far enough forward to be scheduled
|
||||
promptly, not so far as to be owed anything.
|
||||
|
||||
### Forking does not reset your bill
|
||||
|
||||
`fork()` copies the parent's vruntime to the child. Were the child to start
|
||||
level with everyone, a process could clear its own CPU debt by forking, and
|
||||
something that forks per request would collect a fresh full share every time
|
||||
while a long-running process kept paying for everything it had ever used.
|
||||
|
||||
`nice` is inherited for the ordinary reason: the point of `nice make -j8` is
|
||||
that the compilers it forks are niced too.
|
||||
|
||||
## Slices
|
||||
|
||||
A slice is `SCHED_LATENCY_NS` (20 ms) divided between the runnable processes,
|
||||
floored at `SCHED_MIN_GRAN_NS` (2 ms). With few processes each gets a long
|
||||
slice and switches are rare; as the count grows the slice shrinks until the
|
||||
floor, below which a switch would cost more than the work done inside it, and
|
||||
the period is allowed to stretch instead.
|
||||
|
||||
The floor is deliberately **shorter than a timer tick**. The tick is when
|
||||
preemption is *considered* — `trap()` asks `sched_slice_expired()` and only
|
||||
then yields — but time is *measured* with the TSC, against the `tsc_hz` the
|
||||
LAPIC calibration already establishes at boot. Tick-granularity accounting on
|
||||
a 100 Hz timer would make every slice a multiple of 10 ms whether it deserved
|
||||
it or not.
|
||||
|
||||
## Nice, and who may use it
|
||||
|
||||
`nice` runs −20 to 19 with 1.25 between adjacent levels, so one step is about
|
||||
a 10% change in share when two processes compete — the convention UNIX has
|
||||
used long enough that programs and people both expect it. The weight table is
|
||||
`1024 / 1.25^nice`, with nice 0 at 1024 so the common case multiplies by one.
|
||||
|
||||
`setpriority(2)` is the only way to set it, and it is the one place in
|
||||
`kernel/proc/proc.c` where the uids are actually consulted rather than merely
|
||||
tracked. Two rules, with the two errnos POSIX gives them:
|
||||
|
||||
- **`EPERM`** — you may only renice something you own. Without it any user can
|
||||
push every root process to nice 19 and have the run of the machine.
|
||||
- **`EACCES`** — *lowering* nice, which is asking for more CPU, is root's
|
||||
alone. Without it nice is not a courtesy but a way for any process to
|
||||
outrank the rest of the system by asking.
|
||||
|
||||
Both are inert today, because everything runs as root. They are written now
|
||||
because the alternative is growing a priority knob and a permission model in
|
||||
separate commits, and the window between the two is exactly the bug.
|
||||
|
||||
## Deliberately not a red-black tree
|
||||
|
||||
Linux keeps the runnable set in a red-black tree and Zircon in a WAVL tree, so
|
||||
"smallest vruntime" is O(log n) with the leftmost node cached. Here it is a
|
||||
linear scan of the process table — the same scan the old round-robin did.
|
||||
|
||||
`NPROC` is 128 slots; the scan is a handful of cache lines and runs with
|
||||
`ptable.lock` already held, whereas a balanced tree is three hundred lines of
|
||||
rotations that have to stay correct through every exit path in `proc.c`. The
|
||||
scan is the right trade until something measures it as the problem, and the
|
||||
measurement comes first. Replacing it changes `pick_next()` and nothing else,
|
||||
which is the point of it being one function.
|
||||
|
||||
## Still one lock, still one queue
|
||||
|
||||
Every CPU picks out of the same table under `ptable.lock`. Per-CPU run queues
|
||||
with work stealing are the other half of a real SMP scheduler, and they are a
|
||||
separate change: they need a load balancer, and a load balancer that is wrong
|
||||
is worse than a global lock that is merely contended. At the core counts this
|
||||
actually runs on, the lock is not yet what hurts.
|
||||
|
||||
## Looking at it
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `/proc/<pid>/schedstat` | vruntime in nanoseconds, nice, slices run. |
|
||||
| `/proc/<pid>/status` | `voluntary_ctxt_switches` and `nonvoluntary_ctxt_switches`. |
|
||||
|
||||
The middle field of `schedstat` differs from Linux, which reports run-queue
|
||||
wait time there. That time is not measured here, and inventing a number is
|
||||
worse than putting something true in its place.
|
||||
|
||||
The two switch counts are worth telling apart: a high **voluntary** count
|
||||
means a process is waiting on something, and a high **involuntary** count
|
||||
means it is being preempted. That is the difference between "this is slow" and
|
||||
"this is competing".
|
||||
|
||||
### A note on measuring it under QEMU
|
||||
|
||||
Without KVM, the guest's timer interrupts coalesce under load, so the tick
|
||||
clock — and therefore `utime`/`stime` in `/proc/<pid>/stat`, and `sleep` —
|
||||
runs slow against the TSC. `schedstat` is TSC-based and does not, which is why
|
||||
the two disagree on a busy TCG guest and why shares are worth measuring as a
|
||||
**delta between two samples** rather than as absolute totals.
|
||||
Loading…
Reference in a new issue