Concurrency
📋 Jump to Takeaways🎁 With one keyword — go — you can launch a thousand concurrent tasks, and with channels you can coordinate them without a single mutex. How does Go make concurrency this easy?
Concurrency means structuring a program so multiple tasks can make progress — overlapping their execution rather than running strictly one after another. Parallelism means tasks are literally executing at the same instant on different CPU cores. The two often go together: Go's runtime schedules goroutines across all available cores by default, so a web server handling 1000 connections gets both. It doesn't need 1000 OS threads — goroutines are cheap enough to have one per connection, and the runtime handles the rest.
Go was built for this from day one. Goroutines, channels, and a runtime scheduler that handles the hard parts. You can spin up a hundred thousand goroutines on a laptop and barely touch your memory.
Concurrency alone deserves its own course, and we'll have a dedicated one. Don't feel overwhelmed by this lesson. The goal here is to know what Go offers and how the pieces fit together. Getting comfortable takes practice, and nobody becomes an expert overnight.
Goroutines
A goroutine is a lightweight thread managed by the Go runtime. Prefix any function call with go to run it concurrently.
func sayHello() {
fmt.Println("hello from goroutine")
}
func main() {
go sayHello()
fmt.Println("hello from main")
time.Sleep(time.Millisecond) // wait so goroutine can finish
}
// Output:
// hello from main (order may vary)
// hello from goroutineGoroutines are cheap. Each starts with about 2 KB of stack (compared to 1-8 MB for an OS thread), and the stack grows as needed. You can run thousands without issue.
The problem: main doesn't wait for goroutines. When main returns, everything gets killed. time.Sleep is a hack. Channels and WaitGroups are the real fix.
Channels
Channels are typed pipes for passing data between goroutines.
ch := make(chan int)
go func() {
ch <- 42 // send
}()
val := <-ch // receive
fmt.Println(val) // 42Sends block until another goroutine receives. Receives block until another goroutine sends. This is how goroutines synchronize.
func worker(ch chan string) {
time.Sleep(time.Second) // simulate work
ch <- "done"
}
func main() {
ch := make(chan string)
go worker(ch)
result := <-ch // blocks until worker sends
fmt.Println(result) // done
}Buffered Channels
Buffered channels don't block until the buffer is full.
ch := make(chan int, 3)
ch <- 1 // doesn't block
ch <- 2 // doesn't block
ch <- 3 // doesn't block
// ch <- 4 would block — buffer full
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3Use buffered channels when the sender shouldn't wait for the receiver. Think of it as a mailbox: the sender drops off messages without waiting, as long as the box isn't full.
Deadlock
If all goroutines are blocked, Go detects a deadlock and crashes.
func main() {
ch := make(chan int)
ch <- 1 // blocks forever — no one is receiving
}
// fatal error: all goroutines are asleep - deadlock!Closing Channels
close(ch) signals that no more values will be sent. Receivers get the zero value after a channel is closed.
Only the sender should close a channel, never the receiver. Sending on a closed channel panics, so the sender is the only one who knows when it's done.
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch)
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 0 (zero value, channel closed)You can check if a channel is closed with the two-value receive.
val, ok := <-ch
// ok is false when there are no more values (channel closed, buffer drained)
// ok is true as long as there are still values to receive, even on a closed channelRange Over Channel
range reads from a channel until it's closed.
ch := make(chan int)
go func() {
for i := 0; i < 3; i++ {
ch <- i
}
close(ch) // must close or range blocks forever
}()
for val := range ch {
fmt.Println(val) // 0, 1, 2
}Select
select is like switch for channels. It picks whichever case is ready first.
ch1 := make(chan string)
ch2 := make(chan string)
go func() { time.Sleep(100 * time.Millisecond); ch1 <- "one" }()
go func() { time.Sleep(50 * time.Millisecond); ch2 <- "two" }()
select {
case msg := <-ch1:
fmt.Println(msg)
case msg := <-ch2:
fmt.Println(msg) // two (arrives first)
}Without default, select blocks until one of the channels is ready. Add a default case and it skips immediately if nothing is available.
select {
case msg := <-ch:
fmt.Println(msg)
default:
fmt.Println("no message ready")
}The most common real-world use of select is timeouts. time.After returns a channel that sends after a duration:
select {
case result := <-ch:
fmt.Println(result)
case <-time.After(3 * time.Second):
fmt.Println("timed out")
}sync.WaitGroup
A WaitGroup waits for a bunch of goroutines to finish. Add before launching, Done when finished, Wait to block until the count hits zero.
Two ways to declare one:
var wg sync.WaitGroup // idiomatic — value on the stack, zero-value is ready to use
wg := &sync.WaitGroup{} // pointer — allocates on the heapvar wg sync.WaitGroup is the idiomatic choice for local use. sync.WaitGroup's zero value works immediately with no initialization. Use the pointer form (*sync.WaitGroup) when passing the WaitGroup to other functions — this makes the shared ownership explicit and avoids accidentally copying it. Copying a WaitGroup after first use is a bug and go vet will catch it.
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println("worker", n)
}(i)
}
wg.Wait() // blocks until all 3 call Done()
fmt.Println("all done")sync.Mutex
When multiple goroutines read and write the same variable, you get a race condition. The result is unpredictable.
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // race condition: multiple goroutines writing at once
}()
}
wg.Wait()
fmt.Println(counter) // not always 1000A sync.Mutex locks access so only one goroutine can touch the variable at a time:
var mu sync.Mutex
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(counter) // always 1000Rule of thumb: if goroutines share data without a channel, you probably need a mutex. For simple counters and flags, sync/atomic is faster since it avoids locking entirely:
var counter atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Add(1)
}()
}
wg.Wait()
fmt.Println(counter.Load()) // 1000Channel Direction
You can restrict a channel to send-only or receive-only in function signatures. This makes your intent clear and the compiler enforces it.
func producer(ch chan<- int) { // can only send
ch <- 42
}
func consumer(ch <-chan int) { // can only receive
fmt.Println(<-ch)
}You'll see this in the worker pool below. The worker takes chan<- int because it only sends results.
Worker Pool
Launch multiple goroutines that share a single output channel. A separate goroutine waits for all workers to finish, then closes the channel. That lets the main goroutine range over results without knowing how many to expect.
This is different from fan-out/fan-in, where each worker has its own channel and results are merged. Here all workers write to one shared channel. The Go Concurrency Patterns course covers the distinction in detail.
func worker(id int, ch chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
ch <- id * 10
}
func main() {
ch := make(chan int, 5)
var wg sync.WaitGroup
// launch 5 workers
for i := 0; i < 5; i++ {
wg.Add(1)
go worker(i, ch, &wg)
}
// close channel when all workers finish
go func() {
wg.Wait()
close(ch)
}()
// collect results
for val := range ch {
fmt.Println(val) // 0, 10, 20, 30, 40 (order varies)
}
}What About context.Context?
In real Go code, you'll see context.Context everywhere. It's how you tell goroutines to stop: timeouts, cancellation, deadlines. We won't cover it in this lesson, but know it exists. The dedicated concurrency course will go deep on it.
Exercises
Try these on your own. They use only what this lesson covers.
Write a function that launches 3 goroutines, each sending their ID to a channel. Print all 3 IDs from
main.Create a buffered channel of size 2. Send 3 values to it from a goroutine and receive all 3 from
main. What happens if you forget to receive one?Write a program with two goroutines: one sends numbers 1-5 to a channel, the other reads and prints them using
range. Make sure it exits cleanly.Use a
sync.WaitGroupto launch 5 goroutines that each print "worker N done". Print "all finished" after they all complete.Create a race condition with a shared counter (like the mutex example), then fix it with either
sync.Mutexorsync/atomic.
Key Takeaways
- Concurrency is tasks overlapping, not necessarily running at the same instant
- Goroutines are lightweight threads. Prefix a call with
goto run concurrently - Channels are typed pipes for goroutine communication:
make(chan T) - Unbuffered channels block on send and receive. This is how goroutines sync
- Buffered channels don't block until the buffer is full
- Deadlock happens when all goroutines are blocked. Go crashes with a fatal error
- Only the sender should close a channel. Sending on a closed channel panics
rangeover a channel reads until it's closedselectpicks the first ready channel. Usetime.Afterfor timeoutssync.WaitGroupcoordinates multiple goroutines:Add,Done,Waitsync.Mutexprotects shared data. Lock before access, unlock after- Use
chan<-(send-only) and<-chan(receive-only) to restrict channel direction - Worker pool: spawn multiple goroutines sharing one output channel, collect results with
range
🚀 Want to go deeper? The Go Concurrency Patterns course covers pipelines, worker pools, context, errgroup, and real-world patterns used in production Go services.
🎁 Next up: Go gives you pointers without pointer arithmetic, the power of C with the safety of Java. Let's demystify memory.