Dev Skills

Goroutines vs threads: 8 differences that matter

Why Go can run a hundred thousand goroutines when the same number of OS threads would sink your machine.

Topic 88 of 98 · 5 min to read · 20 min to build
After this topic you can
  • Explain the 8 differences between a goroutine and an OS thread in an interview.
  • Describe how the Go scheduler runs many goroutines on a few threads, and how it preempts them.
  • Run a program that starts 100,000 goroutines and prints the result.

A goroutine is a function run concurrently by the Go runtime. An OS thread is run by the operating system kernel. Go runs many goroutines on a few threads. Here is the whole comparison in one table. Each row is explained below.

GoroutineOS thread
Managed byGo runtime (user space)Operating system kernel
Starting stackSmall (2 KB), grows and shrinks as neededFixed, usually 1–8 MB reserved
Cost to createVery cheap, no system callExpensive, needs a system call
Switching costCheap, done by the Go schedulerCostlier, done by the kernel
SchedulingGo scheduler, M:N (many goroutines on N threads), preemptive since Go 1.14Kernel scheduler, preemptive
How many you can runHundreds of thousands is normalUsually thousands before memory hurts
IdentityNo ID you can get at; on purposeHas a thread ID
Usual way to talkChannels (or a mutex when simpler)Shared memory plus locks

What is the difference between a goroutine and a thread?

A thread is scheduled by the operating system and costs megabytes of reserved stack. A goroutine is scheduled by the Go runtime, starts with a 2 KB stack, and many goroutines share a few threads. So goroutines are far cheaper to create and switch.

You still get real parallelism: the runtime uses as many threads for Go code as GOMAXPROCS says (by default, the number of CPU cores). The basics are in the Goroutines lesson (Goroutines).

Are goroutines cooperative or preemptive?

Preemptive. Since Go 1.14 the runtime has asynchronous preemption: it can stop a goroutine that runs too long (about 10 ms), even a tight loop with no function calls, by sending its thread a signal. Before 1.14 it was mostly cooperative.

Some older articles (including a well-known GeeksforGeeks page) still say goroutines use "cooperative scheduling". That was true of old Go versions, not of any Go you will install today. If an interviewer says it, you can politely mention Go 1.14.

preempt.go
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1) // only one thread runs Go code
go func() {
for { // a busy loop that never calls a function or blocks
}
}()
time.Sleep(20 * time.Millisecond)
// Before Go 1.14 this line could hang forever: the loop never gave up the thread.
fmt.Println("main still runs: the busy goroutine was preempted")
}
With one thread and a loop that never yields, main only gets to print because the runtime preempts the loop.

Why are goroutines so cheap?

Because they start with a tiny stack that grows only when needed, and creating or switching one never asks the kernel. A thread reserves a big stack up front and every switch goes through the kernel. That is why 100,000 goroutines is fine.

many.go
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var wg sync.WaitGroup
var total atomic.Int64
for i := 1; i <= 100_000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
total.Add(int64(i))
}()
}
wg.Wait()
fmt.Println("goroutines started: 100000")
fmt.Println("sum of 1..100000 =", total.Load())
}
Doing this with 100,000 OS threads would need gigabytes of stack. Here it finishes in a blink.

How does the Go scheduler work (GMP)?

The scheduler matches three things: G (goroutines), M (OS threads) and P (processors, one per GOMAXPROCS). Each P has a queue of runnable Gs and runs them on an M. Idle Ps steal work from busy ones.

When a goroutine blocks on a channel, a mutex or the network, the runtime parks it and runs another G on that thread. When it blocks in a system call, the P is handed to another thread so other goroutines keep running.

Why does Go not give goroutines an ID?

On purpose, so people do not build "thread-local storage" on top of goroutine IDs. In Go you pass what a goroutine needs as arguments, or in a context.Context for request-scoped values (Context).

When are threads still the answer?

In Go, almost never directly. You write goroutines and let the runtime choose threads. runtime.LockOSThread exists for the rare case where a C library or OS API needs the same thread every time, such as some GUI or graphics libraries.

Note: In Java 21+, virtual threads are a similar idea: cheap, runtime-scheduled threads on top of a few OS threads. If you know them, goroutines will feel familiar.
The short version
  • Goroutines are run by the Go runtime; threads by the OS.
  • Goroutines start at 2 KB of stack and grow; threads reserve megabytes.
  • The scheduler runs many goroutines on GOMAXPROCS threads (M:N, the GMP model).
  • Goroutines are preemptive since Go 1.14, even in tight loops. "Cooperative" is outdated.
  • There is no goroutine ID; pass data in arguments or a context.
Remember: Goroutines are cheap, runtime-scheduled, preemptive (since Go 1.14) and run on a few OS threads.
Then try these in your own editor
  1. 1Run preempt.go with runtime.GOMAXPROCS(1). Why does main still print?
  2. 2Change many.go to print runtime.NumGoroutine() just before wg.Wait(). Why does the number change from run to run?
  3. 3In one sentence each, name three things that make a goroutine cheaper than a thread.