Skip to main content
Version: Next

Concurrency and async

G#'s concurrency model 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 .NET Task and Task[T] types.
  • sequence[T] + async sequence[T] — synchronous and asynchronous iterators built on yield and consumed with for / 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.

For runnable examples and cross-language tradeoffs, see Ten concurrency patterns: Go and G#. It separates the published-SDK correctness examples from the benchmark measurements of the compiler and lower-level operations.

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 (an AggregateException) whose FirstFailure is 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.

The following complete example is checked in as samples/WebsiteConcurrency.gs:

workers.gs
package Website.Concurrency

import System

func send(value int32, results chan[int32]) {
results <- value
}

let results = chan[int32](2)

scope {
go send(10, results)
go send(32, results)
}

Console.WriteLine(<-results + <-results)
42

Use scope when a parent operation should not return before its children. The example's two-slot buffer lets both children finish before the parent receives; arbitrary untracked .NET tasks do not automatically become scope 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.

AsyncTask.gs
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. It is not restricted to an async func: awaiting is itself a suspension point, so a plain func may await and the compiler colours it suspending, exactly as a channel operation would. Where a signature is fixed and inference may not change it — an open/override method, an interface member, an accessor, a constructor, an operator, an iterator, a function literal — GS0574 asks you to move the awaited work into a suspend func or an async func and call that instead. Two positions reject await outright because there is nowhere to suspend: a lock body, whose monitor is thread-affine (GS0575, the rule C# spells CS1996), and nested inside a go operand's arguments (GS0576 — bind the value to a local first). Awaiting a non-awaitable operand is diagnosed as before.

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:

using let deadline = after(TimeSpan.FromSeconds(2))
select {
case let job = <-work {
handle(job)
}
case <-deadline {
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. Both take a TimeSpan:

using let beat = tick(TimeSpan.FromMilliseconds(500))

An after timer is armed when created and remains armed until it fires or is disposed. Losing a select removes that select's registration, not the timer itself. Keep an owned deadline in using let when another arm can finish first, especially for long deadlines. Context.None.WithTimeout(duration) is another option for .NET APIs that accept a cancellation token; dispose that context when finished.

Both are backed by System.Threading.Timer, whose resolution is one whole millisecond, so a delay or period is rounded up to the next millisecond and the interval a timer is armed at is never shorter than the one you asked for. A period of 0.5 ms ticks every 1 ms rather than silently stopping after one tick, and a period of 1.5 ms ticks every 2 ms rather than every 1 ms — so a tick is never faster than you asked for, and may be up to 1 ms slower. tick still rejects a period of zero or less. A delay of zero is still immediate.

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. The published helper accepts full chan[T] inputs and uses an unbounded output. Use an explicit bounded forwarding topology when output capacity or different ownership contracts matter. Declaring your own after, tick 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) and TrySendBatch(items) take a Span[T]. They never wait, so a borrowed stack view is safe.
  • ReceiveBatch(buffer, atLeast) and SendBatch(items) take a Memory[T]. They can park, and a destination that survives a park cannot be a Span. They return a ValueTask[int32] you await — let n = await ch.ReceiveBatch(buffer, 1) — like any other awaitable, and .AsTask() on one names the task exactly as it does in C#. The compiler awaits for you only where the syntax is a channel operation (ch <- v, <-ch, select, channel for..in), never because a method returns a task.

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 Context in 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; declare ctx Context before 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:

  1. 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.Exit with an implicit try/finally. Lock on a private object that never leaves your type — never on a value other code can reach.

  2. The BCL via interop — ConcurrentDictionary[K, V] (import System.Collections.Concurrent) for concurrent maps, Interlocked for atomic counters, ReaderWriterLockSlim, SemaphoreSlim, and friends (import System.Threading).

  3. SyncMap[K, V] (import Gsharp.Extensions.Sync) — the idiomatic G# shape for a map shared across goroutines, analogous to Go's sync.Map. It is method-based on purpose: on a shared map m[k] = m[k] + 1 looks atomic and races, so compound read-modify-write is spelled Update and is atomic:

    import Gsharp.Extensions.Sync

    func 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​