Goroutines Explained

A goroutine is a lightweight, independently executing function that runs concurrently with the rest of your Go program. You start one with a single keyword, go, and the Go runtime takes care of scheduling it, growing its stack, and running it alongside thousands of others on just a handful of operating system threads. Goroutines are the foundation of everything concurrent in Go — HTTP servers handling many requests at once, pipelines that process data in stages, and background workers all build on this one primitive.

Overview: How Goroutines Work

In most languages, concurrency means operating-system threads, and threads are expensive: each one typically reserves a megabyte or more of stack space and costs real time to create and context-switch. Go takes a different approach. A goroutine starts with a tiny stack — around 2KB — that grows and shrinks automatically as needed (the runtime copies the stack to a larger block when it fills up). Because goroutines are so cheap, it’s completely normal for a Go program to run tens of thousands of them at once.

The Go runtime multiplexes goroutines onto a small number of OS threads using what’s often called the G-M-P scheduler: Goroutines are the units of work, Machines are OS threads, and Processors are logical contexts that hold a run queue of goroutines ready to execute. This is an M:N scheduler — M goroutines mapped onto N OS threads. The number of Ps (and therefore the amount of true parallelism) is controlled by GOMAXPROCS, which defaults to the number of logical CPUs on the machine.

A goroutine yields control back to the scheduler at certain points — a function call, a channel operation, a blocking system call, or a garbage-collection safepoint. Since Go 1.14, the runtime can also asynchronously preempt a goroutine that has been running too long without yielding, so a tight CPU-bound loop with no function calls won’t starve the rest of the program the way it could in older Go versions.

Crucially, starting a goroutine does not return anything you can directly wait on or read a result from. That’s by design: goroutines communicate and synchronize using channels and the sync package (sync.WaitGroup, sync.Mutex, and friends), following Go’s philosophy: “Don’t communicate by sharing memory; share memory by communicating.” This lesson focuses on how to start and coordinate goroutines; channels are covered in depth in their own lesson, but you’ll see them used here for coordination.

Syntax

The go keyword turns an ordinary function call into a goroutine. The call happens on a new goroutine immediately; execution of the calling goroutine continues on the very next line without waiting for it.

go functionName(arguments)

// or with an anonymous function literal
go func(parameters) {
	// statements
}(arguments)
Part Meaning
go Statement keyword that schedules the following function call to run as a new goroutine.
functionName(arguments) Any function call — a named function, a method, or a function literal (closure). Arguments are evaluated immediately, in the calling goroutine, before the new goroutine starts.
Return values Ignored. A function launched with go cannot return a value directly to the caller — use a channel, a shared variable protected by a mutex, or a callback to get data back out.

Note that arguments to the goroutine’s function are evaluated right away, in the calling goroutine, at the moment the go statement runs — only the function’s body executes later, concurrently. This distinction is the key to avoiding one of the most common goroutine bugs, covered in Common Mistakes below.

Examples

Example 1: Starting a single goroutine

package main

import (
	"fmt"
	"time"
)

func sayHello() {
	fmt.Println("Hello from a goroutine!")
}

func main() {
	go sayHello()
	time.Sleep(100 * time.Millisecond)
	fmt.Println("Hello from main")
}

Output:

Hello from a goroutine!
Hello from main

go sayHello() schedules sayHello to run concurrently and returns control to main immediately. Without something to keep main alive, the program could exit before the goroutine ever runs — when main returns, the whole program terminates, goroutines and all. Here time.Sleep gives the goroutine time to run, but sleeping is not a real synchronization mechanism — it’s a guess about timing. The next example shows the correct tool: sync.WaitGroup.

Example 2: Waiting for multiple goroutines with sync.WaitGroup

package main

import (
	"fmt"
	"sync"
)

func worker(id int, results []string, mu *sync.Mutex, wg *sync.WaitGroup) {
	defer wg.Done()
	msg := fmt.Sprintf("worker %d done", id)
	mu.Lock()
	results[id-1] = msg
	mu.Unlock()
}

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex
	results := make([]string, 3)

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(i, results, &mu, &wg)
	}

	wg.Wait()

	for _, r := range results {
		fmt.Println(r)
	}
}

Output:

worker 1 done
worker 2 done
worker 3 done

A sync.WaitGroup is a counter for in-flight goroutines. wg.Add(1) increments it before each goroutine starts, wg.Done() (deferred, so it always runs) decrements it when a goroutine finishes, and wg.Wait() blocks until the counter reaches zero. The three workers actually run concurrently and could finish in any order, but because each one writes into its own slot of the results slice (protected here by a sync.Mutex as good practice) and we only print after wg.Wait() returns, the printed output is always in the same, predictable order.

Example 3: Fan-out/fan-in with goroutines and a channel

package main

import (
	"fmt"
	"sort"
	"sync"
)

func square(n int, ch chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	ch <- n * n
}

func main() {
	numbers := []int{2, 4, 6, 8}
	ch := make(chan int, len(numbers))
	var wg sync.WaitGroup

	for _, n := range numbers {
		wg.Add(1)
		go square(n, ch, &wg)
	}

	wg.Wait()
	close(ch)

	results := make([]int, 0, len(numbers))
	for v := range ch {
		results = append(results, v)
	}
	sort.Ints(results)

	fmt.Println(results)
}

Output:

[4 16 36 64]

This is a realistic “fan-out, fan-in” pattern: work is distributed across several goroutines (fan-out), each one sends its result into a shared buffered channel, and the main goroutine collects (fans in) all the results once every worker has finished. The channel is buffered with capacity len(numbers) so each square goroutine can send its value without blocking. Because the results arrive in a nondeterministic order, we sort them before printing to get a predictable, testable output — a common and useful technique whenever concurrent work order doesn't matter but the final result does.

How It Works Step by Step

Walking through Example 3 in detail:

  1. The main goroutine creates a buffered channel ch and a WaitGroup.
  2. For each number, wg.Add(1) registers one more in-flight goroutine, then go square(n, ch, &wg) launches it. The argument n is copied into the goroutine at this exact moment — each goroutine gets its own value.
  3. The main goroutine keeps looping and launching without waiting; the four square goroutines are now eligible to run whenever the scheduler gives them a turn, potentially on different OS threads in parallel if GOMAXPROCS > 1.
  4. Each square goroutine computes n * n, sends it on ch, and calls wg.Done() via its deferred call, decrementing the WaitGroup counter.
  5. wg.Wait() in main blocks the main goroutine until the counter returns to zero — i.e., until all four workers have called Done.
  6. Once every worker is done, close(ch) signals that no more values will ever be sent on the channel.
  7. The for v := range ch loop drains all buffered values and then exits automatically because the channel is closed — ranging over a closed, drained channel ends the loop instead of blocking forever.
  8. The results are sorted and printed, giving a deterministic final line despite the nondeterministic order in which the goroutines actually completed.

Common Mistakes

Mistake 1: Not waiting for goroutines to finish

A goroutine you never synchronize with might simply never run, because main can exit first:

package main

import "fmt"

func main() {
	go fmt.Println("this might never print")
	// main can return before the scheduler
	// ever runs the goroutine above
}

When main returns, the process exits immediately — the Go runtime does not wait for other goroutines to finish. Always give goroutines a way to signal completion, such as a WaitGroup:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("this always prints")
	}()
	wg.Wait()
}

Output:

this always prints

Mistake 2: Capturing the loop variable instead of a per-iteration copy

A classic Go bug: closures capture variables, not the values they held at the time go was called.

for i := 0; i < 3; i++ {
	go func() {
		fmt.Println(i) // captures the loop variable, not a snapshot
	}()
}

In Go versions before 1.22, i was a single variable shared and reused across every iteration, so by the time the goroutines actually ran, i could already equal 3 for all of them, or any mix of values — the output was unpredictable and often wrong. Go 1.22 changed for loops to give each iteration its own copy of the loop variable, which fixes this specific case going forward. Even so, the defensive, version-portable fix is to pass the value in explicitly as a parameter, which creates an unambiguous per-goroutine copy on every Go version:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	results := make([]int, 3)

	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			results[n] = n * n
		}(i)
	}

	wg.Wait()
	fmt.Println(results)
}

Output:

[0 1 4]

Passing i as the argument n copies its current value at the moment go runs, so each goroutine gets its own independent number regardless of how the loop variable changes afterward.

Mistake 3: Unsynchronized shared state (a data race)

Reading and writing the same variable from multiple goroutines without synchronization is a data race — the behavior is undefined and the result is unreliable, even though the code compiles and often looks like it works:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var counter int
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++ // unsynchronized read-modify-write: a data race
		}()
	}

	wg.Wait()
	fmt.Println(counter) // not reliably 1000
}

counter++ is really three steps — read, add one, write back — and two goroutines can interleave those steps and overwrite each other's work, silently losing increments. Fix it with sync/atomic for simple counters, or a sync.Mutex for more complex shared state:

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var counter int64
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			atomic.AddInt64(&counter, 1)
		}()
	}

	wg.Wait()
	fmt.Println(counter)
}

Output:

1000

atomic.AddInt64 performs the read-modify-write as one indivisible hardware-level operation, so no increment is ever lost. Run any suspect concurrent code through go run -race (or go test -race) during development — the built-in race detector catches exactly this class of bug.

Best Practices

  • Always have an explicit plan for how a goroutine ends and how the rest of the program learns it's done — a WaitGroup, a result channel, or a context.Context for cancellation.
  • Never rely on time.Sleep to "wait" for a goroutine in real code; it's a guess, not a guarantee, and it's only acceptable for quick demos or throttling.
  • Pass loop values into goroutines as function parameters rather than trusting closure capture, even on Go 1.22+, for readability and portability.
  • Protect any variable read and written by more than one goroutine with a sync.Mutex, or avoid sharing it at all by communicating over a channel instead.
  • Use go run -race or go test -race regularly during development; data races often don't show symptoms until production load exposes them.
  • Don't launch an unbounded number of goroutines from unbounded input (e.g., one per incoming network request with no limit) — use a fixed-size worker pool or a semaphore to bound concurrency.
  • Prefer context.Context for cancellation and deadlines across goroutine boundaries, especially in servers and long-running pipelines.
  • Keep goroutine bodies focused and let panics inside a goroutine be handled deliberately — an unrecovered panic in any goroutine crashes the entire program, not just that goroutine.

Practice Exercises

  • Exercise 1: Write a program that launches five goroutines, each computing the cube of a different integer (1 through 5) and storing it into a shared, pre-sized slice at its own index (no mutex needed since each writes a distinct index). Use a WaitGroup to wait for all of them, then print the slice. Expected output: [1 8 27 64 125].
  • Exercise 2: Take the data-race example from Common Mistakes (the plain counter++ version) and run it through go run -race mentally — explain in a comment which two lines of code race with each other and why passing the same variable to 1000 goroutines without synchronization is unsafe.
  • Exercise 3: Build a small worker pool: launch 3 "worker" goroutines that each read integers from a shared input channel, double them, and send the results to an output channel. Send the numbers 1 through 6 in, close the input channel when done sending, and collect and sort all 6 results before printing them. Hint: you'll need a second WaitGroup to know when all workers have stopped reading before you close the output channel.

Summary

  • A goroutine is a lightweight, independently scheduled function execution started with the go keyword; thousands can run at once because each starts with only a few kilobytes of stack.
  • The Go runtime multiplexes goroutines onto OS threads using an M:N scheduler (the G-M-P model); GOMAXPROCS controls how many run truly in parallel.
  • Starting a goroutine never blocks and never returns a value directly — use channels or shared state protected by sync primitives to get results back.
  • sync.WaitGroup is the standard way to wait for a group of goroutines to finish; Add, deferred Done, and Wait form the core pattern.
  • Arguments to a goroutine's function are evaluated immediately when go runs, which is why passing loop variables as parameters avoids the classic capture bug.
  • Unsynchronized access to shared variables from multiple goroutines is a data race with undefined behavior — use sync/atomic, sync.Mutex, or channels, and verify with go run -race.
  • Always plan for goroutine lifecycle and cancellation; an unbounded, unmanaged flood of goroutines is a common source of production bugs.