← Code Like Go

Nothing to Recover

part 5 · the async machinery Go doesn't need

⚠ warning: slop currently. Work in progress — not final.

The other parts were about syntax Go removed. This one is about a whole category it removed: the machinery other languages build for concurrency. Futures, promises, async/await, colored functions, combinator libraries. Go ships almost none of it. The trap, coming from those languages, is to rebuild it anyway. Don't. There is nothing to recover.

In one line
The async world built futures to survive a world where blocking was expensive. Go made blocking cheap enough that most of that machinery is unnecessary in everyday I/O code, and porting it anyway is the mistake.

What async/await is actually for

In most languages an OS thread is heavy: a stack often a megabyte or more, and enough per-thread overhead that one-thread-per-blocked-call stops scaling in the low thousands. Blocking a thread on every slow call scales badly; you tie up a scarce resource to sit and wait. The escape hatch is to not block: return a placeholder for the answer (a future or a promise) and attach what-happens-next as a callback. async/await is sugar over that. It reads sequential but lowers to a state machine, so the runtime can suspend the wait and resume it later without pinning a thread (on a single-threaded runtime, without blocking the event loop).

That buys throughput and costs you the language. Now a function is either red (async) or blue (sync), and red infects everything that calls it. You can't await from a plain function, so async climbs the whole call stack. The future is a value you have to thread, map, and unwrap. Cheap non-blocking waiting is the load-bearing reason this machinery exists. Not the only one; event loops, cancellation, and backpressure ride along too. But it is the big one.

The inversion
Async-await adds machinery to avoid blocking. Go removes the cost of blocking. Same problem, opposite move, and the removal is the cheaper one to live with.

How Go solves it

A goroutine starts with a tiny stack: a couple of kilobytes that grows as needed, not a megabyte. When one blocks on I/O, the scheduler parks it and keeps the OS thread busy with another runnable goroutine, so the block costs you one goroutine, not one thread. (Some blocking syscalls and CGO calls do spin up extra threads; the cheap case is network I/O, which is most of it.) So you can just block: write the slow call as an ordinary sequential line, and the runtime makes it concurrent underneath.

// looks blocking. is cheap. no future, no await, no color.
func handle(ctx context.Context, id string) (*User, error) {
    u, err := db.Load(ctx, id)   // goroutine parks here; thread runs others
    if err != nil {
        return nil, fmt.Errorf("handle %s: %w", id, err)
    }
    return u, nil
}

No function is colored. Any function can do I/O, so async has nothing to climb. The answer is the return value, not a future you thread. Concurrency became an implementation detail behind an ordinary (T, error) signature, and that signature composes by plain function nesting. The deep version (errgroup, pipelines, channel cost) lives on the Go hub.

What not to recover

The instinct from async-land is to rebuild the tools you knew. Every one of them is a workaround for a constraint Go already deleted. Leave them.

Travels as: when a language gives you a cheaper primitive than the one you're used to, don't emulate the expensive one on top of it. The emulation keeps the old tax and hides the new gift. Learn the local idiom; delete the import.

Signal, don't ship

One more gift hides in close. Closing a channel does more than wake a waiter: the memory model makes the close synchronized-before any receive that sees it. A producer can write a plain field, then close; the consumer receives the close, then reads the field. It is race-free under one discipline: the producer writes the fields before the close, nothing writes them after, and readers touch them only after <-done. Hold that and you need no lock, and the value never had to travel through the channel.

r := &result{}
done := make(chan struct{})
go func() {
    r.val, r.err = expensive()  // plain writes, no lock
    close(done)                 // the close is the barrier
}()

// elsewhere:
<-done                          // saw the close...
use(r.val, r.err)               // ...so this read is safe

Travels as: the synchronization and the payload don't have to ride together. Signal through the channel; keep the value beside it. The close carries the ordering, the field carries the data, and you skip sending the value at all. Make the safe thing the cheap thing.

What you give up, honestly

Go's trade is not free. You lose the rich algebra: there's no clean built-in "first of N", timeouts compose through context instead of a race combinator, and a forgotten ctx check is a goroutine leak the compiler will never catch. You trade expressive combinators for signatures any reader follows top to bottom. For the work Go is built for (services, tools, anything where legibility under load beats cleverness), that's the right trade.

Remove to accelerate
Much of the async world is machinery built around a constraint Go relaxed. Don't port the machinery by reflex. Block plainly where the runtime makes it cheap, return (T, error), and let the scheduler do the work the type system used to.

Read on

Go — Why It's Fast
The systems case behind the discipline — the performance story this series argued for.
Go hub · the deep version
errgroup, pipelines, the channel cost ladder, and how broadcast works under the hood.