A goroutine is a function running alongside the rest of your program, and Go can run thousands of them at once. Learn how to start them, wait for them, avoid leaking them, and how the scheduler shares a few threads between them.
A goroutine is a function that runs at the same time as the rest of your program. You start one by writing go in front of a function call. That part takes a minute to learn. Knowing when your goroutines finish, and what the runtime does with them in the meantime, takes longer.
This post covers starting goroutines, waiting for them, how cheap they are, and the scheduler that runs them. It ends with the most common goroutine bug, the leak. Every program below was run on Go 1.26, and its output is pasted from the run.
go starts a goroutine, and main doesn’t wait for it
Putting go before a function call starts that call in a new goroutine and moves on straight away, without waiting for the call to finish.
package main
import (
"fmt"
"time"
)
func report() {
time.Sleep(time.Minute) // stands in for slow work
fmt.Println("report finished")
}
func main() {
go report()
fmt.Println("main done")
}
It prints:
main done
The report never prints. main started the goroutine, printed its own line and returned, long before the minute was up. When main returns, the program ends. Go doesn’t wait for other goroutines, and it doesn’t warn you that some were still running. They’re simply stopped.
The sleep is there so the result is the same on every run. Take it out and write go fmt.Println("hello from the goroutine") instead, and the outcome becomes a coin toss. We ran that version 200 times on one machine. Roughly half the runs printed the goroutine’s line and the rest didn’t. Nothing in the program decides which. A program that works only when a goroutine happens to be quick has a bug.
You’ll sometimes see time.Sleep at the end of main to “fix” this. It hides the problem on a quiet machine and fails on a busy one. The fix is to wait for the goroutines to say they’re done.
Waiting properly with sync.WaitGroup
A sync.WaitGroup is a counter of goroutines still running, and Wait blocks until that counter reaches zero.
package main
import (
"fmt"
"sync"
)
func main() {
names := []string{"ana", "bo", "chen"}
greetings := make([]string, len(names))
var wg sync.WaitGroup
for i, name := range names {
wg.Add(1)
go func() {
defer wg.Done()
greetings[i] = "hello, " + name
}()
}
wg.Wait()
for _, g := range greetings {
fmt.Println(g)
}
}
It prints:
hello, ana
hello, bo
hello, chen
There are three calls to know:
wg.Add(1)adds one to the counter. Call it before thegostatement, in the goroutine that’s going to wait. If you call it inside the new goroutine,Waitmight run first, see zero, and return early.wg.Done()subtracts one. Putting it behinddefermeans it runs even if the function returns early.wg.Wait()blocks until the counter is back to zero.
Notice how the output stays in order. The three goroutines may run in any order, but each writes to its own slot, greetings[i]. No two goroutines touch the same element, and main reads the slice only after Wait returns. Printing happens in one place, in a fixed order. That’s the habit that keeps concurrent output predictable.
Go 1.25 added wg.Go
Since Go 1.25, WaitGroup has a Go method that does the Add, the go statement and the Done for you:
package main
import (
"fmt"
"sync"
)
func main() {
squares := make([]int, 5)
var wg sync.WaitGroup
for i := range squares {
wg.Go(func() {
squares[i] = i * i
})
}
wg.Wait()
fmt.Println(squares)
}
It prints:
[0 1 4 9 16]
wg.Go(f) is the same as wg.Add(1) followed by a goroutine that calls f and then Done. You can’t forget the Done, and you can’t put the Add in the wrong place. The function you pass takes no arguments and returns nothing, so it reaches the values it needs through a closure, as i does here. The rest of this post uses wg.Go. You’ll still see Add and Done in most existing code, so it’s worth being able to read both.
Closures in goroutines and the loop variable
A goroutine started inside a loop almost always uses the loop variable, and the part on control flow showed why that used to be a trap.
Look at squares[i] = i * i above. The goroutine doesn’t run when the loop reaches it. It runs a little later, maybe after the loop has already finished. So which i does it see?
Since Go 1.22, each loop iteration gets its own i. The goroutine started in the third pass sees the i from the third pass, and nothing changes it afterwards. That’s why the program is correct.
Before Go 1.22 the whole loop shared one i. Goroutines that ran late all read whatever value the loop had reached, often the last one. Code written for old Go works around it with i := i inside the loop, or by passing the value as an argument, go func(i int) { ... }(i). On Go 1.22 and later you don’t need either. The rule follows the go line in go.mod, not the Go version you installed, so a module that still says go 1.21 gets the old behaviour.
Goroutines are cheap
A goroutine costs far less than an operating system thread, so starting thousands of them is normal Go.
package main
import (
"fmt"
"sync"
)
func main() {
const n = 10_000
results := make(chan int, n)
var wg sync.WaitGroup
for i := range n {
wg.Go(func() {
results <- i + 1
})
}
wg.Wait()
close(results)
total := 0
for r := range results {
total += r
}
fmt.Println("goroutines:", n)
fmt.Println("total:", total)
}
It prints:
goroutines: 10000
total: 50005000
Ten thousand goroutines each send one number into a channel. The channel has room for all ten thousand values, so no sender ever has to wait. After Wait, main closes the channel and adds up everything in it. The total is 1 + 2 + … + 10,000, which is 50,005,000, however the goroutines were ordered.
Why is this cheap? The part on memory showed that a new goroutine’s stack starts at a few kilobytes and grows only when it needs to. An OS thread usually reserves a much larger fixed stack up front. Creating a goroutine is also a job the Go runtime does itself, without asking the operating system.
Cheap isn’t free. Every goroutine still holds its stack and anything it references until it finishes. Ten thousand short-lived goroutines are fine. Ten thousand that never finish are a leak, and that comes up at the end of this post.
The channel here is doing a job you’ll meet properly in the part on channels. For now, read results <- i + 1 as “put this value in the queue” and for r := range results as “take values out until it’s closed”.
Concurrency isn’t parallelism
Concurrency means your program is organised as several tasks that make progress independently. Parallelism means several tasks are executing at the same instant, on different CPU cores. Goroutines give you concurrency. Whether you also get parallelism depends on how many cores the runtime is allowed to use.
That limit is called GOMAXPROCS. It’s the number of OS threads that can run Go code at the same moment. runtime.NumCPU() reports how many logical CPUs the process can use, and by default GOMAXPROCS starts from that number. Since Go 1.25, on Linux, the default also respects a cgroup CPU limit, the kind containers set. A program limited to two CPUs gets a lower GOMAXPROCS than the machine’s core count. Both numbers depend on where the program runs, so none of the programs here print them.
You can set GOMAXPROCS yourself. Setting it to 1 takes away parallelism entirely, and the goroutines still work:
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
runtime.GOMAXPROCS(1)
var mu sync.Mutex
total := 0
var wg sync.WaitGroup
for i := range 1000 {
wg.Go(func() {
mu.Lock()
total += i
mu.Unlock()
})
}
wg.Wait()
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
fmt.Println("total:", total)
}
It prints:
GOMAXPROCS: 1
total: 499500
runtime.GOMAXPROCS(1) sets the limit and runtime.GOMAXPROCS(0) reads it back without changing it. With a limit of one, only one goroutine runs Go code at any instant. The thousand goroutines take turns, and the answer is still 0 + 1 + … + 999.
The mutex is still needed. Taking turns doesn’t mean each goroutine finishes its addition before the next one starts, and the program shouldn’t rely on the setting anyway. The part on sync covers mutexes and the race detector. You’ll rarely set GOMAXPROCS in real code. The default is usually right.
How the scheduler runs goroutines
The Go runtime runs many goroutines on a small number of OS threads, and the piece that decides which goroutine runs where is the scheduler.
Explain it like I’m ten
Picture a restaurant kitchen with lots of cooks and only a few stoves. The cooks are goroutines. The stoves are the threads that actually do work. There might be fifty cooks and four stoves.
A head chef, the scheduler, decides which cook stands at which stove. Each cook works on a dish for a while, then another cook gets a turn.
Sometimes a cook has to wait for the oven. They don’t stand at the stove doing nothing. They step aside, and the head chef sends someone else to that stove. When the oven dings, the waiting cook joins the line for a stove again. It may not even be the stove they started at.
That’s why a kitchen with four stoves can keep fifty cooks busy.
The precise version
The runtime’s scheduler uses three kinds of object, usually called G, M and P:
- G is a goroutine: its stack, and where it’s up to.
- M is a machine, meaning an OS thread. Only an M can actually execute code.
- P is a processor: the permission to run Go code, plus a local queue of goroutines ready to run. There are exactly
GOMAXPROCSP’s.
To run Go code, an M must hold a P. The M takes a G from that P’s run queue and runs it. This is called M:N scheduling: many goroutines share a smaller number of OS threads, and the Go runtime, not the operating system, decides which goroutine runs on which thread.
Simplified: two P’s, each attached to a thread, each with a local run queue. When G1 blocks on a channel it is parked and gives up its P, so the thread runs the next goroutine instead of waiting. When G1 becomes runnable it goes back into a run queue, which may belong to a different P. The real scheduler also has a global run queue, and idle P’s steal work from busy ones.
Here are those steps in words, in case the animation doesn’t play for you:
- P0 is attached to thread M0 and runs G1. P1 is attached to thread M1 and runs G2. G3 and G4 wait in P0’s queue, G5 in P1’s.
- G1 tries to receive from a channel that has nothing in it. The scheduler parks G1 in a waiting state. It isn’t using a thread or a P any more.
- M0 doesn’t wait for G1. P0 takes the next goroutine, G3, from its queue and runs it.
- G2 sends a value on that channel. That makes G1 runnable, and G1 goes into P1’s queue, because P1 is where the send happened.
- G2 finishes, and P1 runs G1. G1 started on thread M0 and is now running on M1.
A goroutine isn’t tied to one thread. It runs wherever a P picks it up.
Three more things the scheduler handles:
- Blocking system calls. Some work, like reading a file, blocks the whole OS thread inside the operating system. When that happens, the runtime takes the P away from the blocked M and hands it to another thread, so the other goroutines keep running. The
runtimepackage documentation says it plainly: threads blocked in system calls don’t count against theGOMAXPROCSlimit. Network reads are different. The runtime waits for sockets with a network poller, so a goroutine waiting on the network is parked like G1, without holding a thread. - Preemption. A goroutine doesn’t get to keep a P forever. Since Go 1.14 the runtime can interrupt a goroutine on most platforms, even in a tight loop with no function calls, so one busy goroutine can’t starve the rest.
- Work stealing. When a P’s queue is empty, it looks in the global run queue and then takes goroutines from other P’s. The animation leaves this out to keep it readable.
Where the analogy breaks: in a kitchen there’s one kind of thing at the stove, the cook. Go has two separate ideas there: the thread (M), and the permission to run Go code (P). A thread stuck in a system call is like a stove with a cook who can’t move, and the runtime handles that by bringing in another stove, a new thread, and giving it the P. Also, a head chef thinks about each decision. The Go scheduler makes the same simple choices very fast, and it doesn’t know which goroutine is more important.
Goroutine leaks
A goroutine leak is a goroutine that never finishes, usually because it’s waiting on a channel nobody will ever use again.
package main
import (
"fmt"
"runtime"
)
func firstResult() int {
ch := make(chan int)
go func() {
ch <- 42 // nobody will ever receive this
}()
return -1 // gave up without reading ch
}
func main() {
fmt.Println("before:", runtime.NumGoroutine())
for range 3 {
firstResult()
}
fmt.Println("after:", runtime.NumGoroutine())
}
It prints:
before: 1
after: 4
runtime.NumGoroutine() reports how many goroutines exist. Before the loop there’s one, main itself. Each call to firstResult starts a goroutine that tries to send on an unbuffered channel. A send on an unbuffered channel waits until someone receives. But firstResult returns without receiving, and no other code has ch. So each goroutine waits forever. Three calls, three stuck goroutines, and a count of 4.
Nothing crashes. Go reports a deadlock only when every goroutine is stuck, and here main keeps going. The garbage collector doesn’t help either. It doesn’t free a goroutine that’s blocked, even when nothing else can reach its channel. In a server that calls firstResult once per request, the count climbs with every request until memory runs out.
The count is exact here because a goroutine is counted from the moment go creates it, and these three never exit. Counting goroutines is also how you spot a leak in real code. Watch runtime.NumGoroutine() over time, or look at the goroutine profile from net/http/pprof. If the number keeps rising while the load stays flat, something is leaking. A long list of goroutines all stuck on the same line tells you where.
Go 1.26 also ships an experimental profile called goroutineleak. It exists only in programs built with GOEXPERIMENT=goroutineleakprofile, and it uses the garbage collector to find goroutines blocked on something no running goroutine can reach. We tried it on this program, and it’s a good reminder of what “concurrent” means: in one run it reported two leaked goroutines, not three. Most likely the third had been created but hadn’t reached its send yet when the profile was taken. A leak detector can only see goroutines that are already stuck.
For this bug there are two usual fixes:
- Give the channel room for the one value,
make(chan int, 1). The send completes straight away, and the goroutine exits even if nobody reads. - Give the goroutine a way to hear “stop”. That’s what
selectandcontextare for, in the parts on channels and oncontext.
The rule to carry with you: before you start a goroutine, know how it will end.
What’s next
This post used channels and a mutex without explaining them. The part on channels and select covers how goroutines pass values to each other, including unbuffered channels, closing and select. The part on sync covers mutexes, the race detector and the atomic package, which you need as soon as goroutines share memory.
What to remember
go f()startsfin a new goroutine and doesn’t wait. Whenmainreturns, the program ends, and running goroutines are stopped.- Wait with a
sync.WaitGroup. Since Go 1.25,wg.Go(func() { ... })does theAddandDonefor you. - Goroutines are cheap, so thousands are normal. Collect their results through a channel or a guarded slice, and print from one place.
- Concurrency is how the program is structured. Parallelism is running at the same instant, and
GOMAXPROCSlimits it. - The scheduler runs goroutines (G) on threads (M) through processors (P). A goroutine that blocks is parked, and its thread moves on to other work.
- Since Go 1.22, each loop iteration gets its own variable, so a goroutine started in a loop sees its own iteration’s value.
- A goroutine blocked forever on a channel is a leak. Know how every goroutine you start will end.
Starting a goroutine is one word. Knowing when it finishes is your job.