Menu

Goroutines in Go: How They Work, with Examples

How to run functions concurrently with the go keyword, wait for them to finish, get results back, and avoid the data races, leaks and crashes that goroutines make easy.

This page includes runnable editors - edit, run, and see output instantly.

Starting a goroutine

Put go in front of a function call and that call runs concurrently. The statement returns immediately; the caller does not wait.

Four goroutines run shout at the same time. The order they finish in is not defined, but the output is always in the original order, because each goroutine writes to its own index and main only reads the slice after wg.Wait() returns.

That example already contains the three things almost every goroutine program needs: a way to start work (go), a way to wait for it (sync.WaitGroup), and a way to get results back without two goroutines touching the same memory (one slice element each).

Main does not wait

When main returns, the program exits. Goroutines that are still running are stopped wherever they are. Nothing waits for them.

This usually prints only from main. Sometimes the goroutine gets scheduled in time and you see both lines. That "usually" is the problem: code that works on your machine and fails on a loaded server.

A time.Sleep at the end of main makes the demo print both lines, and it is the wrong fix. It guesses how long the work takes. Wait for the work itself, with a WaitGroup (the WaitGroup page covers it in detail) or with a channel.

Getting results back

A go statement throws away the function's return values. x := go f() does not compile. There are two standard ways to return data.

One slot per goroutine, as in the first example. Pre-size a slice, give each goroutine its index, read after Wait. The order is preserved and no locking is needed, because no two goroutines write the same element.

A channel. Each goroutine sends its result; the receiver collects them. Results arrive in completion order, not start order.

Receiving exactly len(nums) values doubles as the wait: main cannot get past the loop until every goroutine has sent. The arrival order changes between runs, so the program sorts before printing anything order-dependent. The channels page covers buffered channels, closing, and range over a channel.

Loop variables and closures (Go 1.22 change)

Since Go 1.22, each iteration of a for loop gets a fresh copy of the loop variables. A closure started in a goroutine captures that iteration's value, so this is correct:

for i, w := range words {
	go func() {
		results[i] = shout(w) // Go 1.22+: i and w belong to this iteration
	}()
}

Before Go 1.22, all iterations shared one i and one w, and every goroutine tended to see the last value. Old code works around it by passing the values as arguments, go func(i int, w string) { ... }(i, w), or by shadowing, i := i. Both are harmless on Go 1.22 and later, and you will still see them in existing code. The new behavior applies when the module's go.mod says go 1.22 or higher.

Goroutines are cheap

A goroutine starts with a small stack (a few kilobytes) that the runtime grows and shrinks as needed. The Go scheduler runs goroutines on a pool of OS threads, at most GOMAXPROCS of them executing Go code at once, and by default GOMAXPROCS equals the number of CPUs. Blocking on a channel, a mutex, a sleep or network I/O parks the goroutine and frees the thread for another one.

So starting a goroutine per task is fine even at large numbers:

A hundred thousand goroutines finish in a fraction of a second. The sum is always 4999950000, because atomic.Int64 makes each addition indivisible. Cheap does not mean free, though: each goroutine that is still blocked keeps its stack and everything it references alive.

OS threadGoroutine
Created bythe kernelthe Go runtime
Initial stackfixed, often 1 MB or morea few KB, grows on demand
Switchingkernel context switchGo scheduler, in user space
Identityhas a thread IDno ID you can read, by design
Typical counthundredsthousands to millions

Data races

Two goroutines that access the same variable at the same time, with at least one of them writing, is a data race. The result is unpredictable, not just "a bit off": updates get lost, and a race on a string, slice, map or interface value can crash the program or corrupt memory.

On a multi-core machine this prints a different number below 10000 on most runs, because two goroutines read the same old value and both write back that value plus one. On a single core it can print 10000, which is worse: the bug passes your test and shows up in production.

Go ships a race detector. Run your program or tests with -race:

go run -race main.go
go test -race ./...
==================
WARNING: DATA RACE
Read at 0x00c000090038 by goroutine 8:
  main.main.func1()
      /tmp/race/main.go:16 +0x94

Previous write at 0x00c000090038 by goroutine 6:
  main.main.func1()
      /tmp/race/main.go:16 +0xa4
...
Found 2 data race(s)
exit status 66

It points at the exact line (counter++) and both goroutines. It only reports races that actually happen during the run, so run it on tests that exercise the concurrent paths. It slows the program down several times, so it is for tests and staging, not production.

The fixes, from simplest to most general:

  • Do not share. Give each goroutine its own data and combine at the end (the slot-per-goroutine pattern).
  • Use sync/atomic for a single counter or flag: var n atomic.Int64; n.Add(1).
  • Use a sync.Mutex around anything bigger, like a map or a struct with several fields. The mutex page covers RWMutex and sync.Once too.
  • Send the data over a channel so only one goroutine owns it at a time.

A panic in a goroutine kills the program

If a goroutine panics and nothing recovers inside that same goroutine, the whole program crashes, including main and every other goroutine. A recover in main does not help, because recover only catches panics in its own goroutine.

Recovering like this makes sense at the edge of a long-running server, where one bad request must not take down the rest. Inside ordinary code, a panic usually means a bug, and crashing loudly is the right outcome.

Goroutine leaks

A goroutine that blocks forever never exits and never frees its memory. The classic cause is a send that nobody will ever receive:

func firstResult(urls []string) string {
	ch := make(chan string) // unbuffered
	for _, u := range urls {
		go func() { ch <- fetch(u) }()
	}
	return <-ch // takes the first result; the other senders block forever
}

Every call leaks len(urls) - 1 goroutines. In a server that handles this request thousands of times, memory climbs until the process dies. Two fixes: make the channel big enough that every sender can finish (make(chan string, len(urls))), or give the goroutines a way to give up, usually a context.Context plus a select on ctx.Done(). You can watch for leaks with runtime.NumGoroutine() in tests.

Limiting how many run at once

"One goroutine per item" is fine for 10,000 cheap computations. It is not fine for 10,000 HTTP requests to the same server or 10,000 open files. Cap the concurrency with a buffered channel used as a semaphore:

The buffered channel holds at most 3 tokens, so at most 3 goroutines are past the sem <- line at any moment. The peak can never exceed 3, and with twelve tasks that each sleep it reaches 3 in practice. A fixed pool of worker goroutines reading from a jobs channel is the other common shape; the WaitGroup page builds one.

Outside the standard library, golang.org/x/sync/errgroup combines a WaitGroup, the first error, context cancellation and a concurrency limit (g.SetLimit(n)) in one type. It is the usual choice in production code that needs all four.

Common mistakes

  • Forgetting to wait. main returns and the work silently never happens. Every go statement needs a matching way to know it finished.
  • Calling wg.Add inside the goroutine. Wait may run before Add, see a counter of zero and return early. Call Add before the go statement.
  • Sharing a variable without synchronization. Maps are the common case: concurrent writes to a map are usually detected by the runtime and crash the program with fatal error: concurrent map writes, which recover cannot catch.
  • Assuming an order. Goroutines run in whatever order the scheduler picks. If output must be ordered, collect and sort, or write to indexed slots.
  • Using time.Sleep to synchronize. It makes tests slow and still flaky. Wait on the event, not on a guess.
  • Starting a goroutine without a way to stop it. Anything that loops or waits on I/O should take a context.Context so the caller can cancel it.

Frequently Asked Questions

What is a goroutine in Go?

A goroutine is a function call that runs concurrently with the rest of the program. You start one by putting go before a call: go work(). Goroutines are managed by the Go runtime, not the operating system, and the runtime multiplexes many of them onto a small number of OS threads, so starting thousands of them is normal.

How do I wait for goroutines to finish in Go?

Use a sync.WaitGroup: call wg.Add(1) before each go statement, defer wg.Done() at the top of the goroutine, and wg.Wait() where you need all of them finished. If the goroutines produce values, receiving one value per goroutine from a channel also works as a wait.

What is the difference between a goroutine and a thread?

An OS thread has a fixed stack (often 1 MB or more) and is scheduled by the kernel. A goroutine starts with a stack of a few kilobytes that grows as needed, and the Go scheduler switches between goroutines in user space. The runtime runs goroutines on up to GOMAXPROCS threads at once (by default, the number of CPUs).

How do I get a return value from a goroutine?

A go statement discards the function's return values. Send the result on a channel (results <- compute(x)) or write it into your own slot of a pre-sized slice (out[i] = compute(x)) and read it after wg.Wait().

Why does my Go program exit before the goroutine prints anything?

When main returns, the program ends and every other goroutine is stopped without running its remaining code. Nothing waits for goroutines automatically. Block main until the work is done with a WaitGroup or a channel receive. Adding time.Sleep only hides the problem.

Coddy programming languages illustration

Learn to code with Coddy

GET STARTED