Concurrency Basics

Updated

September 13, 2026

Concurrency Basics

A goroutine is a function the Go runtime can run alongside others. Starting one is a single keyword. Stopping it, and knowing it finished, is your job. The boring default is: do not guess that work is done — wait for it.

Mental model

go f() schedules f to run concurrently with the caller. It does not create an operating-system thread per call. The runtime multiplexes goroutines onto a small pool of threads.

Three facts matter on day one:

  • When main returns, the process exits. Other goroutines are not asked politely. They vanish.
  • A WaitGroup counts unfinished tasks. Wait blocks until the count is zero.
  • A channel is a typed pipe. Send (ch <- v) and receive (v := <-ch) transfer a value and, on an unbuffered channel, meet in time.

time.Sleep is not a synchronization tool. It is a pause. If you sleep “long enough,” you will be wrong on a slow machine, a busy desk, or the first production deploy.

Worked examples

Case 1: Main returns, the shift vanishes

Save as lost_shift.go. The goroutine would print a clock-in. main does not wait.

// lost_shift.go
package main

import "fmt"

func main() {
    go fmt.Println("Amina clocked in")
}

Run:

go run lost_shift.go

Typical output (empty — the process already exited):

Sometimes you will see the line. That luck is the bug. The program is not “usually fine.” It is a race against main.

Case 2: WaitGroup counts the work

Save as wait_shift.go. WaitGroup.Go (Go 1.25+) starts the goroutine and accounts for it. Wait returns only after the function returns.

// wait_shift.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    wg.Go(func() {
        fmt.Println("Amina clocked in")
    })
    wg.Wait()
}

Run:

go run wait_shift.go

Output:

Amina clocked in

Older code uses wg.Add(1), go func() { defer wg.Done(); ... }(), then wg.Wait(). Same contract. Prefer Go in new Go 1.27 code. The function passed to Go must not panic.

Case 3: Channel send and receive

Save as handoff.go. The printer goroutine sends one ticket id. main receives it. The receive is the wait.

// handoff.go
package main

import "fmt"

func printer(id int, out chan<- int) {
    out <- id
}

func main() {
    ch := make(chan int)
    go printer(7, ch)
    got := <-ch
    fmt.Println("printed ticket", got)
}

Run:

go run handoff.go

Output:

printed ticket 7

chan<- int is a send-only view. The printer cannot receive. main uses a plain chan int and receives. The send in printer cannot finish until main is ready to receive: that meeting is the rendezvous.

Case 4: Several workers, one WaitGroup

Save as three_windows.go. Three windows clock in. The WaitGroup holds main until all three functions return. Print order is not guaranteed, so we collect names and sort them.

// three_windows.go
package main

import (
    "fmt"
    "slices"
    "sync"
)

func main() {
    names := []string{"Amina", "Bo", "Chen"}
    got := make([]string, len(names))
    var wg sync.WaitGroup
    for i, name := range names {
        wg.Go(func() {
            got[i] = name + " ready"
        })
    }
    wg.Wait()
    slices.Sort(got)
    for _, line := range got {
        fmt.Println(line)
    }
}

Run:

go run three_windows.go

Output:

Amina ready
Bo ready
Chen ready

Each goroutine writes a different index. That is safe. Two goroutines appending to the same slice would not be. The next chapters cover mutexes and channels for shared results.

Case 5: Receive the number of sends you planned

Save as two_tickets.go. Two sends, two receives. main does not exit early. Order of the two lines can swap, so we sort again.

// two_tickets.go
package main

import (
    "fmt"
    "slices"
)

func main() {
    ch := make(chan string)
    go func() { ch <- "ticket 7" }()
    go func() { ch <- "ticket 8" }()

    got := []string{<-ch, <-ch}
    slices.Sort(got)
    fmt.Println(got[0])
    fmt.Println(got[1])
}

Run:

go run two_tickets.go

Output:

ticket 7
ticket 8

If you receive only once, main can return while a send is still blocked. If you receive three times, main waits forever. Count the handoffs.

The trap

Save as sleep_sync.go. This treats a pause as a proof that the printer finished.

// sleep_sync.go
package main

import (
    "fmt"
    "time"
)

func main() {
    go fmt.Println("ticket 7 printed")
    time.Sleep(50 * time.Millisecond)
}

Run:

go run sleep_sync.go

Output on a quiet machine:

ticket 7 printed

It looks correct. Shorten the sleep, load the CPU, or put real work in the goroutine, and the line disappears like Case 1. Sleep is a delay. A WaitGroup or a channel receive is a condition. Use the condition.

The boring rule

  • go f() starts work. It does not wait for work.
  • When main returns, everything dies. Wait on purpose.
  • Use WaitGroup when you need “all of these functions returned.”
  • Use a channel when you need a value (or a signal) from another goroutine.
  • Never use time.Sleep to “give the goroutine time.”
  • Plan the number of sends and receives. A mismatch is a hang or a lost result.

Try this

  1. In wait_shift.go, start a second wg.Go that prints Bo clocked in. Keep one Wait. Sort or accept either print order.
  2. In handoff.go, change make(chan int) to make(chan int, 1). Confirm it still prints. Then try sending two ids from printer without a second receive — the extra send blocks, and the program hangs until you add a receive or stop it.
  3. In two_tickets.go, comment out one receive. Run it. Restore the receive before you move on.
  4. Rewrite three_windows.go with Add / Done instead of Go. Same output.