Concurrency with Goroutines

Reviewed & published by Brayan K

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.

Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

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.

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")
}

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.

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
}

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.

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
}

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.

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
    }
}

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.

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)
}

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

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
}

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.

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
}

Common Errors (and the fix)

Pro Tips

📋 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()

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.

🎉 Lesson Complete!

Practice quiz

How do you start a function running concurrently as a goroutine?

  • async myFunc()
  • concurrent myFunc()
  • go myFunc()
  • spawn myFunc()

Answer: go myFunc(). Putting the keyword go in front of a call launches it as a goroutine and returns immediately.

What does wg.Wait() do on a sync.WaitGroup?

  • Blocks until the counter reaches zero
  • Adds one to the counter
  • Sleeps one second
  • Resets the group

Answer: Blocks until the counter reaches zero. Wait blocks until every Add has been matched by a Done, i.e. the counter is back to zero.

Why can the order of goroutine output vary between runs?

  • The compiler shuffles it
  • Channels reorder values
  • It is a bug in Go
  • The scheduler decides when each goroutine runs

Answer: The scheduler decides when each goroutine runs. Goroutines run concurrently and the Go scheduler chooses when each one runs, so output order is not fixed.

What problem does a sync.Mutex solve?

  • Starting goroutines faster
  • A data race when goroutines update shared state
  • Closing channels
  • Buffering values

Answer: A data race when goroutines update shared state. A mutex serialises access so only one goroutine touches the shared variable at a time, preventing lost updates.

Where is the idiomatic place to put defer wg.Done() in a goroutine?

  • As the first line of the goroutine
  • As the last line
  • Inside wg.Wait()
  • After wg.Add

Answer: As the first line of the goroutine. Making defer wg.Done() the first line ensures it fires even if the function returns early or panics.

What is the difference between make(chan int) and make(chan int, 3)?

  • No difference
  • The first is faster
  • The first is unbuffered; the second buffers up to 3
  • The second cannot be closed

Answer: The first is unbuffered; the second buffers up to 3. make(chan int) is unbuffered (synchronises send/receive); make(chan int, 3) buffers up to three values.

What does a default case make a select statement do?

  • Loop forever
  • Become non-blocking — run default if nothing is ready
  • Block until any case fires
  • Close the channels

Answer: Become non-blocking — run default if nothing is ready. With a default case, select runs default immediately when no other case is ready, making it non-blocking.

What commonly causes 'fatal error: all goroutines are asleep - deadlock!'?

  • Too many goroutines
  • Using a Mutex
  • Closing a channel twice
  • A send on an unbuffered channel with no receiver

Answer: A send on an unbuffered channel with no receiver. A send (or receive) on an unbuffered channel with nothing on the other end blocks every goroutine — a deadlock.

In a pipeline, what does a return type of <-chan int mean?

  • A buffered channel
  • A receive-only channel
  • A send-only channel
  • A closed channel

Answer: A receive-only channel. <-chan int is a receive-only channel, so callers can read results but cannot accidentally send into it.

Why might a program exit before its goroutines print anything?

  • Goroutines are too slow
  • Channels block main
  • When main returns the program exits and kills running goroutines
  • The scheduler stops early

Answer: When main returns the program exits and kills running goroutines. When main returns the whole program exits, killing unfinished goroutines. Make main wait with a WaitGroup or channel.

Continue this course

Frequently asked questions

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.

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.

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.

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.

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.

Related lessons