The lock-free syscall class (docs/kernel-locking.md step 9): read, write, readv, writev, pread64, pwrite64, lseek and rt_sigprocmask, decided by number (syscalls::free_class). The page makes the whole call from its Worker, blocking included, with the same code the kernel runs (FdIo, sigprocmask). A call that waits: - parks in the thread's own run state; - makes its first scheduler turn itself (pick_mine): it finishes the call, or registers the slot on the stream's queue. The idle "why" is per thread and kernel-free (wait_why, HAVE_NET, FileHandle::is_global_socket). waitq::enter and publish_timer read the process's signal state, not the kernel. Only the process's end stays the kernel's (exit_here). Also deletes 8 sites that read the pid or the ABI (my_pid, compat_abi). Sites: 183 -> 175. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_ea9f71488b6e448da93d6c2dabf4879c
33 KiB
Removing the kernel lock (plan, 2026-10-12)
Status: plan, no code yet. Backlog entry: "FULL REMOVAL OF THE GLOBAL KERNEL LOCK (KLock)" (user, 2026-10-11). Owner: stream W.
The goal is that no call into a process's kernel takes a lock shared by all of that process's threads. Each piece of state gets the narrowest synchronisation that is still correct:
- nothing, for state one thread owns;
- atomics or snapshots, for state that is read often and written rarely;
- a lock per object or per subsystem, for the rest.
The browser shim and the native host share gex-kernel, so they share the design. Both land step by step behind the usual gates.
1. What the lock is today
Two hosts, one structure: every guest thread of a process enters the
process's single Kernel, and only one thread is inside it at a time.
-
Browser, shared-memory build:
with_host(shims/browser/src/lib.rs) takesKLockand borrows the staticHOST(Host { kernel: Kernel, threads, jit, wayland, root, proc_state, exit }) in place.KLockis a CAS word withmemory.atomic.wait32(1 ms bound), re-entrant through a per-WorkerDEPTH.- It lives in the process's own memory. So it is per process: it serialises the Workers that run one process's threads, not separate processes.
-
Browser, SP build (no SharedArrayBuffer):
HOSTis aRefCell, and re-entry panics. There is one thread, so this build needs no locks and must stay lock-free at no cost (§4.0). -
Native:
HostState.kernel: Arc<Mutex<Kernel>>, locked per syscall (shims/native/src/lib.rs, 10kernel.lock()sites, plus the NT layer throughKernelCell). -
Cross-process state is already fine-grained. It lives in the shared kernel module (shims/kernel) or behind
Arcs with their own locks:- the module store, the stdin queue and the slot map each have their own
Mutex; - futex generations are 16,384 atomic slots, notified per slot since 28e9789a;
ProcTable,FutexTable, pipes, unix sockets, ptys, tmpfs, the net stack, eventfd and timerfd each lock internally (file.rs, pipe.rs, unix.rs, tmpfs.rs, net.rs: 4-26 locks each).
This plan does not need to change any of it, only stop wrapping it in KLock as well.
- the module store, the stdin queue and the slot map each have their own
What it costs today, with Undertale in the page on worldmachine at the 30 fps cap, busiest thread, ms per frame:
| Tree | KLock in syscalls | KLock elsewhere |
|---|---|---|
| tree C, before the W fixes | 37.3 | |
| units-ut2 WP | 7.0 | 1.7 |
| TW2N | 4.5 | 1.3 |
- The node -e 0 split (edger) shows the same: each of the four network Workers spent 2.2-2.5 s in KLock before TW2's parked network waits, and 0.10-0.12 s after.
2. Inventory of takers
with_host call sites in shims/browser/src on master a2d4c964, grouped by
the state they touch. Each count is the number of sites, by enclosing
function.
| Group | Sites | Hot? | State touched |
|---|---|---|---|
Syscall entry (gex_syscall 15, count_syscall 2, note_process_syscall 2) |
19 | every syscall | the whole Kernel through syscall(), the thread's ThreadState swapped in by enter_thread, threads[cur] |
Dispatch miss and translation (gex_dispatch_miss 20, recompile_object(s) 7, maybe_recompile_at 2, remiss, note_intrinsics 3, scan_intrinsics, object_seeds, executable_object_at, retire_unmapped_code, fjit.rs 8, cgen.rs 7, cgen/libprov.rs 14, elfprov.rs 5, dylink.rs 4, wasmprog.rs 4, wasmdll.rs 3, dense.rs 1) |
about 85 | every miss (Undertale ~9k/s units-only before 67912bc4) |
kernel.mem (VMAs, permissions, file names), jit (page table, dirs, slots, dense sections, pending modules), unit and provider registries |
Scheduling and waiting (schedule 10, gex_park, gex_wake, wasm32_wait 3, park_until_ready 3, gex_paused_for_* 3, report_idle) |
21 | every block/park | threads (state, blocked_on), kernel.futexes, signals pending, the compositor pump (wayland) |
Traps and signals (gex_safepoint 6, gex_breakpoint 9, gex_illegal 2, gex_cache_op 4, gex_sysreg_write, low_access, nt.rs raise_nested 3, set_resume_ctx_none_and_trap 3) |
30 | rare, except safepoints | kernel.signals, the thread's context, jit for SMC |
Process lifecycle (gex_adopt_* 8, gex_process_gone 2, gex_ctx_exited, gex_abandon, gex_exit_code, gex_set_compat 2, gex_shared_seed/dump/count) |
about 17 | fork/exec/exit only | everything (adoption fills a fresh kernel) |
Host devices and sockets (gex_host_connect/bind/accept/send/recv, gex_set_winsize, gex_tty_lflag, gex_set_network, gex_get_file, gex_last_open, gex_cmdline) |
11 | per host I/O | kernel.socket_names, kernel.fds, tty, root |
Introspection and stats (gex_vma_* 5, gex_page_* 3, gex_module_* 5, gex_jit_* 9, gex_dump_state, gex_explain_page, gex_pin_report, gex_coverage_* 2, gex_reuse_stat, object_off_str, object_file_off, check_host_magic, nt.rs report_stats) |
about 32 | cold (reports, xcache save) | read-only views of mem, jit and the module lists |
Fields of Kernel (crates/gex-kernel/src/syscalls.rs), by who writes them:
- Per thread, swapped in under the lock (
enter_thread/ThreadStateandswitch_signals):ctx_addr,tid,clear_child_tid,retry_accept,retry_recvmsg,retry_transfer,restore_sigmask,write_blocked, and Signals'blocked,alt_stack,in_handlerand the frame-layout flag. - Per process, written by syscalls:
fds(FdTable: aVec<Option<Arc<OpenFile>>>, cloexec, generation);mem(MemoryMap: vmas, brk, mmap cursor, arena: no internal locking);- Signals'
actionsand process-directedpending/sources; itimers,next_tid,next_ctx_slot,live_threads,unimplemented;exe_path,argv,envp, uid/gid;fd_activity,compat,sigreturn_legacy.
- Per process, constant after start:
pid,ppid,started,clock,trace. - Shared and already synchronised:
futexes,procs,pipes,unix_remote,ptys,net,resolver,vfs;- every
OpenFile's object.
Host fields:
threads: Vec<Thread>: per-thread run state, pc, waiting, blocked_on, page_table, pending wasm and dirs;jit: per-process translation state;wayland: anArcdevice;root,proc_state:Arcs;exit.
3. Target structure
Process (Arc, one per guest process; shared by its threads)
creds, pid, ppid, exe/argv/env constants, or an RCU snapshot for exec
fds: FdTable epoch snapshot to read, Mutex to write (§3.4)
mm: Mm VMA snapshot to read, Mutex to write (§3.5)
sig: ProcSignals actions behind an RwLock; pending as an AtomicU64 + sources Mutex
itimers, tids, live_threads atomics, or one small Mutex
xlate: Translation lock-free lookups, CAS publication, a Mutex only to compile (§3.6)
threads: ThreadTable fixed slots with atomic state (§3.7)
ThreadCtx (owned by its Worker or OS thread; never shared)
ctx_addr, tid, clear_child_tid, retry_*, restore_sigmask, write_blocked,
blocked/alt_stack/in_handler, pending (AtomicU64, readable by senders)
Syscall handlers become fn(&Process, &mut ThreadCtx, &mut GuestMem, args).
The enter_thread/switch_signals swap disappears, along with the reason
it needed the lock: the per-thread fields are no longer in the shared
struct.
3.1 Calls that need nothing shared
These read constants or the calling thread's own state:
- clock_gettime, gettimeofday, getrusage and sched_yield (already done, in
clock_call,3d9814ec); - getpid, getppid, gettid, getuid/geteuid/getgid/getegid;
- sigprocmask/rt_sigprocmask and sigaltstack (thread-local, plus a pending check against the atomic pending words);
- set_tid_address, set_robust_list, and the per-thread parts of prctl;
- arch_prctl/set_thread_area;
- uname and the read-only sysinfo fields.
They are answered from ThreadCtx and Process constants with no lock,
the way clock_call answers its calls today. The difference is that they
run in syscall() itself, so the separate fast-path dispatcher goes away.
3.2 Futexes
The kernel module already has sharded generation slots with a per-slot
notify. What remains is the per-process FutexTable (crates/gex-kernel
futex.rs, 4 Mutexes): its waiter lists and PI owners.
- Shard it by the same hash, 256 shards, each with its own lock.
- FUTEX_WAIT checks the word and enqueues under one shard lock only.
- FUTEX_WAKE and REQUEUE take at most two shards, in address order.
- PI futexes (LOCK_PI/UNLOCK_PI) keep their owner per shard.
3.3 Wakes
TW2 replaced the global k_wake after every syscall with targeted wakes:
- the futex slot's own notify;
#netWaitfor network waits;- wakes only after calls that can end someone's wait (
15cf6c46).
What is left is to finish it:
- every
BlockOpgets a wake word: its futex slot, its fd's readiness sequence, or its pipe or socket; schedule()waits on that word alone;- no path bumps the global wake word except process exit and signals to a whole process.
3.4 File descriptors
- Reads: most syscalls only look an fd up (read, write, recv, send,
poll, fstat, lseek). Lookup reads an
Arc<[Option<Arc<OpenFile>>]>snapshot published through an atomic pointer, with epoch-based reclamation (§4.2). It takes no lock. - Writes: open, close, dup, dup2/3, fcntl F_DUPFD, socket, pipe and
accept change the table. They take a per-process fd
Mutex, copy, and publish. - Objects:
OpenFileand its object already lock themselves; offsets becomeAtomicU64where they are not already. - Close races: a read on an fd that another thread closes is safe,
because the reader holds an
Arcto theOpenFile(Linux does the same).
3.5 Memory map
- Writes:
MemoryMapgets a per-process mmMutexfor writers: mmap, munmap, mprotect, mremap, brk, madvise(DONTNEED) and fork's copy. - Reads: readers take an immutable snapshot of the VMA list
(
Arc<VmaSnapshot>, published by generation, whichMemoryMap::generationalready counts). The readers are:- dispatch_miss permission and file-name lookups;
- fault classification (
low_access, safepoints); /proc/self/mapsand the introspection exports.
- Holes stay holes. A reader on an old snapshot can see a VMA a concurrent munmap removed. That is the same window Linux allows between a lookup and the access. Guest accesses still fault on the real protection, which the shim maintains separately.
3.6 Translation (dispatch_miss, fjit, units)
About 85 sites, and the hottest path after syscalls. The plan:
- Lookups are lock-free. The dispatch table, closure and owner pages and the dense directory are shared words already. A miss reads them with acquire loads.
- Compile without a lock. Lift, emit and compile run on the missing thread with no lock held; they read code through the mm snapshot.
- Publish with CAS. Installing a module claims its slots and pages
with CAS on the directory words:
- the first writer wins;
- a loser drops its module and retries the dispatch;
- pending modules (
threads[i].wasm/dirs) are already per thread.
- Registries. Units and providers (libprov, elfprov, wasmdll, dylink,
wasmprog) keep their registries behind one
Mutexper registry. They change only when an image is mapped. - Self-modifying code. SMC retirement (
gex_cache_op,retire_unmapped_code, page generations) bumps the page generation atomically. Stale modules are then refused at their next entry check, as fjit'spage_gendoes now.
3.7 Threads and scheduling
Host.threads: Vec<Thread>becomes a fixed-capacity table of slots. Each slot holds:- an atomic run state;
- the owning Worker;
- its
ThreadCtxbehind aMutexthat only its owner takes, plus a try-lock for a sender inspecting it.
- Thread creation claims a free slot with CAS.
schedule():- reads only its own slot;
- pumps the compositor through the
Arcdevice, which has its own lock (today the pump runs insidewith_hostonly to reach the mm, and with §3.5 that becomes a snapshot read).
3.8 Signals
sigactionwritesactionsunder anRwLock; delivery reads it.- Pending signals:
- process-directed pending is an
AtomicU64, plus aMutexfor the sources; - thread-directed pending is the target
ThreadCtx'sAtomicU64; - kill and tgkill set bits and wake the target's wake word.
- process-directed pending is an
- Delivery happens on the receiving thread, from its own context and a snapshot of the actions.
- The kernel module.
signal_pending_foralready runs there for the cross-process case, and stays.
3.9 Process lifecycle
- fork takes the mm and fd mutexes, and copies under them. The other threads keep running: Linux does not stop them either, and the copy is of the forking thread's view.
- exec is single-threaded by the time it replaces the image: the other threads are killed first.
- exit and wait go through
ProcTable, which already locks itself. - Adoption (
gex_adopt_*) fills a fresh process before any of its threads runs, so it needs no lock.
3.10 Introspection, host devices, NT
- Introspection exports read snapshots (mm, fds, translation
counters). The few that need a consistent whole view, such as
gex_dump_stateand the xcache save, take a per-process debugRwLockfor writing. Writers of the structures they read hold it for reading, which is uncontended, so a single atomic add. - Host sockets and tty use
socket_names, which gets its ownMutex, and the fd table (§3.4). - The NT layer.
KernelCell::lock_kernelhands out the wholeKernel. It becomes accessors for what NT actually uses (vfs, mm, fds, devices). NT's ownnt::Processstate keeps its own locks.
3.11 Native
- The native host's
Arc<Mutex<Kernel>>becomesArc<Process>, plus aThreadCtxper OS thread. - Each step below changes gex-kernel for both hosts at once. The native run is the debugging oracle for each step: thread sanitizer and stress tests run there.
4. Mechanisms specific to the page
4.0 One lock type, three builds
gex_kernel::sync::{Mutex, RwLock}wrapsstd::syncnatively.- In the shared-memory shim it is a CAS word with
memory.atomic.wait32/notify, asKLockis. - In the SP build it is a no-op cell, with a debug-build re-entry check.
- Every new lock records its holder for the deadlock reporter, as
KLockdoes with its "spun Ns, held by W" line.
4.1 Traps leave locks held
The shim leaves with_host by trapping when control goes back to JS. A
trap does not unwind, so a guard held across it never drops.
- The rule: no lock is held across anything that can trap back to JS: guest memory faults, a guest callback, or a park. Lock regions are leaf computations.
- Debug check: a per-Worker held-lock count is checked at every trap-out site, and is non-zero only on a bug.
- Process end: a trap that ends the process still releases nothing. That is fine, as today.
4.2 Snapshots and reclamation
- Publication: fds, the mm and signal actions are published as
Arc<T>through anAtomicPtr. - Reclamation: the shared-memory shim has no global quiescent point.
So readers announce an epoch:
- each Worker has a slot in a per-process epoch array;
- a reader stores the global epoch in its slot on entry, and 0 on exit;
- a writer retires the old
Arcand frees it once every non-zero slot has moved past the retire epoch.
- Cost: two relaxed stores and one acquire load per syscall.
- Natively, the same code runs with
std.
5. Order of landing, each step with its gate leg
Every step lands alone, in a gate round. The gate runs:
- gex-kernel's tests for both architectures;
- the native suites;
- the page legs (busybox, native-debian, W3 echo, the Undertale page with units, node -e 0);
- the SP smoke.
Each step also states its measured effect against the step before, on the same box.
Removal, not fast paths (the user, 2026-10-12). Each remaining step
deletes KLock from one subsystem for good: that subsystem's state becomes
concurrent by design (atomics, per-object locks and wait queues, RCU-style
publication), and its with_host uses are deleted. A lock-free shortcut that
falls back to KLock (a snapshot re-checked "every Nth turn", a hint that
sends the call down the locked path) does not count as a step. Such a
fallback may exist only inside one branch, before it lands.
Progress metric, in every report: the call sites in shims/browser/src
that take the kernel lock (with_host, with_host_free, with_jit; lines
that are only comments excluded), and how many the step deleted. Baseline:
232 on master f3689d12 (a495e23c: 236). Plus the per-syscall census of the
calls that still take it (diag/klock-6e's [klock-calls]).
5.1 Landed
Steps 0 to 5, and 6a (getpid, gettid and the uid/gid calls from immutable
per-thread ids, fd2ac628), with the compositor wake fixes on step/klock-6
(b638c279, 1c658a54, 6cf33b9c). Steps 3 to 5 took work out from under the
lock or made it cheaper; they deleted no sites (232 to 219 lock-taking
sites on step/klock-6 at 6cf33b9c, most of it 6a and the idle loop).
| Step | Change | New gate leg / check | Measure |
|---|---|---|---|
| 0 | Census: per with_host site, time held and time waited (GEX_KLOCKDIAG=1, a report every 10 s, as fbdiag does). Diagnostic, not landed. |
none | where the remaining 4.5+1.3 ms/frame of Undertale and the network Workers' 0.1 s go |
| 1 | gex_kernel::sync lock types (§4.0); the trap-out held-lock check (§4.1); the epoch array (§4.2). No behaviour change. |
unit tests for the three lock flavours; a held-across-trap test that must fail without the check | none expected |
| 2 | Split Kernel into Process and ThreadCtx; remove enter_thread. Mechanical, still under KLock. |
all existing tests; the W3 page echo; the native thread suites | no change expected |
| 3 | The idle loop (§3.7, its part) and the cheap half of targeted wakes (§3.3), on w/ut-page-tw2's targeted wake. wasm32_wait/schedule/gex_park take the lock once a pass, not seven times. A safepoint takes it only on a hint of a signal (the global or this process's signal counter, an armed timer, a sweep every 64th), and asks about its own thread, which fixed pthread_kill to a spinning thread. A read or write wakes nobody unless its descriptor is a stream, and a read of a pipe with room wakes nobody. |
tests/browser-thread-signal.sh (a64, x86); check-futex-wake.mjs; the browser pipe and fork legs; the W3 legs |
Undertale takes and wait per frame per process |
| 4 | Translation registry reads without KLock (§3.6, the read half): gex_table_need and the gex_module_* getters read published state, and JS asks once a batch, not once an index or field. |
the fjit legs; units-only Undertale; node -e 0 | node -e 0 wait at those sites |
| 5 | Translation outside KLock (§3.6, the write half). As built: a dispatch miss decides what to lift under KLock, takes the JIT state (page table, translated set, intrinsics) out of Host (jitout::OUT), gives the lock back, lifts, leaves the result marked done, and queues the unit under the lock again. Everything that reads that state goes through with_jit, which puts a finished result back and otherwise waits for it: with the lock given back from outside the kernel, holding it from inside (retire_code, do_exec, do_fork, nested calls), which ends because the lift needs nothing under the lock. Code retired during a lift is retired again once its unit is queued (the order one lock gave); an exec drops the unit. |
the fjit legs (W3 echo with fjit, Undertale with fjit); the page SMC programs (smc-race, smc-threads, smc-direct-jump, mem64-smc, copy-loop); a Muck census |
node -e 0 dispatch_miss held time; Unity HK/Muck presents/s |
5.2 Remaining, ordered by measured wait
Measured on worldmachine, Undertale in the page, units-ut3, &fjit=0,
diag/klock-6e census (2026-10-12). The game's process takes the lock for
these syscalls per 10 s: clock_gettime 55k, pselect6 9.4k, getrusage 7k,
futex 4.8k, sched_yield 3.5k, read 3.5k, rt_sigprocmask 3k, write 2k,
epoll_pwait 1.6k, writev, recvmsg, sendmsg, ppoll and ioctl under 1k each.
On top of the syscalls, every idle Worker of every process takes the lock
in the scheduler pick after each wake of the global WAKE word: about
44k takes and 650 ms of wait per 10 s in the game's process, and
15-22k takes in each idle Wine process.
- Clocks: lock deleted. clock_gettime, clock_getres, gettimeofday,
getrusage and sched_yield never take KLock, with no fallback:
- the time base (
MONOTONIC_BASE,k_boot_secs), the process's ABI and its start time are immutable process constants, set at load, exec and fork, before the process's first syscall; - EFAULT is answered in place;
- pending signals are delivered at the next safepoint (which has its own lock-free signal check) rather than at this syscall's boundary;
- a provider collection's stop protocol (cgen/libprov.rs) parks the thread at syscall exit through libprov's own lock.
- Deletes:
clock_fast_path'sFAST_SIG,FAST_RUNandany_registeredfallbacks and the locked path for these numbers. - Leg: each call answered while another Worker holds KLock for 5 s.
- the time base (
- Waits: the global WAKE re-check deleted (§3.3). Linux's wait-queue
model:
- every thread owns one wait slot (a word in the global kernel's memory it parks on);
- every waitable object (pipe end, unix and TCP socket, eventfd, timerfd, the terminal, a child-status table entry, a futex) owns a waiter list: a small per-object-locked list of thread slots;
- a blocking read, write, accept, connect or wait4 registers the thread's slot on its object, re-checks readiness, parks on its slot, and unregisters on return;
- poll, ppoll, select and pselect register the slot on every watched object (no first-fd-plus-bound polling);
- epoll keeps its interest list on the epoll object; each watched object notifies the epoll object, which notifies its own waiters;
- a writer, closer, connect, exit or signal notifies the slots on its object's list and nothing else; a signal notifies its target thread's slot;
- the scheduler pick asks only its own thread and never takes KLock
for an idle Worker:
wasm32_wait/schedule/gex_parkpark on the thread slot with the thread's own deadline. - Deletes:
k_wakeafter syscalls,wakes_waiters, the pick'swith_host,PARK_PLAN's re-reads,idle_look's locked survey. - Legs: a syscall in one process wakes no Worker in another; a poll on two pipes wakes within 1 ms of a write to the second; 43-thread mspaint click latency (P's edger-lat) and its lock-wait trace.
- fd table: lock deleted (§3.4). The fd table becomes an
Arc<[Option<Arc<OpenFile>>]>published through an atomic pointer with epoch reclamation (§4.2); open, close, dup, fcntl(F_DUPFD), pipe, socket and accept take a per-process fd mutex, copy and publish. read, write, readv, writev, pread, pwrite, recvmsg, sendmsg, poll, select and epoll_pwait then run without KLock; offsets are atomics inOpenFile. Leg: close-while-reading; pipe and socket stress in both hosts. - Futexes: lock deleted (§3.2).
FutexTablesharded by the kernel module's hash, 256 shards with their own locks; WAKE and REQUEUE take at most two shards in address order; PI owners per shard. Leg:check-futex-wake.mjsat 8 Workers on 64 futexes with requeue. - Signal masks and per-thread state: lock deleted. rt_sigprocmask, sigaltstack, set_tid_address, set_robust_list, arch_prctl on a per-thread state only its owner writes; pending words atomic (§3.8, the per-thread half). Leg: thread-signals in the page tier, which fails today (§5.3, thread-signals).
- Memory map: lock deleted (§3.5). mm mutex for writers and a published VMA snapshot for readers (dispatch_miss, faults, /proc/self/maps).
- Signals and the thread table: lock deleted (§3.7, §3.8).
- Lifecycle, devices, introspection, NT; then KLock itself deleted (§3.9, §3.10).
- Native:
Mutex<Kernel>gone (§3.11).
5.3 Measurements and known failures
- Each §5.2 step deletes KLock from one subsystem while every other still takes it. So any step can stop without leaving a half-locked structure: whatever is not yet converted stays under KLock as now.
- Steps 0 to 5 were ordered by the step 0 census (2026-10-11):
-
Undertale (worldmachine, units-ut3, 600 s): every Wine process's idle Workers pass through
wasm32_wait,scheduleandgex_parkabout 550 times a frame, taking KLock 7 times a pass: 80-130k takes a second per process, against about 520 real syscalls a frame in the game's process. Each syscall bumps the global wake word and wakes them all. Hence step 3 first. -
Step 3, measured (worldmachine, Undertale page on a master base without function-tier units, so below the cap; 200 s, last 120 s): the game's process went from 80.1k to 22.7k takes a second and from 827 to 288 ms a second waited (about 3,870 to 1,040 takes a frame); idle Wine processes from 18-26k to 3.3-4.8k takes a second; 20.3-20.7 to 21.9 fps. Of the remaining global wakes all came from the game's process, about 700 a second, most of them reads and writes on the wineserver's pipes; the read filter takes the reads out. Each wake left costs an idle Worker one take of its own process's lock, so the per-object slots that would end them save idle CPU rather than the game's lock wait, and wait for step 6 (6b). To be rerun on units-ut3 once the units are re-keyed.
-
node -e 0 (edger, warm run, 14 processes; 1.71 M takes, 10.5 s waited, 11.8 s held): registry reads from JS (
gex_table_need, 498k takes, andgex_module_*, 357k) waited 7.2 s;gex_dispatch_missheld 5.35 s over 7,200 takes; syscalls held 2.6 s. Hence steps 4 and 5 next. -
Step 5, measured (edger, node -e 0, census builds, two runs each, A = step 4, B = step 5): the cold process's KLock held time went from 3.7 and 6.2 s to 0.76 and 1.25 s; the miss now holds it 0.3-0.4 s for 4.6-7.3k misses (decide and queue), against 3.3 and 5.5 s for 4.9-7.2k. Wait over all processes went from 1.1 and 2.4 s to 0.4 and 0.7 s. The warm run's wall time did not move (13.25 and 13.50 s for A, 13.29 and 13.65 s for B): the cold process's lifting was never the critical path's lock wait, only its own. Undertale tier-1 on worldmachine, 200 s, last 120 s: 21.9-22.9 fps against 22.3-22.5 for step 4.
-
Step 8, the thread-signals "failure" was the test's: it slept 100 ms for its two threads to reach their condition-variable wait, and a page starts a thread's Worker later than that, so the blocker took the signal meant for it before it had blocked anything. It now waits for both under the mutex, and is in
PAGE_PROGRAMSwithsignal-storm. -
Step 8, signal words (as built):
Signalsis twoArcs, the process'sProcSig(dispositions behind anRwLock, the shared pending word, the interval timers with their earliest deadline as an atomic) and the running thread'sThreadSig(pending and mask as atomic words, sources and handler state behind small mutexes). Which thread runs is a pointer swap, not a copy in and out. Each page Worker holds its own thread'sThreadSig(sigs), so a safepoint asks whether a signal would do something, collects the inbox (when the proc table's signal sequence moved, which is now bumped after the bit is set) and fires due timers, and builds the handler frame, with no kernel lock and no hints: the global and local signal counters, the timer flag and the every-64th sweep are gone. A delivery claims its signal with one atomicfetch_andbefore building the frame, so two threads never take one process-directed signal; collecting raises each signal once, with its sender, for the same reason. The thread table (a resume after a handler frame) and process exit stay the kernel's until §3.7 and step 9. -
Step 8, the thread's run state (§3.7, as built): each thread-table entry keeps its pc, its wait, the call it parked in and what that call still owes (
Run:rt_sigsuspend's mask, a write'sSIGPIPE, a call to make again) behind anArc<Mutex>of its own, and its Worker holds thatArc(MY_RUN). The kernel's per-call leftovers (restore_sigmask,write_blocked, theretry_*ofaccept,recvmsgand transfers) are taken out of it as the call parks. An idle turn that finds its wait over (waitq::Turn::Ready) finishes the call with no kernel lock (finish_mine,finish_wait): the answer into its context, the signal half throughsigs::finish, and anaccept,recvmsgor transfer is made again from its instruction (in place, for a wasm32 program or a provider) instead of being redone inside the kernel. A safepoint's handler frame resumes withoutschedule. The locked turn stays for a blocking call's first look (under its own take), the compositor's pump and the deadlock watch; process exit stays the kernel's. The context address and tid a Worker runs (MY_CTX,MY_TID) replaced twenty sites that took the lock to read them. -
The lock-free syscall class (as built):
read,write,readv,writev,pread64,pwrite64,lseekandrt_sigprocmaskare a class decided by number (syscalls::free_class; the 32-bit ABI's positioned calls and_llseekare not in it). A call in the class never takes KLock, blocking included. The page makes the whole call from its Worker (free_syscall,sigs::free_call). The descriptor half (FdIo: the call, the park before it and the park after anEAGAIN) andsigprocmaskare the same code the kernel runs. Before delivering, the call collects signals sent from elsewhere and fires due timers (sigs::gather). ItsSIGPIPEis raised on the thread's own signal state.A call that waits:
- puts its wait into the thread's run state;
- makes the scheduler's first turn itself (
pick_mine): it finishes the call if the stream is ready, or registers the slot on the stream's queue and says why nothing runs.
That "why" (
wait_why) is now the Worker's own thread's alone, asked without the kernel. The process's network isHAVE_NET, which the kernel'snetis made from. A global-kernel socket says so itself (FileHandle::is_global_socket). Registering a wait and publishing the process's next timer (waitq::enter,publish_timer) read the process's signal state, not the kernel.The kernel still takes over when the process ends: a signal's default action or a halted machine (
killed,exit_process). The compositor's pump stays a locked scheduler turn when it has work. -
Known failure, native, not KLock's:
gexc run --fjitlivelocks ("retranslation livelock") once a page of a long direct-jump chain is retired under it, astests/x86-programs/retire-during-liftdoes (2026-10-12). Pre-existing; it is why that test is not named*smc*(run.sh would run it under--fjit). Tier-1 native and the page pass. -
Step 5, the drop freeze (
5892193e): a unit dropped across a retire left the pages it had numbered in the page table with no directory entry, and a block running onto one of them failed its page check for ever (Undertale frozen at its first frame). A dropped unit now forgets those pages;retire-during-liftputs a block across every page boundary and hung 2 of 2 on a build dropping on every retire without the fix. Steps 6 and 7 must keep the rule: a page given a base is either published or forgotten. -
Registry reads touch neither fds nor mm, so step 4 needs neither first; step 5's write half publishes what steps 6 and 7 then stop guarding.
-
6. Risks
- Re-entrancy. Today's code assumes it is alone in the kernel. A
handler that calls back into another subsystem (
execveclosing cloexec fds,exit_grouptearing down mm and fds) must take locks in one global order: fd, then mm, then signals, then threads, then the translation registries.- The lock types assert the order in debug builds.
- Lost wakes. Each removed lock was implicitly ordering a check and a
park. Every park keeps the existing pattern: read the wake word, check
the condition, park only if the word has not changed (
WAKE_SEENinschedule). - The SP build must not pay for any of it (§4.0). Its smoke leg is in every round.
- Memory. The epoch array and the snapshots cost a few KB per process. Snapshot churn on mmap-heavy guests (Unity's GC) is bounded by publishing at most once per syscall.