Concurrency in Go: Goroutines, Channels and Context

· 10 min read · Go, Concurrency, Backend

Go was designed with concurrency in the language, not added through a library. You start a concurrent task with one keyword, pass data between tasks with channels, and the runtime spreads the work across CPU cores for you.

This post covers goroutines, channels, select, the sync package, and the patterns I use most: worker pools, pipelines, cancellation with context, and errgroup. It ends with the mistakes that cause most concurrency bugs.

Concurrency Is Not Parallelism

Concurrency is about structure: several tasks in progress at the same time, taking turns. Parallelism is about execution: several tasks running at the same instant on different cores. A concurrent Go program runs in parallel when there are cores free, but it is correct on a single core too. Write for concurrency and let the runtime handle the parallelism.

Goroutines

A goroutine is a function running concurrently with the rest of the program. Put go in front of a call:

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

func main() {
	go say("from a goroutine")
	say("from main")
	time.Sleep(100 * time.Millisecond)
}

Goroutines are cheap. Each starts with a stack of a few kilobytes that grows as needed, and the Go scheduler multiplexes them onto a small number of OS threads. Running ten thousand of them is normal.

The time.Sleep above is there only because main returning ends the program, including every goroutine still running. Sleeping is not a real way to wait. The next two sections are.

WaitGroup

sync.WaitGroup waits for a group of goroutines to finish. Since Go 1.25, wg.Go starts the goroutine and does the counting for you:

func main() {
	var wg sync.WaitGroup

	for i := range 3 {
		wg.Go(func() {
			fmt.Println("worker", i)
		})
	}

	wg.Wait()
	fmt.Println("all done")
}

On older versions you call wg.Add(1) before go and defer wg.Done() inside the goroutine. Since Go 1.22, each loop iteration gets its own i, so capturing it in the closure is safe.

Channels

A channel is a typed pipe between goroutines. One side sends, the other receives:

ch := make(chan string)

go func() {
	ch <- "ping"
}()

msg := <-ch
fmt.Println(msg)

An unbuffered channel like this one is also a synchronisation point. A send blocks until another goroutine receives, and a receive blocks until another goroutine sends. Both sides meet at the channel, and the value is handed over directly.

A buffered channel has room for a fixed number of values. Sends only block when the buffer is full, and receives only block when it is empty:

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3 // fits; a fourth send would block

Use a buffer when producers and consumers run at uneven speeds and you want to absorb short bursts. Don't add one to make a deadlock go away; that usually hides the bug until the buffer fills.

Closing and Ranging

The sender closes a channel to say no more values are coming. The receiver can loop over it with range, which stops after the last value:

func count(n int) <-chan int {
	ch := make(chan int)
	go func() {
		defer close(ch)
		for i := 1; i <= n; i++ {
			ch <- i
		}
	}()
	return ch
}

func main() {
	for v := range count(3) {
		fmt.Println(v)
	}
}

Three rules worth remembering:

  • Only the sender closes a channel, never the receiver.
  • Sending on a closed channel panics.
  • Receiving from a closed channel returns the zero value immediately. Use v, ok := <-ch when you need to tell the two apart.

The <-chan int return type is a receive-only channel. Direction in the type lets the compiler stop callers from sending on or closing a channel they don't own.

Select

select waits on several channel operations and runs whichever is ready first. It is how you add a timeout:

select {
case res := <-results:
	fmt.Println("got", res)
case <-time.After(2 * time.Second):
	fmt.Println("timed out")
}

With a default case, select doesn't block at all. That turns a send into "send if someone is ready, otherwise move on":

select {
case events <- e:
default:
	log.Println("event dropped, consumer is busy")
}

Mutexes

Channels are for passing data between goroutines. When several goroutines share one piece of state, a mutex is often simpler:

type Counter struct {
	mu sync.Mutex
	n  map[string]int
}

func (c *Counter) Inc(key string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n[key]++
}

sync.RWMutex allows many readers at once and one writer, which helps when reads far outnumber writes. For a single counter, sync/atomic (atomic.Int64) avoids the lock entirely.

My rule of thumb: if the data moves from one goroutine to another, use a channel. If it stays in place and several goroutines touch it, use a mutex.

Worker Pools

Starting one goroutine per job is fine for a hundred jobs. For a hundred thousand jobs that each open a database connection, you want a fixed number of workers pulling from a shared queue:

func worker(jobs <-chan int, results chan<- int) {
	for n := range jobs {
		results <- n * n
	}
}

func main() {
	jobs := make(chan int)
	results := make(chan int)

	var wg sync.WaitGroup
	for range 3 {
		wg.Go(func() { worker(jobs, results) })
	}

	go func() {
		defer close(jobs)
		for n := 1; n <= 9; n++ {
			jobs <- n
		}
	}()

	go func() {
		wg.Wait()
		close(results)
	}()

	sum := 0
	for r := range results {
		sum += r
	}
	fmt.Println(sum) // 285
}

The order of shutdown matters. The producer closes jobs, so each worker's range ends. A separate goroutine waits for all workers and then closes results, so the collector's range ends. If main called wg.Wait() itself before reading results, the workers would block on results <- ... and the program would deadlock.

Pipelines

A pipeline is a chain of stages connected by channels. Each stage receives from the one before, does its work, and sends to the next:

func generate(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for _, n := range nums {
			out <- n
		}
	}()
	return out
}

func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			out <- n * n
		}
	}()
	return out
}

func main() {
	for v := range square(generate(2, 3, 4)) {
		fmt.Println(v) // 4, 9, 16
	}
}

Every stage owns and closes its output channel, so closing ripples from the first stage to the last. Stages run at the same time: while square works on 3, generate can already be sending 4.

Cancellation with Context

Real work gets cancelled. A user closes the tab, a request times out, or one of three parallel calls fails and the other two are no longer worth finishing. context.Context carries that cancellation signal down to every goroutine working on the request.

When the timeout fires, or cancel() is called, every context derived from T is cancelled together. A goroutine notices by selecting on ctx.Done():

func fetch(ctx context.Context, name string, d time.Duration) (string, error) {
	select {
	case <-time.After(d):
		return name + " ok", nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	_, err := fetch(ctx, "orders", time.Second)
	fmt.Println(err) // context deadline exceeded
}

A few conventions keep context useful:

  • Pass ctx as the first parameter. Don't store it in a struct.
  • Always call cancel, usually with defer, even if the work finishes first. It frees the timer.
  • Functions from the standard library that do I/O, like http.NewRequestWithContext and db.QueryContext, already watch the context. Pass it through.

errgroup

Running several tasks, stopping at the first error and cancelling the rest is common enough that golang.org/x/sync/errgroup does it for you:

func loadDashboard(ctx context.Context, userID int) error {
	g, ctx := errgroup.WithContext(ctx)

	var user User
	var orders []Order

	g.Go(func() error {
		var err error
		user, err = fetchUser(ctx, userID)
		return err
	})
	g.Go(func() error {
		var err error
		orders, err = fetchOrders(ctx, userID)
		return err
	})

	if err := g.Wait(); err != nil {
		return err
	}
	render(user, orders)
	return nil
}

Wait returns the first error. As soon as any task fails, the shared ctx is cancelled, so the other tasks can stop early. g.SetLimit(n) caps how many tasks run at once, which turns the group into a small worker pool.

Each goroutine writes to its own variable, so no mutex is needed. Wait guarantees those writes are visible afterwards.

Common Mistakes

Data Races

Two goroutines touching the same variable, with at least one writing and nothing synchronising them, is a data race. The result is undefined, and it often passes tests and fails in production. Run tests with the race detector:

go test -race ./...

It only finds races in code that actually runs, so it is worth enabling in CI on tests that exercise concurrent paths.

Goroutine Leaks

A goroutine blocked forever on a channel nobody will touch again is never collected. This one leaks when the timeout wins:

func search(ctx context.Context) (string, error) {
	ch := make(chan string)
	go func() {
		ch <- slowSearch() // blocks forever if nobody receives
	}()

	select {
	case r := <-ch:
		return r, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

After a timeout, nothing receives from ch, so the goroutine waits forever. Give the channel a buffer of one with make(chan string, 1) so the send always completes, or pass ctx into the work so it can stop. For every goroutine you start, you should be able to say how it ends.

Deadlocks

If every goroutine is blocked, the runtime stops the program with fatal error: all goroutines are asleep - deadlock!. The usual cause is a send on an unbuffered channel with no receiver, or forgetting to close a channel that someone is ranging over. Partial deadlocks, where only some goroutines are stuck, are not detected. They show up as leaks.

Summary

  • Start goroutines with go, and always know how each one ends.
  • Use sync.WaitGroup to wait for a group, and wg.Go on Go 1.25 or later.
  • Use channels to pass data and signal events, and close them from the sending side.
  • Use a mutex or sync/atomic for shared state that stays in place.
  • Pass context.Context through anything that can be cancelled, and reach for errgroup when tasks fail together.
  • Run go test -race before trusting concurrent code.

The Go blog posts on pipelines and cancellation and context go deeper into both patterns, and both are still accurate.