AlgoMaster Logo

Goroutines vs OS Threads

High Priority13 min readUpdated June 6, 2026

Goroutines look like threads on the surface. Both run code concurrently, both have a stack, both can be paused and resumed. Underneath, they're a different shape of thing: a goroutine costs a few kilobytes and starts in microseconds, while an OS thread costs megabytes and starts in milliseconds. This lesson lines the two up side by side so the trade-offs are concrete.

What an OS Thread Actually Is

An OS thread is a unit of execution that the operating system kernel knows about and schedules directly. Creating one is a system call: the program asks the kernel, the kernel allocates a stack, registers, and bookkeeping, and adds the thread to its run queue. Switching from one thread to another is also a kernel operation, since the kernel is the only thing that can preempt running code and swap CPU registers.

The cost of "kernel knows about it" shows up in two places. First, every thread reserves a fixed stack at creation. On Linux, the default is 8 MB of virtual address space per thread (macOS defaults to 512 KB for non-main threads, Windows to 1 MB). The stack isn't all backed by physical memory immediately, but the virtual range is locked in and can't grow past its limit. Hit the limit and you get a stack overflow crash, not a transparent resize. Second, a context switch between threads bounces through the kernel: save registers, swap page tables (sometimes), restore the other thread's registers, return to user space. The round trip takes on the order of one to several microseconds on modern hardware, which adds up fast when threads are switching tens of thousands of times per second.

The model also caps the number of threads you can have. An e-commerce API serving 50,000 concurrent users would need 50,000 threads to dedicate one thread per request, and at the Linux default of 8 MB per thread, that's 400 GB of stack address space. Even at the smaller macOS default of 512 KB, 50,000 threads still claim 25 GB. No server is built that way. Thread-based servers use a pool of a few hundred threads and multiplex requests across them, which means writing code that never blocks for long inside a request, or paying the price of a wedged thread.

What a Goroutine Actually Is

A goroutine is a function scheduled by the Go runtime, not by the kernel. When you write go someFunc(), the runtime allocates a small structure, hands it a tiny stack, and adds it to its own run queue. No system call. No kernel involvement. The whole operation takes a few microseconds and a couple of kilobytes of memory.

The starting stack size is around 2 KB (it's been 2 KB since Go 1.4; earlier versions used 8 KB, and a few internal goroutines start slightly larger). When a goroutine needs more stack than it has, the runtime allocates a bigger one, copies the existing stack over, and updates pointers. The goroutine never knows this happened. When the goroutine returns from deep call chains and the stack shrinks, the runtime can give the memory back. Growth and shrinkage are transparent and bounded only by the process's available memory and a configurable hard cap (runtime/debug.SetMaxStack, default 1 GB on 64-bit systems).

Switching between goroutines happens in user space. The Go runtime maintains its own scheduler, and when one goroutine blocks (waiting on I/O, a channel, a lock) or yields, the runtime saves a small set of registers, picks the next runnable goroutine, and resumes it. No kernel call, no page table swap. The switch takes a few hundred nanoseconds, roughly ten times faster than an OS thread switch.

The outcome is many goroutines running on few threads.

Side-by-Side Comparison

PropertyGoroutineOS Thread
Created byGo runtime (go statement)Kernel (system call)
Initial stack~2 KB, grows as needed~512 KB to 8 MB, fixed at creation
Stack growthAutomatic, transparentNone, overflow crashes the thread
Creation costMicrosecondsTens of microseconds to milliseconds
Context switchUser space, ~hundreds of nsKernel space, ~1-2 microseconds
SchedulerGo runtime (cooperative + preemptive since Go 1.14)Kernel preemptive scheduler
Per-instance memory~2 KB at start~512 KB to 8 MB virtual, plus kernel state
Practical countMillions on a normal machineThousands before the system struggles
IdentityNo public ID, no thread-local storageOS-assigned ID, supports TLS
Killable from outsideNo (cooperative cancellation only)Yes (e.g., pthread_cancel), but unsafe

The table is the short version of everything else in this lesson. The rest of the sections explain where each number comes from and why it matters.

Memory: Where the Numbers Come From

Stack size is the biggest practical difference between the two. The arithmetic is worth walking through, because it explains why "spawn a goroutine per request" works in Go and why the equivalent thread-per-request server doesn't scale.

A Go program with one million blocked goroutines, each holding its initial 2 KB stack, consumes about 2 GB of stack memory. That's not tiny, but it fits on a modern server. People run this benchmark all the time: spawn a million goroutines, park them, watch RSS settle around 2 to 3 GB. The program keeps running.

The same program with kernel threads is a non-starter. One million threads at the Linux default of 8 MB stack each is 8 TB of virtual address space. The kernel refuses to create them long before that: most Linux distributions cap per-process threads around 32,000 to 60,000 by default (/proc/sys/kernel/threads-max, RLIMIT_NPROC). Even at 50,000 threads, you're claiming 400 GB of virtual memory and asking the kernel scheduler to juggle 50,000 entities. Throughput collapses long before that, since most of the CPU goes into context switching.

The reason goroutines can be this cheap is the growable stack. Most goroutines never use more than a few kilobytes. A handler that decodes a JSON body, calls a database, encodes a response, and returns has a deepest call chain of maybe 20 frames, total stack usage 4 to 8 KB. Goroutines pay for what they use. Threads pay the worst case up front.

Creation Cost

Spawning a goroutine is a function call into the Go runtime. It allocates a g structure (a few hundred bytes), assigns a stack from a per-P cache when one is available, and pushes the goroutine onto a run queue. No system call. On a modern x86-64 machine, the whole operation takes single-digit microseconds when stacks are coming from the cache, and a bit more when a fresh stack has to be allocated.

Spawning a thread is a system call (clone on Linux, pthread_create wraps it). The kernel has to allocate a kernel-side task structure, set up the stack region (often using mmap to reserve the virtual address range), register signal handlers, and put the thread on its run queue. Even on fast hardware, the round trip is in the tens of microseconds at minimum, and can stretch into milliseconds when the system is busy.

The difference shows up at scale. Spawning a goroutine per incoming HTTP request is normal Go code, written without thinking about cost.

Spawning 10,000 goroutines this way takes a few tens of milliseconds on a laptop. The same loop with 10,000 OS threads would either crash from resource limits or take seconds just to create the threads. That's why every Go HTTP server you've seen does this: the standard library's http.Server spawns a goroutine per accepted connection without apology.

Context Switch Cost

A context switch is the work needed to pause one execution unit and resume another. For threads, the kernel does it. For goroutines, the Go runtime does it.

A kernel thread switch saves the current thread's registers, possibly flushes TLB entries if the new thread belongs to a different process, restores the new thread's registers, and returns to user space. On modern x86-64 hardware, a same-process switch is roughly 1 to 3 microseconds, and longer across processes. On busy servers, the L1 and L2 cache footprint of the previous thread is often gone by the time it runs again, which adds indirect cost on top of the raw switch.

A goroutine switch is a small assembly routine inside the Go runtime: save a handful of registers into the g structure, load the next goroutine's registers, jump. No kernel, no TLB flush, no syscall. The cost is in the low hundreds of nanoseconds, roughly an order of magnitude less than a thread switch. Cache effects can still bite, but the direct switching cost is negligible by comparison.

This matters most in systems that block frequently. A web server handling a request with three database queries and four HTTP calls switches dozens of times per request. With OS threads, each block is a few microseconds of overhead; with goroutines, a few hundred nanoseconds. Multiplied across thousands of concurrent requests, the difference shows up in CPU graphs.

The M:N Model

Goroutines don't run on bare metal. The Go runtime keeps a small pool of OS threads (one per CPU core by default) and multiplexes goroutines on top of them. Many goroutines map onto a few threads. This is usually written as "M:N scheduling", where M goroutines run on N OS threads. (Confusingly, the Go scheduler internally also uses the letter M for "machine", meaning an OS thread. Those two Ms are unrelated. This lesson uses "many goroutines on a few threads" to avoid the collision.)

The diagram is the model in one picture. Eight goroutines live on top of three OS threads, and the threads in turn run on whatever CPU cores the kernel hands them. The runtime is free to move goroutines between threads, steal work from a thread that's idle, and park threads that have nothing to do. What matters here is the ratio: it's normal for one Go program to have thousands of live goroutines and only a dozen OS threads.

The number of OS threads the runtime uses is governed by GOMAXPROCS. The short version: by default, the runtime tries to keep around GOMAXPROCS worker threads running goroutines, plus extras for goroutines blocked in system calls. On an 8-core machine running 10,000 goroutines, you'll typically see 8 to 20 OS threads in the process, not 10,000.

Practical Implications

The performance differences add up to a different way of structuring concurrent programs.

In a thread-based language, "spawn one worker per task" is something you do for batch jobs with a small number of tasks. For request-handling servers, you build a thread pool of bounded size and queue work onto it. The pool size becomes a tuning parameter: too small and you have queueing delay, too big and you waste memory and switch overhead. Code inside a worker has to avoid blocking for long, or a wedged worker eats one of your scarce slots.

In Go, "spawn a goroutine per task" is the default pattern, even for request handling. The standard http.Server does exactly this: one goroutine per accepted connection, multiplied across however many requests are in flight. A server handling 50,000 concurrent connections has 50,000 live goroutines, costs maybe 100 MB of stack memory, and runs fine on a small VM. The same load with a thread-per-connection server would need a 50 GB machine and would spend most of its CPU on context switches before throughput collapsed.

Blocking inside a goroutine is also cheap. When a goroutine waits on a database query, a network read, or a sync.Mutex, the runtime parks it and moves another goroutine onto the underlying thread. The thread stays busy with other work. In a thread-per-request server, a blocked thread is a thread that's doing nothing while still holding its stack and scheduler slot. The Go model lets you write straight-line blocking code without paying for the block.

A thousand goroutines, each sleeping for 50 ms, finish in just over 50 ms total. The runtime kept enough threads busy to overlap all the waits. The thread-based equivalent would either need a thread pool of 1,000 threads (memory cost: gigabytes) or would serialize the work and take 50 seconds.

What Goroutines Are Not

Goroutines are cheap, but they're not free, and they're not magic. A few things people read into them that aren't true:

  • A goroutine is not a thread. It has no kernel-visible ID, doesn't show up in ps, doesn't support OS thread-local storage. When calling into C with cgo or using OS APIs that care about thread identity, the rules change and you may need runtime.LockOSThread.
  • A goroutine doesn't escape the laws of physics. A million goroutines doing CPU-bound work on an 8-core machine still get 8 cores of throughput. Concurrency is not parallelism, and a free goroutine can't make one core run two computations at once.
  • A goroutine doesn't clean itself up. It runs until its function returns. If the function blocks forever, the goroutine and its stack stay live for the lifetime of the program. This is a leak, and at scale it eats all your memory.
  • A goroutine isn't killable from outside. There's no goroutine.Kill() method. The standard way to stop one is cooperative cancellation: the goroutine checks a signal (a context.Context, a flag) and exits on its own. If it ignores the signal, it can't be forced.
  • Stack growth isn't free. Most goroutines stay small, but deep recursion or large local arrays trigger stack copies. Each copy is fast, but a function written to deliberately blow up the stack can dominate runtime cost.

These patterns are easy to avoid once you know they exist, and the upside (writing simple, blocking, one-goroutine-per-task code) is what makes Go well-suited for network services. "Lightweight" isn't "weightless".

A Concrete Demonstration

The differences become intuitive on a real program. The code below spawns many goroutines, parks each on a wait group barrier, and reports memory with runtime.ReadMemStats. It's the goroutine version of the "million threads" thought experiment, except it actually runs.

A million goroutines, spawned in under half a second, holding about 2.6 GB of memory (roughly 2.6 KB per goroutine including runtime overhead). On the same machine, attempting to spawn a million OS threads with pthread_create would fail somewhere around the 30,000 mark with EAGAIN. Even if it didn't, the memory cost at 512 KB per stack would be 500 GB.

The comparison runs the other way for raw per-task CPU work. For CPU-bound code that doesn't block, goroutines don't make the CPU faster, the runtime just spreads work across cores. The right benchmark for goroutines isn't "how fast can I compute pi"; it's "how many concurrent waiters can I afford". On that benchmark, they win by orders of magnitude.

Quiz

Goroutines vs Threads Quiz

10 quizzes