Concurrency in Go is easiest to understand when the waiting is obvious. So every program here talks to one tiny HTTP server whose only job is to be slow: each request sleeps for one second before it answers. Ten requests in a row should take ten seconds, and anything faster has to come from doing them at the same time.
All runs below are Go 1.27.1 on an Apple M4 Max (16 cores), timed with /usr/bin/time -p. It is one laptop and one run per program, so treat the numbers as the shape of the result, not a benchmark. The code is on GitHub: huseyinbabal/go-concurrency.
Results at a glance
| Program | What changes | Wall time | Output |
|---|---|---|---|
sequential | plain loop | 10.32 s | pages 1 to 10, in order |
gokeyword | go in front of the call | 0.33 s | nothing at all |
waitgroup | wg.Go + wg.Wait | 1.27 s | all ten pages, random order |
race | goroutines add to one int | ~1.3 s | 101 bytes in 19 of 20 runs, 91 once |
race with -race | same code, race detector on | Found 2 data race(s), total 50 | |
channel with -race | results go through a channel | 2.39 s | 101 bytes, no race reported |
fanout | 3 workers, jobs + results channels | 4.32 s | each line says which worker |
sema | buffered channel of size 3 as a limit | 4.37 s | all ten pages |
The slow server
package main
import (
"fmt"
"log"
"net/http"
"time"
)
// Every request takes one second, like a slow API.
func main() {
http.HandleFunc("/page/", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(time.Second)
fmt.Fprintf(w, "%s ok", r.URL.Path)
})
log.Fatal(http.ListenAndServe("localhost:9090", nil))
}server/main.go
go run ./server &
Every client below shares the same fetch helper and only changes main:
func fetch(url string) string {
resp, err := http.Get(url)
if err != nil {
return err.Error()
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return string(body)
}
1. The baseline: 10.32 seconds
func main() {
for i := 1; i <= 10; i++ {
url := fmt.Sprintf("http://localhost:9090/page/%d", i)
fmt.Println(fetch(url))
}
}sequential/main.go
/page/1 ok
/page/2 ok
...
/page/10 ok
real 10.32output
Ten seconds of sleeping on the server, plus about 0.3 s of process start, connection setup and printing.
2. Only go: fast, and wrong
func main() {
for i := 1; i <= 10; i++ {
url := fmt.Sprintf("http://localhost:9090/page/%d", i)
go func() {
fmt.Println(fetch(url))
}()
}
}gokeyword/main.go
real 0.33output
No pages, no error. go starts each call in a new goroutine and the loop moves on immediately. main then returns, and a Go program ends when main returns, no matter how many goroutines are still running. The 0.33 s is just the program starting and stopping.
A side note on url: before Go 1.22, every closure in this loop would have shared one loop variable, and the usual fix was a url := url line inside the loop. Since 1.22 each iteration gets its own copy, so the code above is safe as written. Here url is declared inside the loop body anyway, which was always per-iteration.
3. Waiting for them: sync.WaitGroup and wg.Go
func main() {
var wg sync.WaitGroup
for i := 1; i <= 10; i++ {
url := fmt.Sprintf("http://localhost:9090/page/%d", i)
wg.Go(func() {
fmt.Println(fetch(url))
})
}
wg.Wait()
}waitgroup/main.go
/page/3 ok
/page/7 ok
/page/1 ok
/page/10 ok
...
real 1.27output
wg.Go arrived in Go 1.25. It replaces the three-line pattern that every Go codebase has somewhere:
// before Go 1.25
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(fetch(url))
}()
Putting Add and Done in one call removes the two classic bugs: calling Add inside the goroutine (so Wait can run before the counter goes up) and forgetting Done on an early return.
All ten requests now wait on the server at the same time, so the run takes one second of sleep plus overhead. The order changes on every run because the goroutines finish in whatever order the scheduler and the network give them.
4. A shared counter, and what the race detector says
Summing the response sizes with a plain int:
func main() {
var wg sync.WaitGroup
bytes := 0
for i := 1; i <= 10; i++ {
url := fmt.Sprintf("http://localhost:9090/page/%d", i)
wg.Go(func() {
bytes += len(fetch(url))
})
}
wg.Wait()
fmt.Println("total bytes:", bytes)
}race/main.go
The correct total is 101 bytes (/page/1 ok to /page/10 ok). Twenty runs of the normal build:
for i in $(seq 1 20); do ./race; done | sort | uniq -c
19 total bytes: 101
1 total bytes: 91output
Nineteen correct answers make this bug easy to ship. bytes += n is a read, an add and a write; when two goroutines read the same old value, one of the two writes is lost. Here that cost 10 bytes in one run out of twenty.
The race detector finds it on the first try, because it watches memory accesses instead of waiting for a wrong answer:
go run -race ./race
WARNING: DATA RACE
WARNING: DATA RACE
total bytes: 50
Found 2 data race(s)output
The -race build also slows the program down, which changes the timing between goroutines; in this run the total dropped to 50. A race detector run is a test, not a measurement.
5. A channel instead of shared memory
func main() {
results := make(chan string)
for i := 1; i <= 10; i++ {
url := fmt.Sprintf("http://localhost:9090/page/%d", i)
go func() {
results <- fetch(url)
}()
}
bytes := 0
for range 10 {
bytes += len(<-results)
}
fmt.Println("total bytes:", bytes)
}channel/main.go
total bytes: 101
real 2.39output
That run was the -race build, and it reports nothing: only main touches bytes, and each page reaches it through the channel. The channel is unbuffered, so every send waits until main receives it. The WaitGroup disappears too, because main knows it needs exactly ten receives.
6. Limiting the load: three workers
One goroutine per URL is fine for ten URLs. For ten thousand, it sends ten thousand requests to one server at the same moment. A worker pool caps that number:
func worker(id int, jobs <-chan string, results chan<- string) {
for url := range jobs {
results <- fmt.Sprintf("worker %d: %s", id, fetch(url))
}
}
func main() {
jobs := make(chan string)
results := make(chan string)
// fan-out: three workers read from the same jobs channel
var wg sync.WaitGroup
for id := 1; id <= 3; id++ {
wg.Go(func() { worker(id, jobs, results) })
}
go func() {
for i := 1; i <= 10; i++ {
jobs <- fmt.Sprintf("http://localhost:9090/page/%d", i)
}
close(jobs)
}()
// fan-in: close results once every worker is done
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Println(r)
}
}fanout/main.go
worker 3: /page/3 ok
worker 1: /page/1 ok
worker 2: /page/2 ok
worker 3: /page/4 ok
worker 1: /page/5 ok
worker 2: /page/6 ok
worker 3: /page/7 ok
worker 2: /page/9 ok
worker 1: /page/8 ok
worker 3: /page/10 ok
real 4.32output
Ten jobs on three workers is four rounds (3 + 3 + 3 + 1), so four seconds. The closing order matters, and it is the part people get wrong:
| Channel | Closed by | When | What it ends |
|---|---|---|---|
jobs | the sender goroutine | after the last URL | each worker’s range jobs loop |
results | a goroutine waiting on wg | after all three workers return | main’s range results loop |
Close results too early and a worker panics with “send on closed channel”; never close it and main blocks forever after the tenth line.
7. The same limit with less code: a buffered channel
When there is no per-worker state, a buffered channel used as a counter of free slots gives the same cap without separate workers:
func main() {
var wg sync.WaitGroup
sem := make(chan struct{}, 3) // at most three requests at a time
for i := 1; i <= 10; i++ {
url := fmt.Sprintf("http://localhost:9090/page/%d", i)
wg.Go(func() {
sem <- struct{}{} // take a slot, wait if all three are taken
defer func() { <-sem }() // give it back
fmt.Println(fetch(url))
})
}
wg.Wait()
}sema/main.go
real 4.37output
Same four rounds. The difference: this version still creates one goroutine per URL (most of them just wait for a slot), while the worker pool creates exactly three. For ten thousand URLs that is ten thousand parked goroutines, which the next section shows is cheap but not free.
8. What a goroutine costs
func main() {
const n = 100_000
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
block := make(chan struct{})
start := time.Now()
for range n {
go func() { <-block }()
}
took := time.Since(start)
runtime.ReadMemStats(&after)
fmt.Println("goroutines:", runtime.NumGoroutine())
fmt.Println("started in:", took.Round(time.Millisecond))
fmt.Println("stack memory:", (after.StackInuse-before.StackInuse)/1024/1024, "MiB")
fmt.Println("per goroutine:", (after.StackInuse-before.StackInuse)/n, "bytes of stack")
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
close(block)
}cost/main.go
goroutines: 100001
started in: 80ms
stack memory: 195 MiB
per goroutine: 2051 bytes of stack
GOMAXPROCS: 16output
100,000 goroutines, all blocked on a channel, started in 80 ms and took 195 MiB of stack: about 2 KB each, which is the runtime’s starting stack size. Stacks grow when a goroutine needs more. GOMAXPROCS is 16 here, one per core: that is how many goroutines can run Go code at the same instant. The rest wait, and a goroutine blocked on the network or a channel is parked and costs no CPU.
For comparison, a new thread on Linux gets an 8 MB stack by default (the ulimit -s value on most distributions). That gap is why a goroutine per request is a normal design in Go and a thread per request usually is not.
Which one to use
| Situation | Tool |
|---|---|
| Start some work and wait for all of it | sync.WaitGroup with wg.Go |
| Collect results from goroutines | a channel, read only by the collector |
| Several goroutines update one value | a channel, sync.Mutex, or sync/atomic; never a plain variable |
| Cap how many run at once, workers keep state | worker pool: jobs channel, results channel |
| Cap how many run at once, nothing else | buffered channel as a semaphore |
| Any of the above in tests | go test -race |
Sources
- Go 1.25 release notes: sync.WaitGroup.Go
- Go 1.22 release notes: per-iteration loop variables
- Go blog: Fixing for loops in Go 1.22
- Data Race Detector
- Go blog: Pipelines and cancellation (fan-out, fan-in)
- Effective Go: Concurrency
- Package runtime: GOMAXPROCS, MemStats
- Code on GitHub: huseyinbabal/go-concurrency