Concurrency Basics
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
mainreturns, the process exits. Other goroutines are not asked politely. They vanish. - A WaitGroup counts unfinished tasks.
Waitblocks 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.goTypical 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.goOutput:
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.goOutput:
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.goOutput:
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.goOutput:
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.goOutput 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
mainreturns, everything dies. Wait on purpose. - Use
WaitGroupwhen you need “all of these functions returned.” - Use a channel when you need a value (or a signal) from another goroutine.
- Never use
time.Sleepto “give the goroutine time.” - Plan the number of sends and receives. A mismatch is a hang or a lost result.
Try this
- In
wait_shift.go, start a secondwg.Gothat printsBo clocked in. Keep oneWait. Sort or accept either print order. - In
handoff.go, changemake(chan int)tomake(chan int, 1). Confirm it still prints. Then try sending two ids fromprinterwithout a second receive — the extra send blocks, and the program hangs until you add a receive or stop it. - In
two_tickets.go, comment out one receive. Run it. Restore the receive before you move on. - Rewrite
three_windows.gowithAdd/Doneinstead ofGo. Same output.