Skip to content
Huseyin Babal
Go back

Go concurrency, measured: ten slow requests from 10.3 s to 1.3 s

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

ProgramWhat changesWall timeOutput
sequentialplain loop10.32 spages 1 to 10, in order
gokeywordgo in front of the call0.33 snothing at all
waitgroupwg.Go + wg.Wait1.27 sall ten pages, random order
racegoroutines add to one int~1.3 s101 bytes in 19 of 20 runs, 91 once
race with -racesame code, race detector onFound 2 data race(s), total 50
channel with -raceresults go through a channel2.39 s101 bytes, no race reported
fanout3 workers, jobs + results channels4.32 seach line says which worker
semabuffered channel of size 3 as a limit4.37 sall 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:

ChannelClosed byWhenWhat it ends
jobsthe sender goroutineafter the last URLeach worker’s range jobs loop
resultsa goroutine waiting on wgafter all three workers returnmain’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

SituationTool
Start some work and wait for all of itsync.WaitGroup with wg.Go
Collect results from goroutinesa channel, read only by the collector
Several goroutines update one valuea channel, sync.Mutex, or sync/atomic; never a plain variable
Cap how many run at once, workers keep stateworker pool: jobs channel, results channel
Cap how many run at once, nothing elsebuffered channel as a semaphore
Any of the above in testsgo test -race

Sources


Share this post:

Previous Post
I built Uber's backend. Then one driver got 59 riders
Next Post
Golang context, run in a terminal: timeouts, cancellation and graceful shutdown