Week 6: Goroutines & Channels: Concurrency Fundamentals

This is the week Go earns its reputation. Concurrency isn't bolted on as a library here — goroutines and channels are language-level primitives, cheap enough to launch by the thousand, and designed around a specific philosophy: don't communicate by sharing memory, share memory by communicating.

Module 6 of 16 Week 6 of 16 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Launch a goroutine and explain why it's dramatically cheaper than an OS thread
  • Use a channel to send and receive values safely between goroutines
  • Explain the difference between a buffered and an unbuffered channel
  • Recognize and avoid the most common way a Go program deadlocks

1. Launching Goroutines

The go keyword before a function call runs it concurrently, as a goroutine, without blocking the caller. A goroutine starts with a tiny (as little as 2KB) growable stack, managed by the Go runtime rather than the OS — which is why launching thousands of them is routine in Go, where launching thousands of OS threads would exhaust memory and scheduler overhead fast.

a first goroutine
func sayHello() {
    fmt.Println("hello from a goroutine")
}

func main() {
    go sayHello()             // runs concurrently, doesn't block
    time.Sleep(100 * time.Millisecond) // crude — just to let it finish before main exits
    fmt.Println("main done")
}

time.Sleep here is a placeholder, not a real synchronization mechanism — if main returns before the goroutine runs, the whole program exits and the goroutine's output never appears. Channels (next) and sync.WaitGroup (Week 7) are the real tools for this.

2. Channels for Safe Communication

A channel is a typed pipe goroutines use to send and receive values — and to synchronize, since a send and a receive on an unbuffered channel both block until the other side is ready.

a channel replacing time.Sleep
func sayHello(done chan bool) {
    fmt.Println("hello from a goroutine")
    done <- true // send: signals completion
}

func main() {
    done := make(chan bool)
    go sayHello(done)
    <-done // receive: blocks until sayHello sends
    fmt.Println("main done")
}

This is Go's core concurrency philosophy in miniature: instead of two goroutines both reaching into a shared variable (and needing locks to do it safely), one goroutine sends its result to another through a channel — ownership of the data transfers cleanly, with no shared mutable state to protect.

3. Buffered vs. Unbuffered Channels

the difference in blocking behavior
unbuffered := make(chan int)     // capacity 0
buffered := make(chan int, 3)     // capacity 3

go func() {
    unbuffered <- 1 // blocks here until something receives
}()
fmt.Println(<-unbuffered) // 1 — unblocks the sender above

buffered <- 1 // does NOT block — there's room
buffered <- 2 // still doesn't block
buffered <- 3 // still fits
// buffered <- 4 would block here — the buffer is full

An unbuffered channel forces a handoff — the sender waits for a receiver and vice versa, which is a strong synchronization guarantee ("this happened before that"). A buffered channel decouples them up to its capacity, useful for smoothing out bursts of work, but it gives up that strict handoff guarantee in exchange.

4. Avoiding Deadlocks

The most common beginner deadlock: sending on an unbuffered channel with nobody ever going to receive.

a classic deadlock
func main() {
    ch := make(chan int)
    ch <- 1 // blocks forever — nothing will ever receive on the main goroutine
    fmt.Println(<-ch)
}
// fatal error: all goroutines are asleep - deadlock!

The fix is almost always structural: the send needs to happen from a different goroutine than the receive, so one can proceed while the other is blocked waiting.

fixed — send from a separate goroutine
func main() {
    ch := make(chan int)
    go func() {
        ch <- 1 // runs on its own goroutine — free to block here
    }()
    fmt.Println(<-ch) // main receives — unblocks the goroutine above
}

Go's runtime is specifically able to detect the case where every goroutine is blocked with no possible way forward, and crashes immediately with a clear "deadlock!" message rather than hanging silently forever — genuinely useful during development, since it turns a subtle bug into a loud, immediate one.

5. Hands-on Exercise

Hands-on

Build a concurrent URL "checker"

Launch several goroutines that do work concurrently and report back over a channel.

Requirements:

  1. A function checkStatus(id int, results chan<- string) that simulates checking something (a time.Sleep of a random short duration is fine — no real network call needed) and sends a result string on results.
  2. A main that launches at least 5 of these as goroutines, all sharing one channel.
  3. Correctly receive exactly as many results as goroutines launched, with no deadlock and no goroutine's result silently dropped.
  4. Try it first with an unbuffered channel, then with a buffered channel sized to the number of goroutines — observe (and note in a comment) any timing difference in when main is able to proceed.
  5. Deliberately reproduce a deadlock in a throwaway snippet (send with no corresponding receive) and paste the runtime's error message in a comment, to see what it actually looks like.
Hint

If some results seem to go missing, check that you're receiving exactly as many times as you're sending — a common mistake is looping to receive a fixed number of times that doesn't match the number of goroutines actually launched, silently leaving one or more sends blocked forever (which itself is a deadlock, just a partial one your program might not immediately surface).

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why can a Go program comfortably launch thousands of goroutines when launching thousands of OS threads would not be practical?

A goroutine starts with a tiny, growable stack (as small as 2KB) managed by the Go runtime's own scheduler, rather than a full OS thread with its comparatively large fixed stack and kernel-level scheduling overhead. That much lower per-unit cost is what makes launching goroutines by the thousand routine in Go.

Q2

In the "channel replacing time.Sleep" example, why does <-done in main reliably wait for the goroutine to finish, where time.Sleep(100ms) did not?

<-done blocks until a value is actually sent on the channel, which happens exactly when sayHello finishes — the wait is tied to the real event, not a guessed duration. time.Sleep just waits a fixed amount of time regardless of whether the goroutine actually finished by then, which is unreliable under any real timing variance.

Q3

What real guarantee does an unbuffered channel give you that a buffered one does not?

An unbuffered channel forces a synchronous handoff — the sender's send only completes once a receiver is actually ready to receive, guaranteeing the send happened-before the receive. A buffered channel lets a send complete and return immediately (as long as there's room), decoupling sender and receiver in time — useful for absorbing bursts, but it no longer guarantees that tight ordering.

Q4

Why does ch <- 1 in a bare main function (with no goroutines) always deadlock on an unbuffered channel?

An unbuffered send blocks until some other goroutine is ready to receive on that same channel — but with no other goroutine running, there is nothing that could ever perform that receive, so the send blocks forever with zero possibility of proceeding. Go's runtime detects that every goroutine (here, just the one) is permanently blocked and reports a deadlock rather than hanging silently.