Code of the Day
AdvancedConcurrency

Goroutines and channels

Launch concurrent tasks with goroutines and communicate between them safely with channels.

GoAdvanced13 min read
Recommended first
By the end of this lesson you will be able to:
  • Launch a goroutine with the go keyword
  • Explain the goroutine lifecycle and what happens when main returns
  • Create buffered and unbuffered channels with make
  • Send and receive values on a channel
  • Close a channel and range over it to drain all values

Go was designed for concurrency from the start. The language primitives — goroutines and channels — are built into the runtime, not bolted on as a library. Understanding them is not optional for production Go work: almost every non-trivial service uses them, and Go's standard library assumes you know the model.

The guiding slogan: "Don't communicate by sharing memory; share memory by communicating." Channels are the mechanism for that communication.

Goroutines

A is a lightweight, concurrently executing function. The go keyword launches one:

go func() {
    fmt.Println("running in a goroutine")
}()

Goroutines are much cheaper than OS threads — a new goroutine starts with a few kilobytes of stack. A Go program can run hundreds of thousands of goroutines simultaneously.

Important: when main returns, all goroutines are killed immediately, including ones still running. If you launch a goroutine in main and return before it finishes, its work is lost.

func main() {
    go fmt.Println("this may not print")
    // main returns immediately, goroutine may not get scheduled
}

You need a synchronisation mechanism to wait. Channels are one way; sync.WaitGroup (covered later) is another.

Channels

A is a typed conduit for sending values between goroutines. Create one with make:

ch := make(chan int)      // unbuffered
bch := make(chan int, 5)  // buffered, capacity 5

Send a value with <-:

ch <- 42   // send 42 into ch

Receive a value:

v := <-ch   // receive from ch, store in v

An unbuffered channel blocks the sender until a receiver is ready, and blocks the receiver until a sender sends. This synchronises the two goroutines at every exchange.

func main() {
    ch := make(chan string)

    go func() {
        ch <- "hello from goroutine"
    }()

    msg := <-ch
    fmt.Println(msg)   // hello from goroutine
}

The main goroutine blocks on <-ch until the other goroutine sends. This is how you wait for a goroutine to produce a result.

Buffered channels

A can hold up to its capacity without a receiver waiting:

ch := make(chan int, 3)
ch <- 1   // does not block — buffer has room
ch <- 2
ch <- 3
// ch <- 4 would block here — buffer is full

Buffered channels decouple sender and receiver. Use buffered channels when you know roughly how many items the producer will generate before the consumer catches up — for example, a worker queue.

An unbuffered channel provides synchronisation: sender and receiver meet at the exchange point. A buffered channel provides decoupling: the sender can proceed up to the capacity limit without waiting. Neither is universally better — choose based on whether you want the sender to wait.

Closing a channel

A sender can close a channel to signal that no more values will be sent:

close(ch)

Receivers can detect a close with the comma-ok pattern:

v, ok := <-ch
if !ok {
    // channel is closed and empty
}

More commonly, range over a channel drains it until close:

for v := range ch {
    fmt.Println(v)   // receives each value until ch is closed
}

Only the sender should close a channel. Closing a channel from the receiver side, or closing it twice, panics. If multiple goroutines might send, coordinate with a separate done signal or use a sync.Once to close exactly once.

A simple pipeline

Channels compose naturally into pipelines:

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

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

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

Each stage is independent. The goroutines run concurrently — generate sends while square computes and main prints.

Check your understanding

Knowledge check

  1. 1.
    You send to an unbuffered channel but no goroutine is receiving. What happens?
  2. 2.
    It is safe for any goroutine (sender or receiver) to call close() on a channel.
  3. 3.
    A goroutine sends values into a channel and closes it when done. What is the idiomatic way to receive all values in the consumer?

Do it yourself

Write a fanOut function that sends the integers 1 to 10 into a channel, then read and print them in main using a range loop. Add a second goroutine that squares each value as it arrives.

go run main.go
go vet ./...

Where to go next

You have goroutines and channels. The next lesson covers the select statement — how to wait on multiple channels simultaneously, handle timeouts, and build non-blocking operations.

Finished reading? Mark it complete to track your progress.

On this page