User-Level vs Kernel-Level Threads
Imagine a web server handling ten thousand simultaneous connections. Each connection needs its own thread of execution. Should the operating system kernel know about all ten thousand threads individually? Or should it see only a handful of underlying execution vehicles, while a runtime library juggles the rest? This question — who implements threads, and where — is one of the oldest design forks in operating systems. It still shapes how modern language runtimes like Go, Java, and Erlang behave under load. The choice matters because threads are not free. Creating one, switching between two, and blocking one on I/O all cost cycles. Those costs differ by orders of magnitude depending on whether the kernel is involved. A poorly chosen threading model can turn a scalable server into one that collapses under thousands of concurrent clients. Or it can waste the multiprocessor hardware a machine actually has. Understanding the tradeoff between user-level and kernel-level threads explains why some systems can spawn a million lightweight "goroutines." Others cap out at a few thousand OS threads. This article builds the concept from the ground up. It covers what a thread control block is and where it can live. It explains the three classic mapping models (many-to-one, one-to-one, many-to-many). It also covers the mechanics of how each model handles thread creation, scheduling, and blocking system calls. We connect the abstract idea of "thread implementation" to concrete pseudocode for a user-level scheduler. We also include a state-machine diagram of thread lifecycles across the kernel boundary. By the end, you should be able to predict something useful. Given a workload and OS, you should know whether user-level or kernel-level threads (or a hybrid) will perform better, and why. This sets up later material on Context Switching mechanics and CPU Scheduling Fundamentals. Both of these depend on knowing where thread state actually lives.
- A thread needs a thread control block (TCB); it can be maintained by a user-space library or by the kernel — this choice defines the threading model.
- User-level threads (ULT): managed entirely in user space, extremely cheap to create/switch, but a blocking system call can stall the entire process.
- Kernel-level threads (KLT): managed by the OS scheduler, visible to it, can run in true parallel on multiple cores, but creation/switching costs a system call.
- Three mapping models: many-to-one (all ULTs on one KLT), one-to-one (each ULT is a KLT), many-to-many (M ULTs multiplexed over N KLTs, M ≥ N).
- Many-to-one gives cheap threads but no parallelism and full blocking; one-to-one gives parallelism but expensive threads; many-to-many aims for both, at the cost of scheduler complexity.
- Modern practice mostly uses one-to-one (Linux NPTL, Windows) or many-to-many hybrids in user-space runtimes (Go's goroutines, Erlang processes) built atop one-to-one kernel threads.
Threading models trade off the cost of thread operations (cheap in user space) against the visibility the kernel needs to schedule threads onto real cores and unblock a process when one thread blocks.
The idea
A thread is an independent sequence of execution sharing an address space with its siblings; it needs its own program counter, register set, and stack, bundled into a thread control block (TCB). The central design question is: where does the TCB live, and who switches between TCBs? If the answer is "a library linked into the user process, with no kernel involvement," we call these user-level threads. If the answer is "the kernel itself, which knows about and schedules each thread," we call these kernel-level threads. This is not a cosmetic distinction. The kernel is the only entity that can (a) preempt a running thread via a timer interrupt, (b) assign threads to physical CPU cores, and (c) block/unblock a thread on a system call like `read()`. If the kernel does not know a user-level thread exists, it can do none of these things for that thread individually — it only sees the one (or few) kernel-level entities that host the user threads. Conversely, every operation the kernel does perform on a thread's behalf — creating it, context-switching it, scheduling it — normally requires a trap into kernel mode, which costs far more than a user-space function call. The three canonical mapping models answer the question: how many user-level threads (M) map onto how many kernel-level threads (N)? 1. Many-to-one: all M user threads run on top of a single kernel thread. The user-space library does all scheduling; the kernel sees one schedulable entity. 2. One-to-one: each user thread has its own dedicated kernel thread, so M = N. The kernel scheduler directly manages every thread. 3. Many-to-many: M user threads are multiplexed over N kernel threads, with M ≥ N and typically N ≈ number of CPU cores. A two-level scheduler exists — one in user space (mapping user threads to a pool of kernel threads) and one in the kernel (mapping kernel threads to cores). A useful special case of many-to-many is the two-level model, where a few user threads are also allowed to bind permanently to their own kernel thread (for threads that must never be blocked behind others), while the rest float over the shared pool.
How it works
Thread creation. In the many-to-one model, creating a thread is a pure user-space operation: allocate a TCB and a stack, push it onto a ready queue maintained by the threading library — no system call, no kernel involvement, cost is roughly a memory allocation. In one-to-one, thread creation requires a system call (e.g., `clone()` on Linux, `pthread_create` which itself calls `clone()`) so the kernel can allocate its own kernel-level TCB, schedule the new thread, and set up whatever kernel bookkeeping (signal masks, scheduling class, CPU affinity) it needs.
Thread switching (yield or preemption). In many-to-one, when a running user thread yields or its user-space timer fires, the library's own scheduler runs entirely in user mode: save registers to the current TCB, pick the next runnable TCB, restore its registers. No mode switch, no kernel involvement — extremely fast (tens of nanoseconds). In one-to-one, a thread switch is a kernel operation, requiring at least a trap into kernel mode, saving/restoring more state (page tables if switching address spaces, kernel stack pointer), and going through the kernel scheduler — typically one to several microseconds, two to three orders of magnitude slower.
The blocking problem. This is where many-to-one breaks down. Suppose user thread T1, part of process P, calls a blocking system call such as `read()`. The kernel does not know about T1 — it only knows about the single kernel thread hosting P's user threads. When that kernel thread blocks on the I/O, the entire process — every user thread inside it — is descheduled, even though T2, T3, ... might have useful work to do. One-to-one avoids this entirely: T1's dedicated kernel thread blocks, but T2's kernel thread is independently schedulable, so the kernel simply runs it. Many-to-many mitigates the problem via mechanisms like scheduler activations or jacketing wrapper functions that convert a would-be blocking call into a non-blocking one at the user level (e.g., using `select`/`epoll` internally), so the library never lets a whole kernel thread get stuck behind one user thread's I/O.
Parallelism. On a machine with C physical cores, many-to-one can never use more than 1 core for a given process, no matter how many user threads exist, because the kernel only ever sees one schedulable entity. One-to-one can use up to min(M, C) cores simultaneously, since the kernel independently schedules each kernel thread. Many-to-many can use up to min(N, C) cores, and by tuning N to match C, it captures nearly all the available parallelism while still allowing M to be huge (thousands to millions) because ULT creation/switching stays cheap. Below is pseudocode for a simplified many-to-one style user-level scheduler (the kind found inside a "green threads" library), followed by the parallel kernel-visible variant. ``` // ---- User-level thread library (many-to-one style) ---- struct TCB { id registers // saved PC, SP, general registers stack state // READY, RUNNING, BLOCKED, DONE } readyQueue = empty queue of TCB currentTCB = null function thread_create(function f): tcb = new TCB() tcb.stack = allocate_stack() tcb.registers.PC = f tcb.registers.SP = top_of(tcb.stack) tcb.state = READY enqueue(readyQueue, tcb) return tcb.id // Cooperative or timer-driven switch, ALL in user space function thread_yield(): if readyQueue is empty: return // nothing else to run prev = currentTCB save_registers(prev) // pure register save, no trap if prev.state == RUNNING: prev.state = READY enqueue(readyQueue, prev) next = dequeue(readyQueue) next.state = RUNNING currentTCB = next restore_registers(next) // jump into next thread // execution resumes here for 'prev' next time it's picked // The danger case: a "blocking" call must be intercepted function my_read(fd, buf, n): if would_block(fd): currentTCB.state = BLOCKED register_io_wait(fd, currentTCB) thread_yield() // let other user threads run // ... woken up later when data arrives, if the library // uses select()/epoll() under the hood (jacketing) return real_kernel_read(fd, buf, n) ``` ``` // ---- Kernel-level view (one-to-one) ---- // Each user thread creation issues a system call: function pthread_create(f): trap_into_kernel(CREATE_THREAD, f) // kernel allocates a kernel TCB, adds it to the // kernel's ready queue, schedules it on some core // A blocking read() in one-to-one: function read(fd, buf, n): trap_into_kernel(SYS_READ, fd, buf, n) // kernel marks THIS kernel thread's TCB as BLOCKED, // context-switches CPU to a different kernel thread — // sibling threads of the same process are unaffected ```
Complexity & correctness
Thread creation cost. User-level thread creation is O(1) amortized user-space work: allocate a stack (often a fixed-size mmap or a slab-allocated block) and initialize a TCB struct — no system call. Kernel-level thread creation is O(1) work too, but with a constant factor dominated by a mode switch (trap in, trap out) plus kernel bookkeeping (scheduling entity setup, possibly a new kernel stack), which is measured in microseconds versus the tens-of-nanoseconds cost of a user-space allocation. The asymptotic complexity class is the same; the practical constant differs by 100×–1000×, which is exactly why systems that spawn huge numbers of short-lived threads (e.g., one goroutine per request) cannot afford one-to-one kernel threads at scale.
Thread switch cost. A user-level switch under many-to-one is a register save/restore in user space: O(1) with a small constant (no TLB flush needed if staying in the same address space, no privilege-level change). A kernel-level switch is also O(1) but must go through the kernel scheduler's data structures — for a run-queue based scheduler this is typically O(1) amortized (as in Linux's CFS red-black tree operations, O(log n) for the tree update, O(1) for picking next) — plus the fixed cost of the mode switch itself, which dominates for small n.
Correctness invariant — no lost or duplicated thread. Any correct threading model must preserve the invariant that every created thread is in exactly one of {READY, RUNNING, BLOCKED, TERMINATED} at all times, and the transition function is total: every event (creation, yield, timer interrupt, I/O completion, exit) maps the TCB to exactly one new state. In the many-to-one pseudocode above, `thread_yield` preserves this: it always dequeues before enqueuing, so a thread is never in the ready queue and running simultaneously, and never disappears from all queues while still alive. In the kernel-level model, the same invariant is enforced by the kernel scheduler's run queue plus wait queues, with the crucial addition that the kernel guarantees fairness at the level of kernel-visible entities — it can only reason about entities it sees, so a many-to-one process, having 1 visible entity, gets 1 entity's worth of scheduling no matter how many user threads it contains internally. This is precisely the theorem-like fact that explains the blocking problem: since the kernel scheduler operates on kernel TCBs, any state change requiring the kernel (blocking on I/O, preemption to another process) affects the entire set of user threads riding on that single kernel TCB.
Parallelism bound. Formally, if a process has M user threads mapped onto N kernel threads on a machine with C cores, the maximum simultaneous progress is: ``` speedup_max = min(M, N, C) ``` For many-to-one, N = 1, so `speedup_max = 1` regardless of M or C — no parallel speedup is achievable even on a 64-core machine. For one-to-one, N = M, so `speedup_max = min(M, C)`, achieving full hardware parallelism once M ≥ C. For many-to-many with N chosen ≈ C, `speedup_max = min(M, C) = C` once M ≥ C, matching one-to-one's parallelism ceiling while keeping thread-creation/switch costs at the cheap user-space rate for the M-side operations.
Tradeoff honesty. Many-to-many is not free: it requires a genuinely two-level scheduler, careful handling of blocking system calls (jacketing, or scheduler activations — a kernel mechanism that upcalls into user space whenever a kernel thread is about to block or has become idle, so the user-level scheduler can rebalance). This complexity is why real systems mostly retreated to one-to-one at the OS level (Linux NPTL, Windows threads) and instead build many-to-many inside a user-space runtime (Go, Erlang/BEAM) that itself sits atop one-to-one kernel threads — pushing the two-level scheduling problem into a place where the runtime controls both ends.
Worked example
Consider a process with 4 user-level threads (T1–T4) on a machine with 2 physical cores, and trace what happens when T1 issues a blocking `read()` call, under each model.
| Step | Many-to-one (M:1) | One-to-one (1:1) | Many-to-many (M:N, N=2) |
|---|---|---|---|
| Setup | T1–T4 on 1 kernel thread K1 | T1–T4 each on own K1–K4 | T1–T4 on 2 kernel threads K1, K2; say T1,T2→K1 and T3,T4→K2 |
| T1 calls `read()` (blocks) | K1 traps into kernel, kernel marks K1 BLOCKED | K1 (hosting only T1) traps, kernel marks K1 BLOCKED | K1 traps into kernel; K1 marked BLOCKED |
| Effect on T2, T3, T4 | All blocked — they were riding K1, which the kernel just descheduled entirely | T2, T3, T4 unaffected — each has its own kernel thread, scheduled independently on cores | T2 (sharing K1 with T1) is also stalled unless the runtime uses jacketing/scheduler activations to detect the block and move T2 to K2 or spin up a replacement kernel thread |
| Cores in use afterward | 0 useful cores (whole process idle) | up to 2 cores active running T2,T3,T4 (3 threads, 2 cores → 2 run, 1 waits) | with jacketing: T2 migrates, so up to 2 cores stay busy (T2 + T3 or T4); without jacketing: same stall as M:1 for T2 |
| Kernel TCBs involved | 1 | 4 | 2 (plus possibly a 3rd spun up temporarily) |
| Creation cost for all 4 threads | ~4 small mallocs, no syscalls | 4 `clone()` syscalls | 4 mallocs + 2 `clone()` syscalls (for K1, K2) |
The key numeric contrast: under M:1, T1 blocking costs the process 100% of its parallel capacity even though 2 cores sit idle and 3 other threads are ready. Under 1:1, the same event costs the process 0% of its remaining capacity — the kernel simply continues scheduling K2, K3, K4 on the 2 available cores (running 2 at a time, rotating). Under M:N with proper jacketing, the runtime detects the impending block (often via non-blocking I/O and an event loop, e.g., Go's netpoller using `epoll`) before committing K1 to a real blocking syscall, so T1's block never stalls T2 — the practical goal being to approach 1:1's responsiveness while keeping M:1's cheap creation cost for the other three threads that never blocked.
Common pitfalls & traps
| Pitfall | Why it happens | Fix / correct mental model |
|---|---|---|
| Assuming user-level threads give parallel speedup | Confusing "concurrent" (many threads exist) with "parallel" (many threads run simultaneously on distinct cores) | Under pure many-to-one, `speedup_max = min(M,N,C) = 1` since N=1; no amount of user threads changes that |
| Forgetting the kernel can't preempt what it can't see | Assuming a user-level thread stuck in a tight loop will be time-sliced like a kernel thread | Without a user-space timer/signal mechanism, a non-yielding user thread starves its siblings; kernel-level threads get preempted by the OS timer regardless |
| Treating one-to-one as "always better" | Ignoring the fixed syscall cost of every create/switch | For workloads spawning thousands of very short-lived threads, one-to-one's per-thread overhead can dominate runtime — this is exactly why Go/Erlang use M:N runtimes |
| Ignoring the blocking-call problem in many-to-one | Believing "the library handles scheduling" means blocking calls are automatically safe | Any unjacketed blocking syscall (disk I/O, some blocking libc calls) freezes the whole M:1 process; only jacketed (non-blocking, polled) I/O avoids this |
| Confusing "thread" with "process" isolation | Assuming kernel-level threads get separate address spaces like processes | All threads (user or kernel level) in the same process share one address space; only the execution context (registers, stack, PC) is per-thread |
Pioneers & history
Origins · 1 minButler W. Lampson and how this was discovered
Lampson's work in the 1960s–70s on operating system structure, alongside collaborators like David Redell, helped formalize how threads, monitors, and process scheduling should be layered — directly informing later distinctions between user-space and kernel-space thread management found in systems like Mesa and its successors.
Where this matters
Real production systems still make this exact tradeoff today. Linux and Windows expose one-to-one kernel threads as the OS-level primitive (`pthread_create` on Linux maps to a `clone()` syscall; Windows threads are kernel objects), because it is the simplest model that guarantees correctness under blocking I/O and full multicore parallelism, at the cost of per-thread overhead. Go's goroutine runtime implements a many-to-many scheduler entirely in user space: millions of goroutines can exist, multiplexed over a small, tunable pool of OS threads (`GOMAXPROCS`), with the Go runtime's netpoller handling I/O via non-blocking syscalls and `epoll`/`kqueue` so a blocked goroutine never stalls an OS thread. Erlang/OTP's BEAM VM takes the same many-to-many approach for its lightweight "processes," enabling systems like WhatsApp and telecom switches to run millions of concurrent lightweight units. Java's original "green threads" (pre-Java 1.2 on some platforms) used many-to-one and hit exactly the blocking-I/O wall described above, which is part of why the JVM moved to native (one-to-one) threads, and why Java's newer virtual threads (Project Loom) reintroduce an M:N-style model with careful integration into the JVM's own non-blocking I/O to avoid the classic pitfall. Database connection pools, thread-pool-based web servers (e.g., older Apache MPM prefork/worker models), and async-I/O frameworks (Node.js's single-threaded event loop, which sidesteps the whole question by minimizing thread count altogether) are all direct descendants of this same design fork. Understanding this tradeoff is prerequisite to reasoning about Context Switching costs and to the scheduling policies covered under CPU Scheduling Fundamentals, since a scheduler can only make decisions about the entities the kernel actually sees.
Ask the tutor: How would scheduler activations concretely change the pseudocode of the many-to-one blocking-call handler shown above?
Are user-level threads and "green threads" the same thing?
Yes, essentially — "green threads" is a historical name (popularized by early Java implementations) for threads implemented and scheduled entirely in user space, without kernel involvement, which is exactly the definition of user-level threads. The term is often used loosely today for any lightweight, runtime-managed thread abstraction, including many-to-many models like goroutines, even though technically those also rely on underlying kernel threads for actual parallel execution. When precision matters, "user-level thread" refers to the abstraction the kernel cannot see, regardless of how many kernel threads back it.
Why not just always use one-to-one threading, since it avoids the blocking problem entirely?
One-to-one avoids the blocking-stalls-everything problem, but it pays a fixed system-call cost for every thread creation, destruction, and context switch, plus per-thread kernel memory (kernel stack, scheduling metadata). For workloads with tens of threads this overhead is negligible, but for workloads wanting hundreds of thousands or millions of concurrent lightweight tasks (e.g., a server handling that many simultaneous connections), the syscall overhead and per-thread kernel memory become the bottleneck. Many-to-many or M:N user-space runtimes exist specifically to keep the per-task cost low while still achieving multicore parallelism and avoiding the many-to-one blocking trap.
What exactly does the kernel scheduler "see" in each model, concretely?
In many-to-one, the kernel's run queue contains exactly one entry (one kernel TCB) per process, regardless of how many user threads that process has spawned internally — the kernel is unaware those user threads even exist. In one-to-one, the kernel's run queue contains one entry per user thread, since each maps directly to its own kernel TCB, so the kernel scheduler treats them as fully independent schedulable units competing for CPU time like separate processes would. In many-to-many, the kernel's run queue contains one entry per kernel thread in the pool (N of them), while the user-space runtime maintains its own separate ready queue of the M user-level threads waiting to be assigned to one of those N kernel threads.
How does a many-to-many runtime avoid the many-to-one blocking trap in practice?
Two common techniques: jacketing, where the runtime wraps potentially blocking calls (like `read`) so that instead of calling the blocking syscall directly, it first checks (via non-blocking mode and `epoll`/`kqueue`/`select`) whether the operation would block, and if so parks only the calling user thread while the underlying kernel thread goes on to run other user threads; and scheduler activations, a kernel-level mechanism that notifies (upcalls) the user-space scheduler whenever a kernel thread is about to block or has just become idle, letting the runtime immediately schedule a replacement user thread onto a fresh or freed kernel thread. Go's runtime uses a jacketing-style netpoller for I/O and additionally spins up extra OS threads if a goroutine makes a genuinely blocking syscall the netpoller can't intercept.
Does the choice of threading model affect memory usage per thread?
Yes, substantially. A user-level thread typically needs only its own stack (which can be small and even grown dynamically, as with Go's segmented/growable goroutine stacks starting around a few kilobytes) plus a small TCB struct, all in user-space memory. A kernel-level thread additionally requires kernel-side resources: a kernel stack (often fixed-size, e.g., 8 KB or larger on Linux), scheduling metadata, and entries in kernel data structures, all of which are pinned kernel memory that cannot be swapped out as flexibly. This is a major reason why systems wanting to support hundreds of thousands of concurrent lightweight tasks prefer user-level or M:N models over spawning that many one-to-one kernel threads.
Is many-to-many strictly better than the other two models, then?
Not strictly — it trades reduced overhead and better scalability for significantly higher implementation complexity. Building a correct M:N scheduler requires solving the blocking-call problem (via jacketing or scheduler activations), handling load balancing between N kernel threads, and managing thread migration, all of which are substantial engineering efforts prone to subtle bugs (this complexity is part of why Solaris eventually simplified away from a strict two-level model toward a design closer to one-to-one with an optional LWP layer). For a simple application with a handful of long-lived threads, plain one-to-one is simpler to reason about and just as fast in practice, since the overhead of a few dozen kernel threads is negligible.
Where does this topic connect to what I'll learn about context switching and scheduling?
The cost of a Context Switching operation is exactly why user-level and kernel-level thread switches differ by orders of magnitude — a user-level switch avoids the mode-switch and kernel-scheduler overhead that a kernel-level switch must pay. Likewise, the scheduling policies covered under CPU Scheduling Fundamentals (priority scheduling, multilevel queues, fairness) only ever apply to entities the kernel can see, which is precisely the population of kernel-level threads (N of them), making the mapping model a prerequisite for reasoning correctly about what "fair scheduling" even means for a many-to-one or many-to-many process.