part 5 · the async machinery Go doesn't need
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 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.
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.
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.
.then().map().recover() chain. Go won't let a method declare
its own type parameters, so the fully generic fluent chain is out, and
it fights the plain (T, error) style.
Do return (T, error) and nest the calls.Result[T, error] or
Option just to wrap an ordinary fallible call.
Go has no sum types and the idiom is the second return value.
Do use (T, error); check it; wrap with
%w. (A real either/or domain state can still earn its own
type.)RunAsync wrappers, no callback parameters to fake
non-blocking. Do just block; spawn with go
only when you want a second thing running.ch := make(chan T, 1); go func(){ ch <- f() }(); v := <-ch),
that's ceremony without concurrency. Do call
f().errgroup and context
first; they cover fan-out, fail-fast, and timeout. Collect-all-with-partials,
first-success-wins, and bounded pools still need a little explicit code;
write that, not a framework.sync.Cond for a one-shot
wake-everyone: a close(chan) already
broadcasts to every waiter at once, which is exactly what
ctx.Done() is. Do keep Cond
for the case it's actually for: repeated wakeups tied to protected shared
state.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.
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.
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.
(T, error), and let the scheduler do the work the type system used to.