Guide

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:

Go
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:

14

The 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:

  1. 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.
  2. Scheduling happens in user space. The runtime switches goroutines without a kernel call, so it's far cheaper than a thread switch.
  3. 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 async and await keywords? 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.