Skip to content
JavaGo
Part IV · Concurrency

Defer — Advanced

defer in concurrent code: unlocking, closing channels, containing panics in background goroutines, and annotating errors on the way out.

Section 19 of 31 4 min read

With mutexes, channels and goroutines covered, here are the defer patterns that keep concurrent code correct. Each one is short; each one prevents a class of production incident.

Lock, then defer Unlock

two consecutive lines, every time
func (c *Cache) Set(key string, v Value) {
    c.mu.Lock()
    defer c.mu.Unlock()
    // Any return below, or a panic, still unlocks.
    // Forget it once and every other goroutine calling
    // Lock() waits forever: a silent, total deadlock.
    c.items[key] = v
}

// Keep critical sections short. If you must do slow work
// (I/O, network calls), copy what you need under the lock,
// unlock, then do the slow part:
func (c *Cache) Snapshot() map[string]Value {
    c.mu.Lock()
    snap := maps.Clone(c.items)
    c.mu.Unlock()
    return snap // callers can iterate without holding the lock
}

defer close(ch): tell consumers you are done

the producer closes on every exit path
func stream(ctx context.Context, rows *sql.Rows) <-chan Message {
    out := make(chan Message)
    go func() {
        defer close(out)    // runs on success, error, or cancellation
        defer rows.Close()  // runs first (LIFO)
        for rows.Next() {
            var m Message
            if err := rows.Scan(&m.ID, &m.Topic); err != nil {
                return // consumers still see the channel close
            }
            select {
            case out <- m:
            case <-ctx.Done():
                return
            }
        }
        // rows.Next() is also false after an error: real code checks
        // rows.Err() and reports it (an error channel or an Err method).
    }()
    return out
}

Containing panics in background goroutines

A panic that escapes any goroutine terminates the process. net/http recovers panics in handlers, but nothing recovers panics in goroutines you start yourself. For long-lived workers, recover at the top of the goroutine, log with a stack trace, and report the failure.

a panic-safe goroutine launcher
// Go runs fn in a goroutine. A panic becomes an error on errc
// instead of crashing the process.
func Go(ctx context.Context, name string, fn func(context.Context) error) <-chan error {
    errc := make(chan error, 1) // buffered: never blocks the sender
    go func() {
        defer func() {
            if r := recover(); r != nil {
                slog.ErrorContext(ctx, "goroutine panicked",
                    "name", name, "panic", r, "stack", string(debug.Stack()))
                errc <- fmt.Errorf("%s: panic: %v", name, r)
            }
            close(errc)
        }()
        errc <- fn(ctx)
    }()
    return errc
}
  • recover() only stops a panic when called directly by a deferred function. Calling it from a helper that the deferred function calls does nothing.
  • recover() returns nil when there is no panic, so the check is always if r := recover(); r != nil.
  • A recovered panic means something is already broken. Log it loudly, count it in metrics, and consider exiting cleanly rather than continuing in an unknown state.
  • sync.WaitGroup.Go requires that its function does not panic. For worker goroutines that might, use a wrapper like the one above.

Annotating every error on the way out

one deferred wrap instead of one per return
func (s *Service) Transfer(ctx context.Context, from, to string, amt Money) (err error) {
    defer func() {
        if err != nil {
            err = fmt.Errorf("transfer %s→%s: %w", from, to, err)
        }
    }()

    if err := s.accounts.Debit(ctx, from, amt); err != nil {
        return err // wrapped by the deferred func
    }
    if err := s.accounts.Credit(ctx, to, amt); err != nil {
        return err // wrapped too
    }
    return nil
}

stream()'s goroutine: defer close(out)

Transfer(): one deferred wrap

Fig. 19.1However a function leaves, its deferred calls run. That is why one defer close(out) is enough to tell consumers the stream ended, and one deferred closure can add context to every error the function returns.
↑ ↓ to navigateEnter to openEsc to close