Concurrency Basics
10 examples to get you started with Concurrency - 7 basic and 3 intermediate.
Prerequisites
- Install Go 1.26.x or later from go.dev/dl.
- Create a module:
mkdir conc && cd conc && go mod init example.com/conc. - Save each snippet as
main.goand run withgo run .. - Run
go test -race ./...when examples use shared state.
Basic Examples
1. Launch a goroutine
Start work concurrently with go.
package main
import (
"fmt"
"time"
)
func main() {
go func() {
fmt.Println("from goroutine")
}()
time.Sleep(100 * time.Millisecond) // wait so output appears before exit
fmt.Println("from main")
}goschedules the function on a new goroutine; it returns immediately.- Without a wait,
mainmay exit before the goroutine prints. - Prefer
sync.WaitGroupor channels overtime.Sleepin real code.
Related: Concurrency in Go: Goroutines and Channels First - CSP mental model
2. Unbuffered channel handoff
Sender and receiver meet at the channel.
package main
import "fmt"
func main() {
ch := make(chan string)
go func() { ch <- "ping" }()
msg := <-ch
fmt.Println(msg)
}make(chan string)creates an unbuffered channel - zero capacity.- Send blocks until receive is ready; that is the synchronization point.
- The value passes by copy (for small types); large structs may use pointers.
Related: Channels: Buffered, Unbuffered & Closing Rules - buffer and close rules
3. Buffered channel
Decouple sender and receiver up to capacity.
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
fmt.Println(<-ch, <-ch)
}make(chan int, 2)allows two sends without a waiting receiver.- Third send would block until someone receives.
- Size buffers to your pipeline's natural burst, not arbitrarily large.
Related: Channels: Buffered, Unbuffered & Closing Rules - when to buffer
4. sync.WaitGroup
Wait for a batch of goroutines to finish.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println("worker", n)
}(i)
}
wg.Wait()
fmt.Println("done")
}Addbeforego,Donein adeferinside the worker.Waitblocks until the counter returns to zero.- Pass loop variables as parameters to avoid closure capture bugs on older patterns.
Related: sync.Mutex, RWMutex & WaitGroup - WaitGroup details
5. Close and range over a channel
Signal completion by closing from the sender.
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
for v := range ch {
fmt.Println(v)
}
}rangedrains until the channel is closed and empty.- Only the sender should
close; sending on a closed channel panics. - Receiving after close yields zero values with
ok == falsein two-value form.
Related: Channels: Buffered, Unbuffered & Closing Rules - ownership rules
6. select with one case
select waits on channel operations (here, a single receive).
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
time.Sleep(50 * time.Millisecond)
ch <- "ready"
}()
select {
case msg := <-ch:
fmt.Println(msg)
}
}selectblocks until one case can proceed.- With one case it behaves like a receive but sets up for adding timeouts.
- Add a
defaultbranch for non-blocking behavior.
Related: select Statement & Default Cases - multiplexing patterns
7. Mutex protecting shared state
When multiple goroutines mutate data, lock it.
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
count := 0
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
count++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(count)
}Mutexserializes critical sections - herecount++.- Always pair
LockwithUnlock;defer mu.Unlock()afterLockis idiomatic. - Run with
-raceto catch missing locks.
Related: sync.Mutex, RWMutex & WaitGroup - lock discipline
Intermediate Examples
8. select with timeout
Avoid waiting forever on slow work.
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
select {
case msg := <-ch:
fmt.Println(msg)
case <-time.After(100 * time.Millisecond):
fmt.Println("timeout")
}
}time.Afterreturns a channel that fires after the duration.- Whichever case is ready first wins; here nothing sends, so timeout prints.
- In servers, tie timeouts to
context.Contextdeadlines instead of ad-hoc sleeps.
Related: select Statement & Default Cases - timeout recipes
9. Fan-out simple pipeline
One producer, one consumer goroutine.
package main
import "fmt"
func main() {
jobs := make(chan int, 5)
done := make(chan struct{})
go func() {
for j := range jobs {
fmt.Println("processed", j)
}
close(done)
}()
for i := 1; i <= 3; i++ {
jobs <- i
}
close(jobs)
<-done
}- Closing
jobslets the workerrangeexit cleanly. donesignals the consumer finished - alternative toWaitGroup.- Pipelines scale by adding stages and bounded buffers between them.
Related: Goroutines: Creation Cost & Scheduling Overview - scheduling many workers
10. Detect races in tests
Build with -race to catch unsynchronized access.
package main
import (
"sync"
"testing"
)
func TestCounter(t *testing.T) {
var wg sync.WaitGroup
n := 0
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
n++ // race: uncomment mu from example 7 to fix
}()
}
wg.Wait()
}- Run
go test -race- the race detector reports conflicting accesses. - Fix with mutex, atomic, or channel handoff - not by ignoring reports.
- Enable
-racein CI for packages that use goroutines.
Related: Race Detector: Running and Interpreting Output - reading race output
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).