Concurrency and async
G#'s production concurrency surface is built on three pieces:
scope { ... }— structured-concurrency blocks that wait for the work they own and surface its failures.async func+await— task-based asynchrony that interoperates with the .NETTaskandTask[T]types.sequence[T]+async sequence[T]— synchronous and asynchronous iterators built onyieldand consumed withfor/await for.
This guide focuses on the structured surface. The Go-flavored layer
(go, chan[T], select, for v in ch) is part of the language too and
is documented in Go-flavored concurrency.
scope — structured concurrency
scope { ... } runs its body and, before returning, joins every
goroutine started inside it with go call(...). Inside the block an
implicit ctx of type Gsharp.Concurrency.Context is bound: the first
failing goroutine cancels it immediately, so siblings that observe
ctx.IsCancelled (or park on a channel) stop before the join completes.
Exceptions are never dropped:
- the body throws and every goroutine succeeds — the body's exception propagates unchanged;
- goroutines fail — the scope throws a
Gsharp.Concurrency.ScopeException(anAggregateException) whoseFirstFailureis the cause and whose inner exceptions list every failure in completion order; sibling cancellations caused by that failure are not listed; - both fail — the body's exception is first.
A scope is a suspension point: a function containing one is compiled as
a suspending function (see below), so the join parks the state machine
rather than a thread; only the entry point blocks.
import System
import System.Threading.Tasks
async func work(label string) {
await Task.Delay(1)
Console.WriteLine("done: $label")
}
scope {
work("a").Wait()
work("b").Wait()
}
Console.WriteLine("after scope")
Use scope when a parent operation should not return before its
children. If you find yourself reaching for a Task[] array and
Task.WhenAll, a scope block is usually the simpler shape.
async func and await
async func with an omitted return type produces Task; declaring T
produces Task[T]. Explicit async func handler() void instead matches C#
async void: it is a fire-and-forget void callable intended for event
handlers and cannot be awaited. await expr suspends the surrounding async
function until the awaited task completes and yields its result.
package GSharp.Samples.AsyncTask
import System
import System.Threading.Tasks
async func compute(n int32) int32 {
await Task.Delay(5)
return n * 2
}
async func runAll() int32 {
let a = await compute(3)
let b = await compute(4)
Console.WriteLine("a = $a")
Console.WriteLine("b = $b")
return 0
}
runAll().Wait()
Console.WriteLine("done")
Top-level scripts that need to drive an async entry point can await
inside a scope, or call .Wait() (or .GetAwaiter().GetResult()) on
the returned task when blocking is acceptable.
Async function type clauses are written async (T) -> R and lower to
(T) -> Task[R] (or (T) -> Task for void). Writing the explicit task
type in the return slot of an async (T) -> ... clause is diagnosed —
the async marker already supplies the task shape.
// Field that holds an async callback.
var onReady (string) -> Task = (msg string) -> Task.CompletedTask
var publish async (string) -> void = (msg string) -> Console.WriteLine(msg)
await is a prefix expression and is only valid inside async
contexts; using it elsewhere or on a non-awaitable operand is
diagnosed.
suspend func — suspension without a task
A suspend func is the shape a channel-consuming helper wants: it may
suspend (on a channel operation or an await), but callers never see a
Task. Inside another suspending function or an async func the call is
awaited implicitly and yields the value directly:
suspend func take(ch in chan[int32]) int32 {
return <-ch
}
suspend func sum(ch in chan[int32], n int32) int32 {
var total = 0
for i in 0 ... n {
total = total + take(ch) // implicit await; `take` yields int32
}
return total
}
The emitted method returns ValueTask[int32], so C# callers await it as
usual. async func and suspend func are never combined on one declaration.
You rarely need to write suspend: suspension is inferred. A plain
func that receives, sends, drains a channel, or calls a function that does
is compiled as a suspending function automatically, so the worker-pool and
pipeline samples read exactly like Go and still park a state machine rather
than a thread. The keyword is for the places inference cannot reach — an
open or override method, an interface member, a method implementing
one, a function literal — and for library authors who want to pin the
contract. Inside those boundaries a call to a suspending function has nowhere
to await, so it blocks the thread until the callee completes; the compiler
says so with GS0558. Top-level statements are the one place that block is
right, so the entry point calls suspending functions silently.
Timers and fan-in
Two helpers come with the language, by bare name — the namespace they live in is imported for you:
select {
case let job = <-work {
handle(job)
}
case <-after(TimeSpan.FromSeconds(2)) {
Console.WriteLine("timed out")
}
}
for value in merge(left, right) {
Console.WriteLine(value)
}
after(d) fires once; tick(d) fires every d until you dispose it, so hold
it in a using let when you select on it in a loop. merge drains every input
concurrently and closes its result once the last input closes; the result is
receive-only, because only merge writes to it. Declaring your own after or
merge shadows these.
Choosing among more than channels
Three arm shapes cover what Go reaches for other tools to express.
scope {
var draining = false
select {
case let job = <-work when !draining {
handle(job)
}
case let page = await fetch {
render(page)
}
case cancelled {
Console.WriteLine("giving up")
}
}
}
A when guard decides once, when the select is entered, whether its arm takes
part at all. A false guard keeps the arm out of the waiter entirely, so it can
never win — this is what Go expresses by setting a channel variable to nil.
The guard is evaluated before the arm's binding exists, so it cannot mention
job.
case await task and case let v = await task let a Task or Task[T] race
the channels on the same waiter. A losing task's continuation is removed when
the select finishes, so a long-running one does not retain it.
case cancelled turns the ambient context's cancellation into an arm. Without
it a cancelled select unwinds with an OperationCanceledException; with it the
arm runs instead. The arm needs a context to observe — an enclosing scope, a
declared ctx Context parameter, or the one the compiler threads through a
suspending call — and GS0557 says so when there is none, because the arm
would otherwise be silently unreachable. Cancellation is consulted only after
the channel arms, so a select that can do its work does it rather than bail
out.
Spawn now, use later
go covers fire-and-forget. When you want the value a child produces, use
async let.
scope {
async let user = fetchUser(id)
async let orders = fetchOrders(id)
return render(await user, await orders)
}
Both fetches start immediately, as children of the block, and run
concurrently. The binding names the result, not a task: await user is a
User. Reading it a second time returns the completed value without
suspending.
The await is required at every use. The read is where the suspension
happens, and this language keeps suspension visible, so a bare user is
GS0569.
An async let needs a scope to own it (GS0551 when there is none), which
is what makes the spawn impossible to leak: the binding is not a value, so it
cannot be stored, returned, or collected. A binding you never read has its
child cancelled at the end of the block and joined there. If that child had
already failed, the failure still reaches the block, so nothing is silently
swallowed. GS0559 points out that you started work only to cancel it.
A failing async let does not cancel its siblings. Catching one child's
failure leaves the others running, which is the difference between async let
and a go inside the same block.
Batching, and chunks
A channel operation moves one element and pays for one lock acquisition and, when it has to wait, one park. A data pipeline moving millions of elements should not pay that per element.
for batch in chunks(input, 1024) {
process(batch)
}
chunks(ch, n) hands over whole buffers: batch is a ReadOnlyMemory[T] of
up to n elements, and the loop is ordinary channel iteration — no extra
goroutine, nothing more for the block to join. A batch arrives as soon as
anything is available rather than waiting to fill, so a producer slower than
the chunk size does not stall the consumer. Each batch owns its array, so a
stage may keep or forward what it was handed.
Underneath it, and available directly, are four operations on a channel handle:
TryReceiveBatch(buffer)andTrySendBatch(items)take aSpan[T]. They never wait, so a borrowed stack view is safe.ReceiveBatch(buffer, atLeast)andSendBatch(items)take aMemory[T]. They can park, and a destination that survives a park cannot be aSpan.
atLeast = 1 is Go's range shape, take what is there. atLeast = buffer.Length is a full-fill barrier. Both are legitimate, which is why there
is no default.
A batch that is cut short returns the count it moved rather than throwing: a closed channel returns what it transferred and reports closed on the next call, and cancellation mid-batch returns the count so far. A bare throw would hide that count and a retry would duplicate elements.
Batching a rendezvous channel is pointless — capacity 0 means one value in
flight by definition, so a batch is that many sequential handovers — and
GS0562 says so.
The slogan is share buffers by communicating. Not spans: a borrowed stack view is exactly the thing that cannot cross a suspension.
Cancellation
The block's ctx is not only for your own checks: every channel operation
inside a scope parks on it. When one goroutine fails, its siblings waiting on
a channel unwind with an OperationCanceledException instead of waiting
forever, defers run, and the block collapses. An operation that already
completed its transfer keeps its value — cancellation wins only before the
transfer commits, so a receive never drops an element it has already taken.
Cleanup during cancellation
A defer body runs shielded: it does not observe the cancellation that is
unwinding the block, so cleanup that drains a channel or sends a completion
signal still completes instead of being skipped. The shield carries a grace
budget (five seconds by default, GSHARP_DEFER_GRACE_MS), so cleanup that
blocks forever cannot hold cancellation up — when the budget expires the
cleanup is abandoned and GsharpRuntime.DeferGraceExpired reports it.
How the context reaches a function
Cancellation follows calls, not just blocks. A suspending function receives the
caller's context as a trailing optional parameter the compiler supplies, so a
receive inside a helper unwinds when the caller's scope is cancelled without
either of you writing a parameter. Two cases are yours to steer:
- Write
ctx Contextin the signature when you want it visible — a public API whose callers choose the context, or one a C# caller passes to. The compiler then uses your parameter and adds nothing. - A variadic function (
...T) carries no context, because that parameter must stay last and one placed before it could not be skipped by callers. Its operations run uncancelled; declarectx Contextbefore the variadic parameter when you need cancellation, and callers pass it there.
From C#, a G# suspending function is an ordinary ValueTask-returning method:
call it as its signature reads, or pass a Gsharp.Concurrency.Context as the
last argument to make it cancellable. The compiler fills in the default for you
— but reflection does not. MethodInfo.Invoke's default binder is strict about
arity, so a reflection caller has to pass the parameter, either as a context or
as Type.Missing under BindingFlags.OptionalParamBinding:
// Compiled call: the parameter is optional, so this is enough.
var value = await GsLib.Pipe();
// Reflection: say that the trailing optional is being defaulted.
var pending = pipe.Invoke(
null,
BindingFlags.OptionalParamBinding,
binder: null,
new object[] { Type.Missing },
culture: null);
Debugging and hot reload through suspension
A suspending function is compiled to a state machine, but the tooling keeps the
logical view. Its entry method carries [AsyncStateMachine], so
Environment.StackTrace, exception traces, and debuggers show
Pkg.<Program>.take(…) rather than <take>d__1.MoveNext; the Portable PDB
records where every receive, send, or suspending call yields and resumes, so
stepping over a channel operation that parks lands on the next source line
once the value arrives. Hot reload treats a change to whether a function
suspends as the signature change it is: adding the first channel operation to
a plain func, or removing the last one, is rejected with GSHR1002 naming
the function, and the process must be restarted.
Sequences and async sequences
A function returning sequence[T] can use yield to produce values
lazily; the compiler emits a synchronous iterator that materializes
each value on demand.
func numbers() sequence[int32] {
yield 1
yield 2
yield 3
}
for n in numbers() {
Console.WriteLine(n)
}
async sequence[T] is the asynchronous counterpart and is consumed
with await for. The compiler lowers it to IAsyncEnumerable[T], so
it interoperates directly with .NET async streams.
async sequence[int32] pulses() {
for i in 1 ... 4 {
await Task.Delay(5)
yield i
}
}
async func consume() {
await for n in pulses() {
Console.WriteLine(n)
}
}
Combine scope with await for to bound the lifetime of an async
iterator. The scope joins the iterator's outstanding work before it
returns.
Cleanup and concurrency
defer and using are scoped constructs and compose naturally with
scope:
scope {
using let stream = File.OpenRead(path)
let total = await ProcessAsync(stream)
Console.WriteLine("total: $total")
}
Prefer using for any value that implements IDisposable (or
IAsyncDisposable in async contexts) and defer for small cleanup
calls that are not themselves represented by a disposable value.
Concurrency model
The async lowering preserves loop back-edges across suspension points,
so awaits inside for, while, and nested loops behave the same as
they do in straight-line code. The runtime is the standard .NET
Task/Task<T> machinery, so synchronization primitives
(SemaphoreSlim, channels, locks) from System.Threading.* are all
available through normal imports.
Synchronization and shared state
The first answer to "how do concurrent tasks share state" is the same
as Go's: prefer not to — pass values through channels (or task results)
and let scope own the joins.
When sharing is unavoidable, the toolbox is, in order:
-
lock— a statement that guards a critical section with the .NET monitor:lock guard {// critical section}The target must be a reference type, is evaluated once, and the body runs under
Monitor.Enter/Monitor.Exitwith an implicittry/finally. Lock on a private object that never leaves your type — never on a value other code can reach. -
The BCL via interop —
ConcurrentDictionary[K, V](import System.Collections.Concurrent) for concurrent maps,Interlockedfor atomic counters,ReaderWriterLockSlim,SemaphoreSlim, and friends (import System.Threading). -
SyncMap[K, V](import Gsharp.Extensions.Sync) — the idiomatic G# shape for a map shared across goroutines, analogous to Go'ssync.Map. It is method-based on purpose: on a shared mapm[k] = m[k] + 1looks atomic and races, so compound read-modify-write is spelledUpdateand is atomic:import Gsharp.Extensions.Syncfunc bump(m SyncMap[string, int32]) int32 {m.Update("hits", func(v int32) int32 { return v + 1 })return 0}func run() int32 {var m = SyncMap[string, int32]()scope {for var i = 0; i < 50; i++ {go bump(m)}}return m.Load("hits") // exactly 50}
Plain map[K,V] is not goroutine-safe: it is a bare
Dictionary<K,V> with no implicit synchronization, and concurrent
access to one is undefined behavior — the same posture as Go maps. See
the standard-library reference
for the full SyncMap API.
See also
- Tutorial: Async and sequences
- Go-flavored concurrency
— channels,
go,select, andch.Close(); no import required. - Standard library: Gsharp.Extensions.Sync
— the
SyncMap[K, V]API reference.