Вся документация
Как это устроено
Планировщик
docs/scheduler.mdОткрыть на GitHubЭта страница документации пока есть только на английском. Документация собирается прямо из каталога docs/ репозитория, поэтому всегда соответствует коду. По-русски написаны руководства.
Preemptive round-robin over per-CPU run queues, with no priorities and no
user space. Everything in this kernel that is not an interrupt handler runs
as a Task: the shell, the softirq workers, the USB poll loop, the DHCP
client, and each CPU's idle loop. The code is kernel/sched.cpp,
kernel/task.cpp and kernel/preempt.cpp, with the context switch itself in
arch/x86_64/asm.asm and arch/arm64/asm.S.
The model
One TaskQueue per CPU, owned by that CPU's Cpu object. A queue is a
linked list plus a spinlock; Schedule() rotates it. Tasks move between
queues only when the load balancer says so.
SelectNext walks the list from the head and takes the first candidate that
is not the current task, not preemption-disabled, not blocked and not the
idle task:
- Blocked tasks are skipped outright. A task blocks itself immediately
before the
Schedule()that switches it out, and whoever has work for it clears the flag; a blocked task has nothing to run, so it is not even kept as a fallback. - The idle task is remembered as a last resort and taken only if nothing else can run. It used to sit in the list as an equal, which meant a reschedule — and every softirq raised from an interrupt handler forces one — could pick it over the task that had just been given work. It then halted the CPU, and the work waited for whatever interrupt came next.
- If the walk reaches the current task before finding a candidate, nothing
ahead of it was runnable: it is rotated to the tail and the walk stops.
The CPU keeps it — unless the idle task was passed on the way, in which
case that is what runs, for a voluntary
Schedule(). The reschedule an interrupt asks for,Preempt(), keeps a task that can still run over the idle task (below).
The chosen task and the current one are both moved to the tail, which is what makes the rotation round-robin.
The context switch
TaskQueue::Switch marks the outgoing task StateWaiting, charges it the
time it ran, marks the incoming one StateRunning, records
next->Prev = curr, and calls:
SwitchContext(next->Rsp, &curr->Rsp, &TaskQueue::SwitchComplete, next)
SwitchContext pushes the callee-saved state (x86: PushAll — all GPRs and
the flags; arm64: x19–x30 and DAIF), stores the old stack pointer through
currSp, loads the new one, and calls the completion callback on the new
stack before popping the incoming task's registers.
That callback is the whole trick. The queue lock, the outgoing task's lock
and the incoming task's lock were all taken by the previous task, on the
previous stack; they are released by SwitchComplete running as the
incoming task. It then does one of two things with the task it just
displaced:
- Still runnable: ask
SelectNextTaskQueue()whether it belongs on a lighter CPU, move it if so, and drop its preempt-disable count. - Exited: put it on the CPU's
ExitedListinstead of releasing it. The finalPut()frees a 32 KiB stack, which triggers a blocking cross-CPU TLB shootdown, and this code runs with interrupts off — two CPUs freeing exited stacks here concurrently would deadlock on each other's shootdown IPI.ReapExited()does it later from the idle task and the softirq task, both of which run with interrupts enabled.
A brand-new task has no saved frame, so Hal::BuildTaskFrame fabricates one
that looks exactly like a frame SwitchContext would have pushed, with the
entry point where the return address goes (x86) or in x30 via a thunk
(arm64). The first switch into a task is therefore indistinguishable from
any other.
Preemption
Two independent gates:
- Global.
PreemptOn()is called once by the BSP, late in boot, right before thePreempt is now onmarker. Until thenSchedule()returns immediately, which is what lets the whole bring-up run without a scheduler. APs spin inPreemptOnWait()until they see it. - Per task.
Task::PreemptDisableCounter.Schedule()increments it and bails out if it was already non-zero, so a task insidePreemptDisable()keeps the CPU;SelectNextalso passes over any other task with a non-zero count, since it cannot be switched to safely either. A reschedule that bails out is not forgotten: it leavesTask::PreemptPendingset, and thePreemptEnable()that brings the count back to zero makes it (see below).PreemptIrqSave()/PreemptIrqRestore()pair the two with interrupt disabling, stashing "preemption was on" in bit 63 of the saved flags so the restore balances correctly even if the global gate moves in between. - Spinlocks.
RawSpinLock::Lock()— and so everySpinLock— disables preemption untilUnlock(), as Linux'sspin_lockdoes: a holder is never switched away with the lock taken, so no taker spins out a time slice waiting for it. The lock remembers whose count it raised: the scheduler takes its own locks in one task and releases them in the next, acrossSwitchContext, andSwitchCompletechecks thatSchedule()'s is the only count the switched-out task still has.PreemptCanBlock()asks the whole question — interrupts on and preemption enabled — for code that has to know whether it may wait.RawRwSpinLockdoes the same on both sides; a read lock has many holders at once, soReadLock()hands back whose count it raised andReadUnlock()takes it, the wayWriteLockIrqSave()hands back its flags.
Involuntary preemption comes from the per-CPU timer tick — the local APIC
timer on x86-64, the generic timer on arm64, both at 100 Hz — whose handler
ends in Preempt(). The timer is one-shot, armed for the next tick or
sooner for a task asleep until a deadline (The local
timer); an interrupt for a deadline only reschedules. So does the IPI handler, which is what makes an IPI a
reschedule request. Voluntary preemption is a direct Schedule() call.
The two differ in one case: when nothing but the idle task could take the
CPU. Schedule() hands it over — its caller asked to let others run, and the
idle task halting the CPU until the next interrupt is what makes Sleep()
cheap. Preempt() leaves the CPU with the task the interrupt landed on, if
that task can still run. The tick and the IPI handler used to end in
Schedule(), and that made stalls: the idle task only halts, and a task
displaced while runnable — not blocked — gets no IPI from whoever has work
for it, so it waited for the next tick. On the AX41 the netblk worker,
polling for requests with poll= and never blocking, was switched out that
way by the tick some 25 times a second, a tick each: the idle task took a
quarter of its CPU while it had work, and the server's p99 was 10 ms. An IPI
landing on a running task could do the same to it.
A reschedule that finds preemption off
The tick or an IPI can land on a task with preemption off -- inside a
spinlock, most often -- where the reschedule cannot be made. It used to be
dropped: the task went on, and whatever the reschedule was for waited for the
next tick or IPI. For the tick's own round-robin that costs nothing; for the
IPI a wakeup sends, the wakeup is lost. SoftIrq::Raise in an interrupt
handler unblocks its CPU's softirq task and sends the CPU an IPI to switch to
it. Landing on the idle task inside the lock ReapExited() takes -- a few
instructions, with interrupts on -- that IPI's reschedule was dropped, and the
idle task went on to halt with the softirq task runnable. Nothing else woke
it: a raise that finds its bit already up sends no IPI, a device interrupt
never reschedules on its own, and the igb driver keeps its vector masked
until the receive poll re-arms it. The CPU slept on its receive queue until
its next tick. On the AX41 under netblk's load that was the receive path going
quiet for up to 10 ms some 80 times a second -- 13% of round trips over 2 ms,
nearly every silence ending on the same 10 ms grid, one CPU's tick -- while
netblk's own service time stayed under half a millisecond (see
netblk).
A refused reschedule now sets Task::PreemptPending, and PreemptEnable()
or PreemptEnableTask() -- every unlock goes through one of them -- makes it
the moment the count is back to zero with interrupts on: on that idle task,
right after the unlock and before the hlt. With interrupts off it waits,
since that is an interrupt handler, whose own Preempt() or the next tick
will do, or a section its caller keeps closed, until its next
PreemptEnable(). Only the running task's reschedule is made that way
(SwitchComplete releases locks for the task it switched away from), and
none once a panic has begun. Schedule(), when it keeps the CPU, also drops
its own count before it turns interrupts back on, so an IPI waiting on the
way out finds the task preemptible.
One consequence for code that sends and then waits for an answer: the answer
can be in before the send returns. The receive softirq may run on the way out
of the transmit path, at the transmit lock's unlock -- rare while it took a
tick landing just there, likely now that the reschedule an interrupt asked for
meanwhile is made there. The DHCP client cleared its answer flag after
sending, and over QEMU's igb, which transmits within the doorbell's write,
lost every answer that way; it clears it before the send now
(DhcpClient::ArmResponse). Arm first, then send.
top prints how many reschedules were deferred in its window and since
boot, and how many of those landed on an idle task; the count since boot
starts at one per CPU, made on purpose on each idle task by the boot
self-test (TestPreemptDeferred). Under netblk's load in QEMU (virtio-net, 12
vCPUs), 20 seconds of it put more than a thousand on idle tasks, and on the
AX41 some 200 a second land there -- before, each one a CPU asleep on work
until its next tick or IPI. Made instead of dropped, they took the AX41's
receive path from 397 silences in 5 seconds to none (see
netblk for the rest).
Sleep(ns) blocks. The task sets its deadline (Task::SleepUntil) and
Block()s, interrupts off as below, and nobody wakes it: the scheduler's walk
on its CPU (SelectNext) treats a blocked task whose deadline has passed as
runnable, and the task clears the flag itself once it runs. The walk runs at
every scheduling point the CPU has, and its timer makes one at the deadline
(The local timer), so a sleep ends there. It used to end
at the first tick after its deadline -- a sleep of a millisecond took ten --
which is where it ended when Sleep was a loop around Schedule() that
checked the clock each time it got the CPU back. That loop kept the task
runnable: alone on its CPU it let the idle task halt it until the tick, but
two sleepers on one CPU handed it to each other without end and the idle task
never ran. Two idle hypervisor guests on one CPU of the AX41, each vCPU asleep
until its guest's next timer edge, kept that CPU busy between them, 46% each;
a usb poll task and an ssh session's top did the same. The self-test
TestSleepBlocks puts two sleepers on one CPU at every boot and fails it if
they run more than a tenth of what they sleep -- a poller runs close to half.
Where a task cannot block -- before preemption is on, off a task's stack, in
a CPU's idle task, with preemption disabled -- Sleep still polls.
Blocking and waking
Block() and Unblock() are a single atomic bit each and take no lock;
Block() is called by the task itself, Unblock() from any CPU including
hard IRQ context. The ordering is what makes it safe, and both sides do the
mirror image of each other:
- The sleeper sets the flag first, then re-checks whether there is work,
then calls
Schedule(). - The waker publishes the work first (sets the pending bit, or clears the "running" bit), then clears the flag.
A wakeup landing anywhere in that window is either seen by the re-check or
clears the flag before Schedule() can act on it. Both stores are
sequentially consistent (lock bts on x86, stlr on arm64); relaxed
ordering would hold on x86 and fail on arm64 only.
The sleeper's side runs with interrupts off, from the flag going up
until it is down again, its own Schedule() included. The tick and every
IPI end in a reschedule of their own, and one landing while the flag is up
switches the task out as a blocked one -- asleep on work it never saw, or had
just seen and not yet cleared the flag for, while a waker that found the
work already published may not clear it again. Schedule() saves and
restores interrupts per task, so a task that blocks with them off comes back
with them off, and turns them back on itself.
The idle task only halts (Cpu::Idle), so a task that yields on a CPU with
nothing else to run gives that CPU to it: the CPU sleeps until the next
interrupt it takes itself -- the tick, when the device's interrupt is routed
elsewhere -- and a waker that finds the task merely runnable sends no IPI.
Schedule() is a way to let others run, not a way to wait: wait by blocking,
or poll with YieldToRunnable(), which gives the CPU to another runnable task
and never to the idle task, returning at once when there is none. A plain spin
is no better than the yield: it keeps whatever else is runnable on the CPU off
it until the tick -- a softirq task the tick preempted mid-handler included,
with its type's work held up on every CPU meanwhile. Sleep() is not for
waiting on work either: it ends at its deadline, which the work may come long
before; block on the work, with Event::WaitFor when there is a deadline as
well.
SoftIrq::Run is the worked example, and it also shows the one case where
"the task is already running" is true and useless: between Block() and the
Schedule() that follows it, a task is out of the scheduler's walk while
still being the current task, so a raise landing there must kick anyway. Its
TickKick is the belt and braces — one atomic read per tick turns a lost
wakeup from a wedged CPU into at most a tick of latency. (Before its window
had interrupts off, that tick was paid a few times in every thousand network
round trips.)
Event (kernel/event.h) packages the pattern for any task with something
to wait for: one waiter, Signal() from anywhere, a hard IRQ handler
included, and an IPI to the waiter's CPU when it finds the waiter blocked.
WaitFor(ns) is the same wait with Sleep()'s deadline beside it -- the
task blocked, woken by a Signal() at once or by the scheduler when its time
has come -- and says which: how a halted guest's vCPU waits for its timer's
next edge or a frame, whichever comes first.
The netblk module's worker sleeps on one. A CPU's idle task must never block,
on an event or otherwise: it is the scheduler's last resort, and a task
exiting on a CPU whose idle task is blocked has nothing to switch to.
The local timer
Each CPU's timer -- the local APIC's on x86-64, the generic timer's CNTV on
arm64 -- is one-shot (Cpu::LocalTimer*, hal/timer.h). It is armed for
the earlier of two things: the CPU's next tick, on a 100 Hz grid that the
tick keeps however the interrupts in between fall, and the soonest deadline
of a task asleep on the CPU until one -- Sleep(), Event::WaitFor(). An
interrupt at the tick does the tick's work, as the periodic timer's did; one
before it only reschedules, and the walk that follows runs the sleeper whose
time has come.
Who arms it for what:
- A task blocking until a deadline brings its CPU's timer forward to it, in
the
Schedule()that switches it out (TaskQueue::WakeSleepers), with interrupts off and the queue's lock held: a write of the local APIC's initial count, or of CNTV's compare value, when the deadline is sooner than what the timer has. - The interrupt re-arms it for the tick only, and leaves a flag; the next
Schedule()on the CPU -- thePreempt()at the end of the same handler, as a rule -- walks its queue for the soonest deadline left and brings the timer forward to it. The walk is under the queue lockSchedule()holds anyway, so the interrupt takes no lock of its own -- one taken there would spin, interrupts off, on a CPU whose queue another holds, and a stuck one would stop that CPU's tick and with it the watchdog that reports it. - A task asleep until a deadline is not moved to another CPU at its
switch-out (
SwitchComplete): only its own CPU's timer is armed for it. It moves, if it should, once it runs. - A panic re-arms the tick and nothing else: the queue's lock may be the panicking CPU's.
So every task asleep on a CPU has that CPU's timer armed at or before its
deadline, but in the moment between an interrupt and the walk after it --
and the walk is what Preempt() does, or the PreemptEnable() that makes a
reschedule the interrupt found deferred. An interrupt for a deadline that a
Signal() already answered finds nothing to run and costs a reschedule.
TestSleepPrompt, at every boot on every CPU, has a task of its own sleep a
millisecond at a time while the CPU's idle task halts between interrupts --
not polling the clock, which would wake the sleeper on time whatever the
timer did -- and fails the boot if the median sleep is over 5 ms. Measured:
1.007-1.07 ms under HVF, 1.1-2.4 ms under TCG; with the arming taken out,
10.0 ms on both architectures. The median, because a TCG host that stalls a
vCPU now and then puts a sleep or two at 15-50 ms. On the EX44 a guest's
usleep 1000, its vCPU halted between, took 10 ms before.
Everything that polls with a short sleep now polls at the rate it asked for:
the USB poll at 4 ms instead of the tick's 10, netconsole's idle poll at 2,
an ARP wait at 1 -- shorter waits, and more wakeups. TCP's blocking calls --
connect, accept, send and recv, which nothing signals -- polled at 1 ms
too, and on the EX44 an idle nos with sshd up and two sessions open woke
2,220 times a second, 1.2% busy over its 20 CPUs; they back off now, 1 ms
doubling to 10 (Poll in net/src/tcp.rs), and the same nos woke 741 times
a second, 0.4% busy, most of it the USB poll.
Load balancing
Task::SelectNextTaskQueue() returns the queue this task should be on: the
shortest one its affinity mask allows, or nullptr for "stay put". It runs
at task creation and on every context switch (for the task being displaced),
and it reads the lock-free mirror of the running-CPU set rather than taking
the CPU table lock, because it is on the switch path.
Two rules keep it from thrashing:
- The queue the task is already on competes like any other. It used to be skipped, which meant every task moved to another CPU on every context switch — not balancing, just motion, and it threw away whatever the task had warm in that core's caches.
- A move needs a difference of two (
MigrateThreshold). The task is still counted where it is, so moving it takes one off that side and adds one to the other; a difference of one would be reversed on the next switch and the task would trade places forever.
Affinity is a ulong bitmask, which is one of the reasons MaxCpus is 64.
Idle tasks and softirq tasks are pinned to their own CPU; everything else
runs anywhere. top prints the number of migrations since boot, which is
the only way to know whether "rare" is actually true.
Tasks and stacks
A task's stack is 32 KiB and carries its own identity: a Task* and a magic
word at the base, a second magic word at the top. Task::GetCurrentTask()
rounds the stack pointer down to a 32 KiB boundary and reads the pointer
from there — no per-CPU variable, no register convention. It works because
the page allocator's 8-page bucket hands out blocks aligned to their own
size. The bottom page is a tripwire: a stack pointer inside it is a
BugOn, not a silent corruption. There is deliberately no Trace() in
GetCurrentTask — tracing takes a lock, which calls PreemptDisable, which
calls GetCurrentTask.
Stacks are filled with a pattern before first use, so stacks can report
each task's high-water mark: what is still intact is what the task never
reached. That catches a spike that lasted microseconds during boot and costs
nothing while the machine runs.
Tasks are refcounted (Get/Put), registered in a 512-bucket TaskTable
hashed by pointer, and given a pid by an ObjectTable. A task ends by
returning from its function: ExecCallback calls Exit(), which stamps the
exit time, removes the task from the table, sets StateExited and calls
Schedule() — which never returns.
The idle task
Each CPU's idle task is created by Cpu::Run(), pinned to that CPU and
marked with FlagIdleBit. Its body is the CPU's bring-up function
(ApStartup / BpStartup), which ends in a loop over Cpu::Idle():
reap any tasks that exited on this CPU, then hlt (x86) or wfi (arm64).
The BSP's idle task additionally watches for a shutdown or reboot request
from the shell.
Because the idle task is now the scheduler's last resort rather than an
equal, a CPU with something always runnable may not reach ReapExited for a
while — which is why the softirq task calls it too. The same starvation
used to swallow poweroff and reboot outright: Sleep() yielded rather
than blocked then, so a CPU carrying the shell, DHCP and USB poll tasks never
ran its idle task again, and the request sat unseen (on a two-CPU QEMU, every
time). Cmd::RequestShutdown()/RequestReboot() now clear the BSP idle
task's FlagIdleBit, making it an ordinary task for what remains of its
life: it takes its turn, sees the flag, unmounts the filesystems while the
soft IRQs still run, and halts.
Looking at it from the shell
| Command | Shows |
|---|---|
ps |
Every task: pid, state, flags, accumulated runtime, context switches, name |
top [ms] |
Per-task CPU use over a window, as a percentage per CPU (a busy thread reads 100%, a 20-CPU box tops out at 2000%), plus the migration count and the deferred reschedules |
stacks |
High-water mark of every task stack and every static boot stack |
bt <pid> |
Stack trace of a task, using an IPI to capture it if it is running on another CPU |
profile |
Where the time actually goes — see Profiler |