group

package
v0.1.32 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 12, 2026 License: MIT Imports: 5 Imported by: 0

README

group

Structured concurrency for the SDK kernel — stdlib-only, no domain vocabulary. Run N tasks, wait at one point, get the first error, and receive a panic raised in a child goroutine instead of losing the process to it.

tasks, ctx := group.New(parent, 4) // at most 4 at a time

for _, url := range urls {
    tasks.Go(func(ctx context.Context) error {
        return fetch(ctx, url)
    })
}

if err := tasks.Wait(); err != nil { // the FIRST failure; the rest were cancelled
    return err
}

Collect is the typed form — results in submission order, whatever order the tasks finished in:

sizes, err := group.Collect(ctx, group.Unlimited, probes)

Three edges worth knowing before you use it:

  • A panic in a task is re-raised in Wait as a PanicValue carrying the stack of the goroutine that actually failed — after every sibling has returned, so no task outlives Wait even on the fault path.
  • A non-positive limit means one, not none. Pass group.Unlimited for no bound; a zero that silently meant "unbounded" — or "nothing may run" — is the inert policy ADR 0031 removed from this SDK.
  • Wait waits for tasks to RETURN, not for the context to be cancelled. A task that ignores cancellation keeps Wait blocked. That is the guarantee: returning while goroutines are still live is the leak this exists to prevent.

See CLAUDE.md for the design, and BENCH.md for the measured threshold below which a group costs more than it saves.

Documentation

Overview

Package group — the typed fan-out built on the Group.

Package group runs a set of tasks concurrently and gives their caller one place to wait: one first error, one bounded degree of parallelism, and — the part the familiar shape leaves out — the panic that a child goroutine would otherwise take the whole process down with. It is a kernel primitive (stdlib-only, fully domain-neutral: Group, Go, Wait, Collect; no Job, no Task, no Worker appears in a signature).

A panic in a child is delivered, not fatal

A goroutine that panics and is not recovered ON ITS OWN STACK kills the process. No amount of recover() in the parent helps, which is why the common "spawn N goroutines, collect errors" helper turns one bad input into a crash with a stack pointing at a goroutine the reader has never seen. Here the recovery happens inside the child, together with the stack of the goroutine that actually failed, and is re-raised in Group.Wait as a PanicValue.

The re-raise happens AFTER every other task has returned, so the structured guarantee survives the fault: when Wait leaves — by return or by panic — the group owns no running goroutine.

What Wait guarantees, and what it cannot

Wait returns when every task started by Group.Go has RETURNED. It does not return when the context is cancelled, because cancellation is a request and Go has no way to force a goroutine to honour one.

So a task that ignores its context keeps Wait blocked for as long as it runs, and that is the contract rather than a defect: the alternative — returning while goroutines are still live — is precisely the leak structured concurrency exists to prevent, and it would hand the caller a "finished" group that is still writing to their memory. Bound such a task from the inside (a deadline on the I/O it performs). A group cannot bound it from the outside without abandoning it.

Limits

The concurrency limit is a real bound taken before the goroutine is created, so N submissions against a limit of L hold L goroutines, not N. A non-positive limit is clamped to 1 and Unlimited must be spelled out — see New.

Package group — the panic carried off a task's goroutine and re-raised in the waiter.

Index

Constants

View Source
const Unlimited int = math.MaxInt

Unlimited is the concurrency limit that bounds nothing: a slot is always free, so Group.Go never waits for one.

It has to be written out. A zero that silently meant "no limit" would be a value the caller never chose doing something they never asked for, and a zero that meant "no task may run" is the deadlock ADR 0031 exists to remove from this SDK — errgroup's SetLimit(0) parks every Go call forever. Naming the case leaves neither reading available to a typo.

Variables

This section is empty.

Functions

func Collect

func Collect[T any](parent context.Context, limit int, fns []func(ctx context.Context) (value T, err error)) (results []T, err error)

Collect runs every function in fns concurrently under a Group and returns their results in SUBMISSION order: index i of the result is fns[i]'s value, whatever order the tasks actually finished in.

limit is New's, with the same clamp and the same Unlimited spelling.

It is a function rather than a method because Go's methods take no type parameters of their own: a Group that both bounded concurrency and carried a result type would have to fix that type at construction, forcing Group[struct{}] on every caller who only wants to wait. Keeping the result type here leaves the common shape untyped and this one exact.

On the first error the remaining tasks see a cancelled context and Collect returns (nil, err). No partial slice is handed back: a half-filled result is indistinguishable at the call site from a complete one, and the zero values in it would read as answers.

A panic in any fn propagates out of Collect exactly as it does out of Group.Wait — after every other task has returned, carrying the failing goroutine's stack.

Types

type Group

type Group struct {
	// contains filtered or unexported fields
}

Group runs tasks concurrently under one context and one wait point.

The zero value is NOT usable — construct with New, which hands back the context the tasks receive alongside it. A Group is used once: after [Wait] has returned, submit to a new one.

[Go] may be called from any goroutine. [Wait] may not run concurrently with [Go] — "have all submissions been made" is a question only the submitter can answer, and a Group that guessed would report a total that was true for an instant.

func New

func New(parent context.Context, limit int) (*Group, context.Context)

New returns a Group and the context every task submitted to it receives.

limit bounds how many tasks run at once. A non-positive limit is CLAMPED to 1 — one task at a time, slow but correct — and is read neither as "no limit" nor as "no task may run"; pass Unlimited for the former, and note that the latter is what makes a zero limit a deadlock elsewhere. The clamp follows the SDK's own bulkhead precedent (ADR 0031 §"Why not extend this to the other clamps"): a concurrency floor of one still runs the caller's work under a meaningful, if minimal, policy, so it is a floor rather than a guess at intent.

The returned context is cancelled when the first task fails, when a task panics, or when Group.Wait returns — whichever happens first. On a task failure that task's error is the context's context.Cause.

func (*Group) Go

func (g *Group) Go(fn func(ctx context.Context) error)

Go starts fn on a goroutine of its own, blocking while the group already has its limit of tasks running.

fn receives the group's context. A task submitted after the group has already failed still RUNS, with an already-cancelled context that a cooperative task returns from at once. Skipping it silently would be the worse half of the trade — a task that never ran and never said so.

Go must not be called after Group.Wait.

func (*Group) Wait

func (g *Group) Wait() error

Wait blocks until every task started by Group.Go has returned, releases the group context, and returns the first error any task reported.

If a task panicked, Wait re-raises it as a PanicValue carrying the stack of the goroutine that failed — after every other task has finished, so no task outlives Wait on the panic path either. A panic outranks an error: an error is an outcome the caller can handle, a panic says the result is not one.

Wait does NOT return early on cancellation. See the package documentation for what that guarantees and what it costs.

type PanicValue

type PanicValue struct {
	// Raised is the value the original panic carried, verbatim.
	Raised any
	// Stack is the stack of the goroutine that ran the task, captured at
	// recovery.
	Stack []byte
}

PanicValue carries a panic raised inside a task across the goroutine boundary to whoever is waiting on the group.

It is deliberately NOT an error. A panic is a programming fault, and turning one into a value the caller may ignore is how a broken invariant becomes a silent wrong answer — the same position kernel/singleflight takes, and the reason neither type carries an error code.

The stack is captured at the moment of recovery, inside the goroutine that ran the task. Without it a re-raised panic would point at the waiter's stack — code that did nothing wrong — and the frame that actually failed would be gone, which is the failure mode that makes a crashed worker pool so expensive to debug.

It is a separate type from singleflight's namesake rather than a shared one: each names its own package in the message a crash prints, and hoisting the two into a fourth kernel package would add a primitive whose entire content is two fields.

func (PanicValue) String

func (p PanicValue) String() string

String renders the original panic followed by the stack of the goroutine that raised it. The Go runtime prints a panic value through String when the value has one, so an uncaught re-raise shows the task's stack rather than only the waiter's.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL