Concurrency
📋 Jump to TakeawaysConcurrency means multiple tasks making progress at the same time. Not necessarily running at the exact same instant (that's parallelism), but structured so they can overlap. A web server handling 1000 connections doesn't need 1000 OS threads. It needs concurrency.
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
// 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 if channel is closed and emptyRange 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.
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
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Add(1)
}()
}Channel 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 fan-out pattern below. The worker takes chan<- int because it only sends results.
Fan-Out, Fan-In
Fan-out: launch multiple goroutines to do work. Fan-in: collect their results through a single channel.
The trick is a separate goroutine that waits for all workers to finish, then closes the channel. That lets the main goroutine range over results without knowing how many to expect.
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
// fan-out: 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)
}()
// fan-in: 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 - Fan-out/fan-in: spawn workers, collect results through a channel