Two-level (M:N) scheduling multiplexes many user-level threads over a smaller set of kernel-scheduled threads. What does it buy, and why did several mature operating systems abandon their M:N implementations in favour of one-to-one kernel threading?
answer
- Two schedulers must agree
- Blocking syscall eats a host
- Scheduler activations = kernel upcall on block
- Signals, priorities, affinity, debuggers attach to hosts
- Returned in runtimes that own all blocking
basics
~20 sM:N buys cheap, numerous threads that still use every core. It is hard because a blocking system call removes a host kernel thread from service, and two schedulers must agree about priorities, signals, locks, and what debuggers and profilers see. Once kernel threads got cheap, the complexity stopped paying.
solid answer
~60 s**What it buys:** creation and switching at user-space cost, tiny growable stacks, and — unlike N:1 — real parallelism, because several host kernel threads run on several cores. **Why it is hard:** the model assumes a user thread that blocks can be parked and its host reused. A raw blocking system call breaks that assumption: the kernel blocks the host, and the user scheduler is not running to notice. Fixing it needs either interception of every blocking call or kernel cooperation — scheduler activations, where the kernel upcalls the runtime to say "your host just blocked, here is a replacement". Beyond blocking there is signal delivery, priority inversion between two schedulers, locks held across an unmount, CPU affinity and per-thread accounting that attach to the host, and debuggers and profilers that see hosts rather than user threads. **Why history reverted:** kernel thread creation and switching got much cheaper, so simple 1:1 won on total engineering cost. M:N returned inside *language runtimes* — which, unlike a threading library, own the entire I/O and locking surface and can guarantee blocking calls yield.
go deeper
Recognise the shape: many user threads over few kernel threads, cheap and parallel, but the runtime has to handle blocking calls itself.
Explain the blocking-syscall hole concretely and name at least one systemic friction — signals, priorities, or tooling visibility.
Argue the historical reversal on engineering-cost grounds and explain why runtime-owned blocking surfaces changed the calculus, including where pinning is the leftover of the old problem.
Frame it as a control-boundary question: two-level scheduling is viable exactly when one component owns blocking, priority and observability end to end; otherwise the correctness cost is unbounded.
## What M:N is In a two-level model the program creates M user-level threads; the runtime schedules them onto N kernel-scheduled host threads, with N typically near the number of cores. Each host runs a small dispatch loop: pick a ready user thread, restore its continuation, run it until it blocks or yields, save it, pick another. Work-stealing between per-host run queues keeps the cores busy. The goal is to have both halves of the tradeoff at once: from N:1, cheap creation, tiny growable stacks and nanosecond switches; from 1:1, execution on all cores and tolerance of a thread that blocks. ## What it buys - **Thread count decoupled from kernel resources.** Hundreds of thousands of user threads over a handful of hosts, because the kernel only accounts for the hosts. - **Parallelism.** Unlike N:1, several hosts can be on several cores simultaneously. - **A programming model people can actually reason about.** Sequential, blocking-looking code with straightforward stack traces, rather than callbacks or a hand-rolled state machine — while the runtime quietly turns each block into a park. - **Locality control.** The runtime can keep a user thread on the host that last ran it, or steal deliberately, in ways the kernel cannot because it lacks application knowledge. ## The central difficulty: blocking system calls Everything rests on being able to unmount a user thread when it is about to wait. If the wait happens *inside the kernel* — a synchronous read, a page fault, a native library that blocks on its own — the host thread is gone from service for the duration, and the user scheduler is not executing to do anything about it. With N hosts and N such calls in flight, the program is stopped even though thousands of user threads are ready. Two families of fixes exist: 1. **Interception.** The runtime replaces every blocking operation with a non-blocking equivalent plus a park: register with an event notifier, save the continuation, run something else, resume on completion. This works only if the runtime controls the entire blocking surface — its own I/O library, its own locks, its own sleeps. 2. **Kernel cooperation — scheduler activations.** The kernel notifies the runtime by upcall when a host blocks and hands it a fresh execution context, then notifies it again when the original unblocks. This was the classic research answer and it does work, but it requires a kernel interface, careful handling of nested upcalls, and considerable subtlety around signals and preemption. ## The rest of the friction - **Signals.** POSIX signals are delivered to kernel threads. Which user thread should see a signal that arrives while its host is running someone else? Getting this right across all the corner cases is notoriously delicate. - **Priorities and inversion.** The kernel prioritises hosts; the runtime prioritises user threads. A low-priority user thread holding a lock can block a high-priority one with no way for either scheduler to see the dependency, and classic remedies like priority inheritance do not span the two levels. - **Locks held across a park.** If a user thread parks while holding a lock the *kernel* knows about, the host that resumes it may be a different one — and constructs that record ownership by kernel-thread identity are now wrong. - **Per-thread OS state.** CPU affinity, scheduling class, CPU time accounting, resource limits, and native thread-local storage all attach to the host, not to the user thread that happens to be mounted on it. - **Tooling.** Debuggers, profilers, core dumps, and OS-level thread lists show hosts. Without deliberate runtime support, a stack trace shows the dispatcher rather than the application's logical thread, and a CPU profile attributes time to the wrong unit. - **Preemption.** A user thread that never yields — a tight compute loop — occupies its host until the runtime can interrupt it, which requires injected yield points or signal-based preemption. ## Why systems reverted to 1:1 During the 1990s and early 2000s, several Unix-family systems shipped two-level thread implementations and then replaced them with straightforward one-to-one kernel threading. The drivers were consistent: kernel thread creation and context switching got dramatically cheaper, so the performance gap narrowed; the correctness surface of the two-level design — signals, blocking, priorities, debugging — stayed expensive to maintain; and application demand at the time rarely exceeded a few thousand threads. Simplicity won on total cost, not on raw benchmark numbers. ## Why it came back The modern revival lives in *language runtimes* rather than in C threading libraries, and that distinction is the whole answer. A runtime that provides the I/O library, the lock implementations, the sleep primitives and the stack layout can guarantee that virtually every blocking operation yields, can capture continuations precisely, and can render its own stack traces and profiles. It does not need scheduler activations, because it never lets the block reach the kernel unmediated. Where it *cannot* mediate — native calls, some filesystem operations — the old failure mode returns as host pinning, which is exactly the residue of the classic problem. The interview-grade summary: M:N is the right model when one component owns the blocking surface, and the wrong model when blocking can arrive from anywhere.
- What are scheduler activations and what problem do they solve?They are a kernel mechanism that tells a user-level scheduler about events affecting its hosts: when a host blocks, the kernel upcalls the runtime with a fresh execution context so it can run another user thread, and it upcalls again when the blocked operation completes. This closes the fundamental M:N hole — that the runtime is not executing at the moment its host disappears into the kernel. The cost is a non-trivial kernel interface plus tricky handling of nested upcalls, preemption and signals.
- Why can a language runtime make M:N work today where a general-purpose threading library could not?Because it owns the whole blocking surface. The runtime supplies the I/O library, the locks, the sleeps and the stack representation, so it can convert each blocking operation into a park and capture the continuation exactly. A C threading library sits underneath arbitrary code that can block in any way it likes, so it can never guarantee the unmount. Where even a runtime cannot mediate — native calls, some file operations — the classic problem resurfaces as host pinning.
saying these in an interview costs you the question
- Saying M:N is strictly better than 1:1 with no downside
- Claiming M:N fixes data races or removes the need for synchronisation
- Forgetting that a blocking system call takes a host thread out of service
- Assuming OS priorities and CPU affinity apply to user-level threads
- Believing M:N was abandoned because it was slower, rather than because its correctness surface was expensive