A production service reports 100,000 active tasks, but Linux shows only twelve threads in the process.
Both observations can be correct.
The application may represent each request as a runtime-managed task while multiplexing those tasks over twelve kernel-scheduled threads. Another runtime may create one kernel thread for every language-level thread. A third may run most application callbacks on one event-loop thread while using helper threads for selected operations.
The word thread at the language level therefore does not reveal what the kernel schedules.
A runtime threading model defines how language-level execution units are mapped onto kernel-level threads.
That mapping determines whether a blocking operation occupies an operating-system thread, how much CPU parallelism is possible, what Linux tools can observe, and how concurrency limits should be chosen.
Application code can pass through several schedulers before reaching hardware:
The runtime scheduler understands language-level work. It may know that a task is waiting for a future, a timer, a channel, or an asynchronous I/O result.
The kernel scheduler understands kernel-visible threads. It knows their runnable state, priority, CPU affinity, and CPU usage, but it does not automatically know which application task is mounted on a carrier.
When the kernel schedules a carrier, the runtime gets an opportunity to run one of its tasks. When the kernel preempts that carrier, the mounted task stops too.
This produces two distinct scheduling questions:
A runtime model can add or remove logical concurrency. It cannot bypass the kernel scheduler or execute code without a kernel-visible thread.
Several different objects appear in runtime discussions.
An OS thread or kernel-level thread is the schedulable task the operating system can place on a CPU.
A platform or native thread is a language or runtime object backed directly by an OS thread. In a one-to-one implementation, its lifetime occupies one kernel thread.
A runtime-managed thread is a stackful logical thread scheduled by the runtime over one of several carriers. Java virtual threads and Go goroutines fit this broad category, although their precise semantics differ.
An asynchronous task or coroutine is a resumable unit of work scheduled by an event loop or executor. It often suspends only at defined points and may not own a traditional native stack for its whole lifetime.
These names describe different layers:
One process can use several models at once. An event-loop runtime may also maintain a blocking-operation thread pool and allow explicit worker threads for CPU-intensive work.
Consider a logical task that starts a read and must wait for data.
A runtime can handle that wait in several ways.
The task remains attached to its OS thread, and the kernel marks that thread waiting:
A logical task occupying KLT 4 performs a blocking read, and KLT 4 waits.
This is ordinary one-to-one blocking. Other kernel threads can continue, but KLT 4 cannot carry other application work until the read completes.
The runtime arranges the operation so the logical task can be parked while its carrier runs something else:
No kernel thread must remain dedicated to A during the wait.
Some operating-system or library operations do not provide a convenient non-blocking interface. A runtime may execute them on a bounded pool of helper OS threads:
An event-loop task hands the work to a helper pool thread, which blocks in the operation. The event-loop thread stays available for other callbacks.
The blocking has not disappeared. It has moved to a controlled group of kernel threads.
If code performs an unrecognized synchronous operation on an event-loop or carrier thread, that OS thread can still block. Every logical task depending on that worker loses execution capacity until it returns.
The syntax used by the application does not guarantee the kernel behavior. The complete path through runtime, library, system call, and device support determines whether an OS thread remains occupied.
The simplest runtime mapping is one language-level thread to one OS thread:
Language thread A maps to KLT A, B to KLT B, and C to KLT C.
POSIX threads on modern Linux use this model. Common implementations of native C++, Rust, Java platform threads, and managed runtime threads also ultimately execute on kernel threads, though language specifications may leave implementation details open.
The model has direct semantics:
Thread pools make one-to-one threads reusable and bound their population, but they do not change the mapping. Eight long-lived pool workers remain eight kernel-level threads.
One-to-one is a good fit when thread count is moderate and direct kernel visibility is valuable. It becomes expensive when an application wants enormous numbers of mostly waiting execution contexts.
A many-to-many runtime maintains many logical execution contexts over a smaller or independently controlled set of OS threads:
Six logical threads are cheap because only the three carriers cost the kernel anything.
Only mounted logical threads execute. Six logical threads over three scheduled carriers can produce at most three-way CPU parallelism.
When the runtime understands a blocking operation, it can unmount or park the logical thread and reuse the carrier. This permits a very large waiting population without an equally large OS-thread population.
Runtime-managed contexts still consume resources:
“Lightweight” means the per-context cost can be much smaller than an OS thread. It does not mean that millions of contexts consume no memory.
The runtime must also preserve logical identity across carriers. A task may run on carrier 1, suspend, and later resume on carrier 3. Runtime-level stack traces and task-local state must follow the logical task rather than accidentally exposing carrier state.
Runtime-managed concurrency commonly appears in two forms.
A stackful logical thread preserves a call stack across suspension. Code can look like an ordinary blocking sequence:
The call chain runs handle request, then query service, then waits for the response. The logical thread suspends at that point and resumes with the chain intact to continue parsing.
Java virtual threads and Go goroutines provide this style.
A stackless task or coroutine preserves a continuation at defined suspension points. The compiler or runtime records the local state required to resume, and the event loop later advances it.
JavaScript promise continuations, Python asynchronous tasks, .NET asynchronous methods, and Rust futures commonly participate in this style.
Both forms can multiplex many logical operations over a small number of OS threads. Their programming semantics and runtime representation differ, but the kernel sees only the workers that actually execute them.
A Java platform thread uses an underlying OS thread for its lifetime. On Linux, the JVM asks the operating system to schedule that native thread just as it schedules other KLTs in the process.
If a platform thread blocks in I/O, the underlying OS thread blocks. A platform-thread pool limits how many such workers exist and reuses them across tasks.
The mapping is conceptually:
A Java platform thread maps to a native OS thread, which the kernel places on a logical CPU.
This gives direct parallelism and familiar kernel observability, but the number of concurrent blocked requests is limited by the number of platform threads the process can afford.
The JVM also creates internal OS threads for garbage collection, compilation, signal handling, and other runtime services. A Linux thread count can therefore be larger than the number of application platform threads.
Java virtual threads are scheduled by the JDK rather than directly by the OS. The JDK mounts a virtual thread on a platform thread called its carrier:
When a supported blocking operation cannot complete, the virtual thread can unmount. Its carrier becomes available to run another virtual thread. When the operation completes, the JDK makes the virtual thread runnable and later mounts it on an available carrier, which may be a different one.
The result is a stackful many-to-many model:
Virtual threads do not make CPU-bound code faster. Ten thousand runnable virtual threads executing calculations still compete for finite carrier and CPU capacity.
They also do not remove downstream limits. Ten thousand virtual threads issuing database queries can still overwhelm a database with 50 useful connections.
The scalability benefit depends on freeing carriers during waits. Some operations may capture or pin the carrier because the runtime cannot safely unmount the virtual thread.
This behavior is version-dependent. Starting with JDK 24, blocking while holding a Java monitor through synchronized no longer causes the broad pinning behavior present in earlier virtual-thread releases. Remaining cases are uncommon but can include blocking across certain native or foreign-function interactions and some JVM initialization paths.
The durable rule is:
Verify whether important blocking paths release their carriers on the deployed JDK version.
A runtime upgrade can change this mapping detail without changing the application source.
Virtual threads are intended to represent tasks directly rather than to be pooled as scarce OS threads. The scarce resource is the carrier population and the underlying CPU or dependency capacity, not the virtual-thread object itself.
The Go runtime describes its scheduler with three entities:
The runtime matches one of each:
All three are required at once. A runnable goroutine with no P waits, which is how the runtime caps how much Go code runs in parallel.
The number of Ps is controlled by GOMAXPROCS. This bounds how many goroutines can ordinarily execute user Go code simultaneously.
There can be more Ms than Ps. If an M enters a blocking system call, the runtime can detach its P and let another M use that P to execute a different G. Pollable network I/O can park a goroutine through the runtime's network poller without dedicating an M to the entire wait.
This produces several observations that surprise newcomers:
Current Go runtimes can choose a default GOMAXPROCS using logical CPU availability, affinity, and on Linux the cgroup CPU quota. Explicit configuration can override that choice, so production diagnostics should inspect the runtime setting rather than infer it from host CPU count.
The Go execution trace can show goroutine scheduling states that kernel tools cannot. Kernel tools remain necessary for seeing whether the Ms receive CPU time, block in system calls, or migrate between CPUs.
The ordinary CPython threading model creates native OS threads. Linux sees and schedules those threads individually.
In a conventional GIL-enabled CPython interpreter, a thread must hold the global interpreter lock, or GIL, to access Python objects and execute through the interpreter.
Three real OS threads exist and the kernel can place all three on CPUs, but interpreter work funnels through one lock at the end.
The kernel can run the OS threads on different CPUs, but only the GIL holder can execute GIL-protected Python interpreter work at that instant. CPU-bound pure-Python threads in one interpreter therefore do not normally scale across cores simply because more threads are added.
The GIL is commonly released around blocking I/O. Native extension code can also release it while performing work that does not need Python objects. During those intervals, other Python threads can execute, and properly written native extensions may perform computation in parallel.
This makes GIL-enabled threads useful for many blocking workloads even though they do not provide unrestricted parallel execution of Python bytecode.
Starting with CPython 3.13, an optional free-threaded build can run with the GIL disabled. In Python 3.14, free-threaded Python is officially supported rather than only experimental.
With the GIL disabled, multiple threads can execute Python code in parallel, subject to ordinary synchronization, memory, extension, and workload constraints.
The distinction is a runtime configuration, not just a source-language property. Some C extensions that are not marked as free-threading compatible can cause the GIL to be enabled again.
The accurate operational question is therefore:
Saying “Python threads cannot use multiple cores” is too broad. It remains a useful description of CPU-bound code in conventional GIL-enabled CPython, but it is not a universal property of Python or current CPython builds.
Loading simulation...
Python's event-loop model adds a separate kind of logical concurrency.
One event loop runs one asynchronous task at a time on its OS thread. When a task reaches a supported suspension point and waits for an event, the loop runs another ready task:
On the single event-loop OS thread, task A runs and awaits I/O, task B runs and awaits a timer, task C runs and completes, and task A resumes when its I/O is ready.
The kernel does not see these tasks as threads. It sees the event-loop thread and any helper threads the runtime uses.
If a task performs a long CPU calculation or synchronous blocking call without suspending, it occupies the event-loop thread. Other tasks assigned to that loop cannot execute until control returns.
An event loop provides high logical concurrency for cooperative, non-blocking work. It does not turn one event-loop thread into multi-core CPU execution. Multiple OS workers or processes are needed when Python computation itself must run in parallel, with the exact choice affected by whether the interpreter uses the GIL.
A typical Node.js process executes JavaScript callbacks on an event-loop thread:
JavaScript callbacks and promise continuations run on the event-loop thread, which the OS scheduler places on a CPU.
Network I/O is commonly integrated with operating-system readiness and completion mechanisms, allowing the event loop to track many connections without one blocked thread per connection.
Node also uses a libuv worker pool for selected operations that need blocking native work or are intentionally offloaded, including categories of filesystem, name-resolution, cryptographic, and compression work.
A Node process contains:
A CPU-heavy or synchronous JavaScript callback blocks progress for other callbacks on that event loop. The kernel may show low thread count and high utilization on one event-loop thread while other CPUs remain available.
Node worker threads provide independent JavaScript execution threads for CPU-intensive work. Each worker has its own JavaScript execution environment and event loop, and the operating system schedules its underlying thread.
The important mapping is:
In common .NET deployments, managed thread-pool workers execute on operating-system threads. The runtime maintains and adjusts a reusable worker population.
A .NET Task is not necessarily a thread. It represents an operation and its eventual completion. CPU work may execute on a thread-pool worker, while true asynchronous I/O can remain pending without holding a worker thread for the duration of the wait.
An await-style suspension does not block the thread evaluating the method. When the underlying operation completes, runtime scheduling determines where the continuation runs.
If the supposedly asynchronous path calls a synchronous blocking operation, a worker can still be occupied. A large number of blocked pool workers can delay unrelated work even though the surrounding methods use asynchronous syntax.
The kernel sees the .NET runtime's OS threads. It does not see each Task as a schedulable thread.
Native thread libraries in C++ and Rust commonly create operating-system threads on Linux. A native thread that blocks occupies its KLT, and the kernel can schedule native threads in parallel.
Their ecosystems also provide asynchronous runtimes and executors that multiplex many tasks over one or more worker threads:
Many futures or coroutine tasks are run by a smaller set of executor workers, which are themselves OS threads.
The source language alone does not determine the mapping. A Rust service using native threads has a different OS behavior from one using a multithreaded async executor, and an executor configured for one worker behaves differently from one configured for several.
Library documentation and runtime configuration are part of the system's threading model.
The kernel-facing differences can be summarized as follows:
| Runtime abstraction | What the kernel schedules | What happens during a supported wait | CPU parallelism |
|---|---|---|---|
| POSIX or native platform thread | One KLT per thread | That KLT blocks | Up to runnable KLTs and available CPUs |
| Java virtual thread | Carrier platform threads | Virtual thread usually unmounts; carrier can run another | Bounded by carriers and available CPUs |
| Go goroutine | Runtime Ms, with Ps governing Go execution | G can park; runtime reuses execution capacity | Bounded by Ps, runnable Ms, and available CPUs |
| GIL-enabled CPython thread | One KLT per Python thread | I/O often releases GIL while KLT may block | Native work can parallelize; GIL-protected Python work is serialized |
| Free-threaded CPython thread | One KLT per Python thread | That KLT may block | Ordinary thread parallelism, subject to runtime and application limits |
| Python or JavaScript event-loop task | Event-loop and helper KLTs | Task suspends and loop runs another when operation is non-blocking | One task at a time per event-loop thread |
| .NET asynchronous operation | Thread-pool and runtime KLTs | True async I/O can wait without holding a worker | Continuations parallelize across available workers |
No row means “unlimited.” Each model eventually reaches CPUs, memory, file descriptors, connections, and downstream systems with finite capacity.
Loading simulation...
Suppose a service must complete 1,000 requests per second. Each request requires:
Little's Law implies about:
The CPU demand is:
The workload needs about two fully utilized CPUs for request computation, plus headroom.
A one-to-one blocking design may need roughly 100 OS threads to represent the 100 requests in flight.
A stackful many-to-many runtime may represent 100 logical threads over a much smaller carrier population because waiting logical threads can unmount.
An event loop can represent all 100 pending operations on one OS thread, but one loop cannot provide two CPU-seconds of JavaScript or Python computation per wall-clock second. CPU work must also be distributed across additional execution capacity.
All three designs face the same network and downstream limits. The runtime model changes how waiting is represented; it does not reduce the two CPU-seconds of computation or create additional dependency capacity.
Linux shows kernel-level threads:
This answers:
It does not answer:
Those questions require runtime-aware data such as thread dumps, goroutine profiles, execution traces, event-loop utilization, task diagnostics, and runtime queue metrics.
The two views should be correlated:
The mismatch is not itself a bug. It becomes useful evidence about where scheduling or waiting occurs.
Runtime multiplexing works only while the runtime can regain control of its carrier.
Native code can complicate that guarantee. A foreign call may:
The result can be carrier capture: one logical task occupies an OS thread for the duration of the call.
Carrier capture is not automatically a correctness problem. It is a capacity problem when many long operations capture most or all carriers.
This is why two operations that look equally “blocking” in application code can scale differently. One is integrated with runtime suspension, while the other pins or blocks the underlying worker.
Production sizing should measure carrier or OS-thread behavior for real libraries, database drivers, filesystem paths, and native extensions—not only for synthetic timers.
Lightweight tasks make it possible to represent more concurrent work. That is not permission to send unlimited work to a dependency.
Consider one million virtual threads waiting for a database:
Runtime memory may comfortably support the logical threads, but the database still has finite connections and query capacity.
The runtime may avoid one million OS stacks, but the application still retains request state, deadlines, buffers, and database demand.
Concurrency limits should move to the scarce resource:
Runtime-managed concurrency changes the cost of waiting. It does not eliminate overload, queueing, or tail latency.
A runtime threading model maps language-level execution onto kernel-level threads. One-to-one models dedicate an OS thread to each language thread, many-to-many models multiplex logical threads over carriers, and event-loop models advance resumable tasks on one or more workers.
Java virtual threads and Go goroutines provide stackful runtime-managed concurrency. Python and Node.js event loops schedule asynchronous tasks without making each task a KLT. Conventional CPython threads are native threads constrained by the GIL for interpreter work, while optional free-threaded builds remove that restriction. .NET asynchronous I/O can suspend operations without holding pool workers.
The kernel schedules only OS threads. To understand performance, ask how blocking is implemented, how many carriers can execute, whether native calls capture them, and which runtime diagnostics reveal the logical work hidden above the kernel boundary.
5 quizzes