Concurrency: Goroutines & Channels

Difficulty

Goroutines are Go's core concurrency primitive, and their cost model is what makes Go's "just spawn a goroutine" style practical.

Starting one

func sayHello() {
    fmt.Println("hello")
}

go sayHello()          // runs concurrently, doesn't block the caller
time.Sleep(time.Millisecond) // without this, main might exit before sayHello runs

main exiting ends the whole program immediately, even if goroutines are still running. Real code coordinates completion with a sync.WaitGroup or a channel, not time.Sleep.

Why goroutines are cheap

OS threadGoroutine
Initial stack sizeUsually 1-8MB, fixed~2KB, grows/shrinks dynamically
Typical max countA few thousandHundreds of thousands to millions
Scheduled byThe OS kernelThe Go runtime (in user space)
Context switch costRelatively expensive (kernel involved)Much cheaper (userspace scheduler)

The M:N scheduler

The Go runtime maps many goroutines (G) onto a smaller number of OS threads (M), coordinated through logical processors (P), roughly one per CPU core by default. When a goroutine blocks on a channel operation or a network call, the runtime parks it. It lets the OS thread run a different goroutine instead of sitting idle. This is why a Go program with 100,000 goroutines might only use a handful of OS threads under the hood.

Practical implication

Spawning a goroutine per incoming HTTP request, or per item in a batch job, is a completely normal Go pattern precisely because goroutines are this cheap. The same pattern with OS threads would exhaust system resources long before you got to that kind of concurrency.

Related Resources

Channels are how goroutines talk to each other safely, following Go's philosophy: share memory by communicating, not by locking.

Unbuffered: a rendezvous point

ch := make(chan int)

go func() {
    ch <- 42       // blocks until someone receives
}()

v := <-ch          // blocks until someone sends
fmt.Println(v)     // 42

The send and receive happen at the same logical moment. This makes an unbuffered channel useful as a synchronization signal, not just a value carrier — receiving from it tells you the sender has reached that specific point.

Buffered: decoupled, up to a point

ch := make(chan int, 2)
ch <- 1   // doesn't block, buffer has room
ch <- 2   // doesn't block, buffer now full
ch <- 3   // blocks — buffer is full, waits for a receive

Closing and detecting closure

ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch)

v, ok := <-ch  // 1, true
v, ok = <-ch   // 2, true
v, ok = <-ch   // 0, false — channel closed and drained

for v := range ch also stops automatically once a channel is closed and drained, making it the idiomatic way to consume every value from a channel until its producer is done.

A key rule

Only the sender should close a channel, never the receiver, and never close a channel twice — both cause a panic. Sending on a closed channel also panics, so coordinate shutdown carefully in real producer/consumer code.

Related Resources

select is Go's multiplexer for channel operations, and it shows up constantly in real concurrent code.

Waiting on the first of several channels

select {
case v := <-ch1:
    fmt.Println("from ch1:", v)
case v := <-ch2:
    fmt.Println("from ch2:", v)
}

Whichever channel has a value ready first wins. If both are ready at the same instant, Go picks randomly between them.

Implementing a timeout

select {
case result := <-workCh:
    fmt.Println("got result:", result)
case <-time.After(2 * time.Second):
    fmt.Println("timed out")
}

Non-blocking check with default

select {
case v := <-ch:
    fmt.Println("received:", v)
default:
    fmt.Println("no value ready, moving on")
}

Without default, this select would block until ch had a value. With it, the select returns immediately either way.

Cancellation with context

func worker(ctx context.Context, workCh <-chan int) {
    for {
        select {
        case <-ctx.Done():
            return   // caller cancelled, stop working
        case v := <-workCh:
            process(v)
        }
    }
}

This pattern, select between real work and a cancellation signal, is the standard way to make a long-running goroutine stoppable from the outside.

Related Resources

A goroutine leak doesn't crash your program immediately. It just quietly accumulates blocked goroutines until memory or other resources run out.

A classic leak

func startWorker(dataCh chan int) {
    go func() {
        for {
            v := <-dataCh  // blocks forever if nobody ever sends again, and nobody closes it
            process(v)
        }
    }()
}

If the caller stops sending to dataCh and never closes it, this goroutine blocks on the receive forever. It's never garbage collected, since it's still technically runnable, just permanently parked.

Fixing it with context cancellation

func startWorker(ctx context.Context, dataCh chan int) {
    go func() {
        for {
            select {
            case <-ctx.Done():
                return   // clean exit when the caller cancels
            case v := <-dataCh:
                process(v)
            }
        }
    }()
}

Detecting leaks

import "go.uber.org/goleak"

func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)  // fails the test suite if goroutines are still running after tests finish
}

goleak snapshots running goroutines before and after a test and fails if new ones are still alive at the end, which catches leaks that are otherwise invisible until they show up as a slow memory creep in production.

The general rule

Every goroutine you start should have a clear, reachable path to termination. If you can't answer "what makes this goroutine return" when you write the go statement, that's a strong signal you're about to leak one.

Go gives you two legitimate concurrency tools, mutexes and channels, and picking the right one for the job matters.

Basic mutex usage

type Counter struct {
    mu    sync.Mutex
    count int
}

func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

defer c.mu.Unlock() right after Lock() is the idiomatic pattern — it guarantees the lock releases even if the function panics or has multiple return paths.

RWMutex for read-heavy workloads

type Cache struct {
    mu   sync.RWMutex
    data map[string]string
}

func (c *Cache) Get(key string) string {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.data[key]
}

func (c *Cache) Set(key, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
}

Many goroutines can call Get concurrently, since RLock doesn't exclude other readers. Set still needs the exclusive Lock, since a write must not race with any read or another write.

Mutex vs. channel: a rule of thumb

SituationPrefer
Multiple goroutines read/write the same in-memory state (counter, cache, map)Mutex
Handing ownership of a piece of data from one goroutine to anotherChannel
Signaling "this event happened" or coordinating a pipelineChannel
Protecting a small, hot piece of state where channel overhead would be wastefulMutex

Go's proverb "share memory by communicating" is a design philosophy, not a hard rule. Real Go codebases use both, choosing whichever tool more directly expresses the actual problem.

Related Resources