Async, consumption and concurrency
DirectSharp distinguishes waiting for an operation from retaining it and scheduling owned concurrent work. Use await for a result, task_of for the operation itself and run for an owned producer. concurrent creates structured overlap and joins its children before leaving.
Async actions and cancellation
public async action ReadReport(path as String)
returns String
Cancellation.ThrowIfCanceled()
return await Global.System.IO.File.ReadAllTextAsync(path, Cancellation.Token)
end action
Reference C# for the normal .NET async contract:
public async global::System.Threading.Tasks.Task<string> ReadReport(
string path,
global::System.Threading.CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return await global::System.IO.File.ReadAllTextAsync(path, cancellationToken);
}
The logical source result is String; the CLR contract is Task-equivalent of String. An explicitly async action participates in cooperative cancellation through a final optional standard token and the Cancellation context:
| Context member | Meaning |
|---|---|
Cancellation.Token |
Current standard cancellation token |
Cancellation.IsCanceled |
Cancellation requested |
Cancellation.CanBeCanceled |
Token can receive cancellation |
Cancellation.ThrowIfCanceled() |
Throw standard cancellation failure when requested |
A missing token means a non-cancelable token. DirectSharp-to-DirectSharp calls propagate it when the callee's cancellation contract is compiler-known. Imported APIs can receive it automatically only when the mapping is safely unambiguous; explicit passing remains available.
Using Cancellation in a non-explicit-async action is a compile-time error unless an imported/overridden recognized contract provides it or the use belongs to an owned producer context. An inline run body has its producer context; its enclosing inferred action does not acquire one. Inferred async behavior, including public inference, does not silently add a CLR cancellation parameter. No hidden per-loop cancellation checks are added.
Imported Task and ValueTask APIs retain their actual ABI. An interface implementation cannot replace one with the other merely because both are awaitable.
Keep an operation instead of waiting
Use await call to wait for a result and task_of(call) to retain a fresh returned operation. A bare awaitable-returning call is a compile-time error, including a task-typed assignment. The compiler asks you to choose rather than silently wait or keep the task:
public async action ReadTwoReportsAsync(firstPath as String, secondPath as String)
returns String
let first = task_of(Global.System.IO.File.ReadAllTextAsync(firstPath, Cancellation.Token))
let second = task_of(Global.System.IO.File.ReadAllTextAsync(secondPath, Cancellation.Token))
await Global.System.Threading.Tasks.Task.WhenAll(first, second)
let firstText = await first
let secondText = await second
return firstText + secondText
end action
Both reads are invoked before the explicit await on WhenAll waits for their combined operation, including settlement on failure; this example uses native .NET combinator behavior. Individual Task results can be awaited afterwards. For application work needing DirectSharp's owned scheduling, publication and failure policies, prefer concurrent scopes.
Task capture evaluates its operand once and preserves the returned awaitable's type and operation, including Task identity and ValueTask's existing consumption contract. It adds no wrapper, background thread, automatic join or ownership. Existing awaitable variables remain awaitables: let other = first copies the handle; await first obtains the value. Unlike Task, an arbitrary ValueTask must not be assumed safe to await repeatedly.
The following binding rules keep ordinary calls predictable:
| Expression | Meaning |
|---|---|
let value = LoadAsync() |
Compile-time error: choose await or task_of |
task_of(LoadAsync()) |
Invoke and retain the returned awaitable |
await LoadAsync() |
Invoke and wait once explicitly |
await pending |
Wait for an existing awaitable |
Inspect(await LoadAsync()) |
Bind Inspect using the logical result type |
Inspect(task_of(LoadAsync())) |
Bind Inspect using the raw awaitable type |
let other = pending |
Copy an existing task value without waiting |
An expected Task parameter or assignment type does not acknowledge capture. Use task_of explicitly for a fresh call. The same rule applies to returns, generic/Object consumers and discarded statements. Ordinary overload resolution then applies to one set of argument types; the compiler does not guess between raw and awaited alternatives. Arguments and receivers inside a captured call still need their own await/capture choices for separate fresh awaitable calls. Existing task variables and members can be passed, returned and copied normally.
Await unwraps one level, so awaiting Task<Task<T>> leaves the inner Task<T> as its logical result. Custom .NET awaitables use the same explicit await/capture choices. To operate on a fresh task before waiting, write await task_of(LoadAsync()).ConfigureAwait(false): the inner operation is captured and the configured outer operation is awaited. These boundaries are defined by CS-ASYNC-006/007 in the specification.
The established task_of(result) query on a concurrent result retains its producer identity meaning. It can access the producer without reading an unpublished result or starting the call again. Capturing an unrelated call with task_of inside concurrent does not register an owned producer; use run for that.
Forgotten operations and exception observation
A bare fresh awaitable call without await/task_of/run is an error. An explicit capture that local analysis proves is abandoned is a warning (CS-ASYNC-010). Examples include discarding task_of(call), losing the last tracked local alias by overwrite, or leaving a reachable path without observing or handing off its captured operation. Awaiting an alias counts; copying a local or reading IsCompleted alone does not. Returning, passing or storing a handle beyond local analysis can transfer responsibility, so this diagnostic is not a whole-program guarantee. No hidden await, cancellation, logging or failure suppression repairs the program.
| Source situation | Failure boundary / diagnostic |
|---|---|
| Unmarked fresh awaitable invocation | Compile-time error, even for a Task-typed destination |
| Explicit capture discarded or locally abandoned | Compiler warning; task_of alone is not observation |
| Capture within try, await outside it | Try catches synchronous invocation failure only; operation failure reaches the later await |
| Await within try | Operation failure reaches that handler even if the task failed earlier |
| Task.Run callback throws | Its task records the failure; await it inside try to observe it there |
| Owned invocation or inline run | No individual-await warning; owning scope observes and joins work |
| Raw task_of inside concurrent | Not owned; the same local-abandonment analysis applies |
| Task deliberately returned/handed to an API | Responsibility can transfer; external observation is not statically guaranteed |
Task.Run is an ordinary .NET thread-pool API. It is different from DirectSharp run's structured ownership. A try surrounding an entire concurrent block handles its propagated outcome after settlement, following all/first/any rules rather than a blanket "every child fault fails the group" rule.
Continuation context and UI code
Ordinary Task/ValueTask await uses the .NET default continuation-context behavior. In UI application code entered on a UI synchronization context, this normally allows statements after await to access controls. ConfigureAwait(false) opts that await out of capturing the context; it does not select a thread, promise background execution or affect every await in a caller. Completed operations can continue inline, and other ambient ExecutionContext state is not generally suppressed. Custom awaitables keep their own contracts. See CS-ASYNC-011 and the UI example.
Use await task_of(LoadAsync()).ConfigureAwait(false) where library code deliberately has no need for the captured context. There is no await_outofcontext keyword. Generated cleanup and joins must preserve the normal continuation behavior of surrounding authored code. For CPU-bound work use the ordinary Task.Run API when appropriate; for UI dispatch use the framework's dispatcher. Neither requires a new DirectSharp feature adapter.
Inference and callable boundaries
Ordinary actions, including public actions, infer async implementation from explicit awaits, async enumeration, owned asynchronous joins or async-only cleanup. The compiler generates the required C# async machinery; it does not insert waits at unmarked calls to inferred actions. Capturing a raw task alone does not trigger inference. A dual-interface disposable alone does not trigger it either; disposal prefers the async protocol once the body is otherwise asynchronous. See resource selection.
Optional explicit async declares the asynchronous contract independently of the current body and provides the established cancellation contract. Without it, adding/removing required suspension can change an ordinary action's emitted CLR return type. Interfaces, abstract/overridable contracts and overrides retain their declared signatures. Constructors, synchronous iterators, accessors, plain dispose, unmanaged cleanup and atomic regions cannot silently become async or insert blocking waits.
public action ReadText(path as String)
returns String
return await Global.System.IO.File.ReadAllTextAsync(path)
end action
The explicit await makes this ordinary public action asynchronous without an async modifier. Reference C#:
public static async Task<string> ReadText(string path)
{
return await File.ReadAllTextAsync(path);
}
The excerpt assumes System.IO and System.Threading.Tasks imports. Its CLR result is Task<string>; inference adds no cancellation parameter or Cancellation context. Writing public async action instead would fix the async contract and opt into the explicit action's standard cancellation contract.
A task-returning delegate can accept an async implementation with its declared Task/ValueTask shape. An explicitly captured compatible operation may instead be forwarded without a suspending callback wrapper. A synchronous or void delegate cannot silently become async void. An expression tree must remain representable as the required tree; a raw task-returning call and an asynchronous suspension are different operations. Incompatible callback targets are compile-time errors, including when the author writes explicit await. These rules add no async event-delivery policy or cancellation parameters to callback signatures.
Async enumeration and consumption
action ... yields T produces synchronous enumeration. async action ... yields T produces asynchronous enumeration. Ordinary for_each recognizes IAsyncEnumerable and uses asynchronous acquisition without a separate await for_each token.
for_each invoice in ReadInvoicesAsync()
await SaveInvoiceAsync(invoice)
end for_each
This action fragment remains sequential: one iteration's work finishes before the next proceeds. consume item from source instead performs destructive/forward-only acquisition until permanent completion. Temporary emptiness in a live source is not completion.
concurrent consume job from jobs with limit: 8
await ProcessAsync(job)
end consume
The source here must support safe concurrent acquisition through the consumable protocol or a supported adapter. A raw database reader has one mutable cursor and is invalid for direct concurrent consume. Arrays and arbitrary enumerables do not become consumable automatically. See consumable source contracts.
Custom implementations can declare IConcurrentConsumable<T>, the ordinary marker derived from IConsumable<T>, when overlapping acquisition and acquired-item lifetime are safe for concurrent bodies. It adds no new acquisition operation or blanket thread safety for mutable items. Native adapter capabilities and the direct raw-reader prohibition remain separate.
The standard library contract is IConsumable<T>.AcquireAsync(CancellationToken) returning ValueTask<ConsumeResult<T>>. HasItem, not a null/default payload, distinguishes an item from permanent completion. Sources remain borrowed; no implicit Dispose, producer cancellation or acknowledgement occurs on completion/exit. Native faults and cancellation propagate. Safe concurrent acquisition still requires appropriate source/adapter guarantees.
Standard coverage includes queue/stack drains, completion-aware concurrent queue/stack/bag wrappers, BlockingCollection, ChannelReader, current-result-only database readers, explicit text-line adapters and copied byte chunks. Open concurrent collections need completion/arrival signaling. Text blank lines and positive short byte reads are items; EOF is source-specific. See the adapter contracts.
A completion-aware live collection wrapper with an explicitly reported producer failure closes admission, drains buffered items in their native order, then propagates the original failure on acquisition. It does not report successful completion or silently requeue data. This wrapper contract leaves native channel behavior intact.
Concurrent result scopes
concurrent all
returns customer as Customer, invoices as Invoices
customer = run LoadCustomerAsync(id)
invoices = run LoadInvoicesAsync(id)
end concurrent
Display(customer, invoices)
The application functions/types must be declared in the surrounding program. Each run starts owned work. Result bindings become ordinary values after the scope completes; their logical values are not readable inside it. A sibling that needs another sibling's result is a dependency and must be expressed sequentially or through nested structure.
| Completion mode | Selection rule |
|---|---|
all |
All required child operations complete |
first |
First successful producer wins |
any |
First completed producer wins, including unsuccessful completion |
first does not discard a later success merely because an earlier producer failed. any does select that earlier failed completion.
In v1, first/any publish one result binding. If a winner must supply several related values, return one ordinary result object and read its members after the scope. Each producer returns that same result type; the scope selects one producer's complete object. Separate competing result bindings and a special multi-output branch form are not supported. all can still publish separate outputs from separate operations. See related values from one winner and CS-CONCURRENT-021.
This grouping adds no deep copy, transaction or shared-mutation guarantee. A selected failure publishes no fabricated object or member defaults; ordinary failure/cancellation settlement and task_of identity remain unchanged.
After owned work is joined, a successful first or any retains its selected result despite losing producer faults, subject to external causes recorded before publication. An unsuccessful any propagates its selected producer's original exception. If all has failures, or first has no success, one producer failure propagates unchanged and several produce System.AggregateException in source-declaration order. Observe losing outcomes without automatic logging; retained producer handles preserve their actual state. Cancellation solely attributable to the scope caller/timeout is an external outcome rather than an independent producer failure. Exceptions reported by cancellation callbacks/requests are observed and suppressed after all owned joins, preserving the ordinary producer/external outcome. Do not automatically log them, add an error-history API or include them in producer aggregates. An independently failing producer still follows the producer rules.
A statically producer-free first/any is a compile-time error. If a permitted runtime path launches zero producers, throw InvalidOperationException without fabricated results. Empty concurrent loops still complete normally; an empty all cannot supply required bindings by inventing defaults.
Inside a concurrent scope, task_of(result) denotes the latest run producer for that binding up to that lexical point. After publication it denotes the actual producer of the result. A handle is distinct from permission to read an unpublished logical value.
Inline run blocks
Use an inline body when a producer needs several steps without a separate named action:
concurrent all
returns invoiceLength as Integer, contractLength as Integer
invoiceLength = run
let text = await client.GetStringAsync(invoiceAddress, Cancellation.Token)
return text.Trim().Length
end run
contractLength = run
let text = await client.GetStringAsync(contractAddress, Cancellation.Token)
return text.Trim().Length
end run
end concurrent
The fragment assumes a supplied HttpClient and two addresses. Each branch uses its producer's effective Cancellation.Token. Each continues independently after its own await. No explicit async block modifier or final source await is required; end concurrent performs the owned join. An inline producer is registered once, and task_of(result) retains the existing producer-identity meaning.
| Boundary | Rule |
|---|---|
| Syntax | result = run through end run; omit the assignment for a resultless producer |
| Placement | Requires an enclosing concurrent owner; not a general task expression or detached statement |
| Result typing | Destination's logical type; every reachable normal exit returns a compatible value |
| Resultless branch | Bare return or fallthrough; return with a value is invalid |
| Return | Completes this callable branch after cleanup and owned joins; never exits the enclosing action |
| Nested control flow | Return cannot cross a concurrent iteration; break/continue/exit cannot escape the run callable boundary |
| Locals/captures | Branch-local declarations; ordinary captures without deep copying; shared mutation still needs coordination |
| Suspension | Explicit await for ordinary calls; automatic structured cleanup/acquisition/joins |
| Limits | Capacity covers the complete branch, cleanup and owned descendants |
| Cancellation | Existing producer token applies through the body; explicit different tokens remain overrides |
| Failure/selection | Existing all/first/any policy; a value-producing return is not success until cleanup settles |
First/any can use inline competitors returning the same single result type. To return related values, construct one result object; this adds no compact multi-output competing syntax. An inline branch does not make unpublished sibling values readable. Nested scopes keep their own result visibility. Raw task_of capture inside a branch still adds no ownership. Yield is not supported as a way to turn the branch into an iterator. See CS-CONCURRENT-022–024 and the reference C# lowering.
For repeated input items, prefer a bounded concurrent for_each to copying a branch for every item. For a reusable operation, prefer a named action. Inline branches suit a small fixed group of distinct workflows.
Bounded concurrent loops
concurrent for_each customer in customers with limit: 4
let profile = await LoadProfileAsync(customer)
await SaveProfileAsync(profile)
await WriteAuditAsync(customer)
end for_each
Up to four whole iterations overlap. Work remains sequential within each iteration. A body needs no extra run to overlap with other bodies. Nested runs belong to that iteration; its slot remains occupied until all owned work settles.
An integer limit is private to the construct. A Gate shares capacity with other constructs using it. A nonpositive constant limit is a compile-time error; a dynamic nonpositive limit is a runtime argument error. Omitting a limit imposes no language-level cap; internal scheduling windows must not become a hidden observable Gate bound.
A loop limit bounds whole iterations. An inner enter databaseGate bounds a particular region. Twenty active iterations can legitimately share a two-permit database region.
| Control operation | Ordinary loop | Concurrent iteration |
|---|---|---|
continue |
End current iteration | End current iteration only |
break |
Exit innermost loop | Stop scheduling and join active iterations |
Bare exit |
Exactly equivalent to break |
Exactly equivalent to break |
return |
Exit enclosing action | Direct action return from iteration body is forbidden |
Break and equivalent bare exit do not automatically cancel active iterations. Nested loops have distinct structured lifetimes; either statement affects only the innermost loop and joins its active work. Iteration-local declarations are independent. An empty concurrent source completes with zero iterations. Ordinary for_each ... in range ... remains sequential; parallel range processing uses explicit concurrent for_each.
Targeted loop exits may leave a loop nested inside an iteration or stop its containing concurrent loop. They cannot leave a loop outside that containing concurrent loop. A matching target beyond that boundary is a compile-time error. For example, this fragment attempts an invalid outward transfer:
while HasMoreBatches()
concurrent for_each item in batch with limit: 4
if ShouldStop(item) then
exit while // error: while lies outside this concurrent loop
end if
await ProcessItemAsync(item)
end for_each
end while
Inside this iteration, exit for, bare exit or break can stop the containing concurrent enumeration, joining active iterations without automatically cancelling them. Execution then continues after that enumeration within the outer while. A targeted exit from an inner concurrent loop is constrained by that inner loop's boundary in the same way.
Concurrent conditional/post-test loops evaluate conditions sequentially through their coordinator. When full, the coordinator waits for capacity before checking again. A while condition stops scheduling when false; an until condition stops scheduling when true. Both join active work. Post-test processing starts at least one iteration. The loop specification gives the applicable forms and control constraints.
| Form | Opening line | Closing line |
|---|---|---|
| Pre-test | concurrent while condition with limit: expression |
end while |
| Post-test until | concurrent repeat with limit: expression |
until condition |
| Post-test while | concurrent repeat with limit: expression |
while condition |
The limit expression is a positive integer or shared Gate. Post-test closers do not have a subsequent end repeat. A condition is tested by the coordinator; it does not implicitly reserve work for a worker.
LastLoop.TerminatedByExit distinguishes explicit termination from normal condition completion or source exhaustion. break and equivalent bare exit set it to true; a matching targeted exit sets it for its target loop. Normal completion, including an empty source, gives false. Observe concurrent completion after joining active iterations; the usual lexical scope rules for LastLoop apply.
Collecting ordered results
concurrent for_each order in orders collects amounts as Decimal with limit: 8
collect amounts = await CalculateAsync(order)
end for_each
let total = amounts.Sum()
This fragment requires the ordinary Sum extension import. collects declares an element type and publishes the result collection after completion. Canonical assignment is destination-first: collect amounts = expression.
Collection order follows source/enumeration order, not completion order. Basic collection supplies one value per successful iteration for each declared output. task_of(amounts[index]) denotes the producing iteration. No dedicated concurrent aggregation grammar is needed; use ordinary collection operations afterwards.
Winner settlement
First supplies producer-effective child tokens linked to caller cancellation and any scope deadline. After a successful winner is selected, request cancellation of unfinished losers and retain the winner. Internal loser cleanup cancellation does not fail that successful result. Any does not request cancellation merely because a completion was selected. Both modes join all owned work, so calls ignoring cancellation can delay scope exit.
Existing implicit token forwarding uses the producer's effective token; an explicit Cancellation.Token within the producer expression denotes it too. A different explicitly supplied token remains an override. Unsupported/ambiguous imported cancellation overloads are not guessed, and no caller-token cancellation or thread termination is introduced.
When scope failure is attributed to caller cancellation or timeout, preserve the first cause recorded by the scope; if both are already requested at the initial observation, prefer the caller. Cancellation during timeout cleanup does not relabel a timeout. Use ordinary cancellation or TimeoutException respectively. A recorded external cause before publication, including during the join after selecting a winner, prevents successful publication; a later success cannot erase an earlier cause. Signals after publication do not change the published result. Otherwise-propagated genuine producer failures take precedence over external causes, retaining their original/single or source-ordered aggregate form. Observation-only losing faults stay excluded and cancellation-request errors stay suppressed.
For example, if a fast first winner succeeds but a token-ignoring loser keeps the scope open, deadline expiry during that join produces TimeoutException after the loser settles. A deadline covers the whole scope. The compiler/runtime must coordinate external-cause recording with the publication boundary, rather than freezing cause state at winner selection.
Gate regions and atomic state
enter accountGate
let account = await LoadAsync(id)
await SaveAsync(account)
leave
Gate()/Gate(n)/Gate(n, timeout) use a positive capacity; count-taking constructors reject n <= 0 with ArgumentOutOfRangeException. A zero acquisition timeout tries immediately and gives TimeoutException when no permit is available. Timeout.InfiniteTimeSpan means unlimited acquisition waiting; other negative durations are invalid. An explicit timeout overrides the Gate's default for that acquisition. These waits do not impose a deadline on the protected body's duration.
Each entry takes another permit. Gate is non-reentrant: nested entry to the same one-permit Gate waits and may time out/be cancelled, while a multi-permit Gate can admit it when another permit is free. No FIFO or fairness guarantee is supplied. Structured permit bounds and source-order collected publication remain distinct from waiter order.
For valid acquisition arguments on a live Gate, an initially cancelled caller fails with OperationCanceledException before taking capacity, even when a permit is free. Competing failed-wait causes retain the first recorded caller/timeout cause; initially competing signals prefer caller. A later caller signal does not relabel a recorded timeout.
Grant, caller cancellation and acquisition timeout compete through one atomically recorded acquisition outcome. A cause recorded first prevents entry; a grant recorded first permits the body to begin even if cancellation arrives just afterwards. Gate adds no second cancellation check before the body. Protected work can still respond to its caller token normally. If native capacity was obtained after a cause had committed, restore it without running the body. This acquisition boundary does not replace whole-concurrent-scope cancellation or result-publication rules.
Gate keeps its primitive private and implements IDisposable. Supplied Gates are borrowed by enter/leave and concurrent-limit scopes; those scopes do not dispose them. The owner disposes after all users settle. Disposal with pending/held acquisitions throws InvalidOperationException and leaves the object usable; retry after settlement. Successful disposal is idempotent and later acquisition throws ObjectDisposedException. See the lifetime pattern.
An acquired permit is released on normal/control-flow/exception/cancellation exit. The Gate supports async coordination and has explicit identity. Acquisition timeout differs from a deadline for the entire concurrent operation.
Ordinary shared mutation such as count += 1 requires explicit coordination:
atomic count += 1
For a supported integer target, a representative CLR operation is:
global::System.Threading.Interlocked.Increment(ref count);
The actual target, storage and operation must support a correct native atomic lowering. Unsupported operations are diagnosed rather than silently implemented as unsafe writes.
atomic ... end atomic groups a short synchronous protected state transition. It permits no explicit or implicit suspension. A block calling an async method that would suspend is invalid; use a Gate when coordination must span asynchronous work.
atomic swap(a, b) protects an exchange. Ordinary swap(a, b) in a concurrent body automatically uses atomic exchange; swap inside an atomic block participates in that block's protection. Both targets must be writable and have the same effective type. The value type need not support native Interlocked operations: compatible CLR reference and value types can use a generic helper under shared protection.
The compiler coordinates exchange with atomic regions protecting the same state. It must use a stable compatible synchronization domain; a newly allocated lock on every call cannot coordinate callers. Consistent pair reads and competing writes use compatible protection. Uncoordinated accesses, including external .NET calls, do not acquire a guarantee merely because swaps are protected. No implicit suspension is introduced.
Readable/writable properties and custom indexers are supported. Resolve the left receiver/index arguments once, then the right ones. Under shared protection, read left, read right, write the saved right value to left, then write the saved left value to right. A getter failure prevents exchange setter calls; setter failure can leave partial changes. Exceptions propagate and protection is released, with no rollback or compensating writes. An adapter must preserve original mutable-struct storage rather than modify a temporary copy. Inaccessible/missing accessors, incompatible types, suspension and unsupported CLR adapters are diagnosed.
The compiler must diagnose provable unsynchronized captured shared writes from concurrent bodies and direct the programmer to explicit atomic, Gate or collection publication. The specific swap exception does not authorize automatic synchronization of other mutations. Opaque aliasing can limit proof. Publication and safe initialization do not make mutable application objects automatically thread-safe.
Named loops and jump boundaries
Concurrent loops may carry with name: identifier on their opening header.
break identifier selects a named enclosing loop; if it is the containing
concurrent loop, scheduling stops and admitted iterations are joined without
automatic cancellation. continue identifier selecting that concurrent loop
ends only the current iteration. Naming an outer loop does not authorize leaving
it from across the containing concurrent iteration boundary.
A mark is a statement destination, not a loop name. Forward/backward goto does not provide an additional escape from a worker or inline run callable. Cleanup, resource ownership and settlement remain required. See the named-loop and jump rules.