Go is the deliberate odd one out in this section: it has no async/await keyword and no Future/Promise type in its standard library, by design -- goroutines and channels already give you the same non-blocking-from-the-caller's-perspective behavior without a separate syntax. The other four all have async/await, but the underlying value-not-ready-yet type differs (CompletableFuture, Deferred, coroutine Task, Promise), and all four share the same sharp edge: calling a blocking API from inside 'async' code silently defeats the entire point.
Language Verdict: Pros, Cons & Recommendation
- No function coloring at all -- any function can run in a goroutine, blocking or not
- Scheduler transparently handles blocked-in-syscall goroutines, same benefit as virtual threads with zero opt-in
- No Future/Promise type or async composition helpers -- select and channels cover it, but with more boilerplate for simple 'wait for one value' cases
- No built-in equivalent of allSettled/any -- you build that composition by hand
- Virtual threads let you write plain blocking code and get async-like I/O scalability with zero new syntax
- CompletableFuture gives full combinator-based composition when explicit async pipelines are still wanted
- Two competing idioms (CompletableFuture vs. virtual threads) coexist during the transition, a real 'which one do I use' question
- No async/await syntax at all if you do want explicit suspend points -- only combinator chaining
- suspend + structured concurrency was mature years before Java's virtual threads
- select{} gives race-with-explicit-cancellation-control composition
- Choosing the wrong Dispatcher for a blocking call is a real, silent foot-gun
- Deferred vs. Job (result vs. no-result) is an extra distinction to track relative to JS's single Promise type
- async/await syntax is clear and directly mirrors JS's, easy to learn if you know one
- TaskGroup/gather cover the common composition needs
- A single blocking call inside async code stalls the ENTIRE event loop, a sharper foot-gun than the JVM/Go equivalents
- Two non-interchangeable Future classes (asyncio.Future vs. concurrent.futures.Future) is a genuine, confusing wart
- Richest built-in combinator set (all/allSettled/race/any) of the five languages
- Single, unified Promise type underlies every async primitive in the language
- Same single-event-loop-stall foot-gun as Python for any accidental blocking call
- No cancellation on any combinator (all/race/any) -- losing operations keep running unless you wire AbortController yourself
Recommendation: State explicitly whether the language's async model is 'colored' (suspend/async keywords propagate through signatures: Kotlin, Python, JS) or 'uncolored' (ordinary blocking-looking code scales anyway: Go always, Java only inside virtual threads) -- it changes how you reason about where interleaving is even possible in a given codebase.
Concurrency Mechanics, Side by Side
Go is the one language in this manual with neither async/await syntax nor a Future/Promise type in its standard library -- and this is a deliberate design choice, not a missing feature.
// No async/await, no Future/Promise type in the standard library at all.
// A goroutine + channel gives the same 'runs concurrently, get the
// result later' behavior with completely ordinary function calls:
resultCh := make(chan User)
go func() { resultCh <- fetchUser() }()
user := <-resultCh // this IS the 'await', just spelled as a channel receive
This is a genuine, deliberate design decision documented across Go team talks: rather than adding async/await syntax and a Future type, Go gives you one general concurrency mechanism (goroutines + channels) that already composes with completely ordinary, un-colored functions. There is no 'async function' vs. 'sync function' distinction to propagate through your call stack the way there is in every other language in this section -- any function can be run in a goroutine, full stop.
// No Future/Promise type at all -- a channel of exactly one value
// (or a struct{ Value T; Err error }) plays this role when needed:
type Result struct { Value int; Err error }
resultCh := make(chan Result, 1)
go func() { v, err := compute(); resultCh <- Result{v, err} }()
Go code that specifically needs a 'future' shape (a single eventual value, possibly with an error) typically models it by hand as a single-capacity channel carrying a small result struct -- there is no standard-library Future type, and reaching for one is rare enough in idiomatic Go that most codebases just use channels directly instead of building a Future abstraction on top.
// 'all': WaitGroup, shown earlier. 'race': select on multiple channels --
// this is precisely what select was built for:
select {
case r := <-chA:
fmt.Println("A won:", r)
case r := <-chB:
fmt.Println("B won:", r)
}
// Neither goroutine is automatically cancelled -- pass a context if you need that.
select is Go's native 'race' primitive, built into the language itself rather than a library function -- whichever channel has a value ready first is chosen (pseudo-randomly if multiple are ready simultaneously). Like Java's anyOf and JS's Promise.race, the losing goroutines are not automatically cancelled; that requires explicitly passing and checking a context.Context.
This is arguably the single most common real-world bug across every async/await-flavored language: one accidental blocking call inside async code doesn't error, it just silently stalls everyone else.
// A blocking call inside a goroutine is completely normal and fine --
// the Go scheduler detects a goroutine blocked in a syscall and hands
// its OS thread to another runnable goroutine automatically:
go func() { rows, _ := db.Query(sql) }() // safe by design
Go's runtime scheduler specifically handles this case: when a goroutine blocks in a syscall (file I/O, network I/O, etc.), the scheduler detaches the OS thread and lets another goroutine run on a fresh or existing thread, similar in spirit to how a virtual thread unmounts in Java. This is a genuine structural advantage over async/await languages -- there's no 'wrong pool' to accidentally block, because there's only ever goroutines.
Java's virtual threads are worth a dedicated callout because they solve the exact problem async/await solves, via the opposite approach — and this contrast is a genuinely good senior-level talking point.
- async/await's approach (Kotlin, Python, JS): make the 'this might suspend' property visible in the type system / syntax. A
suspend fun, async def, or async function is explicitly a different kind of function than a regular one, and that difference propagates through every caller ("async all the way up") — you always know, from the signature, that a function might yield control.
- Virtual threads' approach (Java 21+): keep every function looking identical — ordinary, blocking-style code, no keyword, no different return type — and let the runtime transparently unmount a virtual thread from its carrier whenever it blocks, then remount it (possibly on a different carrier thread) when the blocking call completes. The scalability benefit is the same (many concurrent, cheap, mostly-blocked units of work), but nothing in the function's signature reveals it.
- Go's goroutines land closer to the virtual-thread end of this spectrum: ordinary function calls, no coloring, with the scheduler (not a type system distinction) doing the equivalent of unmounting a blocked goroutine's OS thread.
- The trade-off: async/await's explicit coloring makes it obvious, from a function's signature alone, which calls might suspend — useful for reasoning about where interleaving can happen. Virtual threads/goroutines make ordinary code scale without any syntax changes, at the cost of that same visibility: any call might now be doing something concurrency-relevant under the hood, and you can't tell just by reading the signature.
Being able to state this trade-off out loud — not just that Java 'added something like async' — is a strong signal in a senior/staff interview.