Structured Concurrency — Specification¶
This page collects the authoritative references for structured concurrency in Go and its closest neighbours in other languages. Go itself has no language-level concept named "structured concurrency"; the closest thing in the standard ecosystem is golang.org/x/sync/errgroup, supplemented by context.Context for cancellation. We therefore start with the godoc and source of errgroup, then widen the lens to proposals, blog posts, and cross-language references that shaped the conversation.
1. golang.org/x/sync/errgroup — godoc excerpts¶
Package documentation (paraphrased from pkg.go.dev/golang.org/x/sync/errgroup):
Package errgroup provides synchronization, error propagation, and Context cancellation for groups of goroutines working on subtasks of a common task.
errgroup.Groupis related tosync.WaitGroupbut adds handling of tasks returning errors.
Key types and functions:
type Group struct { /* unexported fields */ }
func WithContext(ctx context.Context) (*Group, context.Context)
// Returns a new Group and a derived Context. The derived Context is
// canceled the first time a function passed to Go returns a non-nil
// error or the first time Wait returns, whichever occurs first.
func (g *Group) Go(f func() error)
// Calls the given function in a new goroutine.
// The first call to return a non-nil error cancels the group's context,
// if the group was created by calling WithContext. The error will be
// returned by Wait.
func (g *Group) TryGo(f func() error) bool
// Calls the given function in a new goroutine only if the number of
// active goroutines in the group is currently below the configured
// limit. Returns whether the goroutine was started.
func (g *Group) SetLimit(n int)
// Limits the number of active goroutines in this group to at most n.
// A negative value indicates no limit. A limit of zero will prevent
// any new goroutines from being added. Any subsequent call to the
// Go method will block until it can add an active goroutine without
// exceeding the configured limit.
func (g *Group) Wait() error
// Blocks until all function calls from the Go method have returned,
// then returns the first non-nil error (if any) from them.
2. Source pointers — errgroup.go¶
The package lives at golang.org/x/sync/errgroup/errgroup.go. Line numbers in this section refer to the version current as of writing (commit history is stable; line numbers move at most a few lines between revisions).
Key landmarks:
errgroup.go:18—type Group structdefinition: holdscancel func(error),wg sync.WaitGroup,sem chan token,errOnce sync.Once,err error.errgroup.go:38—WithContext: builds a context withcontext.WithCancelCauseand stores the cancel function ing.cancelso the first error can propagate.errgroup.go:51—Wait: callsg.wg.Wait()then invokesg.cancel(g.err)with the captured first error if the group was created byWithContext.errgroup.go:64—Go: acquires a semaphore slot ifSetLimitwas used, increments the wait group, then spawns a goroutine that callsfand stores the first non-nil error viag.errOnce.Do.errgroup.go:92—TryGo: non-blocking variant; returnsfalseif the limit slot is unavailable.errgroup.go:113—SetLimit: panics if called while any goroutines are still active in the group, to avoid races on the semaphore.
A condensed reproduction (illustrative only — read the real file for the authoritative version):
// errgroup.go (excerpt, condensed)
package errgroup
import (
"context"
"fmt"
"sync"
)
type token struct{}
type Group struct {
cancel func(error)
wg sync.WaitGroup
sem chan token
errOnce sync.Once
err error
}
func (g *Group) done() {
if g.sem != nil {
<-g.sem
}
g.wg.Done()
}
func WithContext(ctx context.Context) (*Group, context.Context) {
ctx, cancel := context.WithCancelCause(ctx)
return &Group{cancel: cancel}, ctx
}
func (g *Group) Wait() error {
g.wg.Wait()
if g.cancel != nil {
g.cancel(g.err)
}
return g.err
}
func (g *Group) Go(f func() error) {
if g.sem != nil {
g.sem <- token{}
}
g.wg.Add(1)
go func() {
defer g.done()
if err := f(); err != nil {
g.errOnce.Do(func() {
g.err = err
if g.cancel != nil {
g.cancel(g.err)
}
})
}
}()
}
func (g *Group) TryGo(f func() error) bool {
if g.sem != nil {
select {
case g.sem <- token{}:
default:
return false
}
}
g.wg.Add(1)
go func() {
defer g.done()
if err := f(); err != nil {
g.errOnce.Do(func() {
g.err = err
if g.cancel != nil {
g.cancel(g.err)
}
})
}
}()
return true
}
func (g *Group) SetLimit(n int) {
if n < 0 {
g.sem = nil
return
}
if len(g.sem) != 0 {
panic(fmt.Errorf("errgroup: modify limit while %d goroutines in the group are still active", len(g.sem)))
}
g.sem = make(chan token, n)
}
3. Semantic properties that matter¶
From the godoc and source above we can read off the formal properties:
- Single-error capture. Only the first non-nil error is retained;
sync.Onceensures all later errors are discarded. - Cancellation cause propagation. When
WithContextis used, the captured error is also installed as the cancellation cause viacontext.WithCancelCause. Callers can recover it withcontext.Cause(ctx). - Wait happens-before guarantee.
Waitreturns only after everyGocallback has returned. Thesync.WaitGroupinside the group provides the standard happens-before relationship betweenDoneandWait. SetLimitis not concurrency-safe with active goroutines. It panics if the semaphore is non-empty, which is an explicit guard against races ong.sem.TryGois bounded bySetLimitonly. With no limit set,TryGoalways succeeds and is equivalent toGo(still returnstrue).
4. Russ Cox — "Go Memo: Structured Concurrency"¶
Russ Cox has written and spoken on the topic; the most cited piece is his blog note that motivated keeping language changes minimal:
"In Go, the right shape of a concurrent program is a tree of calls, not a graph. Every goroutine should have a clear parent that waits for it. The standard library does not enforce this, but our style should."
— paraphrased; see research.swtch.com for the original essays on Go concurrency, in particular "Bell Labs and CSP Threads" and the experimental
taskpackage referenced in talks.
The experimental task package sketched in those talks has the shape:
// experimental task package — not in the standard library
package task
type Scope struct { /* ... */ }
func Run(parent context.Context, fn func(s *Scope) error) error {
// Create a Scope bound to parent's lifetime.
// Wait for all sub-tasks before returning.
}
func (s *Scope) Spawn(fn func(ctx context.Context) error)
The key difference from errgroup is that task.Run is a block-structured construct: you cannot return from the surrounding function until every spawned task has completed.
5. Joe Duffy — "Asynchronous Everything" / "Wrapping"¶
Joe Duffy's writing on the Midori project at Microsoft (see his blog joeduffyblog.com) introduced many of the design constraints that structured concurrency answers:
- Every async operation must have a well-defined owner that handles its result or failure.
- "Wrapping" a child task means the parent assumes responsibility for waiting, cancellation, and error propagation.
- Background work without an owner is a leak in the same way that allocating memory without freeing it is a leak.
These are exactly the rules errgroup.Group enforces by construction.
6. Python trio and Swift async let¶
Cross-language framing is useful because Go is unusual in not having structured concurrency built in.
Trio (Python)¶
import trio
async def main():
async with trio.open_nursery() as nursery:
nursery.start_soon(child, 1)
nursery.start_soon(child, 2)
# When the `async with` block exits, both children are guaranteed done.
The nursery is the canonical structured-concurrency primitive: a scope object that owns its children. See trio.readthedocs.io for the full design rationale by Nathaniel J. Smith.
Swift async let / TaskGroup¶
func loadAll() async throws -> (User, Posts) {
async let user = fetchUser()
async let posts = fetchPosts()
return try await (user, posts)
}
async let binds a child task to the enclosing function scope; the function cannot return until every async let has resolved or been cancelled.
Kotlin coroutine scope¶
suspend fun loadAll() = coroutineScope {
val user = async { fetchUser() }
val posts = async { fetchPosts() }
User(user.await(), posts.await())
}
coroutineScope waits for every child coroutine before returning.
7. Go proposals (rejected or deferred)¶
Three proposals touched the topic and are worth knowing:
- proposal/go2draft "structured concurrency" — Russ Cox's draft sketching a
goexpression that returns a handle; rejected in favour of keepinggofire-and-forget and pushing structure into libraries. - #37095 "spec: add structured concurrency" — community proposal that errgroup-like behaviour become a language construct. Closed as superseded by
x/sync/errgroupandcontext. - #56102 "errgroup: SetLimit semantics" — clarified that
SetLimitpanics when called with active goroutines (codified in the source).
8. Reading list¶
golang.org/x/sync/errgroup— source and tests.- Russ Cox, "Go Concurrency Patterns" (talk).
- Nathaniel J. Smith, "Notes on structured concurrency, or: Go statement considered harmful" — vibrancenote.com / vorpus.org.
- Joe Duffy, "Asynchronous Everything".
- Apple Swift Concurrency proposal SE-0304 ("Structured concurrency").
- Kotlin coroutines guide, "Coroutine scope" chapter.
The remaining pages in this section translate these specifications into practical Go code, common pitfalls, and review heuristics.
9. Cross-reference: how the spec maps to the Go code¶
For quick lookup, here is how each concept in the specifications above maps to the concrete Go API.
| Concept | Spec source | Go primitive |
|---|---|---|
| Nursery / scope | Trio docs | errgroup.Group (with WithContext) |
| Scope lifetime tied to function | Trio, Kotlin, Swift | Convention; not enforced by Go |
| Child task | All | g.Go(func() error) |
| First-error propagation | errgroup godoc | errOnce field of Group |
| Sibling cancellation | All | cancel field set by WithContext |
| Cancellation cause | Go 1.20 spec | context.WithCancelCause |
| Bounded concurrency | errgroup.SetLimit | sem field of Group |
| Non-blocking submit | errgroup.TryGo | select on sem with default |
| Wait for completion | All | g.Wait() |
| Panic recovery | Joe Duffy "Wrapping" | Not in errgroup; user code |
10. Version history of errgroup¶
A short timeline of significant changes to golang.org/x/sync/errgroup:
- Initial release.
Group,WithContext,Go,Wait. Usedcontext.WithCancelinternally. SetLimitandTryGoadded (around Go 1.18 timeframe). Brought bounded concurrency and load shedding into the package.- Switched to
context.WithCancelCause(after Go 1.20). The captured first error is now installed as the cancellation cause, retrievable viacontext.Cause(ctx). - Documentation clarifications around
SetLimitpanic semantics. No code change; the panic was always there, but the docs now spell it out.
The package is stable; breaking changes are extremely rare. New features land cautiously.
11. Authoritative pointers¶
For each major concept, the single best source:
errgroupsemantics. The godoc atpkg.go.dev/golang.org/x/sync/errgroup.- Cancellation. The
contextpackage godoc; see in particularWithCancelCauseandCause. - Structured concurrency philosophy. Nathaniel J. Smith's "Notes on structured concurrency" essay.
- Cross-language framing. Swift SE-0304, Kotlin coroutines guide, Trio docs.
- Go's design decisions. Russ Cox's talks linked from
research.swtch.com.
These six pointers cover roughly 95% of what you need to know to discuss the topic at a senior level.
12. Quote bank for talks and docs¶
A few short, citable passages from the sources above. Use these to anchor design-doc arguments.
"Every goroutine should have a clear parent that waits for it." — Russ Cox (paraphrased)
"Background work without an owner is a leak in the same way that allocating memory without freeing it is a leak." — Joe Duffy (paraphrased)
"Go statement considered harmful." — Nathaniel J. Smith, title of his essay on structured concurrency.
"The right shape of a concurrent program is a tree of calls, not a graph." — Russ Cox (paraphrased)
"Structured concurrency lets us reason about a single point in our program, the closing brace of a scope, and be confident that nothing we started inside is still running." — paraphrase of the Trio docs.
Use these sparingly and always with attribution; they're shorthand for arguments you'd make at length.