Goroutines: lightweight concurrency with the go keyword
A goroutine is a function call that runs concurrently with the rest of your program. You start one by prefixing a function call with go. Goroutines are lightweight: you can create many of them, but you still need structure to avoid leaks, races, and runaway fan-out.
Starting a goroutine
When you write go f(), the current function continues immediately. If the program exits, all goroutines stop, so you typically need a way to wait for them (covered later with sync.WaitGroup).
package main
import (
"fmt"
"time"
)
func main() {
go func() {
fmt.Println("running in a goroutine")
}()
// Give the goroutine time to run (not a good long-term pattern).
time.Sleep(50 * time.Millisecond)
}Common pitfalls
- Data races: two goroutines access the same variable concurrently and at least one write occurs without synchronization. This can produce incorrect results and is hard to debug. Use channels or mutexes to coordinate access.
- Loop variable capture: closures started in a loop can accidentally capture the same variable. Fix by creating a new variable inside the loop.
- Uncontrolled fan-out: starting a goroutine per item without limits can overwhelm memory, file descriptors, or downstream services. Prefer bounded worker pools.
Loop variable capture example (and fix)
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
// BAD: captures i; goroutines may all print the same value.
go func() {
defer wg.Done()
fmt.Println("bad:", i)
}()
}
wg.Wait()
for i := 0; i < 3; i++ {
wg.Add(1)
i := i // GOOD: new variable per iteration
go func() {
defer wg.Done()
fmt.Println("good:", i)
}()
}
wg.Wait()
}Channels: coordinating goroutines with typed communication
Channels let goroutines communicate safely by sending typed values. A send on a channel (ch <- v) and a receive (v := <-ch) form a synchronization point. This often avoids shared-memory races by moving data between goroutines instead of sharing it.
Creating channels and sending/receiving
ch := make(chan int) // unbuffered
go func() {
ch <- 42 // send blocks until a receiver is ready
}()
v := <-ch // receive blocks until a sender is ready
Unbuffered vs buffered channels
- Unbuffered: send and receive rendezvous. Great for handoffs and backpressure.
- Buffered: channel has capacity; sends can proceed until the buffer is full. Useful for smoothing bursts, but too much buffering can hide slow consumers and increase memory usage.
jobs := make(chan int, 10) // buffered: up to 10 queued items
jobs <- 1
jobs <- 2
Closing channels and ranging
Closing a channel signals that no more values will be sent. Receivers can continue draining buffered values. Only the sender should close a channel; closing from the receiver side is a common bug.
ch := make(chan int)
go func() {
defer close(ch) // sender closes
for i := 0; i < 3; i++ {
ch <- i
}
}()
for v := range ch { // stops when channel is closed and drained
_ = v
}If you need to detect closure on a single receive, use the “comma ok” form.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
v, ok := <-ch
if !ok {
// channel closed
}Select: multiplexing, timeouts, and cancellation basics
select waits until one of multiple channel operations can proceed. It helps you build responsive concurrent code: timeouts, cancellation, and non-blocking behavior.
Multiplexing receives
select {
case v := <-ch1:
_ = v
case v := <-ch2:
_ = v
}Timeouts with time.After
time.After(d) returns a channel that delivers a time value after duration d. You can use it in a select to avoid blocking forever.
import "time"
select {
case v := <-ch:
_ = v
case <-time.After(200 * time.Millisecond):
// timeout
}Cancellation basics with a done channel
A simple cancellation pattern is a read-only done channel that gets closed to broadcast “stop” to many goroutines. Receivers select on done and exit quickly.
done := make(chan struct{})
go func() {
// later...
close(done) // broadcast cancellation
}()
select {
case <-done:
// stop work
case v := <-ch:
_ = v
}This chapter uses this pattern in the mini-project. In larger programs, context.Context is the standard tool for cancellation and deadlines, but the core idea is the same: a signal that propagates through your goroutines.
Synchronization: joining goroutines and protecting shared state
Waiting for goroutines with sync.WaitGroup
sync.WaitGroup lets you wait until a set of goroutines finishes. The pattern is: Add before starting, Done when finishing, and Wait in the parent.
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
// work
}()
go func() {
defer wg.Done()
// work
}()
wg.Wait() // blocks until both call Done
Mutex vs channels: when to use which
Both can be correct; choose based on what you are modeling.
- Use channels when you are coordinating ownership or sequencing of data (pipelines, worker pools, request/response, backpressure). Channels make the flow explicit.
- Use a mutex when you have shared state that must be accessed by multiple goroutines and channel-based ownership would make the design more complex (caches, counters, maps with frequent reads/writes). A mutex can be simpler and faster for protecting a small critical section.
A common hybrid is: channels for orchestration (starting/stopping work), mutexes for internal shared structures (like a memoization map) when needed.
Practical mini-project: a concurrent worker pool with results, shutdown, and error propagation
This mini-project builds a bounded worker pool that processes jobs concurrently and returns results. The design goals are: (1) bounded concurrency (no uncontrolled fan-out), (2) deterministic cleanup (no goroutine leaks), (3) safe shutdown on error, and (4) clear structure.
Step 1: Define job and result types
We will process integer jobs and return a computed value. Results include either a value or an error.
type Job struct {
ID int
Input int
}
type Result struct {
JobID int
Value int
Err error
}Step 2: Implement the worker function
Each worker reads from jobs until the channel is closed or cancellation is signaled. It sends a Result for each job. The worker must also stop promptly if the system is shutting down.
package main
import (
"errors"
"fmt"
"sync"
)
type Job struct {
ID int
Input int
}
type Result struct {
JobID int
Value int
Err error
}
func worker(id int, jobs <-chan Job, results chan<- Result, done <-chan struct{}, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-done:
return
case job, ok := <-jobs:
if !ok {
return
}
// Simulate work with a deterministic rule.
if job.Input < 0 {
select {
case results <- Result{JobID: job.ID, Err: errors.New("negative input")}:
case <-done:
}
continue
}
value := job.Input * job.Input
select {
case results <- Result{JobID: job.ID, Value: value}:
case <-done:
return
}
}
}
}
func main() {
_ = fmt.Sprintf("worker pool")
}Step 3: Orchestrate the pool (bounded concurrency + deterministic cleanup)
We will create:
jobschannel: producer sends jobs, then closes it.resultschannel: workers send results; a closer goroutine closes it after all workers exit.donechannel: closed to broadcast cancellation when an error occurs or when we decide to stop early.
package main
import (
"errors"
"fmt"
"sync"
)
type Job struct {
ID int
Input int
}
type Result struct {
JobID int
Value int
Err error
}
func worker(id int, jobs <-chan Job, results chan<- Result, done <-chan struct{}, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-done:
return
case job, ok := <-jobs:
if !ok {
return
}
if job.Input < 0 {
select {
case results <- Result{JobID: job.ID, Err: errors.New("negative input")}:
case <-done:
}
continue
}
value := job.Input * job.Input
select {
case results <- Result{JobID: job.ID, Value: value}:
case <-done:
return
}
}
}
}
func runWorkerPool(inputs []int, workerCount int) ([]Result, error) {
jobs := make(chan Job)
results := make(chan Result)
done := make(chan struct{})
var wg sync.WaitGroup
wg.Add(workerCount)
for i := 0; i < workerCount; i++ {
go worker(i, jobs, results, done, &wg)
}
// Close results after all workers finish.
go func() {
wg.Wait()
close(results)
}()
// Producer: send jobs then close jobs.
go func() {
defer close(jobs)
for i, in := range inputs {
select {
case <-done:
return
case jobs <- Job{ID: i, Input: in}:
}
}
}()
var out []Result
var firstErr error
// Collector: range until results is closed.
for r := range results {
out = append(out, r)
if r.Err != nil && firstErr == nil {
firstErr = r.Err
// Cancel the whole pipeline; workers and producer will stop.
close(done)
}
}
return out, firstErr
}
func main() {
inputs := []int{2, 3, -1, 4, 5}
results, err := runWorkerPool(inputs, 3)
for _, r := range results {
if r.Err != nil {
fmt.Printf("job %d error: %v\n", r.JobID, r.Err)
continue
}
fmt.Printf("job %d value: %d\n", r.JobID, r.Value)
}
if err != nil {
fmt.Println("first error:", err)
}
}Step 4: Understand the shutdown and error strategy
- Bounded concurrency: only
workerCountgoroutines do work, regardless of how many inputs exist. - Safe shutdown: closing
donebroadcasts cancellation. Workers stop selecting onjobsand stop trying to send results. - Deterministic cleanup:
resultsis closed only after all workers exit (wg.Wait()). The collector ranges overresultsand terminates reliably. - Error propagation: the collector records the first error and triggers cancellation. You still may receive some in-flight results that were already computed or already in the send path; this is normal and keeps the system simple and safe.
- Who closes what: producer closes
jobs; coordinator closesresults(after workers finish); cancellation closesdone. This clear ownership prevents panics from double-closing.
Optional refinement: avoid blocking on results if the collector stops early
If you ever change the collector to stop reading results early (for example, returning immediately on first error), workers could block trying to send. Two common fixes are: (1) keep draining results until results is closed (as shown), or (2) make results buffered enough for worst-case in-flight sends. Draining is usually the most deterministic approach.