Concurrency

📋 Jump to Takeaways

Concurrency means multiple tasks making progress at the same time. Not necessarily running at the exact same instant (that's parallelism), but structured so they can overlap. A web server handling 1000 connections doesn't need 1000 OS threads. It needs concurrency.

Go was built for this from day one. Goroutines, channels, and a runtime scheduler that handles the hard parts. You can spin up a hundred thousand goroutines on a laptop and barely touch your memory.

Concurrency alone deserves its own course, and we'll have a dedicated one. Don't feel overwhelmed by this lesson. The goal here is to know what Go offers and how the pieces fit together. Getting comfortable takes practice, and nobody becomes an expert overnight.

Goroutines

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

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

func main() {
    go sayHello()
    fmt.Println("hello from main")
    time.Sleep(time.Millisecond) // wait so goroutine can finish
}
// Output:
// hello from main
// hello from goroutine

Goroutines are cheap. Each starts with about 2 KB of stack (compared to 1-8 MB for an OS thread), and the stack grows as needed. You can run thousands without issue.

The problem: main doesn't wait for goroutines. When main returns, everything gets killed. time.Sleep is a hack. Channels and WaitGroups are the real fix.

Channels

Channels are typed pipes for passing data between goroutines.

ch := make(chan int)

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

val := <-ch // receive
fmt.Println(val) // 42

Sends block until another goroutine receives. Receives block until another goroutine sends. This is how goroutines synchronize.

func worker(ch chan string) {
    time.Sleep(time.Second) // simulate work
    ch <- "done"
}

func main() {
    ch := make(chan string)
    go worker(ch)
    result := <-ch // blocks until worker sends
    fmt.Println(result) // done
}

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

fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3

Use buffered channels when the sender shouldn't wait for the receiver. Think of it as a mailbox: the sender drops off messages without waiting, as long as the box isn't full.

Deadlock

If all goroutines are blocked, Go detects a deadlock and crashes.

func main() {
    ch := make(chan int)
    ch <- 1 // blocks forever — no one is receiving
}
// fatal error: all goroutines are asleep - deadlock!

Closing Channels

close(ch) signals that no more values will be sent. Receivers get the zero value after a channel is closed.

Only the sender should close a channel, never the receiver. Sending on a closed channel panics, so the sender is the only one who knows when it's done.

ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch)

fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 0 (zero value, channel closed)

You can check if a channel is closed with the two-value receive.

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

Range Over Channel

range reads from a channel until it's closed.

ch := make(chan int)

go func() {
    for i := 0; i < 3; i++ {
        ch <- i
    }
    close(ch) // must close or range blocks forever
}()

for val := range ch {
    fmt.Println(val) // 0, 1, 2
}

Select

select is like switch for channels. It picks whichever case is ready first.

ch1 := make(chan string)
ch2 := make(chan string)

go func() { time.Sleep(100 * time.Millisecond); ch1 <- "one" }()
go func() { time.Sleep(50 * time.Millisecond); ch2 <- "two" }()

select {
case msg := <-ch1:
    fmt.Println(msg)
case msg := <-ch2:
    fmt.Println(msg) // two (arrives first)
}

Without default, select blocks until one of the channels is ready. Add a default case and it skips immediately if nothing is available.

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

The most common real-world use of select is timeouts. time.After returns a channel that sends after a duration:

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

sync.WaitGroup

A WaitGroup waits for a bunch of goroutines to finish. Add before launching, Done when finished, Wait to block until the count hits zero.

var wg sync.WaitGroup

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

wg.Wait() // blocks until all 3 call Done()
fmt.Println("all done")

sync.Mutex

When multiple goroutines read and write the same variable, you get a race condition. The result is unpredictable.

counter := 0
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        counter++ // race condition: multiple goroutines writing at once
    }()
}

wg.Wait()
fmt.Println(counter) // not always 1000

A sync.Mutex locks access so only one goroutine can touch the variable at a time:

var mu sync.Mutex
counter := 0
var wg sync.WaitGroup

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

wg.Wait()
fmt.Println(counter) // always 1000

Rule of thumb: if goroutines share data without a channel, you probably need a mutex. For simple counters and flags, sync/atomic is faster since it avoids locking entirely:

var counter atomic.Int64

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

Channel Direction

You can restrict a channel to send-only or receive-only in function signatures. This makes your intent clear and the compiler enforces it.

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

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

You'll see this in the fan-out pattern below. The worker takes chan<- int because it only sends results.

Fan-Out, Fan-In

Fan-out: launch multiple goroutines to do work. Fan-in: collect their results through a single channel.

The trick is a separate goroutine that waits for all workers to finish, then closes the channel. That lets the main goroutine range over results without knowing how many to expect.

func worker(id int, ch chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    ch <- id * 10
}

func main() {
    ch := make(chan int, 5)
    var wg sync.WaitGroup

    // fan-out: 5 workers
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go worker(i, ch, &wg)
    }

    // close channel when all workers finish
    go func() {
        wg.Wait()
        close(ch)
    }()

    // fan-in: collect results
    for val := range ch {
        fmt.Println(val) // 0, 10, 20, 30, 40 (order varies)
    }
}

What About context.Context?

In real Go code, you'll see context.Context everywhere. It's how you tell goroutines to stop: timeouts, cancellation, deadlines. We won't cover it in this lesson, but know it exists. The dedicated concurrency course will go deep on it.

Exercises

Try these on your own. They use only what this lesson covers.

  1. Write a function that launches 3 goroutines, each sending their ID to a channel. Print all 3 IDs from main.

  2. Create a buffered channel of size 2. Send 3 values to it from a goroutine and receive all 3 from main. What happens if you forget to receive one?

  3. Write a program with two goroutines: one sends numbers 1-5 to a channel, the other reads and prints them using range. Make sure it exits cleanly.

  4. Use a sync.WaitGroup to launch 5 goroutines that each print "worker N done". Print "all finished" after they all complete.

  5. Create a race condition with a shared counter (like the mutex example), then fix it with either sync.Mutex or sync/atomic.

Key Takeaways

  • Concurrency is tasks overlapping, not necessarily running at the same instant
  • Goroutines are lightweight threads. Prefix a call with go to run concurrently
  • Channels are typed pipes for goroutine communication: make(chan T)
  • Unbuffered channels block on send and receive. This is how goroutines sync
  • Buffered channels don't block until the buffer is full
  • Deadlock happens when all goroutines are blocked. Go crashes with a fatal error
  • Only the sender should close a channel. Sending on a closed channel panics
  • range over a channel reads until it's closed
  • select picks the first ready channel. Use time.After for timeouts
  • sync.WaitGroup coordinates multiple goroutines: Add, Done, Wait
  • sync.Mutex protects shared data. Lock before access, unlock after
  • Use chan<- (send-only) and <-chan (receive-only) to restrict channel direction
  • Fan-out/fan-in: spawn workers, collect results through a channel

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

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