Go Intermediate #3 Goroutines and Channels Intro

7 min read

After #2 Error-Handling Patterns, it’s time for Go’s biggest strength — concurrency. We’ll start with how lightweight goroutines and channels work.

Goroutines — lightweight concurrent units #

starting a goroutine
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")    // run as a goroutine
	say("world")        // run on the main goroutine

	time.Sleep(time.Second)
}

A single go keyword runs the function concurrently in a new goroutine, separate from the main flow.

Why are goroutines lightweight? #

A single OS thread is usually 1MB ~ 2MB of memory. A goroutine starts at about 2KB. You can spawn hundreds of thousands without trouble.

The Go runtime automatically schedules many goroutines across a small number of OS threads. You express what should run concurrently; how it actually runs is the runtime’s job.

When main ends, all goroutines die #

A common pitfall.

main ends — goroutines end
func main() {
	go say("hello")
	// main exits immediately — say may not print a single line
}

When main ends — every running goroutine dies instantly. The earlier time.Sleep was to make main wait long enough. In real code we use synchronization primitives like channels or WaitGroup to wait.

Channels — communication between goroutines #

A channel is a tool for passing values between goroutines. It also handles synchronization.

basic channel
func main() {
	ch := make(chan int)

	go func() {
		ch <- 42       // send on the channel
	}()

	v := <-ch          // receive from the channel
	fmt.Println(v)     // 42
}

<- points right when sending and left when receiving — an intuitive arrow.

Channels are synchronization tools #

A default channel is unbuffered. Send and receive must meet to proceed.

  • Sender: blocks until a receiver is ready
  • Receiver: blocks until a sender is ready
synchronization meaning
ch := make(chan struct{})

go func() {
	doWork()
	ch <- struct{}{}    // "done" signal
}()

<-ch                    // main proceeds only after receiving the signal
fmt.Println("work done")

struct{} (empty struct) is used often — when the value itself is meaningless and only the signal matters.

Buffered channels — like a queue #

buffered channel
ch := make(chan int, 3)    // capacity 3

ch <- 1     // doesn't block
ch <- 2     // doesn't block
ch <- 3     // doesn't block
// ch <- 4  ← blocks here (buffer full)

<-ch        // 1
<-ch        // 2
<-ch        // 3
// <-ch    ← blocks here (empty)

Sends block when capacity is full, receives block when empty. Behaves like a queue.

Unbuffered vs buffered — the unbuffered default is safer. Use a buffered channel only when there’s a clear reason (throughput, batching a known number of jobs, etc.).

Closing a channel — close #

When the sender has nothing more to send.

close
ch := make(chan int)

go func() {
	for i := 0; i < 5; i++ {
		ch <- i
	}
	close(ch)    // nothing more to send
}()

for v := range ch {    // automatically ends when closed
	fmt.Println(v)
}
// 0, 1, 2, 3, 4

The for range ch pattern is the most common. It automatically ends when the channel is closed.

close rules #

  • Receivers don’t close — only the sender
  • Sending on a closed channel → panic
  • Receiving from a closed channel → returns the zero value immediately (no block)

Check whether closed — comma-ok #

closed check
v, ok := <-ch
if !ok {
	fmt.Println("channel closed")
}

Same pattern as map’s comma-ok. If ok is false, it’s closed and there’s nothing more to receive.

WaitGroup — wait for many goroutines #

To wait until many goroutines finish — sync.WaitGroup is standard.

WaitGroup
import (
	"sync"
)

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			fmt.Printf("goroutine %d\n", id)
		}(i)
	}

	wg.Wait()    // wait until all are done
	fmt.Println("done")
}

Three methods:

  • Add(n) — register the number of goroutines to wait for
  • Done() — one goroutine finished (usually with defer)
  • Wait() — wait until all are Done

Pitfall — closure capture #

common pitfall
for i := 0; i < 3; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println(i)    // ✗ all may capture the same i (3, 3, 3)
	}()
}

A classic Go pitfall — every goroutine captured the same i variable, producing unexpected results. From Go 1.22+, each iteration gets a fresh i and the issue is resolved (similar to JS’s let).

For compatibility with older code, you’ll often see explicit copying to a local variable.

capture via local — older-code compatible
for i := 0; i < 3; i++ {
	i := i    // fresh variable per iteration
	go func() {
		fmt.Println(i)
	}()
}

Pattern — Worker Pool #

A common pattern — N goroutines pull jobs from a queue and process them.

worker pool
func worker(id int, jobs <-chan int, results chan<- int) {
	for j := range jobs {
		fmt.Printf("worker %d: processing job %d\n", id, j)
		time.Sleep(time.Second)
		results <- j * 2
	}
}

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

	// start 3 workers
	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results)
	}

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

	// collect results
	for a := 1; a <= 9; a++ {
		fmt.Println("result:", <-results)
	}
}

<-chan int (receive-only) and chan<- int (send-only) notation makes intent explicit in the function signature. Here it’s clear that worker only receives jobs and only sends results.

Pattern — Fan-out / Fan-in #

Distribute work to many goroutines (fan-out), gather results into one channel (fan-in). Covered in detail in #1 Concurrency Patterns.

Channels are like mailboxes — analogy #

Similar to OOP message passing. Two goroutines don’t share memory; they send data through channels.

“Don’t communicate by sharing memory; share memory by communicating.”
— Go aphorism

Instead of locking shared variables, the recommended model is to flow data through channels. Concurrent code is easier to read and has fewer bugs.

Pitfall — deadlock #

Channels are powerful, but used wrong they cause deadlock (everyone waiting on each other).

deadlock
func main() {
	ch := make(chan int)
	ch <- 1     // ✗ no receiver — blocks forever
	fmt.Println("never")
}
// fatal error: all goroutines are asleep - deadlock!

When the Go runtime detects all goroutines stuck, it panics. It can’t catch partial deadlocks, but full deadlocks are detected automatically.

The most common causes:

  1. Sender/receiver mismatch
  2. for range ch waiting forever because the channel was never closed
  3. Two goroutines waiting on each other’s channels (cycle)

Pitfall — goroutine leak #

When a goroutine never ends and lives forever — a memory-leak-like situation arises.

goroutine leak
func leak() {
	ch := make(chan int)

	go func() {
		v := <-ch    // waits forever
		fmt.Println(v)
	}()

	// no one sends on ch and the function ends
	// the goroutine survives, occupying memory
}

The solution is to make sure every goroutine has an exit path. A context or a done channel are the standard tools for this (see #5 context).

Channels as function parameters — declare direction #

directional channel types
func produce(out chan<- int) {       // send only
	for i := 0; i < 5; i++ {
		out <- i
	}
	close(out)
}

func consume(in <-chan int) {        // receive only
	for v := range in {
		fmt.Println(v)
	}
}

func main() {
	ch := make(chan int)
	go produce(ch)
	consume(ch)
}

How the function uses the channel — enforced by the signature. Trying to receive inside produce is a compile error. Intent is expressed in code.

Wrap-up #

What we covered:

  • go func() to start a goroutine — lightweight concurrent execution (~2KB)
  • When main ends, all goroutines end — synchronization required
  • Channels — value transfer + synchronization between goroutines
  • Unbuffered channels — send/receive must meet to proceed
  • Buffered channels — queue-like, blocks when full
  • close(ch) + for range is the standard termination pattern
  • sync.WaitGroup to wait for many goroutines
  • Worker pool pattern (<-chan / chan<- direction)
  • “Share memory by communicating” — the aphorism
  • Deadlock and goroutine-leak pitfalls

In the next post (#4 select and Timeouts) we cover the select statement that handles many channels at once, plus timeout and cancellation patterns.

X