Skip to main content
    Courses/Go/Concurrency with Goroutines

    Lesson 6 • Intermediate

    Concurrency with Goroutines 🐹

    By the end of this lesson you'll be able to run work concurrently with goroutines, pass data safely between them with channels, wait on several operations with select, and coordinate everything with a WaitGroup and a Mutex — the foundation of every fast Go program.

    What You'll Learn in This Lesson

    • Launch concurrent work with go func() goroutines
    • Send and receive on channels, and pick buffered vs unbuffered
    • Wait on multiple channels (and add timeouts) with select
    • Wait for goroutines to finish with sync.WaitGroup
    • Protect shared state from data races with sync.Mutex
    • Apply Go's rule: "share memory by communicating"

    1️⃣ Goroutines: go func()

    A goroutine is a function running concurrently with the rest of your program — like hiring a worker who goes off and does a job while you carry on. You start one by putting the keyword go in front of a function call. They are extremely cheap (around 2KB each), so running thousands is normal. The catch: when main returns, the program exits and any unfinished goroutines are killed — so you need a way to wait for them. That's what a sync.WaitGroup does: Add counts goroutines up, each one calls Done when finished, and Wait blocks until the count is zero.

    Worked example: launching goroutines with a WaitGroup
    package main
    
    import (
        "fmt"
        "sync"
    )
    
    // A goroutine is a function that runs CONCURRENTLY with the rest of
    // your program. The Go runtime manages them (~2KB of stack each), so
    // running thousands is cheap. You start one by writing  go <call>.
    func sayHello(name string, wg *sync.WaitGroup) {
        defer wg.Done() // tell the WaitGroup "I'm finished" when this returns
        fmt.Println("Hello,", name)
    }
    
    func main() {
        // A WaitGroup is a counter the main goroutine waits on.
        var wg sync.WaitGroup
    
        names := []string{"Alice", "Bob", "Charlie"}
        for _, name := range names {
            wg.Add(1)             // +1 to the counter for each goroutine
            go sayHello(name, &wg) // 'go' launches it concurrently and moves on
        }
    
        wg.Wait() // BLOCK here until the counter hits 0 (all Done() called)
        fmt.Println("all greetings done")
    }
    Output
    Hello, Charlie
    Hello, Alice
    Hello, Bob
    all greetings done
    This is real code — run it for free atthe Go Playgroundor in your own editor.

    Notice the three greetings can print in any order — the scheduler decides who runs when. Only all greetings done is guaranteed last, because wg.Wait() holds main back until every goroutine has finished.

    2️⃣ Channels: send <- and receive

    A channel is a typed pipe that goroutines use to pass values to each other safely — the conveyor belt from the analogy. You send with ch <- value and receive with value := <-ch (the arrow always points the way the data moves). Channels come in two flavours. An unbuffered channel (make(chan T)) has no storage, so a send blocks until a receiver is ready — that makes it a synchronisation point as well as a pipe. A buffered channel (make(chan T, n)) holds up to n values, so sends only block once it's full.

    Worked example: unbuffered vs buffered channels
    package main
    
    import "fmt"
    
    func main() {
        // A channel is a typed pipe goroutines use to pass values safely.
        // make(chan T) with NO size = UNBUFFERED: a send blocks until a
        // receiver is ready (it hands the value over directly).
        done := make(chan string)
    
        go func() {
            // This runs in a separate goroutine. The send (ch <- value)
            // waits until main is ready to receive on the other end.
            done <- "worker finished" // SEND a value into the channel
        }()
    
        msg := <-done                  // RECEIVE — blocks until a value arrives
        fmt.Println("got:", msg)       // got: worker finished
    
        // make(chan T, n) = BUFFERED, capacity n. Sends DON'T block until
        // the buffer is full, so you can send a few values up front.
        nums := make(chan int, 3)      // holds up to 3 ints without a reader
        nums <- 10                     // doesn't block (buffer has room)
        nums <- 20
        nums <- 30
        close(nums)                    // no more sends; receivers can drain it
    
        // Ranging over a channel reads values until it is closed.
        total := 0
        for n := range nums {
            total += n
        }
        fmt.Println("buffered total:", total) // buffered total: 60
    }
    Output
    got: worker finished
    buffered total: 60
    This is real code — run it for free atthe Go Playgroundor in your own editor.

    Your turn. The program below pings a "server" goroutine and waits for a reply. Fill in the two blanks marked ___ using the hints, then run it.

    🎯 Your turn: send and receive on a channel
    package main
    
    import "fmt"
    
    func main() {
        // 🎯 YOUR TURN — fill in the blanks marked with ___
    
        ch := make(chan string) // an unbuffered string channel
    
        go func() {
            // 1) SEND the text "pong" into ch
            ___              // 👉 ch <- "pong"
        }()
    
        // 2) RECEIVE one value from ch into the variable reply
        reply := ___         // 👉 <-ch
        fmt.Println("server says:", reply)
    
        // ✅ Expected output:
        //    server says: pong
    }
    Output
    server says: pong
    Fill in the ___ blanks, then run it in the Go Playground and check it matches the expected output.

    3️⃣ select: wait on many channels

    select is like a switch for channels: it waits on several channel operations at once and runs whichever becomes ready first. This is how you add timeouts (race a real result against time.After) and how you merge inputs from multiple goroutines. Add a default case and select becomes non-blocking — if nothing is ready right now, it runs default instead of waiting.

    Worked example: select with a timeout and a default case
    package main
    
    import (
        "fmt"
        "time"
    )
    
    func main() {
        ch := make(chan string)
    
        // Send a value after 100ms from a separate goroutine.
        go func() {
            time.Sleep(100 * time.Millisecond)
            ch <- "work finished"
        }()
    
        // select waits on SEVERAL channel operations at once and runs
        // whichever is ready first. Here it races a real result against a
        // 1-second timeout, so the result (100ms) always wins.
        select {
        case msg := <-ch:
            fmt.Println("got:", msg)             // got: work finished
        case <-time.After(1 * time.Second):
            fmt.Println("timed out")
        }
    
        // A 'default' case makes select NON-BLOCKING: if nothing is ready
        // right now, run default instead of waiting. ch is empty here, so:
        select {
        case msg := <-ch:
            fmt.Println("got:", msg)
        default:
            fmt.Println("no value ready right now") // this one runs
        }
    }
    Output
    got: work finished
    no value ready right now
    This is real code — run it for free atthe Go Playgroundor in your own editor.

    4️⃣ sync.WaitGroup and sync.Mutex

    When several goroutines touch the same variable, you have a problem: count++ is actually read-add-write, and two goroutines can interleave and lose an update. That's a data race, and the result is wrong and unpredictable. A sync.Mutex (mutual-exclusion lock) fixes it: Lock lets only one goroutine through at a time and Unlock releases it for the next. Pair it with a WaitGroup to wait for all the goroutines to finish.

    Worked example: a Mutex guarding a shared counter
    package main
    
    import (
        "fmt"
        "sync"
    )
    
    func main() {
        var wg sync.WaitGroup  // waits for all 100 goroutines
        var mu sync.Mutex      // a lock that guards the shared counter
        count := 0             // shared state — many goroutines touch it
    
        for i := 0; i < 100; i++ {
            wg.Add(1)
            go func() {
                defer wg.Done()
                mu.Lock()      // only ONE goroutine past this at a time
                count++        // safe: the lock makes count++ exclusive
                mu.Unlock()    // let the next goroutine in
            }()
        }
    
        wg.Wait()                       // wait for every increment to finish
        fmt.Println("count:", count)    // count: 100  (always — no lost updates)
    }
    Output
    count: 100
    This is real code — run it for free atthe Go Playgroundor in your own editor.

    Now you wire up the WaitGroup yourself. Fill in the three blanks — Add before launching, Done when finishing, and Wait at the end:

    🎯 Your turn: coordinate goroutines with a WaitGroup
    package main
    
    import (
        "fmt"
        "sync"
    )
    
    func worker(id int, wg *sync.WaitGroup) {
        // 2) Signal completion when this function returns
        defer ___            // 👉 wg.Done()
        fmt.Println("worker", id, "done")
    }
    
    func main() {
        // 🎯 YOUR TURN — fill in the blanks marked with ___
        var wg sync.WaitGroup
    
        for i := 1; i <= 3; i++ {
            // 1) Add ONE to the WaitGroup before launching each worker
            ___              // 👉 wg.Add(1)
            go worker(i, &wg)
        }
    
        // 3) Block until all three workers have signalled Done
        ___                  // 👉 wg.Wait()
        fmt.Println("all workers finished")
    
        // ✅ Expected output (the three "worker N done" lines may be in
        //    ANY order; the last line is always last):
        //    worker 1 done
        //    worker 2 done
        //    worker 3 done
        //    all workers finished
    }
    Output
    worker 1 done
    worker 2 done
    worker 3 done
    all workers finished
    Fill in the ___ blanks, then run it. The three worker N done lines may print in any order; all workers finished is always last.

    5️⃣ Putting It Together: a Pipeline

    Here's the philosophy in action. Instead of many goroutines fighting over one shared slice, each stage owns its data and passes it to the next stage through a channel — generate → square → consume. Notice the return type <-chan int: a receive-only channel, so callers can read results but can't accidentally send into it. No mutex needed, because nothing is shared.

    Worked example: a generate → square pipeline
    package main
    
    import "fmt"
    
    // "Don't communicate by sharing memory; share memory by communicating."
    // Each stage owns its data and passes it on through a channel.
    
    // Stage 1: generate produces numbers, then CLOSES its output channel.
    func generate(nums ...int) <-chan int {
        out := make(chan int)
        go func() {
            defer close(out)        // closing tells the next stage "no more"
            for _, n := range nums {
                out <- n
            }
        }()
        return out                  // <-chan int = receive-only (read it, can't send)
    }
    
    // Stage 2: square reads from 'in' and writes squares to a new channel.
    func square(in <-chan int) <-chan int {
        out := make(chan int)
        go func() {
            defer close(out)
            for n := range in {     // ends when 'in' is closed and drained
                out <- n * n
            }
        }()
        return out
    }
    
    func main() {
        // Pipeline: generate -> square -> consume. No mutex, no shared
        // variable — the channels carry the data between stages.
        for v := range square(generate(1, 2, 3, 4, 5)) {
            fmt.Print(v, " ")
        }
        fmt.Println()               // 1 4 9 16 25
    }
    Output
    1 4 9 16 25 
    This is real code — run it for free atthe Go Playgroundor in your own editor.

    Common Errors (and the fix)

    • "fatal error: all goroutines are asleep - deadlock!" — usually a send on an unbuffered channel with no receiver (or vice-versa). Make sure something is reading the other end, e.g. do the send inside a go func() while main receives.
    • Race condition (wrong/unstable results) — many goroutines update a shared variable without a lock, so updates get lost. Guard it with mu.Lock() / mu.Unlock(), and run go run -race . to detect races.
    • Program prints nothing / exits earlymain returned before the goroutines ran, killing them. Make main wait with wg.Wait() or by receiving on a channel.
    • Hangs forever on wg.Wait() — you forgot a wg.Done() (or did more Add than Done), so the counter never reaches zero. Put defer wg.Done() as the first line of each goroutine.
    • "panic: send on closed channel" — you sent after close(). The sender should close a channel, and only once, after all sends are done.

    Pro Tips

    • 💡 Make defer wg.Done() the first line of every goroutine — it then fires even if the function returns early or panics.
    • 💡 The sender closes the channel, never the receiver. Closing signals "no more values"; ranging over the channel then ends cleanly.
    • 💡 Prefer channels to a mutex when you're handing off data; reach for a mutex when goroutines must update shared state in place.
    • 💡 Run with go run -race . while developing — the race detector catches unsynchronised access you'd never spot by eye.

    📋 Quick Reference

    TaskGo Syntax
    Start a goroutinego myFunc()
    Unbuffered channelch := make(chan int)
    Buffered channelch := make(chan int, 5)
    Send / receivech <- v // v := <-ch
    Close / rangeclose(ch); for v := range ch {}
    Selectselect { case v := <-ch: ... }
    WaitGroupwg.Add(1); defer wg.Done(); wg.Wait()
    Mutexmu.Lock(); ...; mu.Unlock()

    Frequently Asked Questions

    Q: Why does the output order change every time I run goroutines?

    Goroutines run concurrently and the Go scheduler decides when each one gets to run, so the order is not fixed. If you need a guaranteed order, make the goroutines hand values to one collector through a channel, or print from a single goroutine after the others finish.

    Q: What's the difference between a buffered and an unbuffered channel?

    An unbuffered channel — make(chan T) — has no storage: a send blocks until a receiver is ready, so it also synchronises the two goroutines. A buffered channel — make(chan T, n) — can hold up to n values, so sends only block once the buffer is full. Use unbuffered when you want a handoff/synchronisation point; use buffered to smooth out bursts.

    Q: When should I use a channel and when should I use a Mutex?

    Prefer a channel when you are passing ownership of data from one goroutine to another — Go's motto is 'share memory by communicating'. Reach for a sync.Mutex when several goroutines must read and update the same shared variable in place (like a counter or a cache) and passing it around would be awkward.

    Q: Why does my program finish before the goroutines print anything?

    When main returns, the whole program exits and any still-running goroutines are killed instantly. Make main wait — most often with a sync.WaitGroup (wg.Wait()) or by receiving the results on a channel — so it stays alive until the work is done.

    Q: What does 'fatal error: all goroutines are asleep - deadlock!' mean?

    Every goroutine is blocked waiting for something that will never happen — commonly a send on an unbuffered channel with no receiver, or a receive with no sender. Make sure there is a goroutine on the other end of each channel, and close channels you range over so the loop can end.

    Mini-Challenge: Safe Concurrent Sum

    No blanks this time — just a brief and an outline. Launch five goroutines that each add their number to a shared total, protected by a mutex, and wait for them all with a WaitGroup. Run it and check your output against the expected line in the comments.

    🎯 Mini-Challenge: add 1..5 across goroutines
    package main
    
    import (
        "fmt"
        "sync"
    )
    
    func main() {
        // 🎯 MINI-CHALLENGE: sum work across goroutines, safely
        // 1. Make a sync.WaitGroup and a sync.Mutex, and a total := 0.
        // 2. Loop i from 1 to 5. For each i:
        //      - wg.Add(1)
        //      - launch a goroutine that locks the mutex, does total += i,
        //        unlocks, and defers wg.Done().
        // 3. wg.Wait(), then print:  "total:" total
        //
        // ✅ Expected output (1+2+3+4+5):
        //    total: 15
    
        // your code here
    }
    Output
    total: 15
    Write the code yourself, then run it in the Go Playground. The total should always be 15 — if it varies, your mutex isn't guarding every update.

    🎉 Lesson Complete!

    • Goroutines run functions concurrently — start one with go func()
    • Channels pass values safely: send ch <- v, receive v := <-ch
    • Unbuffered channels synchronise; buffered channels hold a few values
    • select waits on many channels and adds timeouts / non-blocking checks
    • WaitGroup waits for goroutines; Mutex protects shared state
    • ✅ Goroutine output order isn't fixed — design for it, don't rely on it
    • Next lesson: Error Handling — Go's explicit error values and how to wrap them

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service