{
"$type": "site.standard.document",
"canonicalUrl": "https://rednafi.com/go/structured-concurrency/",
"description": "How Python and Kotlin provide structured concurrency out of the box while Go achieves the same patterns explicitly using errgroup, WaitGroup, and context.",
"path": "/go/structured-concurrency/",
"publishedAt": "2026-02-21T00:00:00.000Z",
"site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
"tags": [
"Go",
"Python",
"Kotlin",
"Concurrency"
],
"textContent": "At my workplace, a lot of folks are coming to Go from Python and Kotlin. Both languages have\nstructured concurrency built into their async runtimes, and people are often surprised that\nGo doesn't. The go statement just launches a goroutine and walks away. There's no scope\nthat waits for it, no automatic cancellation if the parent dies, no built-in way to collect\nits errors.\n\nThis post looks at where the idea of structured concurrency comes from, what it looks like\nin Python and Kotlin, and how you get the same behavior in Go using errgroup, WaitGroup,\nand context.\n\nFrom goto to structured programming\n\nIn 1968, Dijkstra wrote a letter to the editor of Communications of the ACM titled [Go To\nStatement Considered Harmful]. His core argument was that unrestricted use of goto made\nprograms nearly impossible to reason about:\n\n> The unbridled use of the go to statement has as an immediate consequence that it becomes\n> terribly hard to find a meaningful set of coordinates in which to describe the process\n> progress.\n\nStructured programming replaced goto with scoped constructs like if, while, and\nfunctions. The key insight was that control flow should be lexically scoped: you can look at\na block of code and know where it starts, where it ends, and that everything in between\nfinishes before execution moves on.\n\nThe same problem showed up later in concurrent programming.\n\nThe same problem, but with concurrency\n\nSpawning a thread or goroutine that outlives its parent is the concurrency equivalent of\ngoto. The spawned work escapes the scope that created it, and now you have to reason about\nlifetimes that cross boundaries.\n\nMartin Sustrik, creator of ZeroMQ, coined the term \"structured concurrency\" in his\n[Structured Concurrency] blog post. He framed the idea as an extension of how block\nlifetimes work in structured programming:\n\n> Structured concurrency prevents lifetime of green thread B launched by green thread A to\n> exceed lifetime of A.\n\nEric Niebler later expanded on Sustrik's idea, tying it directly to how function calls work\nin sequential code:\n\n> \"Structured concurrency\" refers to a way to structure async computations so that child\n> operations are guaranteed to complete before their parents, just the way a function is\n> guaranteed to complete before its caller.\n>\n> -- Eric Niebler, [Structured Concurrency (Niebler)]\n\nNathaniel J. Smith (NJS) took this further in his essay [Notes on structured concurrency]:\n\n> That's right: go statements are a form of goto statement.\n\nNJS's broader point was that spawning a background task breaks function abstraction the same\nway goto does. Once a function can spawn work that outlives it, the caller can no longer\nreason about when the function's effects are complete:\n\n> Every time our control splits into multiple concurrent paths, we want to make sure that\n> they join up again.\n\nStructured concurrency boils down to a few rules:\n\n- Concurrent tasks are spawned within a scope and can't outlive it\n- If the parent scope is cancelled or a task fails, the remaining tasks are cancelled too\n- The scope doesn't exit until all its tasks have finished\n- Errors propagate from children back to the parent\n\nThis essay prompted [Go proposal #29011], filed by smurfix, which proposed adding structured\nconcurrency to Go. NJS participated in the discussion and made a point that stuck with me:\n\n> Right now you can structure things this way in Go, but it's way more cumbersome than just\n> typing go myfunc(), so Go ends up encouraging the \"unstructured\" style.\n>\n> -- Nathaniel J. Smith, [Go proposal #29011]\n\nThe proposal was eventually closed. Before getting into Go's approach, it helps to see what\nstructured concurrency actually looks like in practice across the three languages.\n\nPython's TaskGroup\n\nPython 3.11 introduced [asyncio.TaskGroup] as the structured concurrency primitive. Here's\nan example that runs three tasks concurrently, where one of them fails:\n\nHere:\n\n- (1) await is a cancellation point; the runtime can interrupt the coroutine here\n- (2) async with creates a scope that waits for all tasks to finish\n- (3) tasks are spawned inside the group and tied to its lifetime\n- (4) if any task raises, the group cancels the remaining tasks and collects the errors\n- (5) finally runs regardless of success or failure\n\nThe thing that makes this work is that await expressions are cancellation points. When the\ngroup decides to cancel, the runtime delivers that cancellation at the next await in each\nrunning coroutine.\n\nKotlin's coroutineScope\n\nKotlin has had structured concurrency since kotlinx.coroutines 0.26. The equivalent\nconstruct is [coroutineScope]. Here's the same scenario with three tasks and one failure:\n\nHere:\n\n- (1) delay is a suspension point where cancellation can be delivered\n- (2) coroutineScope waits for all children and cancels siblings if one fails\n- (3) launch starts a coroutine tied to this scope\n- (4) the exception propagates after all children are cancelled\n- (5) finally runs as expected\n\nLike Python's await, Kotlin's suspension functions (delay, channel operations, etc.) are\ncancellation points. When the scope cancels, the runtime delivers a CancellationException\nat the next suspension point in each running coroutine.\n\nKotlin also has [supervisorScope], which is the variant where siblings keep running when one\nfails. We'll see the Go equivalent of that shortly.\n\nGo doesn't do this by default\n\nGo's go statement is unstructured. When you write go func() { ... }(), the runtime\nspawns a background goroutine and immediately moves on. The calling function doesn't wait\nfor it, doesn't get notified when it finishes, and has no way to cancel it. Unless you\nexplicitly synchronize with something like a WaitGroup or a channel, that goroutine can\noutlive the function that spawned it. There's no built-in scope that ties their lifetimes\ntogether.\n\nBut you can compose the same patterns using channels, sync.WaitGroup, context, and\nerrgroup from x/sync.\n\nerrgroup for cancel-on-error\n\nThis is Go's equivalent of TaskGroup and coroutineScope. Same scenario: three tasks, one\nfails, siblings get cancelled:\n\nHere:\n\n- (1) creates a group with a derived context; if any goroutine fails, the context cancels\n- (2) fetches users; observes cancellation via ctx.Done()\n- (3) fails immediately, triggering cancellation of the shared context\n- (4) fetches products; also observes cancellation\n- (5) Wait blocks until all goroutines finish and returns the first non-nil error\n\n> Notice how the Go version requires each goroutine to explicitly check ctx.Done(). In\n> Python and Kotlin, the runtime handles that at await/suspension points. In Go, you wire\n> it in yourself.\n\nWaitGroup for supervisor-like behavior\n\nThis is Go's equivalent of Kotlin's supervisorScope. Siblings keep running regardless of\nindividual failures:\n\nHere:\n\n- (1) Go launches a goroutine and handles Add/Done internally (Go 1.25+)\n- (2) errors are collected but don't affect other goroutines\n- (3) Wait blocks until all goroutines finish\n\nThose two examples cover Go's equivalents of the structured patterns in Python and Kotlin.\nBut the code looks noticeably different, and the reason comes down to how these runtimes\nhandle concurrent execution.\n\nGoroutines aren't coroutines\n\nThe fundamental difference between the Python/Kotlin approach and Go's approach comes down\nto how cancellation gets delivered.\n\nIn Python, async def functions are coroutines. They run on a single-threaded event loop\nand yield control at every await. In Kotlin, suspend functions are coroutines. They run\non cooperative dispatchers (which can be backed by thread pools) and yield at every\nsuspension point. Both languages have [colored functions] (async/suspend) - the \"color\"\nmeans async functions can only be called from other async functions, which lets the runtime\ntrack every point where a coroutine can yield. These yield points are also cancellation\npoints, so when a scope cancels, the runtime delivers the cancellation at the next such\npoint.\n\nGo's goroutines aren't coroutines. They're functions running on a preemptive scheduler\nbacked by OS threads. The runtime multiplexes goroutines onto OS threads and can preempt\nthem, but it has no knowledge of application-level cancellation. There's no concept of a\n\"suspension point\" where the runtime can inject a cancellation signal. A goroutine doing\nCPU-bound work will keep running even if its context was cancelled. The goroutine has to\ncheck ctx.Done() explicitly via a select statement.\n\nHere's the cooperative pattern in Go:\n\n- (1) checks for cancellation on each iteration\n- (2) does a chunk of work, then loops back to the cancellation check\n\nAnd here's a goroutine that ignores cancellation:\n\nThis goroutine will keep running until the process exits, regardless of whether its context\nwas cancelled.\n\nPython and Kotlin workers also need to cooperate for cancellation to actually work. If a\ncoroutine does CPU-bound work without hitting an await or a suspension point, the runtime\ncan't interrupt it either.\n\nIn Python, a non-cooperative worker looks like this:\n\n- (1) no await anywhere, so the runtime never gets a chance to deliver cancellation\n\nTo make it cooperative, you insert an explicit cancellation check:\n\n- (1) await asyncio.sleep(0) yields control back to the event loop, giving it a chance to\n cancel this coroutine\n\nIn Kotlin, the same situation looks like this:\n\n- (1) no suspension point, so cancellation can't be delivered\n\nTo fix this, use coroutineContext.ensureActive() to check whether the coroutine's scope\nhas been cancelled:\n\n- (1) throws CancellationException if the scope has been cancelled\n\nThis isn't too different from what Go does with ctx.Done(). In all three languages, a\ntight loop doing CPU-bound work won't cancel unless the worker explicitly checks. The\ndifference is that in Python and Kotlin, most standard library functions (asyncio.sleep,\ndelay, channel operations) are cancellation points by default, so you hit them naturally\nin typical code.\n\nExplicit by design\n\nGo's concurrency model is built on CSP (Communicating Sequential Processes). Goroutines\ncommunicate via channels, not via structured scopes. The go statement is deliberately\nlow-level. It gives you a concurrent execution unit and gets out of your way.\n\nPython and Kotlin start from the structured side and require you to opt out. Python's\nasyncio.create_task outside a group, or Kotlin's supervisorScope, are the escape\nhatches. Go starts from the unstructured side and requires you to opt in. errgroup and\nWaitGroup are how you add structure. Different design priorities lead to different\ndefaults.\n\n[Go proposal #29011] was closed after Ian Lance Taylor pointed out the practical problem:\n\n> I think these ideas are definitely interesting. But your specific suggestion would break\n> essentially all existing Go code, so that is a non-starter.\n\nIn a later comment, he acknowledged that there are good ideas in the space but argued for\nimproving existing primitives rather than changing the language:\n\n> There are likely good ideas in the area of structured concurrency that we can do better\n> at, in the language or the standard library or both.\n\nNJS also noted that structured concurrency helps with error propagation, because when a\ngoroutine exits with an error, there is somewhere to propagate that error to. That's a real\nshortcoming of the current model. The response from the Go team was that errgroup,\ncontext, and WaitGroup already provide the building blocks, and language-level changes\nweren't justified given the cost.\n\nThere's also a [Trio forum discussion] on Go's situation. NJS was cautious about overstating\nthe benefits, noting that structured concurrency wouldn't have prevented about a quarter of\nthe concurrency bugs in a [study on real-world Go bugs] they examined (classic race\nconditions). But he pointed out that some of the hardest-to-understand bugs involved\nstandard library modules that spawned surprising background goroutines. That couldn't happen\nin a language with truly scoped concurrency. He also observed that all mistakes in using\nGo's WaitGroup API seemed like they'd be trivially prevented by structured concurrency.\n\nMaking Go's concurrency more structured\n\nIf you're writing Go and want structured concurrency, there are a few practices that help.\nThe core idea is:\n\n> Never start a goroutine without knowing when it will stop.\n>\n> -- Dave Cheney, [Practical Go]\n\nHere are some concrete ways to follow that:\n\n- Know the lifetime of every goroutine you spawn. Before writing go func(), you should\n be able to answer: what signals this goroutine to stop, and what waits for it to finish?\n If you can't answer both, the goroutine's lifetime is unknown and it can leak.\n\n- Use go func() sparingly. A bare go func() { ... }() sends a goroutine into the\n background with no handle to wait on it or cancel it. Prefer launching goroutines through\n errgroup or behind a WaitGroup so something always owns their lifetime.\n\n- Let the caller decide concurrency. If you're writing a library function, return a\n result instead of spawning a goroutine internally. Let the caller choose how to run it\n concurrently. This keeps goroutine lifetimes visible at the call site.\n\n- Pass context down, check it inside. Accept context.Context as the first parameter\n and check ctx.Done() in long-running loops or blocking operations. This is how the\n caller communicates \"I don't need this anymore.\"\n\nHere's what a well-structured goroutine launch pattern looks like:\n\n- (1) the group owns the goroutines and the context ties their lifetimes to the caller\n- (2) each goroutine is launched through the group, so Wait knows about it\n- (3) the actual work, which also receives the context for deeper cancellation\n- (4) all goroutines finish before this function returns\n\nEvery goroutine has a clear owner and exit condition. If any task fails, the context cancels\nand the others observe it on their next check.\n\nCatching mistakes\n\nSince Go doesn't enforce structured concurrency at the language level, it's possible to leak\ngoroutines or miss cancellation signals. I wrote about one common case in [early return and\ngoroutine leak].\n\nThere are a few tools that help catch these issues:\n\n- [goleak] is a library from Uber that you add to TestMain. It checks that no goroutines\n are still running when your tests finish. It's useful for catching the \"forgot to cancel\"\n class of bugs, which is the most common way unstructured goroutines cause trouble.\n- The race detector (go test -race) catches data races between goroutines. It won't catch\n leaks, but unstructured goroutines with unclear lifetimes are more likely to race because\n their access to shared state is harder to reason about.\n- [testing/synctest] (Go 1.24+) lets you test concurrent code with fake time. You can verify\n that goroutines exit when their context cancels or their parent scope ends, without\n relying on real time.Sleep calls that make tests slow and flaky.\n- Go 1.26 adds an experimental [goroutine leak profile] via runtime/pprof. It uses the\n garbage collector's reachability analysis to find goroutines permanently blocked on\n synchronization primitives that no runnable goroutine can reach. Unlike goleak, which\n only works in tests, this profile can be collected from a running program via\n /debug/pprof/goroutineleak, making it useful for finding leaks in production.\n\nClosing words\n\nIf you're coming from languages like Python or Kotlin, Go's concurrency can feel overly\nverbose, and it is. Wiring up errgroup, checking ctx.Done() in every goroutine, guarding\nshared state with a mutex around a WaitGroup; that's a lot of ceremony for something the\nother languages hand you for free.\n\nBut as covered earlier, the concurrency paradigms are fundamentally different. Python and\nKotlin's cooperative runtimes can own the cancellation because they own the scheduling. Go's\npreemptive scheduler doesn't know what your goroutine is doing or when it should stop.\nThat's why cancellation is your job.\n\nThe same structured patterns are all achievable in Go. You just build them yourself out of\nerrgroup, WaitGroup, context, and channels. That gives you more control over goroutine\nlifetimes, but it also means more surface area for bugs. Forget a ctx.Done() check and a\ngoroutine leaks. Misuse a WaitGroup and you deadlock. The [study on real-world Go bugs]\nfound 171 concurrency bugs across projects like Docker and Kubernetes, with more than half\ncaused by Go-specific issues around message passing and goroutine management.\n\n\n\n\n[asyncio.TaskGroup]:\n https://docs.python.org/3/library/asyncio-task.html#asyncio.TaskGroup\n\n[coroutineScope]:\n https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/coroutine-scope.html\n\n[supervisorScope]:\n https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/supervisor-scope.html\n\n[colored functions]:\n https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/\n\n[Go To Statement Considered Harmful]:\n https://homepages.cwi.nl/~storm/teaching/reader/Dijkstra68.pdf\n\n[Structured Concurrency (Niebler)]:\n https://ericniebler.com/2020/11/08/structured-concurrency/\n\n[Structured Concurrency]:\n https://www.250bpm.com/p/structured-concurrency\n\n[Notes on structured concurrency]:\n https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/\n\n[Go proposal #29011]:\n https://github.com/golang/go/issues/29011\n\n[Trio forum discussion]:\n https://trio.discourse.group/t/structured-concurrency-in-golang/174\n\n[study on real-world Go bugs]:\n https://songlh.github.io/paper/go-study.pdf\n\n[Practical Go]:\n https://dave.cheney.net/practical-go/presentations/qcon-china.html#_never_start_a_goroutine_without_knowning_when_it_will_stop\n\n[early return and goroutine leak]:\n /go/early-return-and-goroutine-leak/\n\n[goleak]:\n https://github.com/uber-go/goleak\n\n[testing/synctest]:\n https://pkg.go.dev/testing/synctest\n\n[goroutine leak profile]:\n https://go.dev/doc/go1.26#goroutineleakprofile",
"title": "Structured concurrency & Go"
}