Goroutines

A goroutine is a function that runs on its own, alongside the rest of your program. In this lesson you’ll start goroutines with the go keyword, wait for them the right way, and use the race detector to catch the bug that concurrent code is most likely to have.

Concurrency and parallelism in plain words

These two words get mixed up all the time, so here’s how I think about them.

  • Concurrency is about structure: your program is made of several tasks that can make progress independently. One cook who chops onions while the soup simmers is working concurrently.
  • Parallelism is about execution: several things literally happen at the same instant, on different CPU cores. Two cooks in the same kitchen are working in parallel.

In Go you write concurrent code with goroutines, and the Go runtime decides how to run them. It spreads goroutines over a small number of operating system threads, and on a machine with several cores they really do run in parallel. You don’t manage threads yourself. You just say “run this function concurrently”, and Go handles the rest.

Starting a goroutine with go

Put the keyword go in front of a function call and that call runs in a new goroutine. The go statement doesn’t wait for the function to finish. It returns right away, and the next line of main runs immediately.

package main

import "fmt"

func say(msg string) {
	fmt.Println(msg)
}

func main() {
	go say("hello from a goroutine")
	fmt.Println("hello from main")
}Code language: Go (go)
hello from mainCode language: plaintext (plaintext)

That’s what I get almost every time: the goroutine’s message never shows up. Once in a while you might see both lines. Why?

When main returns, every goroutine stops

main itself runs in a goroutine, the main goroutine. When main returns, the whole program exits, and any other goroutines are stopped mid-flight. Nobody waits for them and nothing tells you they were cut off.

In the program above, main starts the goroutine, prints its own line, and returns. The new goroutine usually hasn’t had a chance to run yet, so its Println never happens. So the first rule of goroutines is: if you need a goroutine’s work, main (or whoever started it) has to wait for it.

Why time.Sleep is not the answer

The first fix most people try is to sleep for a bit before returning:

package main

import (
	"fmt"
	"time"
)

func say(msg string) {
	fmt.Println(msg)
}

func main() {
	go say("hello from a goroutine")
	time.Sleep(100 * time.Millisecond)
	fmt.Println("main is done")
}Code language: Go (go)
hello from a goroutine
main is doneCode language: plaintext (plaintext)

It works, but only by luck. The sleep is a guess about how long the goroutine needs. If the work takes longer (a slow disk, a busy machine, a bigger input), the guess is wrong and the work is lost. If the work is quick, you waste the rest of the 100 milliseconds for nothing. A sleep doesn’t connect the two goroutines in any way. What you want is for main to wait exactly until the goroutine says “I’m done”.

A first look at sync.WaitGroup

The standard library’s sync.WaitGroup does that. It keeps a count of running tasks. wg.Go starts a function in a new goroutine and adds it to the count, and wg.Wait blocks until every one of those functions has returned.

package main

import (
	"fmt"
	"sync"
)

func say(msg string) {
	fmt.Println(msg)
}

func main() {
	var wg sync.WaitGroup
	wg.Go(func() {
		say("hello from a goroutine")
	})
	wg.Wait()
	fmt.Println("main is done")
}Code language: Go (go)
hello from a goroutine
main is doneCode language: plaintext (plaintext)

Now the output is guaranteed. Wait returns as soon as the goroutine finishes, no sooner and no later. The zero value of a WaitGroup is ready to use, so var wg sync.WaitGroup is all the setup you need.

WaitGroup.Go was added in Go 1.25. Older code does the same thing by hand with wg.Add(1), go func() { defer wg.Done(); … }(). I cover both forms in WaitGroups and mutexes.

Running several goroutines

The real point of goroutines is running many things at once. Here three workers each “work” for a short while. Note that each closure uses i directly: since Go 1.22 every loop iteration gets its own copy of the loop variable, so each goroutine sees its own number.

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var wg sync.WaitGroup
	for i := range 3 {
		wg.Go(func() {
			time.Sleep(10 * time.Millisecond)
			fmt.Println("worker", i, "done")
		})
	}
	wg.Wait()
	fmt.Println("all workers done")
}Code language: Go (go)
worker 2 done
worker 0 done
worker 1 done
all workers doneCode language: plaintext (plaintext)

Run it a few times and the order of the worker lines changes. That’s normal. Goroutines run independently, and nothing promises which one gets to Println first. The only thing that is guaranteed is the last line, because it comes after wg.Wait().

Here’s what that looks like over time, with two of the workers:

Goroutines on a timeline The main goroutine starts two goroutines with go. They run at the same time as main and finish at different moments. Main blocks in wg.Wait until the last one is done, then continues and exits. goroutine 1 worker 0 main main() wg.Wait() exit goroutine 2 worker 1 go go last one done: Wait returns time
The goroutines run alongside main. wg.Wait blocks main until the last goroutine finishes.

Getting results back in order

Printing from goroutines is fine for experiments, but usually you want results. A simple, safe pattern: make a slice with one slot per goroutine and let each goroutine write only to its own slot. Different goroutines touch different elements, so they never get in each other’s way, and after Wait you read the slice in order.

package main

import (
	"fmt"
	"strings"
	"sync"
)

func main() {
	words := []string{"go", "gopher", "goroutine"}
	upper := make([]string, len(words))

	var wg sync.WaitGroup
	for i, w := range words {
		wg.Go(func() {
			upper[i] = strings.ToUpper(w)
		})
	}
	wg.Wait()

	fmt.Println(upper)
}Code language: Go (go)
[GO GOPHER GOROUTINE]Code language: plaintext (plaintext)

The goroutines may finish in any order, but the output is always the same, because each result lands in a fixed position. In the next lesson, channels give you another way to pass results between goroutines.

Goroutines are cheap

A goroutine is not an operating system thread. It starts with a small stack of a few kilobytes that grows only if it needs to, and the runtime switches between goroutines without involving the operating system. That makes it normal to have thousands of them. This program starts 100,000:

package main

import (
	"fmt"
	"sync"
)

func main() {
	const n = 100_000
	results := make([]int, n)

	var wg sync.WaitGroup
	for i := range n {
		wg.Go(func() {
			results[i] = i * 2
		})
	}
	wg.Wait()

	total := 0
	for _, r := range results {
		total += r
	}
	fmt.Println("goroutines:", n)
	fmt.Println("total:", total)
}Code language: Go (go)
goroutines: 100000
total: 9999900000Code language: plaintext (plaintext)

On my machine the whole thing takes a fraction of a second. Starting 100,000 threads in most languages would be a much bigger deal. Cheap doesn’t mean free, though: doubling a number is far less work than starting a goroutine, so this particular program would be faster as a plain loop. It’s here to show the scale, not as a good use of goroutines.

Data races and the race detector

The slot-per-goroutine trick works because no two goroutines write the same memory. Here’s what happens when they do. A thousand goroutines each add one to the same counter:

package main

import (
	"fmt"
	"sync"
)

func main() {
	count := 0

	var wg sync.WaitGroup
	for range 1000 {
		wg.Go(func() {
			count++
		})
	}
	wg.Wait()

	fmt.Println("count:", count)
}Code language: Go (go)
count: 959Code language: plaintext (plaintext)

I expected 1000 and got 959. The next run gave 941. count++ looks like one step, but it’s really three: read count, add one, write it back. When two goroutines read the same old value at the same time, both write back the same new value, and one increment is lost.

This is a data race: two goroutines access the same variable at the same time, and at least one of them writes. The result is unpredictable, and the bug may hide for months because it only shows up under the right timing. On a different machine this program might even print 1000 most of the time.

Go ships with a tool that finds these bugs for you. Add -race to go run, go build or go test:

go run -race .Code language: Bash (bash)
==================
WARNING: DATA RACE
Read at 0x00c000012168 by goroutine 10:
  main.main.func1()
      …/race/main.go:13 +0x2c

Previous write at 0x00c000012168 by goroutine 7:
  main.main.func1()
      …/race/main.go:13 +0x3c

Goroutine 10 (running) created at:
  sync.(*WaitGroup).Go()
      …/src/sync/waitgroup.go:238 +0x6c

Goroutine 7 (finished) created at:
  sync.(*WaitGroup).Go()
      …/src/sync/waitgroup.go:238 +0x6c
==================
(a second, similar report)
count: 899
Found 2 data race(s)
exit status 66Code language: plaintext (plaintext)

I shortened the file paths and left out the second report, which looks almost the same, but the rest is exactly what Go printed. Read it from the top: goroutine 10 read a variable on line 13 of main.go (that’s count++), while goroutine 7 had written it on the same line, with nothing ordering the two. Below that it tells you where each goroutine was started. The program still runs to the end, then exits with status 66 to signal that races were found.

The race detector only reports races that actually happen while the program runs, so it can’t prove your code is race-free. It does make the program slower and use more memory, so you wouldn’t ship a -race build, but running your tests with go test -race (see Testing) is one of the best habits you can pick up. It’s described in detail in the race detector article on go.dev.

To fix this counter you need either a sync.Mutex or an atomic integer, both covered in WaitGroups and mutexes. Or avoid sharing in the first place, like the slot-per-goroutine pattern above.

When not to use goroutines

Goroutines are easy to start, which makes them easy to overuse. I reach for them when there’s real waiting or real work to overlap: several network requests, files to process, a server handling many clients. I leave them out when:

  • The work is tiny. Starting and coordinating goroutines costs more than adding a few numbers. Measure before assuming concurrency makes something faster.
  • Each step needs the previous result. If step two can’t start before step one finishes, running them in goroutines just adds waiting code.
  • You can’t say how the goroutine ends. A goroutine that waits forever is never cleaned up (a goroutine leak). Before writing go, know who waits for it and what makes it stop. Context is the standard tool for stopping goroutines.
  • You’re writing a library function. A function that simply does its work and returns is easier to use than one that secretly starts goroutines. Callers can always wrap it in a goroutine themselves.

Practice

1. Squares. Start one goroutine per number from 1 to 5. Each goroutine computes the square of its number and stores it in a slice. After waiting, print the slice. The output must always be [1 4 9 16 25].

Show a solution
package main

import (
	"fmt"
	"sync"
)

func main() {
	squares := make([]int, 5)

	var wg sync.WaitGroup
	for i := range 5 {
		wg.Go(func() {
			n := i + 1
			squares[i] = n * n
		})
	}
	wg.Wait()

	fmt.Println(squares)
}Code language: Go (go)
[1 4 9 16 25]Code language: plaintext (plaintext)

2. A race-free counter without locks. Rewrite the racy counter so that 10 goroutines each count to 100, but each goroutine counts in its own slot of a slice. Add the slots up at the end and print the total (1000). Run it with go run -race . to confirm there’s no race.

Show a solution
package main

import (
	"fmt"
	"sync"
)

func main() {
	counts := make([]int, 10)

	var wg sync.WaitGroup
	for i := range counts {
		wg.Go(func() {
			for range 100 {
				counts[i]++
			}
		})
	}
	wg.Wait()

	total := 0
	for _, c := range counts {
		total += c
	}
	fmt.Println("count:", total)
}Code language: Go (go)
count: 1000Code language: plaintext (plaintext)

Key takeaways

  • go f() runs f in a new goroutine and returns immediately.
  • When main returns, the program exits and all other goroutines stop, finished or not.
  • time.Sleep is a guess, not synchronization. Use sync.WaitGroup (wg.Go and wg.Wait) to wait for goroutines.
  • Goroutines run in no particular order. Give each one its own slot if you need ordered results.
  • Two goroutines touching the same variable, with at least one writing, is a data race. Find them with -race.