Files
Computer-Fundamentals/go/03_concurrency_and_goroutines.md
T
tarun-elango be31df2d44 more text
2026-04-26 14:09:04 -04:00

14 KiB

Go: Concurrency and Goroutines

Learning Objectives

  • Understand the difference between concurrency and parallelism.
  • Learn how goroutines work and why they are cheaper than OS threads.
  • Use channels, select, mutexes, and synchronization primitives appropriately.
  • Understand the context package as the control plane for cancellation and deadlines.
  • Build a light but correct mental model of Go's memory model and data races.
  • Recognize common production concurrency patterns and the bugs that come with them.

Why Concurrency Matters in Go

Go became popular partly because it made concurrent programming feel accessible.

Backend and systems software naturally deals with many things at once:

  • handling multiple HTTP requests
  • waiting on databases and other services
  • processing jobs from queues
  • streaming data through pipelines
  • watching timers, sockets, and shutdown signals

If you handle all of that in a single linear flow, the program spends a lot of time idle. Concurrency lets you structure work so independent tasks can make progress without blocking each other unnecessarily.

Concurrency vs Parallelism

  • concurrency is about structuring many tasks in progress
  • parallelism is about tasks literally running at the same time on multiple CPU cores

Go helps with both, but it starts with concurrency as a programming model.

Goroutines: Lightweight Concurrent Execution

What They Are

A goroutine is a function executing independently from other goroutines.

go sendEmail(userID)

That single keyword starts a concurrent unit of execution.

Why Goroutines Exist

OS threads are powerful, but they are relatively expensive to create and manage directly. Go wanted a lighter abstraction so programs could comfortably run thousands or even millions of concurrent tasks, as long as the workload and memory usage made that reasonable.

How Goroutines Work Internally

Go uses an M:N scheduler. In simplified terms:

  • many goroutines are multiplexed onto fewer OS threads
  • the runtime scheduler decides which goroutine runs where
  • the scheduler cooperates with the runtime and system calls to keep work moving

The common mental model is G, M, and P:

  • G is a goroutine
  • M is an OS thread, called a machine in runtime terminology
  • P is a processor token that lets Go code execute and carries scheduler state
flowchart LR
    G1[Goroutine] --> P1[Processor P]
    G2[Goroutine] --> P1
    G3[Goroutine] --> P2[Processor P]
    P1 --> M1[OS Thread M]
    P2 --> M2[OS Thread M]

This model lets Go keep concurrency cheap while still using real CPU parallelism when available.

Why Goroutines Feel Cheap

They start with small stacks that can grow as needed. That is very different from traditional thread models where each thread may reserve a much larger stack up front.

Still, "cheap" does not mean "free."

Each goroutine has:

  • scheduler overhead
  • stack memory
  • potential references keeping heap data alive

Launching goroutines without bounds in a busy server can still create memory pressure and operational problems.

Waiting for Goroutines to Finish

When goroutines need coordination, a common tool is sync.WaitGroup.

var wg sync.WaitGroup

for _, id := range ids {
    wg.Add(1)

    go func(userID int64) {
        defer wg.Done()
        processUser(userID)
    }(id)
}

wg.Wait()

Why it exists:

  • lets one part of the program wait for a known set of concurrent tasks
  • keeps coordination explicit without using channels for every case

In production code, WaitGroup is often simpler than a custom done channel when you only need task completion, not data transfer.

Channels: Communication and Coordination

What a Channel Is

A channel is a typed conduit used to send values between goroutines.

jobs := make(chan int)
results := make(chan string)

Why Channels Exist

Go popularized the idea "share memory by communicating." The point is not that shared memory is forbidden. The point is that ownership transfer through communication is often easier to reason about than unrestricted shared mutation.

Channels are useful for:

  • handing work to workers
  • propagating results
  • signaling completion
  • coordinating pipelines

Unbuffered Channels

An unbuffered channel requires sender and receiver to synchronize.

done := make(chan struct{})

go func() {
    fmt.Println("work complete")
    done <- struct{}{}
}()

<-done

Why this matters:

  • send and receive form a handoff point
  • it is both data transfer and synchronization

Buffered Channels

A buffered channel can hold a fixed number of values without an immediate receiver.

queue := make(chan string, 100)
queue <- "task-1"

Why buffered channels exist:

  • smooth over short bursts
  • decouple producer and consumer timing somewhat
  • model bounded queues naturally

Do not treat buffering as magic. A large enough producer can still fill the buffer and block.

Closing Channels

Closing a channel means no more values will be sent.

close(queue)

Rules that matter:

  • only close from the sending side when it owns completion
  • do not close a channel just because you are done receiving from it
  • sending on a closed channel panics

Receivers can use the two-result form:

value, ok := <-queue

When ok is false, the channel is closed and drained.

When Not to Use Channels

Channels are excellent, but not universal. If you just need to protect a shared map or counter, a mutex may be simpler. Overusing channels can make code look concurrent while actually becoming harder to understand.

select: Wait on Multiple Communication Paths

select lets a goroutine wait on multiple channel operations.

select {
case result := <-results:
    fmt.Println("got result", result)
case <-time.After(200 * time.Millisecond):
    fmt.Println("timed out")
}

Why it exists:

  • real systems often wait on multiple events
  • timeouts and cancellation are first-class concerns
  • many concurrent flows need to react to whichever signal arrives first

Real-World Use: Timeout and Cancellation

select {
case msg := <-incoming:
    handle(msg)
case <-ctx.Done():
    return ctx.Err()
}

This is the backbone of responsive concurrent systems in Go: do work if possible, but remain interruptible.

The context Package: Cancellation, Deadlines, and Scope

What It Is

context.Context carries request-scoped cancellation, deadlines, and small pieces of request metadata across API boundaries.

func FetchUser(ctx context.Context, id string) (User, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.internal/users/"+id, nil)
    if err != nil {
        return User{}, err
    }

    // send request with ctx-aware client
    return User{}, nil
}

Why It Exists

In distributed systems, work rarely lives in a single function. An HTTP request may trigger:

  • JSON parsing
  • database queries
  • downstream HTTP calls
  • cache lookups
  • logging and tracing

If the client disconnects or a deadline expires, you want the whole chain to stop promptly. Context gives the program a standard way to express that control signal.

How It Works Internally

Contexts form a tree.

  • a parent context can be derived into child contexts
  • canceling the parent cancels all children
  • deadlines propagate downward
graph TD
    A[Background Context] --> B[HTTP Request Context]
    B --> C[DB Query Context]
    B --> D[Downstream API Context]
    B --> E[Worker Task Context]

Rules for Using Context Correctly

  • pass it as the first parameter by convention
  • do not store it inside structs for long-lived use
  • do not use it as a bag of optional business parameters
  • respect cancellation by checking ctx.Done() or using context-aware APIs

Common Misuse

Putting every random value into context makes code opaque. Use context values only for request-scoped metadata that crosses process boundaries or middleware layers, such as trace IDs or auth claims when your framework expects it.

Mutexes, RWMutexes, and Atomics

Why These Exist Alongside Channels

The slogan "share memory by communicating" is helpful, but it is not a religion. Some problems are fundamentally shared-state problems.

Example:

  • protecting a cache map
  • incrementing metrics counters
  • updating a shared in-memory registry

For these, a mutex is often clearer than designing a special manager goroutine and channel protocol.

sync.Mutex

type Counter struct {
    mu    sync.Mutex
    value int64
}

func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

Why it works:

  • only one goroutine can hold the lock at a time
  • the critical section becomes explicit

sync.RWMutex

Useful when reads are much more frequent than writes, but do not assume it is always faster. Its benefits depend on workload and contention patterns.

sync/atomic

Atomic operations are useful for low-level counters, flags, and lock-free coordination where the semantics are simple and precise.

Use atomics carefully. They are powerful but easy to misuse if you do not understand memory ordering and invariants.

The Go Memory Model, Lightly Explained

The memory model answers a critical question: when one goroutine writes data, when is another goroutine guaranteed to see it?

If two goroutines touch the same variable without proper synchronization and at least one access is a write, you have a data race.

This is not a style issue. It is a correctness bug.

Synchronization Creates Visibility Guarantees

Common happens-before edges include:

  • sending on a channel before the corresponding receive completes
  • unlocking a mutex before a later lock on that mutex
  • closing a channel before receives observe closure
  • WaitGroup and other primitives coordinating completion

If you rely on plain timing, such as "the other goroutine will probably run first," you do not have a guarantee.

Why This Matters in Production

Data races can pass tests and still fail under load, on different CPUs, or only once every few days. That is why race bugs are among the most frustrating backend failures.

Use the race detector early:

go test -race ./...

Concurrency Patterns You Will Actually Use

Worker Pool

Useful when you have many jobs but want bounded concurrency.

func worker(id int, jobs <-chan int, results chan<- int) {
    for job := range jobs {
        results <- job * 2
    }
}
flowchart LR
    A[Job Producer] --> B[Buffered Jobs Channel]
    B --> C[Worker 1]
    B --> D[Worker 2]
    B --> E[Worker 3]
    C --> F[Results Channel]
    D --> F
    E --> F

Why it exists:

  • prevents unbounded goroutine creation
  • smooths throughput
  • matches CPU or downstream capacity constraints

Fan-Out and Fan-In

This pattern sends work to multiple goroutines and merges results back together. It is common in API aggregation, search, and parallel I/O.

Pipelines

Each stage reads from an input channel, transforms data, and sends to an output channel. This is useful for streaming transformations, though you must design cancellation carefully or you can leak goroutines when downstream stops consuming.

Bounded Semaphores with Channels

A buffered channel can act as a semaphore controlling how many operations run at once. This is handy for limiting downstream API calls or database work.

Real-World Usage Patterns

HTTP Request Fan-Out

An API gateway might receive one request, then concurrently ask a profile service, inventory service, and pricing service for data. Context cancellation ensures that if the client goes away or a deadline expires, those downstream calls stop too.

Background Job Processing

A worker service reading from a queue often uses:

  • one intake goroutine
  • a bounded worker pool
  • retry logic
  • context cancellation for shutdown
  • metrics on success, failure, and latency

Streaming and Event Processing

Go is good at managing concurrent streams from sockets, brokers, or internal pipelines because goroutines map well to independent flows of work.

Common Mistakes and Misconceptions

Mistake: Spawning Unbounded Goroutines

If every request starts many goroutines without a limit, memory and scheduler pressure can explode under load.

Mistake: Forgetting Cancellation

Goroutines that wait forever on channels, I/O, or timers become leaks. In servers, leaked goroutines are a real operational bug.

Mistake: Closing Channels from the Wrong Side

Channel closure should usually be owned by the sender that knows when production is complete.

Mistake: Using Channels for Everything

Sometimes a mutex is the simplest and most correct tool.

Mistake: Assuming Concurrent Means Safe

Starting work in multiple goroutines does not automatically make the code synchronized. Shared state still needs a correctness story.

Mistake: Ignoring the Race Detector

If you write concurrent Go and do not run go test -race, you are skipping one of the most useful safety tools in the ecosystem.

Mistake: Misusing Context Values

Context is for cancellation, deadlines, and narrow request-scoped metadata. It is not general dependency injection.

Summary

Go concurrency is powerful because it combines a simple source-level model with strong runtime support.

  • goroutines make concurrent work cheap to express
  • channels coordinate ownership transfer and signaling
  • select handles multiple events, timeouts, and cancellation
  • mutexes and atomics remain essential for shared-state problems
  • context is the control plane for request-scoped work
  • the memory model and race detector protect correctness when multiple goroutines interact

The next step is learning how to organize real Go codebases: packages, modules, tests, benchmarks, and the toolchain that keeps production Go code clean and maintainable.