Running operations concurrently
A report may need several independent API responses. A batch import may need to read hundreds of documents while protecting a slower database. DirectSharp distinguishes waiting for one operation from owning several overlapping operations. That distinction keeps ordinary application flow readable and makes concurrency a deliberate choice.
await: wait for an asynchronous result
An asynchronous operation can yield execution while waiting for I/O. That does not automatically mean the next statement runs concurrently with it.
public async action DescribeDocumentsAsync(
paths as Global.System.Collections.Generic.IEnumerable <of String>
)
for_each path in paths
Cancellation.ThrowIfCanceled()
let contents = await Global.System.IO.File.ReadAllTextAsync(path, Cancellation.Token)
Global.System.Console.WriteLine("{{path}}: {{contents.Length}} characters")
end for_each
end action
Each file is read before its length is printed, and each iteration finishes before the next one starts. The explicit await gives contents the String value produced by ReadAllTextAsync. Every ordinary fresh awaitable call must choose await for its result or task_of for its operation; the compiler does not silently choose for you.
The compiler infers async implementation from awaits and asynchronous structured operations in ordinary actions, including public actions. You can also declare async action explicitly to fix the asynchronous contract and use the standard cancellation-token context. The returns type is the logical result after waiting. Inference alone does not add a cancellation parameter. Imported .NET names keep their Async suffix; their resolved types determine awaitability.
You can keep an operation for later with task_of(call). For example, inside an async action:
let pending = task_of(Global.System.IO.File.ReadAllTextAsync(path, Cancellation.Token))
let text = await pending
The fragment assumes an existing path and an explicit async action providing Cancellation. The first line invokes the read and keeps its returned task; the second waits for its result. A bare let text = Global.System.IO.File.ReadAllTextAsync(path) is a compile-time error asking for await or task_of. Existing task variables can be copied normally. Capturing a task creates no automatic join: keep its resources alive and observe its completion. For ordinary overlapping application operations, the concurrent scopes below manage that lifetime for you. See task capture and binding for direct .NET interop.
task_of / await: retain and observe an operation
Await is where a task's result or failure reaches your code. A try around only the call that obtains a task cannot catch a failure stored in that task later. Keep the await inside the handler's protected body:
let pending = task_of(Global.System.IO.File.ReadAllTextAsync(path))
try
let text = await pending
Global.System.Console.WriteLine(text)
catch error as Global.System.Exception
Global.System.Console.WriteLine(error.Message)
end try
This fragment assumes a supplied path. It catches the operation's failure even if the task completed before the await. Synchronous failures while obtaining the task occur at the capture line; include that line in try too when those failures need the same handler. The same distinction applies to ordinary .NET Task.Run: await its returned task inside try to catch the callback's failure. Task.Run schedules work on the thread pool; task_of simply keeps an operation's handle.
The compiler warns when local flow reveals that a captured operation is discarded or forgotten without observation or transfer to another owner. Capturing alone is not enough. Returning a task or handing it to a library can legitimately transfer responsibility, so this analysis cannot guarantee that every task in an application is eventually observed. Owned run operations need no individual await: their scope already observes them.
ConfigureAwait: choose continuation behavior
Ordinary await is the usual choice for UI application code. When a UI framework supplies its synchronization context, a normal Task await arranges continuation through that context so the following code can update controls:
let customer = await LoadCustomerAsync(id)
nameLabel.Text = customer.Name
The fragment assumes an application loader, customer identifier and UI label, and executes in a task-returning operation entered on the UI context. It does not introduce automatic async void event handlers. Use the framework's supported asynchronous command/callback integration when wiring it to the UI.
ConfigureAwait(false) is an ordinary .NET method for code that does not require the captured context, commonly reusable library internals:
let text = await task_of(client.GetStringAsync(address)).ConfigureAwait(false)
return text.Trim()
Here client and address are supplied inputs and the following code only processes the text. False means "no need to resume through my captured context," not "move to a background thread." An already-completed operation may continue on the current thread. After opting out, do not assume UI access; use the framework's dispatcher if it is needed. This choice affects this await, not every await in callers or siblings. ASP.NET Core normally has no custom synchronization context to return to. Most application code can simply use await.
async action / Cancellation: cooperate with cancellation
Cancellation is cooperative. A caller requests it; operations observe it at appropriate points. The Cancellation context provides the current .NET-compatible token and an explicit check.
Pass Cancellation.Token to an imported API when it has a suitable token parameter. This makes the intended overload visible. A check before starting a unit of work is useful when that unit has additional synchronous work. It does not substitute for passing a token into a long-running I/O operation.
public async action ReadConfigurationAsync(path as String)
returns String
Cancellation.ThrowIfCanceled()
let contents = await Global.System.IO.File.ReadAllTextAsync(path, Cancellation.Token)
require contents is not whitespace
return contents
end action
Calls between explicit DirectSharp async actions propagate the known cancellation context. Cancellation does not forcibly terminate a thread, roll back a database transaction or make an imported API cancellable when that API has no such capability.
concurrent all / first / any: coordinate independent work
Suppose an application needs two unrelated documents to build a dashboard. Both requests can start before either result is consumed.
public async action LoadDashboardAsync(
client as Global.System.Net.Http.HttpClient,
summaryAddress as Global.System.Uri,
alertsAddress as Global.System.Uri
)
returns (Summary as String, Alerts as String)
concurrent all
returns summary as String, alerts as String with limit: 2
summary = run client.GetStringAsync(summaryAddress, Cancellation.Token)
alerts = run client.GetStringAsync(alertsAddress, Cancellation.Token)
end concurrent
return (Summary = summary, Alerts = alerts)
end action
run starts child work owned by this scope. concurrent all completes the required operations before publishing summary and alerts. The caller supplies a suitably configured HttpClient and owns its lifetime.
A result cannot be read within the concurrent block. If fetching alerts needs the summary's customer identifier, the calls are dependent. Load the summary first, extract the identifier and then start the request that depends on it. Making dependency order explicit prevents a concurrent-looking block from hiding sequential assumptions.
The other completion modes serve different jobs:
| Mode | Use it when |
|---|---|
all |
Every required result is needed |
first |
Several producers can supply a result and the first successful one should win |
any |
The first completion should determine the outcome, even if it failed |
A failed fast response does not win first. It can win any. Applications should treat that difference as part of their error-handling design rather than as a performance detail.
After first chooses a successful winner, it requests cooperative cancellation of unfinished competitors. The compiler forwards each producer's linked token when its call contract permits; explicit Cancellation.Token inside that producer uses the same effective token. A different explicit token is respected. The scope joins all owned work, including calls that ignore cancellation. Cancellation from stopping losers does not turn the successful result into a cancellation exception.
Any selects a completion without cancelling competitors merely because it selected one. A selected failure is rethrown after the owned work settles; a later success does not replace it. In both first and any, genuine losing producer faults are observed but do not destroy a selected success. No automatic logging is added. A throwing cancellation callback/request is also observed and suppressed after all owned work joins; it does not replace the normal producer or external outcome.
When all fails or first gets no success, a single producer failure is rethrown unchanged. Multiple failures use AggregateException with entries in source order. Caller cancellation and timeout are separate scope causes; when choosing between those two, the first recorded cause is retained, with caller preference if both are already requested initially. An external cause recorded before publication can prevent a selected success from being returned, including during the join. For example, a fast winner followed by a deadline while waiting for a token-ignoring competitor produces TimeoutException once that competitor settles. Signals after publication do not change the returned value. Genuine producer failures that would otherwise propagate take priority over cancellation/timeout; ordinary acknowledgements of scope cancellation are not extra producer failures. Losing faults already designated observation-only stay excluded. Neither external cause can forcibly stop imported work.
First and any need a producer: a statically producer-free construct is rejected, and a permitted runtime path launching none throws InvalidOperationException. Empty concurrent loops still complete normally.
For example, race two HTTP reads through ordinary .NET APIs:
public async action ReadFirstAvailable(
client as Global.System.Net.Http.HttpClient,
primaryAddress as Global.System.Uri,
backupAddress as Global.System.Uri
)
returns String
concurrent first
returns body as String
body = run client.GetStringAsync(primaryAddress)
body = run client.GetStringAsync(backupAddress)
end concurrent
return body
end action
The compiler can select the unambiguous cancellation-aware HttpClient overload and pass each producer's linked token. If the primary fails and the backup succeeds, the backup wins. If the primary succeeds while the backup is still running, the scope requests backup cancellation and joins it before returning. No wrapper library or manually supplied cancellation argument is required for this safely bound call.
run: give a concurrent branch a body
A separate action is useful for a reusable operation. For a short workflow used only here, put its steps directly in a run block. This action downloads two documents, processes each as soon as it arrives, and saves each independently:
public async action SaveTwoDocumentsAsync(
client as Global.System.Net.Http.HttpClient,
invoiceAddress as Global.System.Uri,
contractAddress as Global.System.Uri,
invoicePath as String,
contractPath as String
)
returns (InvoiceCharacters as Integer, ContractCharacters as Integer)
concurrent all
returns invoiceCharacters as Integer, contractCharacters as Integer
invoiceCharacters = run
let text = await client.GetStringAsync(invoiceAddress, Cancellation.Token)
let cleaned = text.Trim()
await Global.System.IO.File.WriteAllTextAsync(invoicePath, cleaned, Cancellation.Token)
return cleaned.Length
end run
contractCharacters = run
let text = await client.GetStringAsync(contractAddress, Cancellation.Token)
let cleaned = text.Trim()
await Global.System.IO.File.WriteAllTextAsync(contractPath, cleaned, Cancellation.Token)
return cleaned.Length
end run
end concurrent
return (InvoiceCharacters = invoiceCharacters, ContractCharacters = contractCharacters)
end action
The paths designate different files; the caller owns the HttpClient. If the invoice download finishes first, its write can start while the contract is still downloading. Each await pauses only its own branch. Within a branch, statements remain sequential. This is overlapping asynchronous I/O, not a promise of a dedicated thread per document.
Return finishes that branch. It does not exit SaveTwoDocumentsAsync. Branch-local finally/using cleanup and owned child work finish before the branch is complete; the surrounding end concurrent waits for both branches before publishing their values. A limit applies to the whole branch, including cleanup. For a branch with no published value, use run through end run without an assignment; it may fall through or use bare return.
Put try/catch inside a branch to recover that branch's operation. Put try/catch around the concurrent block to handle failures propagated by the group after joining. All propagates one original producer failure or an AggregateException for several. A failure does not undo a sibling's completed file write. Prefer distinct output files or explicit coordination for shared resources.
The same inline form can supply a first/any competitor returning one result object. Selection waits for that producer's complete outcome, including cleanup. The existing winner, cancellation and settlement rules apply equally to an invocation and an inline block. See inline branch rules for typing and invalid control transfers.
concurrent first / any: publish one result bundle
Suppose primary and backup services can each load a customer and that customer's accounts. Race the complete bundle, so the chosen customer and accounts come from the same service operation:
concurrent first
returns bundle as CustomerBundle
bundle = run LoadPrimaryBundleAsync(id)
bundle = run LoadBackupBundleAsync(id)
end concurrent
let customer = bundle.Customer
let accounts = bundle.Accounts
This fragment belongs in an explicit async action with an existing id. CustomerBundle is an ordinary application class with Customer and Accounts properties; both loader actions return that type. Each loader builds its own complete bundle before returning. After the scope joins its owned work and successfully publishes the winner, member access works as usual. Replacing first with any selects the first completion, including a failure, under the same grouping rule.
In v1, competing first/any operations publish one result binding. Use an ordinary result object when it contains several related values. all still permits separate outputs from separate operations. Grouping values keeps one producer's result together; it does not deep-copy referenced objects or promise a database transaction.
concurrent for_each: limit parallel iterations
Concurrent iteration is useful when items are independent. The following action checks several URLs and returns response lengths in the original address order.
public async action DownloadLengthsAsync(
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 Integer>
concurrent for_each address in addresses collects lengths as Integer with limit: 4
let contents = await client.GetStringAsync(address, Cancellation.Token)
collect lengths = contents.Length
end for_each
return lengths
end action
Up to four whole iterations overlap. Within an iteration, the download finishes before its length is collected. A slow first URL does not move its result behind a fast later URL: published collections preserve source order.
collects lengths as Integer names the element type. The language supplies coordinated publication; you do not need to append to a shared mutable List from worker code. A loop with no collected output is equally useful for side effects such as sending notifications.
An integer limit belongs to the loop. If several separate operations must share the same capacity, supply the same Gate instead. Omitting a limit does not select a magic default cap; choose a limit when the resource being protected has a practical bound.
Gate / enter / leave: protect shared resources
A loop's limit controls active whole iterations. An inner Gate can protect a narrower step.
public async action SaveCopiesAsync(
sourcePaths as Global.System.Collections.Generic.IEnumerable <of String>,
destinationDirectory as String,
writes as Gate
)
concurrent for_each path in sourcePaths with limit: 8
let contents = await Global.System.IO.File.ReadAllTextAsync(path, Cancellation.Token)
let name = Global.System.IO.Path.GetFileName(path)
let target = Global.System.IO.Path.Combine(destinationDirectory, name)
enter writes
await Global.System.IO.File.WriteAllTextAsync(target, contents, Cancellation.Token)
leave
end for_each
end action
A caller can supply new Gate(2) to allow two writes at a time across all callers sharing that object, while this loop still allows eight active iterations. Gate protection covers the async write and releases its permit when control leaves, including exceptional exits. Independent callers must agree on the same Gate identity to coordinate.
The file naming policy must avoid unwanted overwrites when source paths share a filename. That is an application rule; a concurrency limit does not establish a collision policy.
A Gate acquisition timeout bounds waiting for a permit. A concurrent-scope timeout bounds the whole concurrent operation. Neither means that unfinished native work can be terminated immediately.
A Gate count must be positive; its constructors throw ArgumentOutOfRangeException for zero or negative counts. For acquisition, TimeSpan.Zero means try immediately, Timeout.InfiniteTimeSpan means unlimited waiting, and other negative durations are invalid. A failed acquisition runs no protected body and releases no permit.
Gate is non-reentrant. If a routine already holds a one-permit Gate and calls another routine that enters the same object, the inner entry waits; use an acquisition timeout/cancellation or restructure around one outer entry. Every entry consumes a permit, so a multi-permit Gate may admit nested work when another permit is free. Do not rely on which waiter wakes first: Gate guarantees capacity and release, with no FIFO promise.
An already-cancelled caller fails acquisition with OperationCanceledException before the body, even if the Gate is free. If cancellation and an acquisition timeout compete during a failed wait, the first recorded cause determines the exception; a late cancellation does not turn an already-recorded timeout into a cancellation result.
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.
The owner can use ordinary using for a Gate:
using writes = new Gate(2)
await SaveCopiesAsync(sourcePaths, destinationDirectory, writes)
end using
This belongs in an explicit async action with sourcePaths and destinationDirectory inputs. SaveCopiesAsync above borrows writes and joins its work before returning. Only then does the outer using dispose it. If some other holder/waiter still uses the Gate, disposal fails with InvalidOperationException and leaves it usable; finish those users and retry. Successful disposal is idempotent; subsequent acquisition fails with ObjectDisposedException.
atomic / Gate / swap: coordinate shared updates
An iteration-local variable belongs to its iteration. A variable declared outside the loop is shared.
public async action CountReadableFilesAsync(
paths as Global.System.Collections.Generic.IEnumerable <of String>
)
returns Integer
let completed = 0
concurrent for_each path in paths with limit: 4
let contents = await Global.System.IO.File.ReadAllTextAsync(path, Cancellation.Token)
Global.System.Console.WriteLine("{{path}}: {{contents.Length}} characters")
atomic completed += 1
end for_each
return completed
end action
atomic makes the counter update explicit. Removing it would leave a shared unsynchronized update, which the compiler diagnoses when it can prove the sharing. It does not quietly repair the program.
For several related synchronous updates, atomic ... end atomic describes one protected transition. Keep it short. An atomic block cannot suspend, including through an explicit await or generated asynchronous cleanup. Use a Gate when asynchronous work needs protected access. For reporting several per-item values, collecting results and then computing a total is often easier to reason about than shared mutation.
Swap has one specific convenience rule: swap(first, second) in a concurrent body automatically protects the exchange. Elsewhere, write atomic swap(first, second) when protection is required. The compiler coordinates this exchange with related atomic blocks, using shared synchronization rather than a fresh lock for each call. The value type can be an ordinary CLR reference or struct. Readers needing a consistent view of both values must use compatible protection too; other shared assignments still need explicit coordination.
Atomic swap also supports readable/writable properties and indexers. Their receivers and indexes are evaluated once. Accessors run under the shared protection, but their exceptions still propagate: a failing setter can leave a partial exchange. Use simple stored state when accessor side effects would make that failure difficult to reason about.
concurrent while / repeat: coordinate repeated work
When an application has a coordinated pending-work service rather than a fixed enumerable, a concurrent conditional loop can bound overlapping operations:
concurrent while HasWork() with limit: 4
await ProcessNextAsync()
end while
This fragment belongs inside an async action. The application supplies HasWork and ProcessNextAsync; the processing action must safely claim its next work item because checking HasWork does not reserve one. For a sequence you already have, prefer concurrent for_each.
The coordinator waits for capacity before testing HasWork again. A false result stops new scheduling and joins active iterations without automatically cancelling them. The condition is checked sequentially even though the processing operations overlap.
Use a post-test repeat when at least one operation must start before checking the stopping condition:
concurrent repeat with limit: 4
await ProcessNextAsync()
until Finished()
Finished is an application Boolean action reading coordinated state. The trailing until closes the loop and stops scheduling when true; a trailing while instead continues while true. Neither form has end repeat. A shared Gate can replace the integer limit. Break or bare exit stops the containing loop and joins active iterations; a targeted exit cannot leave an outer loop beyond that concurrent loop.
async dispose: declare asynchronous cleanup
Use async dispose ... end dispose when a class owns resources requiring asynchronous cleanup. For example, an owned StreamWriter can be released through its normal .NET API:
async dispose
await writer.DisposeAsync()
end dispose
This is a class-body fragment with an owned writer field. The call is explicitly awaited. The async dispose declaration fixes the class's IAsyncDisposable contract; changing implementation details does not silently turn a synchronous IDisposable class into an asynchronous one. It remains async even if a later implementation completes synchronously.
At the use site, using already selects synchronous or asynchronous disposal from the resource type. The class declaration states its contract; the scope handles the appropriate cleanup. CLR finalization and any unmanaged finalizer section remain synchronous.
yields / yield / for_each: process asynchronous streams
An async iterator supplies values over time without materializing them all first.
public async action ReadDocumentsAsync(
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
yield await client.GetStringAsync(address, Cancellation.Token)
end for_each
end action
public async action PrintDocumentsAsync(
client as Global.System.Net.Http.HttpClient,
addresses as Global.System.Collections.Generic.IEnumerable <of Global.System.Uri>
)
for_each document in ReadDocumentsAsync(client, addresses)
Global.System.Console.WriteLine(document)
end for_each
end action
async action ... yields String declares an asynchronous stream. Ordinary for_each recognizes the async source; a separate await for_each spelling is unnecessary. Calling an iterator obtains the sequence. Its body runs while it is enumerated, and failures occur at that point.
A ChannelReader is a different kind of source: it represents forward-only acquisition until producer completion. Use consume for that contract. Temporary emptiness is not the same as completion. Raw database-reader rows are ephemeral and must be projected into independent values before retention or parallel processing.
Read the precise async and concurrency contracts for evaluation, lifetime and failure rules.
Practice: async ownership and cancellation
Trace dashboard completion and failure, worker cleanup, and request versus job cancellation. Each answer identifies what can be published and when.