Goroutines in Go: Lightweight Concurrency and sync.WaitGroup
What goroutines are, why they are cheap, how the scheduler works, waiting with WaitGroup (and wg.Go in Go 1.25), and avoiding leaks.
On this page
Concurrency is where Go shines. A goroutine is a function running concurrently with others, managed by the Go runtime instead of the operating system. Goroutines are so cheap that a server can run hundreds of thousands of them, one per connection or request.
Starting a goroutine
Put go in front of a function call:
func task(id int) {
fmt.Printf("task %d is running\n", id)
}
func main() {
for i := range 5 {
go task(i)
}
time.Sleep(time.Second) // crude: wait so main does not exit too early
}go task(i) returns immediately; the task runs concurrently. Every Go program starts with one goroutine, the main goroutine, and when main returns the whole program exits, killing any goroutines that are still running. That is why the example needs to wait.
The output order changes from run to run. Goroutines are scheduled by the runtime; never assume an order.
Why goroutines are cheap
| OS thread | Goroutine | |
|---|---|---|
| Initial stack | ~1 to 8 MB, fixed | ~2 KB, grows and shrinks as needed |
| Created by | the kernel (system call) | the Go runtime (a function call) |
| Context switch | expensive, in the kernel | cheap, in user space |
| Practical count | thousands | hundreds of thousands or more |
The Go scheduler multiplexes many goroutines (G) onto a small number of OS threads (M) using logical processors (P), one per CPU core by default (GOMAXPROCS). When a goroutine blocks on network I/O, the scheduler parks it and runs another one on the same thread. This M:N model is why simple blocking code in Go scales so well.
Concurrency is not parallelism
Concurrency is about structuring a program as independent tasks. Parallelism is about executing tasks at the same instant on multiple cores. Goroutines give you concurrency; you get parallelism for free when GOMAXPROCS > 1 and there is work for several cores.
Waiting properly with sync.WaitGroup
time.Sleep is a guess: too short and work is lost, too long and you waste time. A sync.WaitGroup waits for exactly the goroutines you started.
package main
import (
"fmt"
"sync"
)
func doTask(id int, wg *sync.WaitGroup) {
defer wg.Done() // decrement the counter when this task finishes
fmt.Printf("task %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := range 4 {
wg.Add(1) // increment BEFORE starting the goroutine
go doTask(i, &wg)
}
wg.Wait() // block until the counter is back to zero
fmt.Println("all tasks finished")
}Three rules:
wg.Add(1) before the go statement, not inside the goroutine, or Wait might run before Add.defer wg.Done() so it runs even if the task returns early.sync.WaitGroup (or a mutex) breaks it; go vet warns about this.Since Go 1.25 there is a shortcut that handles Add and Done for you:
var wg sync.WaitGroup
for i := range 4 {
wg.Go(func() {
fmt.Printf("task %d done\n", i)
})
}
wg.Wait()Loop variables and goroutines
Older code passes the loop variable as an argument:
for i := 0; i < 3; i++ {
go func(i int) { fmt.Println(i) }(i)
}Before Go 1.22, writing go func() { fmt.Println(i) }() captured one shared i, so goroutines often printed the final value several times. Since Go 1.22 each iteration has its own variable and the bug is gone (for modules declaring go 1.22 or newer). Passing it as a parameter is still perfectly fine and makes the intent explicit.
Goroutine leaks
A goroutine that blocks forever (waiting on a channel nobody will ever send to, or a request without a timeout) is never garbage collected. Leaks slowly eat memory in long-running servers. Every goroutine you start should have a clear way to finish: a closed channel, a cancelled context.Context, or a timeout. The next articles show those tools.
go to a function call, ask: who waits for it, and how does it stop? If you cannot answer, you probably have a leak or a race.Key takeaways
go f() runs f concurrently; the program exits when main returns.sync.WaitGroup (or wg.Go in Go 1.25+) instead of time.Sleep to wait.Add before go, defer Done(), and pass WaitGroups by pointer.Last updated on