Updated Aug 7, 2026

Concurrency

📋 Jump to Takeaways

🎁 With one keyword — go — you can launch a thousand concurrent tasks, and with channels you can coordinate them without a single mutex. How does Go make concurrency this easy?

Concurrency means structuring a program so multiple tasks can make progress — overlapping their execution rather than running strictly one after another. Parallelism means tasks are literally executing at the same instant on different CPU cores. The two often go together: Go's runtime schedules goroutines across all available cores by default, so a web server handling 1000 connections gets both. It doesn't need 1000 OS threads — goroutines are cheap enough to have one per connection, and the runtime handles the rest.

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        (order may vary)
// 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 when there are no more values (channel closed, buffer drained)
// ok is true as long as there are still values to receive, even on a closed channel

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.

Two ways to declare one:

var wg sync.WaitGroup   // idiomatic — value on the stack, zero-value is ready to use
wg := &sync.WaitGroup{} // pointer — allocates on the heap

var wg sync.WaitGroup is the idiomatic choice for local use. sync.WaitGroup's zero value works immediately with no initialization. Use the pointer form (*sync.WaitGroup) when passing the WaitGroup to other functions — this makes the shared ownership explicit and avoids accidentally copying it. Copying a WaitGroup after first use is a bug and go vet will catch it.

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
var wg sync.WaitGroup

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

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

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 worker pool below. The worker takes chan<- int because it only sends results.

Worker Pool

Launch multiple goroutines that share a single output channel. A separate goroutine waits for all workers to finish, then closes the channel. That lets the main goroutine range over results without knowing how many to expect.

This is different from fan-out/fan-in, where each worker has its own channel and results are merged. Here all workers write to one shared channel. The Go Concurrency Patterns course covers the distinction in detail.

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

    // launch 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)
    }()

    // 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
  • Worker pool: spawn multiple goroutines sharing one output channel, collect results with range

🚀 Want to go deeper? The Go Concurrency Patterns course covers pipelines, worker pools, context, errgroup, and real-world patterns used in production Go services.


🎁 Next up: Go gives you pointers without pointer arithmetic, the power of C with the safety of Java. Let's demystify memory.

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy