01 - Concurrency Refresher

📋 Jump to Takeaways

Quick recap. If you took Go Essentials, this is review. If you didn't, this gets you up to speed.

Goroutines

A goroutine is a lightweight thread managed by the Go runtime. Prefix any function call with go.

go doWork()

Goroutines start at ~2 KB of stack. You can run hundreds of thousands on a single machine. The Go scheduler multiplexes them onto OS threads — you don't manage threads directly.

The catch: main doesn't wait for goroutines. When main returns, everything dies.

func main() {
    go fmt.Println("hello")
    // main exits immediately — goroutine may never run
}

Channels

Typed pipes for goroutine communication. Send on one side, receive on the other.

ch := make(chan string)

go func() {
    ch <- "done" // send
}()

msg := <-ch // receive (blocks until a value is available)
fmt.Println(msg)

Unbuffered channels block on both send and receive until the other side is ready. This is synchronization, not just data passing.

The key: sender and receiver must be in different goroutines. One blocks until the other is ready. It doesn't matter which starts first — whichever arrives first simply waits.

ch := make(chan string)

// Receiver goroutine — starts first, blocks until sender is ready
go func() {
    msg := <-ch
    fmt.Println(msg)
}()

// Sender — blocks until the receiver above is waiting
ch <- "hello"

This also works the other way around:

ch := make(chan string)

// Sender goroutine — starts first, blocks until receiver is ready
go func() {
    ch <- "hello"
}()

// Receiver — blocks until the sender above has sent
msg := <-ch
fmt.Println(msg)

Both work. The deadlock happens when send and receive are in the same goroutine with no one on the other side.

Buffered Channels

Buffered channels don't block until the buffer is full.

ch := make(chan int, 3)
ch <- 1 // doesn't block
ch <- 2 // doesn't block
ch <- 3 // doesn't block
// ch <- 4 would block — buffer full

Use them when the sender shouldn't wait for the receiver. Think of it as a mailbox with limited slots.

Channel Direction

Restrict channels in function signatures to make intent clear.

func producer(out chan<- int) { // send-only
    out <- 42
}

func consumer(in <-chan int) { // receive-only
    fmt.Println(<-in)
}

The compiler enforces this. You can't receive from a send-only channel.

Closing Channels

close(ch) signals no more values will be sent. Only the sender closes. Sending on a closed channel panics.

ch := make(chan int)

go func() {
    ch <- 1
    ch <- 2
    close(ch)
}()

for val := range ch {
    fmt.Println(val) // 1, 2 — range exits when channel closes
}

Check if a channel is closed with the comma-ok pattern:

val, ok := <-ch // ok is false if closed and empty

Select

select waits on multiple channel operations. Whichever is ready first wins.

select {
case msg := <-ch1:
    fmt.Println(msg)
case msg := <-ch2:
    fmt.Println(msg)
case <-time.After(3 * time.Second):
    fmt.Println("timed out")
}

Add default for non-blocking behavior:

select {
case msg := <-ch:
    fmt.Println(msg)
default:
    fmt.Println("nothing ready")
}

WaitGroup

Coordinates multiple goroutines. Add before launching, Done when finished, Wait to block.

var wg sync.WaitGroup

for i := 0; i < 3; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        fmt.Println("worker", id)
    }(i)
}

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

Always call Add before go, and always defer wg.Done() as the first line in the goroutine.

Mutex

Protects shared state from concurrent access.

var mu sync.Mutex
counter := 0

for i := 0; i < 100; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        mu.Lock()
        counter++
        mu.Unlock()
    }()
}

Rule of thumb: if goroutines share data without a channel, you probably need a mutex.

When to Use Channels vs Mutexes

This comes up constantly. Simple guideline:

Use case Tool
Passing data between goroutines Channels
Signaling events (done, cancel) Channels
Protecting shared state Mutex
Simple counters/flags sync/atomic

Go proverb: "Don't communicate by sharing memory; share memory by communicating." But don't force channels where a mutex is simpler. Use the right tool.

What's Next

This was the foundation. Starting next lesson, we build on these primitives with real patterns: context for cancellation, pipelines for data processing, worker pools for controlled parallelism.

The rest of this course is about combining these building blocks into patterns that production Go code uses every day.

Key Takeaways

  • Goroutines are cheap (~2 KB), scheduled by the Go runtime, not the OS
  • Channels synchronize goroutines — unbuffered channels block both sides
  • Buffered channels decouple sender and receiver up to the buffer size
  • select multiplexes channel operations — first ready wins
  • WaitGroup coordinates goroutine completion — Add, Done, Wait
  • Mutex protects shared state — channels pass data, mutexes guard it
  • Use channels for communication, mutexes for state protection, atomic for simple counters

🚀 Ready to run?

Complete runnable examples for this lesson.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

© 2026 ByteLearn.dev. Free courses for developers. · Privacy