Free lesson · Operating Systems
Green Threads And Goroutines
Not All Threads Are OS Threads
Everything you have learned about threads so far - kernel threads managed by the OS, context switches involving register saves and scheduler decisions, stacks consuming megabytes of memory - describes OS-level threads. These are the threads that the kernel creates, schedules, and destroys using system calls.
But there is another category of threads that breaks all of these rules. They are created in nanoseconds instead of microseconds. Their stacks start at kilobytes instead of megabytes. Millions of them can exist simultaneously in a single process. The OS kernel does not know they exist.
These are called green threads, lightweight threads, fibers, or - in their most popular modern implementation - goroutines (in Go), virtual threads (in Java 21+), and coroutines (in Python, Kotlin, and other languages). They represent a fundamental shift in how we think about concurrency, and they are increasingly the answer interviewers expect when you discuss high-concurrency system design.
Why OS Threads Hit a Ceiling
The limitations of OS threads become apparent at scale. Consider a chat server that needs to handle 1 million concurrent connections. With the thread-per-connection model using OS threads, you would need 1 million kernel threads.
The memory problem is immediate. Each OS thread has a stack, typically 1-8 MB. At 1 MB per stack, 1 million threads require 1 TB of stack memory alone - more than any single server has. Even at 100,000 threads, you need 100 GB of stack memory. The math simply does not work.
The scheduling problem is equally severe. With 1 million threads, the kernel scheduler must manage an enormous run queue. Context switches happen more frequently because there are more threads competing for CPU time. The scheduler's data structures (often balanced trees in modern kernels like Linux's CFS) grow larger, and each scheduling decision takes longer.
Green threads solve both problems. They have small stacks (starting at 2-8 KB in Go, even smaller in some runtimes), and they are scheduled by a user-space runtime rather than the kernel. A million goroutines consume about 4 GB of stack memory - easily within reach of a modern server with 64 or 128 GB of RAM. And the Go runtime's scheduler is optimized for managing millions of goroutines without the overhead of kernel scheduling.
To put the numbers in perspective: a server with 64 GB of RAM can support roughly 64,000 OS threads at 1 MB stacks each (already a stretch, since those 64 GB are also needed for application data, the OS, and everything else). The same server can support 16 million goroutines at 4 KB stacks each. That is a 250x improvement in maximum concurrency, and it is not a theoretical number - production Go services routinely run hundreds of thousands of concurrent goroutines.
The shift from OS threads to green threads is one of the most significant developments in systems programming in the last decade. It has fundamentally changed how we architect network services, and it is an increasingly important topic in system design interviews where high-concurrency scenarios are common.
How Green Threads Work
Green threads are managed by a runtime - a piece of software that runs inside your application and acts as a miniature operating system for lightweight threads. The runtime creates a small number of OS threads (typically one per CPU core) and multiplexes many green threads across them.
graph TD
subgraph RUNTIME["Language Runtime (Go, Java, Erlang)"]
G1["Goroutine 1"]
G2["Goroutine 2"]
G3["Goroutine 3"]
G4["Goroutine 4"]
G5["Goroutine 5"]
G6["Goroutine 6"]
SCHED["Runtime Scheduler"]
G1 --> SCHED
G2 --> SCHED
G3 --> SCHED
G4 --> SCHED
G5 --> SCHED
G6 --> SCHED
end
subgraph OS["Operating System"]
T1["OS Thread 1<br/>(pinned to Core 1)"]
T2["OS Thread 2<br/>(pinned to Core 2)"]
T3["OS Thread 3<br/>(pinned to Core 3)"]
end
SCHED --> T1
SCHED --> T2
SCHED --> T3
This diagram shows how green threads work. Six goroutines exist inside the language runtime. The runtime scheduler decides which goroutines run on which OS threads. Only three OS threads exist, one for each CPU core. The runtime multiplexes six goroutines across three OS threads, scheduling them without kernel involvement.
The runtime scheduler performs the same job as the kernel scheduler - deciding which green thread runs next - but it operates entirely in user space. A context switch between two goroutines on the same OS thread involves saving and restoring a few registers and the stack pointer, all without any system call. This makes green thread switches 10-100x faster than OS thread switches.
When a green thread blocks on I/O (like reading from a network socket), the runtime does not let the OS thread block. Instead, the runtime moves the blocked green thread off the OS thread, registers the I/O operation with an event loop (using epoll or similar), and schedules another green thread on the now-free OS thread. When the I/O completes, the runtime marks the blocked green thread as ready and schedules it again. This means one blocking green thread does not waste an OS thread - a critical improvement over the many-to-one threading model where a single blocking call stalled everything.
Go's Goroutines: The Most Popular Implementation
Go's goroutines are the most well-known implementation of green threads, and they are the example you are most likely to discuss in interviews. Here is how they work:
Creating goroutines is trivial. The go keyword creates a new goroutine:
func main() {
// Create a goroutine that runs concurrently
go handleRequest(conn)
// This goroutine continues running independently
// No thread creation overhead - just a function call
}
Creating a goroutine costs about 2 kilobytes of initial stack memory and takes nanoseconds. Compare this to creating an OS thread, which costs 1-8 MB of stack memory and takes microseconds. The difference is three orders of magnitude in both memory and time.
Goroutine stacks grow dynamically. Unlike OS threads with fixed-size stacks, goroutine stacks start small (2-8 KB) and grow as needed. If a goroutine calls a deeply nested function chain that requires more stack space, the Go runtime allocates a larger stack and copies the existing data over. This means goroutines use only as much memory as they actually need, rather than reserving a fixed worst-case amount upfront.
The GMP model. Go's scheduler uses three entities: G (goroutine - the green thread), M (machine - an OS thread), and P (processor - a scheduling context, one per CPU core). Each P has a local run queue of goroutines. Each M is assigned to a P and runs goroutines from that P's queue. When a goroutine blocks, the M is detached from the P, and the P grabs another M (or creates one) to keep running goroutines.
This GMP model is what makes Go's scheduler so efficient. The local run queues minimize contention (no single shared queue bottleneck). The work-stealing mechanism (idle Ps steal goroutines from busy Ps) provides load balancing. And the M-detach-on-block mechanism ensures that I/O-bound goroutines do not waste OS threads.
sequenceDiagram
participant G1 as Goroutine A
participant P as Processor (P)
participant M1 as OS Thread (M1)
participant M2 as OS Thread (M2)
participant NET as Network
Note over P: P is running goroutines<br/>on M1
P->>M1: Run Goroutine A
G1->>NET: Read from socket (blocks)
Note over M1: M1 is now blocked<br/>on the network call
P->>P: Detach from M1
P->>M2: Attach to M2<br/>(create if needed)
P->>M2: Run Goroutine B<br/>(from P's run queue)
Note over M2: M2 runs other goroutines<br/>while M1 waits for I/O
NET-->>M1: Data arrives
G1->>P: Goroutine A is ready again
P->>P: Add Goroutine A<br/>back to run queue
This sequence diagram shows how Go handles a blocking goroutine. Processor P runs Goroutine A on OS Thread M1. When Goroutine A blocks on a network read, M1 is blocked. But P detaches from M1 and attaches to a different OS thread M2 (creating one if needed). P continues running other goroutines on M2 while M1 waits for the network data. When the data arrives, Goroutine A is put back on P's run queue. No OS thread time is wasted.
Java's Virtual Threads (Project Loom)
Java traditionally used one-to-one threading - each java.lang.Thread mapped directly to an OS thread. This worked well for moderate concurrency but hit the same scaling ceiling as any OS thread model.
Java 21 introduced virtual threads through Project Loom. Virtual threads are lightweight threads managed by the JVM runtime, multiplexed across a pool of OS threads called carrier threads. The concept is the same as goroutines: millions of virtual threads can exist simultaneously, each with a small stack, scheduled by the JVM rather than the OS kernel.
// Traditional OS thread - heavy
Thread osThread = new Thread(() -> handleRequest(conn));
osThread.start(); // Creates an OS thread
// Virtual thread - lightweight (Java 21+)
Thread virtualThread = Thread.ofVirtual().start(() -> handleRequest(conn));
// Managed by the JVM runtime, multiplexed onto OS carrier threads
The significance of virtual threads is that they allow existing Java codebases to scale to massive concurrency without rewriting their threading model. Code that uses Thread.sleep(), synchronized blocks, and blocking I/O can be run on virtual threads with minimal changes, and the JVM runtime handles the multiplexing transparently.
Python's Coroutines and asyncio
Python takes a different approach. Because of the GIL (Global Interpreter Lock), Python threads cannot achieve true parallelism for CPU-bound work. Python's answer to high-concurrency I/O-bound work is coroutines with the asyncio framework.
Python coroutines are not exactly green threads - they use cooperative scheduling where the programmer explicitly marks yield points with await. But the effect is similar: thousands of concurrent I/O operations can be managed by a single thread, with the asyncio event loop switching between coroutines when one waits for I/O.
import asyncio
async def handle_request(conn):
data = await conn.read() # Yield point - event loop can switch
result = process(data)
await conn.write(result) # Yield point - event loop can switch
async def main():
# Handle thousands of connections concurrently
# on a single thread using coroutines
server = await asyncio.start_server(handle_request, 'localhost', 8080)
await server.serve_forever()
The difference from goroutines and virtual threads is that Python coroutines require the programmer to use async and await keywords - the concurrency is visible in the code. Goroutines and virtual threads are transparent - you write synchronous-looking code and the runtime handles the multiplexing. This difference matters for developer experience: transparent green threads let you use existing blocking libraries unchanged, while coroutines require the entire call chain to be async-aware (sometimes called "function coloring" - every function must be labeled as sync or async).
Erlang and the BEAM VM: The Pioneer
Erlang, designed for telecom systems in the 1980s, pioneered the green thread model decades before Go made it mainstream. Erlang processes (which are actually green threads - not OS processes) are extremely lightweight: each starts with about 300 bytes of memory. The BEAM VM (Erlang's runtime) can run millions of Erlang processes concurrently, each with its own heap (Erlang processes do not share memory - they communicate exclusively through message passing).
WhatsApp famously used Erlang to handle 2 million concurrent connections per server. Discord uses Elixir (which runs on the BEAM VM) for its real-time messaging infrastructure. These are real, proven production validations of the green thread model at extreme scale.
The key design insight from Erlang is that isolating green threads from each other - giving each one its own heap and requiring message passing for communication - eliminates the race condition problem entirely. You cannot have a data race if threads cannot share data. Go takes a middle ground: goroutines can share memory (so race conditions are possible), but Go provides channels as the preferred communication mechanism and includes a built-in race detector tool.
When to Choose Green Threads vs OS Threads
The decision depends on your concurrency requirements and workload characteristics.
Choose green threads when you need massive concurrency (tens of thousands to millions of concurrent tasks), your workload is I/O-bound (network servers, microservices, chat systems), and your language runtime supports them (Go, Java 21+, Erlang/Elixir, Kotlin coroutines). Green threads excel at the "many concurrent connections, each doing a little work" pattern.
Choose OS threads when you need CPU-bound parallelism (scientific computing, image processing, data processing pipelines), your workload has a moderate number of concurrent tasks (tens to hundreds), or you need fine-grained control over scheduling and thread affinity. OS threads are also the only option when your language does not have a green thread runtime.
Use both when your application has both I/O-bound and CPU-bound components. Go does this automatically - goroutines handle I/O concurrency while the OS threads underneath provide CPU parallelism. Java's virtual threads sit atop OS carrier threads for the same reason.
The Tradeoff Summary
| Feature | OS Threads | Green Threads / Goroutines |
|---|---|---|
| Stack size | 1-8 MB (fixed) | 2-8 KB (growable) |
| Creation cost | ~10-50 µs (system call) | ~1-10 ns (function call) |
| Max count | Thousands | Millions |
| Scheduling | Kernel (preemptive) | Runtime (cooperative + preemption points) |
| Blocking I/O | Blocks the OS thread | Runtime moves it, schedules another |
| Parallelism | Yes - multi-core | Yes - via OS threads underneath |
| Debugging | Standard OS tools (strace, gdb) | Runtime-specific tools needed |
| Language support | All languages (pthreads, etc.) | Go, Java 21+, Erlang, Kotlin |
Interview: "What are green threads and how do they differ from OS threads?"
"Green threads - called goroutines in Go and virtual threads in Java - are lightweight threads managed by a language runtime rather than the OS kernel. They have tiny stacks (kilobytes vs megabytes), can be created in nanoseconds instead of microseconds, and can exist in millions instead of thousands. The runtime multiplexes green threads across a small number of OS threads, handling scheduling and I/O blocking in user space. This gives you the concurrency of millions of threads with the parallelism of multiple cores, without the memory and scheduling overhead of OS threads."
Exercise: Starter: If you have Go installed, write a program that creates 100,000 goroutines, each incrementing a shared atomic counter. Time how long it takes. Then try the same in a language that uses OS threads (Java, Python) and compare the creation time and memory usage.
Intermediate: In Go, write a program that creates a goroutine that blocks on a network read for 5 seconds. While it is blocking, print from the main goroutine every second to demonstrate that the blocking goroutine does not prevent other goroutines from running. Then try the same experiment with OS threads in C or Java - does one thread blocking affect others?
Advanced: Research Go's GMP scheduler model in detail. Draw a diagram showing what happens when (a) a goroutine makes a blocking system call, (b) a goroutine performs a long computation without any yield points, and (c) a P's run queue is empty while another P's run queue has many goroutines. How does the scheduler handle each case? How does this compare to the OS kernel scheduler?