Task scheduler wakeups
This page documents the sleep/wake handshake used by Julia's task scheduler and,
in particular, why jl_wakeup_threadpool may wake only a single worker per
multiqueue insert instead of broadcasting to every thread.
The problem
Every @spawn that lands in the multiqueue used to call jl_wakeup_thread(-1),
which loops over all threads and issues a uv_cond_signal to each sleeping
one. On an oversubscribed machine a burst of spawns therefore produces an
$O(nthreads)$ "wake storm" per insert (JuliaLang/julia#61820,
JuliaLang/julia#50425): every idle worker is woken, races for the one new task,
and all but one immediately go back to sleep.
jl_wakeup_threadpool(tpid) replaces that broadcast. A multiqueue insert wakes
at most one sleeping worker in the task's pool. A burst of $N$ inserts wakes
up to $N$ workers. There is no peer-to-peer cascade: a woken worker simply
pops its task and runs it.
Why waking one worker is enough
Correctness rests on the same store-buffering fence ([^store_buffering_1] in
src/scheduler.c)
that the broadcast path relied on. The relevant per-thread invariant is:
A worker is observed
sleepingby the waker, or it has not yet committed to its sleep transition and is therefore guaranteed to re-check its queue and find the freshly-inserted task.
The consumer's sleep transition in jl_task_get_next is, in order:
publish
sleep_check_state = sleeping,jl_fence(),re-check the queue (
check_empty); abort the sleep if work appeared,decrement
n_threads_running,park on the condition variable while
may_sleepholds.
The enqueuer fences after the insert and then inspects each candidate's
sleep_check_state. Because the consumer publishes sleeping in step 1 — before
it re-checks the queue in step 3 — the enqueuer either sees sleeping (and wakes
the worker) or the worker will itself observe the new task in step 3. Either way
the task is serviced.
Why the wake must not be skipped using n_threads_running
A tempting optimization is to skip the scan entirely when
n_threads_running >= jl_n_threads ("everything is already running, nothing is
parked"). This is unsound, for two independent reasons:
The count lags the per-thread state.
n_threads_runningis decremented in step 4, after the consumer has already re-checked its queue in step 3. In the window between steps 1 and 4 a worker has publishedsleepingand may have already read its queue as empty, yet is still counted as running. An enqueuer that consults the count therefore reads stale "all running" information and skips a wake the worker actually needs.The count is global but the wake is pool-local. Busy workers in one pool keep
n_threads_runninghigh while another pool is entirely parked. A cross-pool insert (e.g. spawning an:interactivetask from a:defaultworker) would then be dropped, stranding the task indefinitely.
Scanning sleep_check_state avoids both problems: that flag is set in step 1,
before the re-check, so a scan never misses a worker in the danger window, and
it is inspected per-pool.
TLA+ model
The directory scheduler-wakeup/
contains a small TLA+ model of this handshake that makes the argument above
machine-checkable.
SchedulerWake.tlamodels the sleep transition as the same discrete steps listed above, so the model checker explores every interleaving of that window against an enqueuer that wakes one worker by scanningsleep_check_state.MCFixed— a concrete instance (two workers sharing a pool, plus a cross-pool producer). TLC explores the full state space with no deadlock and noNoLostWakeupviolation.
To reproduce, with tla2tools.jar:
cd doc/src/devdocs/scheduler-wakeup
java -cp tla2tools.jar tlc2.TLC -config MCFixed.cfg MCFixed.tlaToggling the model back to the unsound n_threads_running short-circuit (by
making Wakeup return early when nrun >= N) makes TLC report a NoLostWakeup
violation, confirming the danger window described above is real.
The event loop thread
Outside a threaded region, only one thread (io_loop_tid, normally thread 0)
runs the libuv event loop. When it has nothing else to do it publishes
sleeping, runs uv_run(loop, UV_RUN_ONCE) under the iolock (jl_uv_mutex),
and parks on its condition variable if the loop is idle afterwards (uv_run
returned 0) and nobody woke it in the meantime.
Other threads arm libuv handles (uv_poll_start, uv_timer_start, ...) under the
iolock, but they don't run the loop to get those handles serviced. Instead, a
thread that goes to sleep checks the iolock: if it is free, the thread wakes
io_loop_tid, so that a handle it may have armed gets watched; if it is held, it
leaves that to the holder. Normally every holder takes care of it: another
thread does the same check when it goes to sleep itself, and the event loop
thread is either about to run uv_run (which then sees the handle) or already
running it.
The exception is the event loop thread after uv_run. JL_UV_UNLOCK runs
pending finalizers, and finalizers of libuv-backed objects (uvfinalize) take
the iolock. After the unlock releases the iolock, another thread can arm a
handle. If a finalizer then reacquires the iolock before that thread checks its
owner, the thread skips the wake, and the event loop thread subsequently parks
based on its earlier idle result: nobody runs the loop again.
Since the unlock can invalidate the idle result, jl_task_get_next checks again
that the loop is idle before parking: it takes the iolock with a trylock and
releases it without running finalizers. If the iolock is busy, it retries instead
of parking.
IoLoopWake.tla
models this handoff outside threaded regions, including iolock contention and
finalizers that take the iolock before the loop runs and after its unlock. It
abstracts away blocking inside uv_run and shutdown, and checks the absence of
globally stranded armed handles, not eventual IO service while another thread
keeps running. With IoLoopWake.cfg, TLC finds no NoLostWakeup violation for
the given bounds; IoLoopWakeUnfixed.cfg leaves out that check and produces a
counterexample:
cd doc/src/devdocs/scheduler-wakeup
java -cp tla2tools.jar tlc2.TLC -config IoLoopWake.cfg IoLoopWake.tla
java -cp tla2tools.jar tlc2.TLC -config IoLoopWakeUnfixed.cfg IoLoopWake.tla