TaskGroup 0.1.1

dotnet add package TaskGroup --version 0.1.1
                    
NuGet\Install-Package TaskGroup -Version 0.1.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="TaskGroup" Version="0.1.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="TaskGroup" Version="0.1.1" />
                    
Directory.Packages.props
<PackageReference Include="TaskGroup" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add TaskGroup --version 0.1.1
                    
#r "nuget: TaskGroup, 0.1.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package TaskGroup@0.1.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=TaskGroup&version=0.1.1
                    
Install as a Cake Addin
#tool nuget:?package=TaskGroup&version=0.1.1
                    
Install as a Cake Tool

TaskGroup

Structured concurrency for .NET, mirroring Go's golang.org/x/sync: a TaskGroup with errgroup semantics (heterogeneous spawn, first error cancels siblings, aggregate at join, bounded concurrency) and a SingleFlight for duplicate-call suppression. Zero external dependencies.

Every major ecosystem has shipped this primitive:

Ecosystem Primitive Status
Go errgroup + singleflight (golang.org/x/sync) errgroup imported by 30,000+ packages; singleflight underpins the Go caching ecosystem
Java StructuredTaskScope Shipped in JDK 21
Python asyncio.TaskGroup Shipped in 3.11
Swift TaskGroup / withThrowingTaskGroup Language-level
Kotlin coroutineScope Language-level
.NET nothing Parallel.ForEachAsync covers homogeneous loops only; a BCL TaskGroup has been discussed for years with nothing on the roadmap

The gap this fills: spawning heterogeneous async work where the first failure cancels the siblings and the join rethrows that failure is hand-rolled CancellationTokenSource wiring in every .NET codebase that needs it, and the widely copied ConcurrentDictionary + Lazy<Task> single-flight blog pattern has known cancellation and exception-caching bugs. Both primitives here are small, deliberate ports of the Go semantics, with the sharp edges tested and documented.

string? profile = null;
string? orders = null;
string? recommendations = null;

await using var group = new TaskGroup();

group.Run(async ct => profile = await FetchAsync("/profile", ct));
group.Run(async ct => orders = await FetchAsync("/orders", ct));
group.Run(async ct => recommendations = await FetchAsync("/recommendations", ct));

await group.WaitAsync();

If /orders throws, the other two fetches are cancelled through ct, WaitAsync waits for them to finish unwinding, and then rethrows the /orders exception with its original stack trace. No AggregateException, no leaked tasks, no unobserved exceptions.

Install

dotnet add package TaskGroup

The namespace is TaskGroups (plural), not TaskGroup: a namespace named after its own central class would make TaskGroup group unwritable without aliases. using TaskGroups; once, then the types read clean.

TaskGroup

Semantics reference

These are the errgroup rules, stated precisely, because concurrency APIs live or die on their edge cases:

  • Spawning. Run spawns the delegate immediately on the thread pool and passes the group's linked Token. Work never runs inline on the calling thread. Run is callable from any thread, including from inside group tasks (dynamic spawning), for as long as the group is active.
  • First error wins. The first fault cancels group.Token. WaitAsync waits for every task, including tasks spawned after the fault, then rethrows the first fault, same instance, original stack trace. It never throws AggregateException. Later faults are not lost: group.Exceptions is a snapshot of every recorded fault in completion order.
  • Cancellation is not an error, unless it is. An OperationCanceledException thrown while group.Token is already cancelled is cooperative unwinding and is discarded (observed, never rethrown). An OperationCanceledException thrown while the group is not cancelled means something outside the group cancelled your work; it is a real fault and wins first-fault like any other exception.
  • External cancellation. Pass a token to the constructor and it links into group.Token. If no task faulted but the group was cancelled, WaitAsync throws OperationCanceledException after all tasks finish, even if every task completed successfully before noticing. Divergence from Go, on purpose: Go's Wait cancels the context even on success to release it; here a successful WaitAsync leaves Token uncancelled and DisposeAsync releases the resources instead.
  • After the join. Once the group quiesces (a WaitAsync completed with no work left) it is done: further Run calls throw InvalidOperationException. Groups are one-shot by design; this is stricter than Go, whose errgroup is reusable after Wait (see the divergences list below).
  • Disposal. await using is the intended shape. If you dispose without ever calling WaitAsync, disposal cancels the group, waits for every task to finish, observes every exception, and does not throw; the faults are still readable from Exceptions, but WaitAsync is the intended path and disposal-as-completion should be treated as a bug in the caller. Disposal is idempotent. A Run or TryRun racing DisposeAsync, including from inside a group task, throws ObjectDisposedException the moment disposal starts: the spawn is rejected safely and nothing leaks. Every exception is always observed internally, so no usage pattern, including abandoning a faulted group without waiting or disposing, ever raises TaskScheduler.UnobservedTaskException. The test suite proves this by hooking the event, running the pathological patterns, forcing garbage collection, and asserting zero events.
  • Always dispose a group built on an external token. When you pass a token to the constructor, the group registers on it; completing with WaitAsync alone does not release that registration, which then lives for the external token's lifetime (measured at roughly 143 bytes per group). await using or an explicit DisposeAsync releases it. A group on the default token holds no such registration and does not leak.
  • Disposal waits for in-flight tasks, so a blocked Run hangs it. Because DisposeAsync waits for every task to finish and cancelling the group token does not unblock a synchronous Run stuck on a saturated limit, that blocked Run keeps DisposeAsync from ever completing. Spawn with RunAsync (or TryRun) to avoid ever blocking a thread on the limit; RunAsync makes this trap unreachable, because its slot wait is cancelled by the same token disposal cancels.

Bounded concurrency: SetLimit, and the deadlock traps

SetLimit(n) bounds concurrently running tasks, mirroring errgroup: it must be called before the first spawn, a negative value removes the limit, and zero blocks all spawning. At the limit, Run blocks the calling thread until a slot frees, exactly like Go's Go:

await using var group = new TaskGroup();
group.SetLimit(4);

foreach (string id in customerIds)
{
    group.Run(ct => ProcessAsync(id, ct)); // blocks here, never inside a group task
}

await group.WaitAsync();

The first trap, loudly: because Run blocks at the limit, calling Run from inside a group task can deadlock. If every slot is held by tasks that are themselves blocked in Run, no slot ever frees, and the group never completes. Go has exactly the same trap and this library mirrors it rather than papering over it, because silently queueing would change the backpressure semantics. The suite contains a timed test proving the deadlock behaves as documented.

The safe alternative from inside a group task is TryRun, which never blocks and returns false when no slot is available (Go's TryGo):

group.Run(async ct =>
{
    // At the limit, from inside a group task: never call Run here.
    spawnedMore = group.TryRun(async inner => await Task.Yield());
    await Task.Yield();
});

The second trap, just as loudly: pool starvation. When a limit is set, do not call the blocking Run from thread-pool threads (Task.Run bodies, ASP.NET request handlers, timer callbacks) while group tasks need the pool to complete. Blocked pool threads starve the very continuations that would free slots - up to permanent deadlock on capped pools, and multi-second stalls on default pools that recover only at thread-injection speed. Use TryRun with retry, or spawn from a dedicated non-pool thread. The shape to avoid:

group.SetLimit(2);                          // both slots held by tasks awaiting a signal
for (int i = 0; i < 8; i++)
    _ = Task.Run(() => group.Run(work));    // eight pool threads now block inside Run
// the slot-holders' continuations have no pool thread left to resume on: deadlock

The thread that will later call WaitAsync holds no slot, so spawning from it cannot hit the first trap, but the pool-starvation trap above still applies if it is a pool thread.

Non-blocking spawn: RunAsync

RunAsync is the structural fix for both traps. At a saturated limit it awaits a free slot with SemaphoreSlim.WaitAsync on the group token instead of blocking the calling thread, so no thread is ever pinned. await-ing it gives you the same backpressure the blocking Run gives you, without the thread cost:

await using var group = new TaskGroup();
group.SetLimit(4);

foreach (string id in customerIds)
{
    await group.RunAsync(ct => ProcessAsync(id, ct)); // awaits a slot, never blocks a thread
}

await group.WaitAsync();

The returned task completes once the work has been admitted (a slot acquired, or immediately when no limit is set) and dispatched. The work's outcome still flows through WaitAsync exactly as with Run: same first-error-wins, same fault recording, same accounting. Neither trap can occur: no caller thread is ever blocked, so a group task can spawn with RunAsync without pinning a thread (self-deadlock gone), and pool threads are never pinned inside the spawn (pool starvation gone).

Slot acquisition observes the group token. If the group is cancelled, faulted, or disposed while a caller is still awaiting a slot, that admission is usually cancelled: the work is not spawned and the awaited RunAsync throws OperationCanceledException. This is local to that caller, never recorded as a group fault and never cancelling siblings; it just tells the caller the group is shutting down. It is not guaranteed, though: SemaphoreSlim.WaitAsync has an inherent race where a slot freed at the same instant cancellation fires can still be handed to a waiter, so an admission may occasionally be admitted and run to completion instead of throwing. Either way the outcome is safe: because the wait is cancellable, DisposeAsync still drains and completes cleanly even while callers are awaiting a slot (never hanging, no group fault, no unobserved exception), and the "disposal hangs on a blocked Run" trap below is unreachable with RunAsync. The optional runToken, when cancellable, is linked with the group token for the slot wait only, so a caller can abandon its own wait for a slot without cancelling the group; the work delegate always receives the group Token.

Results

Run takes Func<CancellationToken, Task> and results flow through captured variables, mirroring errgroup's error-only contract. This is deliberate for v0.1: it keeps the join semantics singular (one first-error, one completion) instead of splitting them across per-task result handles. A result-bearing TaskGroup<T> is on the roadmap.

There is no Func<CancellationToken, ValueTask> overload, also deliberately: overloading on Task and ValueTask delegates makes every group.Run(async ct => ...) call site a CS0121 ambiguity error. If your work is a ValueTask-returning method, wrap it: group.Run(ct => DoWorkAsync(ct).AsTask()).

Divergences from Go, all deliberate

  • Groups are one-shot. Once a group quiesces, further spawns throw InvalidOperationException. Go's errgroup is reusable after Wait; a fresh TaskGroup is a one-line allocation, and one-shot groups make the quiesce transition deterministic instead of racy.
  • SetLimit is once, before any spawn attempt, ever. This includes after a rejected TryRun. Go allows SetLimit whenever no goroutines are currently active; the stricter rule here removes a class of limit-swap races.
  • A successful WaitAsync leaves Token uncancelled. Go's Wait cancels the group context even on success, to release it; here DisposeAsync releases the resources instead, so a success is distinguishable from a cancellation.
  • External cancellation surfaces. If the group was cancelled and no task faulted, WaitAsync throws OperationCanceledException even when every task completed successfully. Go's Wait returns nil unless a goroutine returned the context error.

SingleFlight

var flights = new SingleFlight<string, string>();

Task<string> GetProfileAsync(string userId, CancellationToken ct) =>
    flights.RunAsync(userId, token => LoadProfileAsync(userId, token), ct);

A hundred concurrent GetProfileAsync("user-7", ...) calls make exactly one LoadProfileAsync call, and all hundred callers receive its result. That is cache-stampede protection at the source.

What it is not

SingleFlight is not a cache, and this matters. Nothing is retained after a flight completes: the very next call starts a fresh execution. It deduplicates concurrent work only. If you want the result kept warm afterwards, put a cache in front (FusionCache, HybridCache, or a plain IMemoryCache); SingleFlight then protects the cache-miss path. FusionCache bundles its own stampede protection with caching; this library gives you the primitive standalone, Go-shaped, for when you do not want a caching layer's policy surface.

Semantics reference

  • One flight per key. Concurrent RunAsync calls with the same key (default or custom IEqualityComparer<TKey>) share one factory execution. The result fans out to every waiter.
  • Exception fan-out, no exception caching. A factory exception propagates to every current waiter as the same exception instance. The next call after the failure runs the factory again.
  • Only flight-token cancellation counts as cancellation. An OperationCanceledException the factory throws counts as a cancellation only when it is observed while the flight token is cancelled (that is, after the last waiter has left). Any other factory OperationCanceledException, for example one from an unrelated token or thrown while waiters remain, is a fault: it fans out to every current waiter as the same instance, exactly like any other exception, and the next call retries.
  • Per-caller cancellation. A caller's token cancels only that caller's wait; the flight keeps running for the others. The factory's token fires only when the last remaining waiter has cancelled, implemented by reference-counting waiters. Callers with non-cancellable tokens count as permanent waiters. Three callers, two cancel: the flight continues and the third gets the result. All three cancel: the factory token fires.
  • Forget(key) detaches the current flight, Go-style: waiters already on it still complete with its outcome, and the next RunAsync starts fresh.
  • Completed and pre-faulted flights are joined on a synchronous fast path (the shared task is returned directly, no wrapper allocation), as are callers whose token cannot be cancelled.

Allocation notes, honestly

Concurrency primitives allocate; this library does not pretend otherwise.

  • Each TaskGroup allocates one linked CancellationTokenSource, one TaskCompletionSource, one lock object, a fault list, and (only if SetLimit is used) one SemaphoreSlim. When the group is built on an external token, that linked source registers on it; completing with WaitAsync alone and never disposing leaves the registration alive for the external token's lifetime (about 143 bytes per group). await using/DisposeAsync releases it; the default-token path holds no registration.
  • Each RunAsync awaiting a slot at a saturated limit allocates its async state machine, plus one linked CancellationTokenSource only when a cancellable runToken is supplied (disposed as soon as the slot is acquired or the wait is cancelled).
  • Each Run allocates the async wrapper state machine plus the Task.Run dispatch that guarantees work never runs inline.
  • Each SingleFlight flight allocates one CancellationTokenSource and one TaskCompletionSource<TResult>; each cancellable waiter allocates one wrapper state machine. Flight sources are cancelled but not disposed, which is safe for timerless sources; they are unreachable as soon as the flight is forgotten or complete.
  • Sync-completion fast paths avoid the per-caller wrapper where possible, and ConfigureAwait(false) is used throughout.

There is no zero-allocation claim here because it would be false.

Testing

The suite (109 tests) is deterministic: coordination is done with TaskCompletionSource choreography, so no positive outcome depends on a sleep. The only Task.Delay uses are in negative-assertion timeout tests (a task must not complete within a window), which are robust because they never race a real outcome. It includes ports of the semantics from Go's errgroup_test.go (zero groups, first-error-wins, WithContext cancellation, TryGo, GoLimit), thousand-caller SingleFlight storms asserting exactly one execution, seeded random cancellation storms asserting the flight-token invariants, RunAsync non-blocking and disposal-safety coverage, and the unobserved-exception proof described above. Every code sample in this README compiles and runs as pasted, enforced by the suite.

Roadmap

  • RunAsync: asynchronous slot acquisition under SetLimit, the structural fix for both blocking-Run traps (no blocked caller thread, so neither self-deadlock nor pool starvation can occur). Shipped in 0.1.1.
  • TaskGroup<T>: a result-bearing group once the right join shape is settled.
  • AsyncLock and AsyncLazy successor primitives, kept out of v0.1 to keep the Go mirror tight.
  • .NET Framework / netstandard2.0 multi-targeting if there is demand.

License

MIT. See LICENSE.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net8.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.1 101 8/7/2026
0.1.0 84 8/5/2026