Skip to content
C Codeloom
Go

Go Goroutines and Channels Tutorial

A practical introduction to concurrency in Go: goroutines, channels, select, common patterns like fan-out/fan-in, and the pitfalls that cause leaks and races.

·5 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • What a goroutine actually costs
  • How channels coordinate goroutines
  • The select statement and timeouts
  • Fan-out/fan-in and worker pool patterns
  • Common leaks and race conditions

Prerequisites

  • Basic familiarity with the language

Go’s concurrency model is one of the reasons the language took off. Spawning a goroutine is cheap. Communicating between them through channels feels natural. The result is concurrent code that does not look concurrent. But “easy to write” is not the same as “easy to get right.” Goroutine leaks and channel deadlocks are real, and they are the most common Go bugs.

Goroutines

A goroutine is a function running concurrently with other goroutines, scheduled by the Go runtime onto a small pool of OS threads. Starting one is a single keyword.

go doSomething()

A goroutine has a stack of a few kilobytes, which grows as needed. You can have hundreds of thousands of them on a single process. The runtime multiplexes them onto threads using cooperative scheduling at function call boundaries.

package main

import (
    "fmt"
    "time"
)

func say(s string) {
    for i := 0; i < 3; i++ {
        fmt.Println(s)
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    go say("hello")
    say("world")
}

That program prints lines from both goroutines interleaved. When main returns, the program exits regardless of whether other goroutines are done.

Channels

A channel is a typed conduit for sending values between goroutines.

ch := make(chan int)        // unbuffered
buf := make(chan int, 10)   // buffered, capacity 10

ch <- 42      // send
v := <-ch     // receive
close(ch)

Sends and receives synchronize. On an unbuffered channel, a send blocks until a receive is ready, and vice versa. That synchronization is how channels move values and coordinate timing in one step.

A buffered channel can hold up to its capacity without a receiver. Sends only block when the buffer is full.

The mental model

goroutine A:   produce x  ---> ch <---  goroutine B: consume x
                          (rendezvous on unbuffered)

goroutine A:   produce x  ---> [buf: x] <---  goroutine B
                          (decoupled by buffer)
Goroutines synchronizing through a channel

Unbuffered channels enforce a handshake. Buffered channels are queues with bounded capacity. The choice between them is the choice between synchronous and asynchronous handoff.

Select

select lets a goroutine wait on multiple channel operations at once.

select {
case msg := <-ch1:
    fmt.Println("got", msg)
case ch2 <- 42:
    fmt.Println("sent")
case <-time.After(time.Second):
    fmt.Println("timeout")
}

The first case ready to proceed is taken. If multiple are ready, one is chosen at random. default makes the select non-blocking.

Timeouts via time.After are the standard idiom. Cancellation via a done channel (or context.Context) is how long-running goroutines learn to stop.

Patterns

Fan-out / fan-in: multiple workers consume from one channel and produce into another.

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

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

    for w := 0; w < 4; w++ {
        go worker(jobs, results)
    }

    for j := 1; j <= 9; j++ {
        jobs <- j
    }
    close(jobs)

    for r := 1; r <= 9; r++ {
        fmt.Println(<-results)
    }
}

close(jobs) signals the workers to stop. Ranging over a channel ends when the channel is closed.

Done channel for cancellation:

func produce(done <-chan struct{}, out chan<- int) {
    defer close(out)
    for i := 0; ; i++ {
        select {
        case out <- i:
        case <-done:
            return
        }
    }
}

The producer exits cleanly when the caller closes done.

sync.WaitGroup

When you need to wait for a known set of goroutines to finish, a WaitGroup is simpler than a result channel.

var wg sync.WaitGroup
for _, u := range urls {
    wg.Add(1)
    go func(u string) {
        defer wg.Done()
        fetch(u)
    }(u)
}
wg.Wait()

Note the variable capture: u is passed as an argument so each goroutine sees its own value. Without that, all goroutines might see the loop’s final u.

Common pitfalls

Goroutine leaks. A goroutine blocked forever on a channel is a leak. It costs memory and never frees. Always design exit paths: close channels, use done channels, propagate context.Context.

Sending on a closed channel. Panics. Only the sender should close. If many senders share a channel, use an external coordinator or sync.Once.

Range over a channel that is never closed. Loops forever. Symptom of a forgotten close.

Data races on shared state. Channels move data; they do not protect arbitrary fields. If you mutate a struct from multiple goroutines, use a mutex or copy the data through a channel.

Variable capture in goroutines. The closure captures by reference; loop variables change.

Practical tips

Prefer channels for communication and mutexes for protecting state. The Go proverb “share memory by communicating” is genuinely good advice, but it does not forbid mutexes; some problems are mutex problems.

Use context.Context to propagate cancellation through API boundaries. Every long-running function in modern Go takes a ctx.

Run with -race during development. The race detector finds bugs that show up once in a hundred runs in production.

Wrap-up

Goroutines and channels make concurrency in Go approachable. The patterns to internalize are: unbuffered for handshakes, buffered for queues, select for waiting, close for completion, and context for cancellation. Once you have those reflexes, concurrent Go reads as straight-line code with a sprinkle of go, and the bugs that remain are usually about leaks rather than races.