Operating systems and concurrency
Goroutines and green threads
Lightweight threads scheduled by the runtime instead of the OS. How goroutines use a netpoller, why M:N scheduling scales, and the trade-off against async/await.
A goroutine is a function running concurrently with others, managed by the Go runtime. It isn't an OS thread. A goroutine starts with a small stack, a few kilobytes that can grow, and the runtime schedules many of them onto a small pool of OS threads. This pattern is called green threads or M:N scheduling: M goroutines run on N threads.
The result is blocking-style code that scales like async code, with no async or await keywords in the source.
Walking through it
Starting a goroutine is cheap. Here, three of them each compute a square and send it on a channel. The WaitGroup waits for all three, and the sum is printed after the channel closes:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
results := make(chan int, 3)
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
results <- n * n
}(i)
}
wg.Wait()
close(results)
sum := 0
for r := range results {
sum += r
}
fmt.Println(sum)
}Output:
14The code reads like sequential blocking calls. Each go starts a goroutine, and channel sends wait only when the buffer is full.
How the runtime makes it scale
Blocking code is affordable only because the runtime, not the OS, handles the waiting:
- Stacks grow on demand. A goroutine starts small, so 100,000 of them fit in memory. An OS thread usually reserves a much larger fixed stack.
- Scheduling happens in user space. The runtime switches goroutines without a kernel call, so it's far cheaper than a thread switch.
- The netpoller handles sockets. When a goroutine reads a socket that isn't ready, the runtime parks it and registers the socket with epoll or kqueue. The OS thread moves on to another goroutine. When the socket is ready, the goroutine is rescheduled. This is the same readiness mechanism from blocking I/O and epoll, hidden behind a blocking call.
So a goroutine that waits on a socket costs almost nothing while it waits. The programmer never writes a callback or an await.
Trade-offs against async/await
Green threads avoid function color: there is no split between async and sync functions, so libraries compose freely. That's a real advantage in Go.
The costs are real too. Goroutines can run in parallel on several cores, so data races are possible and you need the synchronization tools covered in concurrency. A single goroutine doing a long CPU loop can delay others unless the runtime preempts it, and modern Go does preempt. Stack growth also means a goroutine's memory can spike under deep recursion.
Java's virtual threads and Erlang's processes follow the same idea. Node.js and Python asyncio take the opposite path, with one thread and explicit awaits.
Note
A goroutine leak is still a leak. A goroutine waiting on a channel that nothing will ever send to lives forever and holds its stack. Give each goroutine a way to exit, usually a context or a closed channel.
Common mistakes
- Starting one goroutine per request or per item with no limit, so memory grows with input size.
- Writing a goroutine that waits on a channel with no sender or exit path, so it leaks.
- Sharing a map between goroutines without a mutex or a channel. Go's runtime detects some of these races when the program runs.
Check yourself
- Why can a program hold 100,000 goroutines but only a few thousand OS threads? Answer: goroutines start with small growable stacks and are scheduled in user space, so they are far cheaper than OS threads.
- Why don't Go programs need
asyncandawaitkeywords? Answer: the netpoller parks a goroutine on a blocking-looking call and reschedules it when the socket is ready.
Key takeaways
- Goroutines are lightweight, runtime-scheduled threads, multiplexed onto a small pool of OS threads.
- The netpoller turns blocking socket calls into efficient waits.
- Green threads avoid function color, but parallel execution still needs synchronization.
- Bound the number of goroutines, and give each a clear exit.
Further learning
Next, see backpressure and bounded queues for limiting the work in flight.
Related
Processes, threads and the kernel
What the OS gives every program, and how to see it on a live Linux box. Start here for the process model.
Async and await state machines
What async and await compile down to. Each await is a suspension point, the function becomes a state machine, and function color is why async spreads through a codebase.
Blocking I/O and epoll
How a thread blocks on a socket, why select and poll slow down at scale, and how epoll and kqueue report only the sockets that are ready.