On this page

17. Async, iteration and concurrency

17.1 Async action contracts

CS-ASYNC-001. An action may declare its asynchronous contract with async action or infer an asynchronous implementation from its required suspension operations under CS-ASYNC-008. returns T denotes the logical result type of an asynchronous action. The normal .NET ABI is Task<T> for a result and Task for an action without a result. Explicit async fixes that contract even when some/all paths complete synchronously and retains its separate cancellation contract. Omitting async is permitted on ordinary public as well as private/local actions; their generated asynchronous contract follows inference rather than an implicit blocking bridge.

public async action ReadAccountAsync(
    client as Global.System.Net.Http.HttpClient,
    address as Global.System.Uri
)
    returns String
    require client is not nothing
    require address is not nothing

    return await client.GetStringAsync(address, Cancellation.Token)
end action

CS-ASYNC-002. An invocation returning a statically supported awaitable requires explicit intent: await call waits for its logical result, task_of(call) retains the operation, and a run producer invocation follows CS-CONCURRENT-001. An ordinary invocation must not silently wait or silently capture its awaitable. Diagnose an unmarked fresh awaitable invocation, including an assignment, discarded statement, return or call argument, even when the expected type is Task/ValueTask or Object. This rule uses the resolved return contract, not an Async name suffix or runtime completion state. Generated resource cleanup, asynchronous iteration/acquisition and owned concurrent joins retain their construct-defined waits without requiring an additional source await at each generated step. Imported names such as GetStringAsync remain unchanged for binding, diagnostics and navigation.

CS-ASYNC-003. Ordinary non-overridable actions, including public/protected/internal actions, may infer async implementation. Adding or removing required suspension can change such an inferred action's emitted CLR return contract; optional explicit async fixes the asynchronous contract independently of its current body. Interface declarations, abstract/overridable contracts and overrides remain constrained by their declared signatures: do not infer a change to a synchronous interface/base contract. An imported override or interface implementation must preserve its actual Task or ValueTask contract; the backend may not substitute one for the other. Inference alone adds no cancellation parameter or Cancellation context, regardless of accessibility.

CS-ASYNC-006. task_of(expression) explicitly obtains a raw awaitable. A fresh awaitable-returning invocation must use this capture form when its operation is retained, assigned, forwarded or passed to an ordinary API instead of awaited or launched through run. Evaluate the expression once and preserve its actual awaitable type and represented operation, including reference identity where applicable; do not wrap it, convert ValueTask to Task, wait for it, or schedule it on another thread. The operand must have a supported statically resolved awaitable contract. A non-awaitable operand is a compile-time error, except for the existing concurrent producer queries in CS-CONCURRENT-003/008. Existing awaitable variables/members may be copied, returned and passed normally; task_of on them is an optional identity operation. Parentheses do not change binding. Arguments and receiver computations inside a captured invocation retain their own explicit await/capture requirements: capturing the outer operation does not acknowledge a separate fresh awaitable invocation used to compute an input.

public async action ReadReportLaterAsync(path as String)
    returns String
    let pending = task_of(Global.System.IO.File.ReadAllTextAsync(path, Cancellation.Token))
    let text = await pending
    return text
end action

The call starts according to its ordinary API contract; obtaining its awaitable is not a promise of deferred execution or background-thread execution. Task capture alone does not make the containing action async. The later await in this example suspends as necessary. Invocation/argument failures before the awaitable is returned propagate during capture; failures represented by the returned operation are observed by a subsequent await or other normal .NET observation. Raw awaitables are an interop escape: task_of does not register a concurrent producer, introduce an automatic join or infer resource ownership. The caller remains responsible for completion and resource lifetime.

Reference C# lowering keeps the raw invocation and adds only the requested wait:

public static async Task<string> ReadReportLaterAsync(
    string path, CancellationToken cancellationToken = default)
{
    Task<string> pending = File.ReadAllTextAsync(path, cancellationToken);
    string text = await pending;
    return text;
}

The excerpt assumes ordinary System.IO, System.Threading and System.Threading.Tasks imports. Capture itself introduces no helper or state machine; the declared action and later await determine the asynchronous implementation.

CS-ASYNC-007. Await consumes one supported awaitable and produces its logical result; Task/ValueTask without a result produces no expression value. Supported awaitables include Task/ValueTask shapes and custom forms recognized by the normal .NET awaiter pattern. Existing task variables/members, task_of results and the logical result of a previous wait retain their types until explicitly awaited or passed through ordinary API operations. Awaiting Task<Task<T>> leaves the inner Task<T>; it is not recursively flattened. A task-typed assignment target, parameter, return type, Object conversion or generic inference does not replace the required source intent marker on a fresh awaitable invocation. Overload and generic binding use the explicitly awaited logical type or captured raw type, with ordinary ambiguity diagnostics; they must not search both interpretations.

Await acknowledges its immediate operand invocation once; it does not acknowledge separate calls inside that invocation's arguments or receiver. To operate on a fresh task before waiting, capture it first: await task_of(LoadAsync()).ConfigureAwait(false). The configured outer operation is explicitly awaited and the inner operation is explicitly captured. Apply ordinary ValueTask consumption restrictions: capture does not make repeated waits safe. No runtime completion-state test chooses a different binding.

CS-ASYNC-008. Determine async implementation of inferable ordinary actions from required suspension: explicit awaits, asynchronous enumeration/acquisition, owned asynchronous joins and async-only owned cleanup. This includes ordinary public/protected/internal actions as well as private/local actions. Infer callee contracts and validate each caller's explicit await/capture/run choice; do not propagate a hidden wait into an unmarked call. A task_of capture contributes only explicitly required suspension in its arguments/receiver, not a wait for its captured operation. Classify nested actions and callbacks separately from their containing action. A resource exposing both disposal protocols does not itself trigger async inference: choose its async route only after its execution context is already asynchronous. An otherwise synchronous inferable body uses its synchronous route. An async-only owned resource can require permitted inference even if the body has no literal await.

Inference must stop at fixed synchronous contracts: synchronous interfaces/base contracts, synchronous iterators, constructors/accessors, synchronous callback targets, plain dispose, unmanaged cleanup and atomic regions cannot silently acquire asynchronous signatures or a blocking bridge. Explicit async actions, async iterators, async dispose and task-returning callback targets establish their declared asynchronous boundaries. An owned asynchronous join can require inference in an ordinary containing action; nesting an unrelated callback's await cannot. Cleanup routing and implementation inference do not grant Cancellation or add a token parameter.

CS-ASYNC-009. Anonymous callable binding preserves the target delegate or expression-tree signature. A Task/ValueTask-returning delegate can use a logical-result body with the corresponding async implementation; its actual declared Task versus ValueTask shape is retained. A body that explicitly forwards a compatible raw operation through task_of may remain an ordinary non-suspending delegate. Do not add cancellation parameters to either form. A synchronous-result or void-returning target cannot acquire implicit suspension or silently become async void: diagnose incompatible bodies. Explicit await does not repair an incompatible target signature. Expression-tree targets must remain representable by that expression-tree contract; a raw task-returning call can be represented where supported, but suspension cannot silently replace the tree with a delegate. This rule introduces no asynchronous event-delivery policy.

CS-ASYNC-010. Emit a compiler warning when local flow analysis identifies a captured operation abandoned without observation or transfer of responsibility. This includes a discarded task_of call and a captured local whose last tracked reference is lost on a reachable exit or overwrite without being awaited, returned, handed to another operation owner or otherwise observed through a recognized API. Copying a handle to another local does not itself discharge that responsibility; awaiting an alias can. Reading completion status or enclosing only the capture in try/catch does not observe a later failure. A raw task_of capture inside concurrent is still subject to this warning; only run registers owned work. Do not warn merely because an owned run has no explicit source await: the scope joins and observes it.

The warning is distinct from CS-ASYNC-002's error for a fresh invocation with no explicit intent. It does not insert an await, block, cancel, log, detach or swallow failures. Flow analysis is conservative: returning/passing/storing the operation outside the analyzed local flow can transfer responsibility, but is not proof that external code observes it. Diagnose provable local abandonment without claiming whole-program detection through aliases, fields or opaque APIs. A thrown exception from an awaited operation is observed even when it propagates rather than being caught locally. A configured awaitable still represents its underlying operation; ValueTask consumption restrictions remain unchanged.

CS-ASYNC-011. Ordinary Task/ValueTask awaits preserve the .NET awaiter's default continuation-context behavior. Explicit ConfigureAwait calls retain their ordinary library meaning; they are not language keywords. ConfigureAwait(false) removes the requirement to resume through the captured context for that await, but does not force another thread, guarantee leaving a UI thread, suppress all ambient state or change exception observation. A synchronously completed await may continue immediately on the current thread. Custom awaitables retain their own awaiter contracts. Compiler-generated cleanup and joins that resume surrounding authored code must not silently opt that code out of its normal continuation context. A configured await inside one callee or branch does not configure unrelated awaits in its caller or siblings.

17.2 Cancellation

CS-ASYNC-004. An explicit async action participates in standard cooperative cancellation. Its ordinary .NET contract conceptually includes a final optional CancellationToken with default empty/noncancelable value. Within the action, the contextual members are:

Member Meaning
Cancellation.Token Current standard cancellation token
Cancellation.IsCanceled Whether cancellation has been requested
Cancellation.CanBeCanceled Whether the token can receive cancellation
Cancellation.ThrowIfCanceled() Throw through the standard cancellation-token contract if requested

Cancellation is available in explicit async actions, imported/overridden contracts recognized as cancellation-aware, and owned producer expressions/bodies under CS-CONCURRENT-024. Outside those producer contexts, its use in an ordinary action or merely inferred async action without a recognized cancellation-aware contract is a compile-time error. Inference must not add a hidden extra CLR parameter.

CS-ASYNC-005. A DirectSharp call between explicit async actions propagates the current token when the callee has the compiler-known cancellation contract. For imported .NET APIs, token insertion or cancellation-aware overload selection is permitted only when safe and unambiguous. Otherwise ordinary overload resolution is preserved, with a suggestion to pass Cancellation.Token explicitly. No automatic loop cancellation checks or thread termination are implied.

Within an owned concurrent producer, implicit forwarding uses that producer's effective linked token. An explicit Cancellation.Token in the producer expression denotes that same token. Explicitly supplying a different token is an override; it must not be replaced merely to make winner cleanup cancellable. This does not add a cancellation parameter to merely inferred async actions or incompatible imported contracts.

A representative reference C# lowering of the preceding action is:

public static async Task<string> ReadAccountAsync(
    HttpClient client,
    Uri address,
    CancellationToken cancellationToken = default)
{
    if (client is null)
        throw new ArgumentNullException(nameof(client), "An HTTP client is required: client is not nothing");
    if (address is null)
        throw new ArgumentNullException(nameof(address), "An address is required: address is not nothing");
    return await client.GetStringAsync(address, cancellationToken);
}

The optional token describes a DirectSharp-authored contract. It does not authorize adding parameters to an incompatible imported override.

17.3 Iterators and asynchronous streams

CS-ITERATOR-001. action ... yields T declares a deferred sequence with .NET IEnumerable<T> behavior. Calling the action obtains a sequence without executing its body. Enumeration executes the body, preserves locals between yields and exposes body failures at the point of enumeration. Repeated enumeration normally reruns the body. No hidden materialization occurs.

public action RunningTotals(values as Global.System.Collections.Generic.IEnumerable <of Decimal>)
    yields Decimal

    let total as Decimal = 0
    for_each value in values
        total += value
        yield total
    end for_each
end action

CS-ITERATOR-002. yield expression produces one item compatible with T. yield nothing produces an absent item where permitted by T; it does not skip that item. yield from sequence delegates to one compatible sequence in order, flattening one level and preserving deferred execution, failures and disposal. Plain return ends enumeration. return value is invalid. returns and yields cannot occur together on one action.

CS-ITERATOR-003. async action ... yields T explicitly declares an asynchronous sequence with .NET IAsyncEnumerable<T> behavior. An awaitable operation inside a synchronous iterator must not silently change that public contract.

public async action DownloadDocumentsAsync(
    client as Global.System.Net.Http.HttpClient,
    addresses as Global.System.Collections.Generic.IEnumerable <of Global.System.Uri>
)
    yields String
    for_each address in addresses
        Cancellation.ThrowIfCanceled()
        yield await client.GetStringAsync(address, Cancellation.Token)
    end for_each
end action

CS-ITERATOR-004. Ordinary for_each over an IAsyncEnumerable<T> infers asynchronous enumeration. There is no separate required await for_each keyword. Each sequential iteration completes before acquisition of the next item. Enumerator cleanup follows the source's synchronous or asynchronous protocol.

For the synchronous running-totals example, the reference C# body is an ordinary iterator:

public static IEnumerable<decimal> RunningTotals(IEnumerable<decimal> values)
{
    decimal total = 0;
    foreach (decimal value in values)
    {
        total += value;
        yield return total;
    }
}

The async stream has the corresponding async-iterator shape. On .NET, propagation of an enumeration-time token needs appropriate iterator metadata, such as EnumeratorCancellation, and must preserve the explicit action cancellation contract rather than introduce another source keyword.

17.4 Structured child operations

CS-CONCURRENT-001. A concurrent block owns its child operations. run starts an owned operation rather than immediately awaiting its logical result. returns declares result bindings published to the enclosing scope after successful completion of the concurrent scope. The logical result is unavailable inside that scope.

public async action ReadDashboardAsync(
    client as Global.System.Net.Http.HttpClient,
    balancesAddress as Global.System.Uri,
    ordersAddress as Global.System.Uri
)
    returns (Balances as String, Orders as String)
    concurrent all
        returns balances as String, orders as String with limit: 2
        balances = run client.GetStringAsync(balancesAddress, Cancellation.Token)
        orders = run client.GetStringAsync(ordersAddress, Cancellation.Token)
    end concurrent

    return (Balances = balances, Orders = orders)
end action

Using balances as the input of a sibling run is invalid before publication. If one request depends on another's result, express that dependency sequentially or as a nested workflow.

CS-CONCURRENT-022. An owned producer can be an invocation, result = run Call(...), or an inline body, result = run ... end run. A resultless producer may use run ... end run without a destination. Both inline forms require an enclosing concurrent owner; they are not general task-valued expressions or detached-work statements. A destination must be a result binding of the applicable owning scope, with its existing publication and producer-count rules. The inline body is a separate callable boundary with branch-local declarations and ordinary lexical access to permitted surrounding values. It supplies one producer operation and uses the same limits, task_of producer identity, cancellation, failure and settlement policies as an invocation producer. It does not introduce several separately published outputs for first/any.

concurrent all
    returns invoice as String, contract as String
    invoice = run
        let text = await client.GetStringAsync(invoiceAddress)
        return text.Trim()
    end run

    contract = run
        let text = await client.GetStringAsync(contractAddress)
        return text.Trim()
    end run
end concurrent

The fragment assumes a supplied HttpClient and two addresses. Each branch proceeds to Trim as soon as its own request completes; neither waits for the other's request at that step. The end concurrent boundary joins both and publishes their results. No new named action is needed.

CS-CONCURRENT-023. Return within an inline run completes that branch, not the enclosing action or concurrent scope. A value-producing branch's return expressions must be compatible with its destination's logical type; every reachable normal exit must return a value. A path that throws need not return. A resultless branch permits bare return or fallthrough and rejects return with a value. Return evaluates its expression once, then completes required finally/resource cleanup and owned child settlement before the branch can successfully complete. Cleanup failure prevents successful completion and result publication. A nested action's return belongs to that action; a return must not cross an intervening concurrent iteration boundary. Break/continue/targeted exit cannot escape the run callable boundary to a surrounding loop. Yield does not turn a run body into an iterator.

CS-CONCURRENT-024. An inline run is an asynchronous-capable owned execution context without requiring an async modifier on the block. Ordinary awaitable calls within it still require await/task_of/run intent. Sequential statements within each branch retain their source order; independent branches can overlap as operations suspend. Run alone does not promise a new thread or CPU parallelism. An admitted branch retains its limit capacity through its complete body, cleanup and owned descendants. Nested concurrent scopes retain their own ownership, result visibility and limits; nested owned work cannot escape by returning early. Task capture alone does not create an owned descendant. Captures follow ordinary lexical rules without an implicit deep copy; the shared-write diagnostics and explicit coordination requirements of CS-CONCURRENT-009 apply to inline branches too.

The producer's existing effective cancellation token applies throughout its inline body, including safely bound cancellation-aware calls. Cancellation.Token there denotes that producer token; an explicit different token remains an override. This body context adds no parameter or Cancellation context to an otherwise inferred enclosing action. All/first/any observe an inline branch's completed outcome, including cleanup, using their existing policies; return does not publish early or select a winner before cleanup settles. A synchronous body failure is a failed producer outcome, not permission to abandon already-owned work.

CS-CONCURRENT-002. Completion modes differ as follows:

Mode Selection rule
all All required child operations for the declared result set complete
first First successful producer wins; an earlier failed producer does not win
any First completed producer wins, including an unsuccessful completion

CS-CONCURRENT-021. In v1, a first or any scope that publishes results declares one result binding. To publish several related values, each competing producer returns one ordinary result object containing those values. Read its members after successful scope publication; all come from the same selected producer. A competing branch cannot publish several separate result bindings through a compact multi-output form in v1. No tuple destructuring or multiline take branch is introduced. all retains separate result bindings produced by separate operations.

Grouping values preserves the selected producer's result; it does not add deep copying, transactional consistency or thread safety to the object's members. Normal result typing, scope publication, task identity and failure/cancellation rules still apply. Result selection, owned settlement and failure propagation have distinct obligations.

CS-CONCURRENT-012. Concurrent first gives owned producers effective child tokens linked to caller cancellation and any scope deadline. After selecting the first successful winner, freeze that selection and request cooperative cancellation of unfinished losing producers. Do not cancel the caller token, the winner's source or unrelated work. Concurrent any selects the first completed producer and does not request cancellation merely because that completion was selected. Both modes join every owned producer before leaving; operations without a compatible token contract, using an explicit different token or ignoring cancellation may complete naturally and delay scope exit. Neither mode promises thread termination, abandoned work or immediate return.

CS-CONCURRENT-013. In first or any with a successful winner, observe losing producer faults and cancellation without turning them into failure of the successful result. Cancellation attributable to internally stopping losers is normal cleanup. Do not automatically log losing faults, fabricate an ambient error history or rewrite the actual fault/cancellation state of a captured producer task handle. Under first, an earlier failed competitor does not prevent a later successful one from winning. Caller cancellation and deadlines remain distinct scope causes. Cancellation-callback/request exceptions follow CS-CONCURRENT-017 rather than the producer-failure rules.

CS-CONCURRENT-014. An unsuccessful selected any producer propagates its original exception after all owned work is joined. Preserve its type and propagation information; do not replace it with a later success, aggregate unrelated losing failures into that selected exception, or publish fabricated default result values. Independent producer cancellation remains an unsuccessful completion under this rule; cancellation solely attributable to the scope caller/timeout follows CS-CONCURRENT-016/019. Other producer outcomes are still observed during settlement.

CS-CONCURRENT-015. When all has producer failures, or first settles with no successful producer and has producer failures, propagate the original exception if exactly one producer failed. For multiple failures, throw ordinary System.AggregateException retaining every producer failure in source-declaration order, not completion order. Independently cancelled producers retain their cancellation exceptions as failures when no winning-cleanup rule applies. Cancellation solely attributable to the scope caller/timeout and internal winner-cleanup cancellation are not independent producer failures for aggregation. Cancellation-request errors follow CS-CONCURRENT-017. Preserve original exception objects and propagation information; do not select an arbitrary single failure merely because awaiting Task.WhenAll happens to throw one.

CS-CONCURRENT-016. When reporting a scope failure caused by caller cancellation or scope timeout, use the first cause recorded by the scope. If both are already requested at its initial observation, prefer caller cancellation. A later caller request during timeout cleanup does not relabel the recorded timeout. Use ordinary cancellation behavior for the caller cause and TimeoutException for timeout. Cause recording must be atomic under competing signals; it is observation order, not a promise to reconstruct wall-clock event order across threads. Request supported cooperative cleanup and join owned work before leaving. The publication and failure priorities in CS-CONCURRENT-018/019 apply to this recorded cause.

CS-CONCURRENT-017. Observe and suppress exceptions reported by cancellation callbacks or cancellation requests after joining all owned work. These exceptions do not replace an otherwise successful result, augment the eligible producer-failure list or erase the ordinary producer/external outcome. No automatic logging or new error-history API is introduced. Continue cleanup and joins despite a throwing request; suppression does not authorize abandoning work. This policy concerns exceptions reported by a cancellation request, not an independently failing producer whose ordinary exception remains governed by CS-CONCURRENT-013–015. Normal cancellation acknowledging internally stopping losers also remains suppressed.

CS-CONCURRENT-018. Honor the recorded scope caller-cancellation/timeout cause through settlement and result publication. A cause recorded before publication can prevent a selected successful result from being published, including when it arrives during an owned join. A cause recorded before selection cannot be erased by later producer success. Signals after publication do not retroactively change the published result. Coordinate external-cause observation with the publication boundary; checking a stale cause only at winner selection is insufficient. All owned producers still join, including those ignoring cancellation. Thus a fast successful winner followed by deadline expiry while joining an uncancellable loser produces TimeoutException after that loser settles, subject to CS-CONCURRENT-019.

CS-CONCURRENT-019. Genuine failures that would otherwise propagate take precedence over a recorded external caller/timeout cause. Apply the original-single/source-ordered-aggregate rules to eligible producer failures; do not relabel them as cancellation or timeout. Previously observation-only losing faults remain excluded, and CS-CONCURRENT-017 cancellation-request exceptions remain suppressed. Cancellation solely attributable to the scope caller/timeout is the external-cause outcome, not an additional competing genuine failure; independently cancelled producers remain producer failures. Preserve actual task states and exception objects rather than rewriting faults as cancellation. After joining, report eligible failures first; otherwise report the recorded external cause; otherwise publish the selected successful result.

CS-CONCURRENT-020. Reject a statically producer-free first or any construct at compile time. If a permitted runtime path launches zero producers, throw InvalidOperationException; publish no fabricated values and do not wait for a nonexistent winner. This does not change normal completion of empty concurrent loops or permit an empty all with required result bindings to synthesize defaults.

CS-CONCURRENT-003. task_of(result) provides advanced access to the associated operation. Inside the block, it identifies the most recently declared run producer for that result at that lexical point. After the block, it identifies the actual published producer. It does not make the logical value available early.

When the operand identifies such a declared result binding, or the produced collection element in CS-CONCURRENT-008, this producer-query interpretation takes precedence over raw awaitable capture in CS-ASYNC-006. It neither evaluates an unpublished logical result nor invokes its producer again. Other task_of operands follow the raw awaitable rule; task_of(call) inside concurrent does not replace run or add owned work to that scope.

concurrent first
    returns text as String
    text = run primaryClient.GetStringAsync(address, Cancellation.Token)
    let outer primaryTask = task_of(text)

    text = run backupClient.GetStringAsync(address, Cancellation.Token)
    let outer backupTask = task_of(text)
end concurrent

let winner = task_of(text)

This fragment belongs in an explicit async action with the three referenced inputs. let outer uses ordinary lexical promotion; it is not a special concurrency storage mechanism.

17.5 Limits and timeouts

CS-CONCURRENT-004. with limit: accepts one positive Integer or a Gate. An integer creates a private limit owned by the construct. A supplied Gate shares capacity with other operations using that Gate. limit: 1 is legal. A nonpositive constant is a compile-time error; a nonpositive dynamic value fails with a runtime argument error. There is no language-imposed concurrency limit when the input is omitted.

let apiGate = new Gate(4)

concurrent all
    returns customers as String, accounts as String with limit: apiGate
    customers = run client.GetStringAsync(customersAddress, Cancellation.Token)
    accounts = run client.GetStringAsync(accountsAddress, Cancellation.Token)
end concurrent

The replaced gate: concurrent input is not canonical. Gate remains the ordinary coordination object.

CS-CONCURRENT-005. timeout: on a concurrent construct is a deadline for the whole operation. A timeout on Gate acquisition bounds the wait to obtain a permit. These are different intervals. Timeout requests cooperative cancellation where possible; it never forcibly terminates a thread. Timeout and caller cancellation remain distinct conditions. The normal .NET timeout failure category is TimeoutException.

17.6 Concurrent enumeration and collection

CS-CONCURRENT-006. concurrent for_each starts one structured iteration per source item and joins its owned iterations before leaving. The body remains sequential within an iteration unless it explicitly starts nested child operations. An iteration holds its outer limit slot until its complete body and owned nested run operations settle. An empty source completes normally with zero iterations.

public async action DownloadAllAsync(
    client as Global.System.Net.Http.HttpClient,
    addresses as Global.System.Collections.Generic.IEnumerable <of Global.System.Uri>
)
    returns Global.System.Collections.Generic.IEnumerable <of String>
    concurrent for_each address in addresses collects documents as String with limit: 4
        collect documents = await client.GetStringAsync(address, Cancellation.Token)
    end for_each

    return documents
end action

CS-CONCURRENT-007. collects name as T declares the element type of a published result collection, not its concrete storage type. collect name = expression is the destination-first publication form. The basic contract supplies one value per successful iteration for each declared output. Collections are published after the loop and preserve source enumeration order even when completion order differs. No filtering or reduction grammar is implied.

CS-CONCURRENT-008. task_of(collection[index]) after publication identifies the iteration that produced that element. Source-order publication preserves positional correspondence. A loop limit controls whole iterations; an inner enter otherGate ... leave independently controls the protected resource.

CS-CONCURRENT-009. Iteration declarations are local to that iteration. Statically provable unsynchronized writes to captured/shared state are diagnosed; the compiler must not silently make ordinary mutations atomic. The specific swap exception in CS-ATOMIC-003 does not generalize to other assignments or calls. When aliases or imported code prevent proof, weaker analyzer diagnostics may be appropriate. Explicit atomic operations, Gate protection or collected publication express the intended coordination.

17.7 Concurrent condition-driven loops

CS-CONCURRENT-010. Ordinary counter/range iteration uses sequential for_each ... in range .... Parallel range processing uses explicit concurrent enumeration over that range; there is no separate concurrent counter-for statement. Concurrent pre-test and post-test loops use a sequential coordinator to evaluate conditions; worker iterations do not independently race to evaluate a shared condition.

Canonical forms with a limit are:

concurrent while HasWork() with limit: 4
    await ProcessNextAsync()
end while

concurrent repeat with limit: 4
    await ProcessNextAsync()
until Finished()

concurrent repeat with limit: workerGate
    await ProcessNextAsync()
while HasWork()

These fragments belong in an explicit async action with application helpers and an existing Gate where named. HasWork/Finished observe safely coordinated application state; ProcessNextAsync must claim work safely across iterations. Testing a condition does not itself reserve a work item. Post-test forms close with their condition and have no following end repeat.

The coordinator performs these steps:

  1. Wait for capacity when the limit is full.
  2. Evaluate the next condition sequentially.
  3. If it allows another iteration, start that iteration using the available capacity.
  4. If it does not, stop scheduling and join active iterations.

A post-test loop starts at least one iteration, then checks the condition before subsequent starts. For a while form, true permits another iteration and false stops scheduling. For an until form, false permits another iteration and true stops scheduling. Both stopping conditions join active iterations. Condition evaluation must not proceed speculatively while capacity is full. Conditions may observe state changed by coordinated iterations; no extra memory-consistency policy is implied for unprotected shared writes.

CS-CONCURRENT-011. Innermost-loop termination by break or equivalent bare exit stops new scheduling and joins active iterations without automatically cancelling them. Continuing a concurrent iteration ends only that iteration. Direct return from a concurrent iteration body is forbidden; it cannot return from the enclosing action from a worker. Nested concurrent loops have independent structured lifetimes. Innermost-loop termination affects only that loop and joins its active iterations.

A targeted loop exit may target a nested loop inside the current iteration or its containing concurrent loop. It cannot exit a loop outside that containing concurrent loop. A valid targeted exit of the containing concurrent loop uses the same stop-scheduling and join behavior; a local sequential target leaves that local loop normally without stopping the concurrent loop.

Ordinary loop return exits its enclosing action. There is no loop else clause. Chapter 7 defines the ordinary loop forms and sequential targeted exits.

17.8 Asynchronous consumption

Collection and consumable protocols define supported sources, acquisition and completion. An asynchronous consumable can wait for its next item without blocking a worker thread. Ordinary consume uses the source's appropriate acquisition protocol under the usual async rules; no separate required await consume keyword is introduced.

public async action PrintNotificationsAsync(
    notifications as Global.System.Threading.Channels.ChannelReader <of String>
)
    consume notification from notifications
        Cancellation.ThrowIfCanceled()
        Global.System.Console.WriteLine(notification)
    end consume
end action

Temporary channel emptiness means waiting for acquisition or permanent completion, not ending the loop. The protocol supports cancellation where waiting can occur. An acquired item is processed before the next sequential body begins. The action borrows its reader; consumption does not transfer disposal ownership.

concurrent consume overlaps whole bodies only when the source supports safe concurrent acquisition, using the same positive integer/shared-Gate limits and structured completion model as other concurrent loops. Completion of the source stops acquisition and joins active bodies. Raw database readers remain single mutable cursors and cannot be consumed concurrently. Snapshot rows into independent values before parallel work. Async reader acquisition must not implicitly advance to another result set.

17.9 Gates and atomic state

CS-GATE-001. Gate is an ordinary library object. Gate() provides one permit; Gate(n) provides n permits; Gate(n, timeout) additionally supplies a default acquisition timeout. Both count-taking constructors throw ArgumentOutOfRangeException for n <= 0 rather than normalizing the count. A one-permit Gate provides exclusive access. A shared Gate coordinates all its users; different Gates do not automatically coordinate.

CS-GATE-002. enter gate ... leave acquires a permit and releases it on exit, including early action return, exception and cancellation. leave is structural; it is not manual semaphore release. Gate identity must be explicit. A bare enter must not implicitly search a class for a default Gate.

public async action SaveSnapshotAsync(
    gate as Gate,
    path as String,
    contents as String
)
    enter gate
        await Global.System.IO.File.WriteAllTextAsync(path, contents, Cancellation.Token)
    leave
end action

Gate protection may span asynchronous operations. On .NET its implementation requires an async-capable primitive, such as SemaphoreSlim, with guaranteed release; C# lock is not an equivalent lowering.

CS-GATE-003. A Gate acquisition timeout may be zero, meaning one immediate permit acquisition attempt; unavailable capacity causes TimeoutException. Timeout.InfiniteTimeSpan (minus one millisecond) means no acquisition deadline; other negative durations throw ArgumentOutOfRangeException. Positive durations bound acquisition waiting, not the protected body's execution or the whole concurrent scope. A failed acquisition executes no protected body and releases no unacquired permit. Validate constructor defaults and explicit acquisition overrides. Runtime timer precision is not a promise of exact wall-clock scheduling, and valid large durations must not be narrowed into invalid native timer arguments.

CS-GATE-004. Gate acquisition is non-reentrant. Every enter consumes another permit, including nested entry in a flow already holding the same Gate. A multi-permit Gate admits nested entry only if another permit is available. For a one-permit Gate held by the outer region, inner entry waits rather than receiving a recursive grant; cancellation or acquisition timeout can end that wait. Without either, such nesting can remain blocked. No immediate nested-entry exception, hidden extra capacity or automatic lock-style reentrancy is supplied. This does not change atomic-region coordination.

CS-GATE-005. Gate guarantees its declared permit bound and structured release, not FIFO waiter order or scheduling fairness. Application behavior must not rely on the next waiter selected by the native primitive. Ordered concurrent result publication remains separately guaranteed; completion/waiter order does not reorder the collected source positions.

Acquisition case Result
new Gate(0) or negative count ArgumentOutOfRangeException
Zero timeout, available permit, uncancelled caller Acquire immediately; execute/release normally
Zero timeout, no permit, uncancelled caller TimeoutException; body does not run
Timeout.InfiniteTimeSpan Wait without an acquisition deadline; cancellation may still end waiting
Other negative timeout ArgumentOutOfRangeException
Nested entry, one permit already held Wait for another permit; may time out or be cancelled

A reference backend can use SemaphoreSlim for permit ownership and release in finally only after successful acquisition. The native TimeSpan wait overload has timer-range limits; a valid long timeout may require bounded wait intervals with monotonic elapsed-time tracking. Such lowering must preserve the configured overall acquisition interval. CS-GATE-006–011 additionally require acquisition-outcome and lifetime tracking; native primitive selection alone supplies neither.

CS-GATE-006. For a live Gate with valid acquisition arguments, an already-requested caller cancellation fails acquisition with ordinary OperationCanceledException before taking a permit or executing the protected body, even if capacity is immediately available. Zero-timeout probing does not override that initial cancellation. This is an acquisition check, not a new hidden cancellation check inside ordinary action/loop bodies.

CS-GATE-007. When caller cancellation and acquisition timeout compete during a failed wait, preserve the first cause recorded by that acquisition. If both are already requested at its initial observation, prefer caller cancellation. A later caller request does not relabel a recorded acquisition timeout. Report OperationCanceledException for caller cancellation and TimeoutException for timeout; neither failure executes the body or releases an unacquired permit. Record causes atomically by observation order, without reconstructing wall-clock ordering. This interval is distinct from whole concurrent-scope cause/publication rules. CS-GATE-011 coordinates these causes with successful acquisition.

CS-GATE-008. Keep the underlying synchronization primitive private in v1; Gate exposes no raw Native semaphore property. Gate remains an ordinary .NET-compatible library object usable from C# through its library contract. An equivalent async-capable implementation is permitted; the public API is not tied to SemaphoreSlim.

CS-GATE-009. Gate implements IDisposable. Its owner controls cleanup through ordinary disposal/resource scopes. Enter/leave and a supplied concurrent-limit use borrow that Gate: they release the permits they acquire and do not implicitly dispose the shared object. No IAsyncDisposable-only contract, implicit ownership transfer or synchronous joining of unrelated asynchronous work is supplied.

CS-GATE-010. Disposal with a pending or held Gate acquisition throws InvalidOperationException and leaves the Gate usable. Track pending requests as well as held permits; a native CurrentCount check alone is insufficient. Coordinate acquisition admission and closure under one lifetime protocol so no request slips into the gap between checking quiescence and closing. The owner may retry disposal after all users settle. Successful quiescent disposal is idempotent; later acquisition throws ObjectDisposedException. Do not forcibly cancel holders, abandon waiters or synchronously block to join asynchronous work.

CS-GATE-011. Atomically commit the first acquisition outcome recorded by the Gate: successful grant, caller cancellation or acquisition timeout. A recorded cause prevents a later grant; a committed grant is not revoked by a later signal. Preserve CS-GATE-006's initially requested caller-cancellation preference and CS-GATE-007's failed-acquisition categories. This is recorded observation order, not reconstructed wall-clock ordering. If a native wait supplies a permit after a cause has already committed, restore that provisional permit without entering the body. Once grant commits, invoke the body without a second cancellation check introduced by Gate; the body still receives/observes cancellation under its ordinary contract. Acquisition timeout ends at grant and does not bound the body. Whole-concurrent-scope cancellation/publication rules remain separately applicable.

A C# outcome recorder can use one initially pending state shared by cause callbacks and grant commitment:

enum AcquisitionOutcome { Pending, Granted, Caller, Timeout }

static bool TryRecord(ref int state, AcquisitionOutcome outcome) =>
    Interlocked.CompareExchange(ref state, (int)outcome,
        (int)AcquisitionOutcome.Pending) == (int)AcquisitionOutcome.Pending;

Check initially requested caller cancellation before competing grant. Register cause observation before waiting; a native permit alone does not commit admission. Try to record Granted once capacity is obtained. If recording loses to a cause, release the provisional native permit and report that cause. After a committed grant, execute the protected body and release in finally. Coordinate timer/cancellation observation and admission in the runtime; a native wait return or a stale token check alone does not establish this outcome protocol.

A disposal pattern within a C# reference Gate class can use shared lifetime state:

public void Dispose()
{
    lock (lifetime)
    {
        if (closed) return;
        if (outstanding != 0)
            throw new InvalidOperationException("Gate still has pending or held acquisitions.");
        closed = true;
        permits.Dispose();
    }
}

Here lifetime is a private synchronization object; outstanding counts admitted acquisitions until their wait/body/cleanup settles, closed records successful closure, and permits is the private primitive. Admission checks closed and increments outstanding under the same synchronization before native acquisition begins. Release an acquired permit before dropping its lifetime count, and decrement failed requests too. The lifetime lock protects short synchronous state changes, not the asynchronous protected body. Final acquisition member signatures and complete runtime stress/conformance coverage are separate from this reference pattern.

CS-ATOMIC-001. A supported single-statement atomic update requests one indivisible update, such as an applicable arithmetic or bitwise operation. Ordinary count += 1 remains ordinary mutation. Native/interlocked lowering is used where the operation/type supports it; an unsupported operation must not pretend to be atomic.

CS-ATOMIC-002. atomic ... end atomic expresses one short synchronous state transition. Competing atomic regions over the same shared state must coordinate. Neither explicit nor implicit suspension is allowed. This is distinct from a named, async-capable Gate region.

CS-ATOMIC-003. atomic swap(a, b) requests a protected exchange of two supported writable locations of the same effective type. An ordinary swap(a, b) directly in a concurrent body also uses atomic exchange automatically. A swap inside an atomic block participates in that block's protection. This is a specific exchange rule, not automatic synchronization of arbitrary shared mutation or of an unrelated action called from concurrent code.

The compiler supplies coordination compatible with atomic regions protecting the same shared state; an application does not supply a Gate merely to request atomic exchange. Competing coordinated exchanges, state transitions and pair reads must share compatible protection. Uncoordinated reads/writes, including direct external CLR accesses, receive no pair-consistency guarantee. Protection is synchronous, with no explicit or implicit suspension. Types need not have native Interlocked exchange support: a correctly coordinated generic implementation can exchange references, numbers or ordinary structs.

Supported atomic target storage must admit correct coordination. An unsupported target is diagnosed rather than lowered to an unsafe exchange. Readable/writable properties and custom indexers are eligible through synchronous accessor adapters; they do not need to expose a CLR by-reference return. Normal accessibility, writability and CLR representation restrictions still apply.

CS-ATOMIC-004. For atomic property/indexer exchange, resolve the left target's receiver and index arguments once, then the right target's, in written order, before entering the protected exchange. Preserve the original writable storage when a receiver is a mutable struct; do not exchange properties of an unintended temporary copy. Under the shared protection, read the left value once, read the right value once, write the saved right value to the left target, then write the saved left value to the right target.

Exceptions propagate normally and stop later exchange steps. A failure resolving a target prevents exchange. A getter failure prevents both exchange setter calls, although the getter may have its own side effects. A setter failure can leave changes already made, including effects of the failing setter; no rollback or compensating setter calls are generated. Protection is released on failure. The operation excludes competing coordinated regions while it executes; it is not a transaction over arbitrary accessor side effects. Getters/setters must not introduce explicit or implicit suspension.

let completed = 0

concurrent for_each path in paths with limit: 4
    let bytes = await Global.System.IO.File.ReadAllBytesAsync(path, Cancellation.Token)
    Global.System.Console.WriteLine("{{path}}: {{bytes.Length}} bytes")
    atomic completed += 1
end for_each

The fragment belongs in an explicit async action with an enumerable paths input. The counter is shared; bytes is iteration-local.

A native Integer increment has the reference C# operation Interlocked.Increment(ref completed). A multi-field atomic block needs a synchronous synchronization domain shared by the competing regions. Its generated lock identity is an implementation choice; a distinct lock per region or an async Gate is not a valid substitute.

A C# library can implement the value exchange and protected exchange as generic helpers. For addressable storage, the following illustrative helpers accept a compiler-selected synchronization domain; they are not final exported runtime declarations:

static void Swap<T>(ref T left, ref T right)
{
    T temporary = left;
    left = right;
    right = temporary;
}

static void AtomicSwap<T>(ref T left, ref T right, object synchronizationDomain)
{
    lock (synchronizationDomain)
    {
        Swap(ref left, ref right);
    }
}

The domain is stable and shared with related protected regions and reads; creating new object() for each invocation would provide no coordination. The compiler still binds writable locations, evaluates their expressions once, chooses ordinary versus atomic invocation, and supplies compatible domain identity. A library alone cannot infer DirectSharp's lexical concurrent context or automatically synchronize unrelated callers. Nested use within an already protected region must preserve that region's coordination. Neither helper establishes a universal CLR signature for every possible storage form or implements compiler alias/domain analysis.

For properties/indexers, a callback-based reference helper implements the same protected order:

static void AtomicSwap<T>(Func<T> readLeft, Action<T> writeLeft,
    Func<T> readRight, Action<T> writeRight, object synchronizationDomain)
{
    lock (synchronizationDomain)
    {
        T left = readLeft();
        T right = readRight();
        writeLeft(right);
        writeRight(left);
    }
}

The compiler captures target receivers/index arguments before forming the adapters. For class receivers whose Value properties have the same type, a possible lowering of atomic swap(LoadLeft().Value, LoadRight().Value) is:

var leftReceiver = LoadLeft();
var rightReceiver = LoadRight();
AtomicSwap(() => leftReceiver.Value, value => leftReceiver.Value = value,
    () => rightReceiver.Value, value => rightReceiver.Value = value,
    synchronizationDomain);

The synchronization domain is the compiler-selected shared domain, not a fresh object or an application argument added to the source call. A custom indexer's captured index is reused by its getter and setter. The lock provides release on exception; it does not undo completed setter effects. Receiver/index resolution remains ordinary expression evaluation and does not make its own unrelated side effects atomic. The helpers are implementation patterns rather than final exported runtime declarations.

CS-GATE-012. Gate exposes synchronous Acquire and awaitable AcquireAsync members. Each has an overload taking only optional CancellationToken cancellation, and an overload taking TimeSpan timeout followed by optional CancellationToken cancellation. Acquire returns IDisposable; AcquireAsync returns ValueTask<IDisposable>. Omitting timeout uses the Gate's configured default, while the explicit overload overrides it for that acquisition. Each success returns a distinct permit whose disposal releases its acquisition at most once without disposing the Gate. A failed acquisition produces no permit; no unpaired public Release member is supplied. Coordinate permit release with the existing grant/cause and outstanding-user rules. These members preserve Gate.Dispose's refusal/retry policy rather than inheriting generated authored-disposal failure caching. The library declarations give the exact signatures.

17.10 Reference C# lowering and diagnostics

The two independent requests in ReadDashboardAsync have this successful-path lowering shape:

Task<string> balancesTask = client.GetStringAsync(balancesAddress, cancellationToken);
Task<string> ordersTask = client.GetStringAsync(ordersAddress, cancellationToken);
await Task.WhenAll(balancesTask, ordersTask);
string balances = await balancesTask;
string orders = await ordersTask;
return (Balances: balances, Orders: orders);

The reference illustrates the successful path's start-before-join and post-join publication. On failure, awaiting Task.WhenAll alone is insufficient: its thrown exception need not include every producer failure required by CS-CONCURRENT-015. Observe all outcomes in source slots, then propagate one original exception or an AggregateException retaining all failures.

For a source-ordered list of producer exceptions on a path governed by that rule, a possible propagation helper is:

static void ThrowProducerFailures(IReadOnlyList<Exception> failures)
{
    if (failures.Count == 1)
        System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failures[0]).Throw();
    if (failures.Count > 1)
        throw new AggregateException(failures);
}

The list must preserve original producer exception objects and source order. An any selected failure instead rethrows only its selected original exception after joining. Both patterns must exclude scope-attributable cancellation acknowledgements and observation-only losing faults. Cancellation-request errors are observed and suppressed after joins. Before publication, eligible producer failures take priority over a recorded caller/timeout cause; that cause takes priority over successful result publication. Reject statically producer-free first/any; a permitted runtime zero-producer path throws InvalidOperationException. The helper alone does not implement cause attribution or coordination of external observation with publication.

Task.WhenAny alone is not concurrent first: it selects a failed completion as readily as a successful one. First needs completion observation until success, per-producer linked tokens, loser cancellation requests, owned joins and cause tracking. A blanket catch of OperationCanceledException cannot distinguish caller cancellation from internal loser cleanup. An ordered concurrent collection needs separate source-position slots and a join before publication; appending results when workers complete would change the required order.

Required diagnostics include statically producer-free first/any constructs, premature result reads, incompatible published types, nonpositive limits, unsupported consume sources, unsafe reader concurrency, direct return from a concurrent body, provable shared writes, unavailable Cancellation, incompatible async ABI, and suspension inside atomic blocks or atomic exchange. Reject atomic property/indexer targets lacking an accessible getter or setter, incompatible effective target types, and target adapters that cannot preserve writable storage under the applicable CLR restrictions. Runtime accessor exceptions propagate rather than becoming compile-time diagnostics. Exact diagnostic numbers and wording are compiler concerns; the required failure conditions are language contracts.


Specification contents · Previous: 16. Predicates and pattern matching · Next: 18. Lazy values