DEV Community

Cover image for Structured Concurrency in Go: Stop Letting Goroutines Escape
Serif COLAKEL
Serif COLAKEL

Posted on

Structured Concurrency in Go: Stop Letting Goroutines Escape

If you’ve worked with Go long enough, you’ve probably written code like this:

go doWork()
Enter fullscreen mode Exit fullscreen mode

It works.
Until it doesn’t.

At some point in production, you start asking uncomfortable questions:

  • Why is this goroutine still running?
  • Who is supposed to stop this?
  • Why does shutdown sometimes hang?
  • Why do we have “random” goroutine leaks?

This isn’t a channel problem.
It’s not a mutex problem.

It’s a lifecycle problem.


What Structured Concurrency Means (Without the Buzzwords)

Structured concurrency is simple:

If you start concurrent work, you must know when and how it ends.

That’s it.

Every goroutine should have:

  • an owner
  • a clear lifetime
  • a cancellation signal
  • a place where errors go

If any of those are missing, the code might work locally — but it’s fragile in production.


The Fire-and-Forget Anti-Pattern

A very common example:

func handler(w http.ResponseWriter, r *http.Request) {
    go auditLog(r)
    processRequest(r)
}
Enter fullscreen mode Exit fullscreen mode

Looks innocent.

But what happens when:

  • the request times out?
  • the client disconnects?
  • shutdown starts?
  • auditLog blocks on I/O?

You now have a goroutine:

  • detached from the request
  • uncancellable
  • invisible
  • unaccounted for

Multiply this by traffic and time.


Context Is Necessary — But Not Enough

Yes, you should pass context.Context:

go auditLog(r.Context(), r)
Enter fullscreen mode Exit fullscreen mode

But context alone doesn’t:

  • wait for goroutines
  • propagate errors
  • coordinate siblings

You still need structure.


errgroup: Practical Structured Concurrency in Go

The most useful tool for this is:

golang.org/x/sync/errgroup
Enter fullscreen mode Exit fullscreen mode

Example:

func processOrder(ctx context.Context) error {
    g, ctx := errgroup.WithContext(ctx)

    g.Go(func() error {
        return reserveInventory(ctx)
    })

    g.Go(func() error {
        return chargePayment(ctx)
    })

    g.Go(func() error {
        return notifyUser(ctx)
    })

    return g.Wait()
}
Enter fullscreen mode Exit fullscreen mode

What you get:

  • if one fails → all are cancelled
  • no orphaned goroutines
  • clear ownership
  • predictable shutdown

This single pattern prevents a huge class of bugs.


Worker Pools Without Leaks

Unstructured:

for job := range jobs {
    go handle(job)
}
Enter fullscreen mode Exit fullscreen mode

Structured:

g, ctx := errgroup.WithContext(ctx)

for i := 0; i < workers; i++ {
    g.Go(func() error {
        for {
            select {
            case <-ctx.Done():
                return ctx.Err()
            case job, ok := <-jobs:
                if !ok {
                    return nil
                }
                if err := handle(job); err != nil {
                    return err
                }
            }
        }
    })
}

return g.Wait()
Enter fullscreen mode Exit fullscreen mode

Now:

  • workers stop on cancel
  • errors are visible
  • shutdown is clean

A Simple Mental Checklist

Before starting a goroutine, ask:

  1. Who owns this goroutine?
  2. Who cancels it?
  3. Who waits for it?
  4. Where does its error go?

If you can’t answer all four — it’s a bug waiting to happen.


Takeaways

  • Goroutines must have owners
  • Context enables cancellation, not structure
  • errgroup gives you lifecycle guarantees
  • Structured concurrency prevents:

    • goroutine leaks
    • deadlocks
    • hanging shutdowns

Go doesn’t enforce this — production does.


Happy Coding! 🚀

Top comments (0)