Skip to main content

Go / G# / Evidence before a verdict

Ten patterns.
Two runtimes.

Compare the code, check its contract, and then look at the measurements. Similar syntax is not a substitute for correct lifetime and error behavior.

Examples: G# SDK 0.4.591, Go 1.27.0 or compatible newer Go. No extra Go modules.

Three questions, kept separate.

Is it correct?

Check delivery, closure, cancellation, failure propagation, shared state, and cleanup for the stated workload.

Is it clear?

Compare ownership and bookkeeping, not just line counts. Familiarity and library choices influence the result.

What was measured?

The workflow measures lower-level operations, not these teaching programs. Some patterns have no relevant measurement.

Do not mix the two evidence sets.

The examples use published SDK 0.4.591. The benchmark snapshot uses compiler 0.4.1253 at commit c2b079774f1c. Its numbers do not establish the performance of the example SDK.

Matching-source checks recorded at : every pair run 3 times; go version go1.27.0 linux/amd64; .NET SDK 10.0.401. The Go runs also used the race detector. G# has executable contract checks here, not an equivalent dynamic race-detector result.

Pattern 01

Worker pool​

Four workers consume a bounded queue; every input produces exactly one result, and completion closes the result stream.

Checked cases
Empty input and forty jobs, with a uniqueness set, exact count, and independently expected sum.
G# choices
Directional handles express what each stage can do. Nested scope joins the workers before the coordinator closes results.
Go choices
Channels and WaitGroup.Go express the same topology. A coordinator explicitly waits before closing results.
Boundaries
Result order is unspecified. Queue capacity bounds buffered values, not worker count or total report memory.
Compare the Go and G# implementations

These pattern files share only the dispatcher/assertion helper shown below. The download contains complete projects, not isolated snippets.

worker_pool.go
package main

import (
"fmt"
"sync"
)

func runPool(count int) int {
jobs, results := make(chan int, 4), make(chan int, 4)
var stages, workers sync.WaitGroup
stages.Go(func() {
defer close(jobs)
for value := range count {
jobs <- value
}
})
for range 4 {
workers.Go(func() {
for job := range jobs {
results <- job * 2
}
})
}
stages.Go(func() {
workers.Wait()
close(results)
})
seen, sum := make(map[int]bool), 0
for value := range results {
verify(!seen[value], "duplicate result")
seen[value] = true
sum += value
}
stages.Wait()
verify(len(seen) == count, "lost work")
return sum
}

func workerPoolExample() {
verify(runPool(0) == 0, "empty pool")
verify(runPool(40) == 1560, "pool result")
fmt.Println("worker-pool count=40 sum=1560 empty=0")
}
WorkerPool.gs
package Patterns

import System
import System.Collections.Generic

func poolProduce(count int32, jobs out chan[int32]) {
try {
for value in 0 ... count {
jobs <- value
}
} finally {
jobs.Close()
}
}

func poolWorker(jobs in chan[int32], results out chan[int32]) {
for job in jobs {
results <- job * 2
}
}

func poolWorkers(jobs in chan[int32], results out chan[int32]) {
try {
scope {
for worker in 0 ... 4 {
go poolWorker(jobs, results)
}
}
} finally {
results.Close()
}
}

func runPool(count int32) int32 {
let jobs = chan[int32](4)
let results = chan[int32](4)
let seen = HashSet[int32]()
var sum = 0
scope {
go poolProduce(count, jobs)
go poolWorkers(jobs, results)
for value in results {
verify(seen.Add(value), "duplicate result")
sum += value
}
}
verify(seen.Count == count, "lost work")
return sum
}

func workerPoolExample() {
verify(runPool(0) == 0, "empty pool")
verify(runPool(40) == 1560, "pool result")
Console.WriteLine("worker-pool count=40 sum=1560 empty=0")
}
Expected summary from either implementation
worker-pool count=40 sum=1560 empty=0

Pattern 02

Bounded concurrency​

Acquire admission before spawning work, keep at most three operations active, and return every permit even on a handled failure.

Checked cases
Runtime checks cover twenty-four operations, one injected failure, peak active work, and returned permits. A separate source-order regression rejects moving acquisition inside either worker; it is not inferred from the active-work counter.
G# choices
Await SemaphoreSlim.WaitAsync before go so admission does not block a thread. finally returns the permit and scope joins the admitted work.
Go choices
A buffered channel acts as the semaphore. defer releases admission and WaitGroup.Go tracks completion.
Boundaries
Neither example promises fairness or an admission deadline. Expected work failures are counted as results rather than silently discarded.
Compare the Go and G# implementations

These pattern files share only the dispatcher/assertion helper shown below. The download contains complete projects, not isolated snippets.

bounded.go
package main

import (
"errors"
"fmt"
"runtime"
"sync"
)

func boundedExample() {
permits := make(chan struct{}, 3)
var workers sync.WaitGroup
var mu sync.Mutex
active, peak, completed, failed := 0, 0, 0, 0
work := func(id int) error {
if id == 7 {
return errors.New("expected work failure")
}
runtime.Gosched()
return nil
}
for id := range 24 {
permits <- struct{}{}
workers.Go(func() {
defer func() { <-permits }()
mu.Lock()
active++
peak = max(peak, active)
mu.Unlock()
err := work(id)
mu.Lock()
if err != nil {
failed++
}
active--
completed++
mu.Unlock()
})
}
workers.Wait()
verify(peak <= 3, "concurrency limit")
verify(completed == 24 && failed == 1, "completion or failure accounting")
verify(active == 0 && len(permits) == 0, "permit leak")
fmt.Println("bounded limit=3 completed=24 failures=1 permits=3")
}
Bounded.gs
package Patterns

import System
import System.Threading

class LimitState {
public let Gate Object = Object()
public var Active int32
public var Peak int32
public var Completed int32
public var Failed int32
}

func limitedWork(id int32, permits SemaphoreSlim, state LimitState) {
try {
lock state.Gate {
state.Active++
state.Peak = Math.Max(state.Peak, state.Active)
}
try {
if id == 7 {
throw InvalidOperationException("expected work failure")
}
Thread.Yield()
} catch (e InvalidOperationException) {
lock state.Gate {
state.Failed++
}
} finally {
lock state.Gate {
state.Active--
state.Completed++
}
}
} finally {
permits.Release()
}
}

func boundedExample() {
using let permits = SemaphoreSlim(3, 3)
let state = LimitState()
scope {
for id in 0 ... 24 {
await permits.WaitAsync()
go limitedWork(id, permits, state)
}
}
verify(state.Peak <= 3, "concurrency limit")
verify(state.Completed == 24 && state.Failed == 1, "completion or failure accounting")
verify(state.Active == 0 && permits.CurrentCount == 3, "permit leak")
Console.WriteLine("bounded limit=3 completed=24 failures=1 permits=3")
}
Expected summary from either implementation
bounded limit=3 completed=24 failures=1 permits=3

Pattern 03

Cancellable pipeline​

Stages own and close their outputs. A consumer can stop early without leaving a producer or transformer blocked.

Checked cases
Empty input, full delivery of eight values, early stop after three of one hundred values, and both stage finalizers observed.
G# choices
Scoped channel operations observe the context. The published-SDK example explicitly normalizes expected OperationCanceledException when that context was cancelled.
Go choices
Context cancellation is selected alongside channel operations at both stages; the caller cancels and then joins.
Boundaries
Cancellation remains cooperative. A CPU loop or unrelated blocking API does not become cancellable merely because it sits in a scope or goroutine.
Compare the Go and G# implementations

These pattern files share only the dispatcher/assertion helper shown below. The download contains complete projects, not isolated snippets.

pipeline.go
package main

import (
"context"
"fmt"
"sync"
"sync/atomic"
)

func runPipeline(size, stopAfter int) (int, int) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
first, second := make(chan int, 2), make(chan int, 2)
var stages sync.WaitGroup
var exited atomic.Int32
stages.Go(func() {
defer exited.Add(1)
defer close(first)
for value := range size {
select {
case first <- value:
case <-ctx.Done():
return
}
}
})
stages.Go(func() {
defer exited.Add(1)
defer close(second)
for {
select {
case value, ok := <-first:
if !ok {
return
}
select {
case second <- value * 2:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
})
count, sum := 0, 0
for value := range second {
count++
sum += value
if stopAfter > 0 && count == stopAfter {
cancel()
break
}
}
stages.Wait()
verify(exited.Load() == 2, "pipeline did not join both stages")
return count, sum
}

func pipelineExample() {
empty, _ := runPipeline(0, 0)
complete, total := runPipeline(8, 0)
partial, subtotal := runPipeline(100, 3)
verify(empty == 0 && complete == 8 && total == 56, "pipeline completion")
verify(partial == 3 && subtotal == 6, "pipeline cancellation")
fmt.Println("pipeline full=8/56 cancelled=3/6 joined=2 empty=0")
}
Pipeline.gs
package Patterns

import Gsharp.Concurrency
import System
import System.Threading

class PipelineState {
public var Exited int32
}

func pipelineProduce(count int32, output out chan[int32], state PipelineState, context Context) {
try {
for value in 0 ... count {
output <- value
}
} catch (e OperationCanceledException) {
if !context.IsCancelled {
rethrow
}
} finally {
output.Close()
Interlocked.Increment(ref state.Exited)
}
}

func pipelineDouble(input in chan[int32], output out chan[int32], state PipelineState, context Context) {
try {
for value in input {
output <- value * 2
}
} catch (e OperationCanceledException) {
if !context.IsCancelled {
rethrow
}
} finally {
output.Close()
Interlocked.Increment(ref state.Exited)
}
}

func runPipeline(size int32, stopAfter int32)(int32, int32) {
let first = chan[int32](2)
let second = chan[int32](2)
let state = PipelineState()
var count = 0
var sum = 0
scope {
go pipelineProduce(size, first, state, ctx)
go pipelineDouble(first, second, state, ctx)
for value in second {
count++
sum += value
if stopAfter > 0 && count == stopAfter {
ctx.TryCancel()
break
}
}
}
verify(state.Exited == 2, "pipeline did not join both stages")
return (count, sum)
}

func pipelineExample() {
let (empty, _) = runPipeline(0, 0)
let (complete, total) = runPipeline(8, 0)
let (partial, subtotal) = runPipeline(100, 3)
verify(empty == 0 && complete == 8 && total == 56, "pipeline completion")
verify(partial == 3 && subtotal == 6, "pipeline cancellation")
Console.WriteLine("pipeline full=8/56 cancelled=3/6 joined=2 empty=0")
}
Expected summary from either implementation
pipeline full=8/56 cancelled=3/6 joined=2 empty=0

Pattern 04

Fan-in merge​

Forward multiple independently closed inputs into one output, closing the output only after all forwarders finish.

Checked cases
Zero inputs and three producers; all fifteen values are unique and accounted for, including zero.
G# choices
A nested scope makes the bounded topology explicit. The SDK also provides merge[T], checked here with empty and nonempty inputs; its full-channel inputs and unbounded output differ from this bounded, directional forwarding example.
Go choices
WaitGroup.Go tracks the forwarders and producers; a separate closer waits before closing the merged stream.
Boundaries
Cross-input ordering is unspecified. This finite-stream example does not add early-consumer cancellation; use the pipeline protocol when that is required.
Compare the Go and G# implementations

These pattern files share only the dispatcher/assertion helper shown below. The download contains complete projects, not isolated snippets.

fan_in.go
package main

import (
"fmt"
"sync"
)

func mergeSources(count int) int {
output := make(chan int, 2)
var stages, closer sync.WaitGroup
for id := range count {
input := make(chan int, 2)
stages.Go(func() {
defer close(input)
for value := range 5 {
input <- id*10 + value
}
})
stages.Go(func() {
for value := range input {
output <- value
}
})
}
closer.Go(func() {
stages.Wait()
close(output)
})
seen, sum := make(map[int]bool), 0
for value := range output {
verify(!seen[value], "fan-in duplicated a value")
seen[value] = true
sum += value
}
closer.Wait()
verify(len(seen) == count*5, "fan-in lost a value")
return sum
}

func fanInExample() {
verify(mergeSources(0) == 0, "empty fan-in")
verify(mergeSources(3) == 180, "merged sum")
fmt.Println("fan-in count=15 sum=180 empty=0")
}
FanIn.gs
package Patterns

import Gsharp.Concurrency
import System
import System.Collections.Generic

func fanProduce(output out chan[int32], offset int32) {
try {
for value in 0 ... 5 {
output <- offset + value
}
} finally {
output.Close()
}
}

func fanForward(input in chan[int32], output out chan[int32]) {
for value in input {
output <- value
}
}

func fanSources(count int32, output out chan[int32]) {
try {
scope {
for id in 0 ... count {
let input = chan[int32](2)
go fanProduce(input, id * 10)
go fanForward(input, output)
}
}
} finally {
output.Close()
}
}

func mergeSources(count int32) int32 {
let output = chan[int32](2)
let seen = HashSet[int32]()
var sum = 0
scope {
go fanSources(count, output)
for value in output {
verify(seen.Add(value), "fan-in duplicated a value")
sum += value
}
}
verify(seen.Count == count * 5, "fan-in lost a value")
return sum
}

func fanInExample() {
verify(mergeSources(0) == 0, "empty fan-in")
verify(mergeSources(3) == 180, "merged sum")
let left = chan[int32](1)
let right = chan[int32](1)
left <- 1
right <- 2
left.Close()
right.Close()
var helperSum = 0
for value in merge[int32](left, right) {
helperSum += value
}
verify(helperSum == 3, "SDK merge helper")
var empty = 0
for value in merge[int32]() {
empty++
}
verify(empty == 0, "empty SDK merge")
Console.WriteLine("fan-in count=15 sum=180 empty=0")
}
Expected summary from either implementation
fan-in count=15 sum=180 empty=0

Pattern 05

TTL cache with reader/writer locking​

Reads and writes synchronize access, and an entry is unavailable exactly at its expiry boundary.

Checked cases
A missing key, a hit immediately before expiry, a miss at expiry, expiry-overflow rejection, and forty concurrent key-specific round trips using a controlled clock.
G# choices
ReaderWriterLockSlim supplies the lock; defer releases it and a nullable string communicates a miss.
Go choices
RWMutex and a value/present pair provide the corresponding contract.
Boundaries
The injected clock must suit elapsed-time decisions. ReaderWriterLockSlim is thread-affine: do not add await or channel operations while it is held; Get and Set are synchronous here. TTL controls visibility, not eviction, so expired keys remain allocated until overwritten or removed.
Compare the Go and G# implementations

These pattern files share only the dispatcher/assertion helper shown below. The download contains complete projects, not isolated snippets.

ttl_cache.go
package main

import (
"fmt"
"math"
"strconv"
"sync"
)

type cacheEntry struct {
value string
expires int64
}

type ttlCache struct {
mu sync.RWMutex
entries map[string]cacheEntry
ttl int64
now func() int64
}

func newCache(ttl int64, now func() int64) *ttlCache {
verify(ttl > 0 && now != nil, "positive TTL and a clock are required")
return &ttlCache{entries: make(map[string]cacheEntry), ttl: ttl, now: now}
}

func (c *ttlCache) set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
tick := c.now()
verify(tick >= 0 && tick <= math.MaxInt64-c.ttl, "TTL overflow")
c.entries[key] = cacheEntry{value, tick + c.ttl}
}

func (c *ttlCache) get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.entries[key]
if !ok || c.now() >= entry.expires {
return "", false
}
return entry.value, true
}

func ttlCacheExample() {
var tick int64
cache := newCache(10, func() int64 { return tick })
_, present := cache.get("missing")
verify(!present, "missing cache entry")
cache.set("a", "value")
tick = 9
value, present := cache.get("a")
verify(present && value == "value", "early expiry")
tick = 10
_, present = cache.get("a")
verify(!present, "expiry boundary")
var workers sync.WaitGroup
for id := range 40 {
workers.Go(func() {
key := strconv.Itoa(id)
cache.set(key, key)
value, ok := cache.get(key)
verify(ok && value == key, "concurrent cache access")
})
}
workers.Wait()
tick = math.MaxInt64
rejected := false
func() {
defer func() { rejected = recover() == "TTL overflow" }()
cache.set("overflow", "value")
}()
verify(rejected, "expiry overflow")
fmt.Println("ttl-cache hit=1 expired=1 missing=1 concurrent=40")
}
TtlCache.gs
package Patterns

import System
import System.Collections.Generic
import System.Threading

data class CacheEntry(Value string, Expires int64)

class TtlCache {
private let gate ReaderWriterLockSlim = ReaderWriterLockSlim()
private let entries Dictionary[string, CacheEntry] = Dictionary[string, CacheEntry]()
private let ttl int64
private let now() -> int64

init(ttl int64, now() -> int64) {
verify(ttl > 0, "TTL must be positive")
this.ttl = ttl
this.now = now
}

func Set(key string, value string) {
gate.EnterWriteLock()
defer gate.ExitWriteLock()
let tick = now()
verify(tick >= 0 && tick <= Int64.MaxValue - ttl, "TTL overflow")
entries[key] = CacheEntry(value, tick + ttl)
}

func Get(key string) string? {
gate.EnterReadLock()
defer gate.ExitReadLock()
if !entries.ContainsKey(key) {
return nil
}
let entry = entries[key]
return now() < entry.Expires ? entry.Value: nil
}

func Dispose() {
gate.Dispose()
}
}

func cacheAccess(cache TtlCache, id int32) {
let key = id.ToString()
cache.Set(key, key)
verify(cache.Get(key) == key, "concurrent cache access")
}

func ttlCacheExample() {
var tick int64 = 0
let cache = TtlCache(10, () -> tick)
try {
verify(cache.Get("missing") == nil, "missing cache entry")
cache.Set("a", "value")
tick = 9
verify(cache.Get("a") == "value", "early expiry")
tick = 10
verify(cache.Get("a") == nil, "expiry boundary")
scope {
for id in 0 ... 40 {
go cacheAccess(cache, id)
}
}
tick = Int64.MaxValue
var rejected = false
try {
cache.Set("overflow", "value")
} catch (e InvalidOperationException) {
rejected = e.Message == "TTL overflow"
}
verify(rejected, "expiry overflow")
} finally {
cache.Dispose()
}
Console.WriteLine("ttl-cache hit=1 expired=1 missing=1 concurrent=40")
}
Expected summary from either implementation
ttl-cache hit=1 expired=1 missing=1 concurrent=40

Pattern 06

Keyed token-bucket rate limiting​

Each key has an independent bounded token balance; refill and admission are atomic under concurrent calls.

Checked cases
Initial burst, fractional-time carry, refill boundary, independent keys, capacity cap, concurrent admission, and rejection of a backwards clock.
G# choices
A lock and Dictionary implement the state transition; ordinary .NET synchronization is sufficient.
Go choices
A mutex and map express the same transition with explicit integer tick arithmetic.
Boundaries
This is a discrete integer-token example, not a production policy library. Both use one global lock and retain keys; fairness, eviction, and distributed coordination are separate requirements.
Compare the Go and G# implementations

These pattern files share only the dispatcher/assertion helper shown below. The download contains complete projects, not isolated snippets.

rate_limit.go
package main

import (
"fmt"
"sync"
"sync/atomic"
)

type bucket struct {
tokens int
last int64
}

type keyedLimiter struct {
mu sync.Mutex
buckets map[string]*bucket
capacity int
period int64
}

func (l *keyedLimiter) allow(key string, now int64) bool {
l.mu.Lock()
defer l.mu.Unlock()
b, ok := l.buckets[key]
if !ok {
b = &bucket{l.capacity, now}
l.buckets[key] = b
}
verify(now >= b.last, "clock moved backwards")
added := (now - b.last) / l.period
if added > 0 {
if added >= int64(l.capacity-b.tokens) {
b.tokens = l.capacity
} else {
b.tokens += int(added)
}
b.last += added * l.period
}
if b.tokens == 0 {
return false
}
b.tokens--
return true
}

func rateLimitExample() {
limiter := &keyedLimiter{buckets: make(map[string]*bucket), capacity: 2, period: 10}
verify(limiter.allow("a", 0) && limiter.allow("a", 0), "initial burst")
verify(!limiter.allow("a", 5), "fractional refill")
verify(limiter.allow("b", 5), "independent key")
verify(limiter.allow("a", 10) && !limiter.allow("a", 10), "refill boundary")
verify(limiter.allow("a", 100) && limiter.allow("a", 100) && !limiter.allow("a", 100), "capacity cap")
var accepted atomic.Int32
var workers sync.WaitGroup
for range 40 {
workers.Go(func() {
if limiter.allow("parallel", 200) {
accepted.Add(1)
}
})
}
workers.Wait()
verify(accepted.Load() == 2, "atomic token admission")
rejected := false
func() {
defer func() { rejected = recover() == "clock moved backwards" }()
limiter.allow("a", 0)
}()
verify(rejected, "backwards clock")
fmt.Println("rate-limit burst=2 refill=1 parallel=2 rollback=rejected")
}
RateLimit.gs
package Patterns

import System
import System.Collections.Generic
import System.Threading

class Bucket {
public var Tokens int32
public var Last int64
init(tokens int32, last int64) {
Tokens = tokens
Last = last
}
}

class KeyedLimiter {
private let gate Object = Object()
private let buckets Dictionary[string, Bucket] = Dictionary[string, Bucket]()
private let capacity int32
private let period int64

init(capacity int32, period int64) {
verify(capacity > 0 && period > 0, "positive capacity and period required")
this.capacity = capacity
this.period = period
}

func Allow(key string, now int64) bool {
lock gate {
if !buckets.ContainsKey(key) {
buckets[key] = Bucket(capacity, now)
}
let bucket = buckets[key]
verify(now >= bucket.Last, "clock moved backwards")
let added = (now - bucket.Last) / period
if added > 0 {
bucket.Tokens = added >= capacity - bucket.Tokens ? capacity: bucket.Tokens + int32(added)
bucket.Last += added * period
}
if bucket.Tokens == 0 {
return false
}
bucket.Tokens--
return true
}
}
}

class RateState {
public var Accepted int32
}

func requestToken(limiter KeyedLimiter, state RateState) {
if limiter.Allow("parallel", 200) {
Interlocked.Increment(ref state.Accepted)
}
}

func rateLimitExample() {
let limiter = KeyedLimiter(2, 10)
verify(limiter.Allow("a", 0) && limiter.Allow("a", 0), "initial burst")
verify(!limiter.Allow("a", 5), "fractional refill")
verify(limiter.Allow("b", 5), "independent key")
verify(limiter.Allow("a", 10) && !limiter.Allow("a", 10), "refill boundary")
verify(limiter.Allow("a", 100) && limiter.Allow("a", 100) && !limiter.Allow("a", 100), "capacity cap")
let state = RateState()
scope {
for id in 0 ... 40 {
go requestToken(limiter, state)
}
}
verify(state.Accepted == 2, "atomic token admission")
var rejected = false
try {
limiter.Allow("a", 0)
} catch (e InvalidOperationException) {
rejected = e.Message == "clock moved backwards"
}
verify(rejected, "backwards clock")
Console.WriteLine("rate-limit burst=2 refill=1 parallel=2 rollback=rejected")
}
Expected summary from either implementation
rate-limit burst=2 refill=1 parallel=2 rollback=rejected

Pattern 07

Structured-concurrency All​

Wait for owned work, preserve a real failure, cancel cooperative siblings, and do not return while the blocked sibling is still active.

Checked cases
Empty and successful scopes, a barrier-controlled failure with a blocked sibling whose finalizer must run, and unrequested cancellation while the parent is not cancelled.
G# choices
Normal use is scope with go calls; the barriers and counters shown here are verification scaffolding. ScopeException carries failures, and the example normalizes cancellation using the context state.
Go choices
The self-contained standard-library runner combines Context, error returns, and WaitGroup.Go. A library such as errgroup is another idiomatic option.
Boundaries
Go errors and panics are not CLR exceptions, and this runner does not recover panics. Both normalization policies use error category plus current context state, not cancellation provenance: an independent cancellation concurrent with group cancellation can be normalized. Failure ordering is not promised.
Compare the Go and G# implementations

These pattern files share only the dispatcher/assertion helper shown below. The download contains complete projects, not isolated snippets.

all.go
package main

import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
)

func runAll(tasks ...func(context.Context) error) []error {
ctx, cancel := context.WithCancelCause(context.Background())
defer cancel(nil)
failures := make(chan error, len(tasks))
var workers sync.WaitGroup
for _, task := range tasks {
workers.Go(func() {
if err := task(ctx); err != nil {
if errors.Is(err, context.Canceled) && ctx.Err() != nil {
return
}
failures <- err
cancel(err)
}
})
}
workers.Wait()
close(failures)
var result []error
for err := range failures {
result = append(result, err)
}
return result
}

func allExample() {
successful := make(chan int, 3)
var tasks []func(context.Context) error
for value := 1; value <= 3; value++ {
tasks = append(tasks, func(context.Context) error { successful <- value; return nil })
}
verify(len(runAll(tasks...)) == 0, "successful children")
verify(<-successful+<-successful+<-successful == 6, "successful sum")
verify(len(runAll()) == 0, "empty all")
verify(len(runAll(func(context.Context) error { return context.Canceled })) == 1,
"unrequested cancellation must remain a failure")
output := make(chan int, 1)
ready, started := make(chan struct{}, 1), make(chan struct{}, 1)
var exited atomic.Int32
failures := runAll(
func(context.Context) error {
output <- 7
ready <- struct{}{}
return nil
},
func(ctx context.Context) error {
defer exited.Add(1)
started <- struct{}{}
<-ctx.Done()
return ctx.Err()
},
func(context.Context) error {
<-ready
<-started
return errors.New("expected failure")
},
)
verify(len(failures) == 1 && failures[0].Error() == "expected failure", "failure propagation")
verify(exited.Load() == 1 && <-output == 7, "join")
fmt.Println("all success=6 failures=1 blocked-child-joined=1 empty=0")
}
All.gs
package Patterns

import Gsharp.Concurrency
import System
import System.Threading

class AllState {
public var Exited int32
}

func allValue(output out chan[int32], value int32) {
output <- value
}

func allHealthy(output out chan[int32], ready out chan[bool]) {
output <- 7
ready <- true
}

func allBlocked(quiet in chan[int32], started out chan[bool], state AllState, context Context) {
try {
started <- true
let unexpected = <-quiet
verify(false, "blocked child received unexpected data")
} catch (e OperationCanceledException) {
if !context.IsCancelled {
rethrow
}
} finally {
Interlocked.Increment(ref state.Exited)
}
}

func allFail(ready in chan[bool], started in chan[bool]) {
let (_, readyOk) = <-ready
let (_, startedOk) = <-started
verify(readyOk && startedOk, "failure barrier")
throw InvalidOperationException("expected failure")
}

func allUnrequestedCancellation() {
throw OperationCanceledException("not requested")
}

func allExample() {
scope { }
let successful = chan[int32](3)
scope {
for value in 1 ... 4 {
go allValue(successful, value)
}
}
verify(<-successful + <-successful + <-successful == 6, "successful children")
let output = chan[int32](1)
let ready = chan[bool](1)
let started = chan[bool](1)
let quiet = chan[int32](1)
let state = AllState()
var failures = 0
try {
scope {
go allHealthy(output, ready)
go allBlocked(quiet, started, state, ctx)
go allFail(ready, started)
}
} catch (e ScopeException) {
failures = e.InnerExceptions.Count
verify(e.InnerExceptions[0].Message == "expected failure", "failure cause")
}
verify(failures == 1 && state.Exited == 1 && <-output == 7, "failure propagation and join")
var unrelated = 0
try {
scope {
go allUnrequestedCancellation()
}
} catch (e ScopeException) {
unrelated = e.InnerExceptions.Count
}
verify(unrelated == 1, "unrequested cancellation must remain a failure")
Console.WriteLine("all success=6 failures=1 blocked-child-joined=1 empty=0")
}
Expected summary from either implementation
all success=6 failures=1 blocked-child-joined=1 empty=0

Pattern 08

Timeout wrapper​

Distinguish ready work, a deadline, and explicit cancellation, and join a cooperative operation after stopping it.

Checked cases
An already-ready result, an owned timer with no competing result, explicit cancellation, worker cleanup, and a Context.WithTimeout-backed .NET task; no tight latency assertion.
G# choices
after and case cancelled integrate with select; using let owns the timer and scope supplies the join. Context.WithTimeout is also checked as an alternative for APIs accepting a .NET cancellation token.
Go choices
Timers and Context deadlines integrate with select; the example explicitly stops timers/cancels contexts and waits for the worker.
Boundaries
A timeout does not guarantee arbitrary work has stopped. after starts a one-shot timer that remains armed until it fires or is disposed; losing-select deregistration alone does not stop it. Cleanup may exceed the deadline, and multiple ready select arms do not establish priority.
Compare the Go and G# implementations

These pattern files share only the dispatcher/assertion helper shown below. The download contains complete projects, not isolated snippets.

timeout.go
package main

import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
)

func timeoutExample() {
ready := make(chan int, 1)
ready <- 42
timer := time.NewTimer(time.Minute)
value := 0
select {
case value = <-ready:
case <-timer.C:
panic("ready value timed out")
}
timer.Stop()
verify(value == 42, "ready result")

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
var exited atomic.Int32
var worker sync.WaitGroup
started := make(chan struct{}, 1)
worker.Go(func() {
defer exited.Add(1)
started <- struct{}{}
<-ctx.Done()
})
<-started
timedOut := false
select {
case <-ready:
panic("unexpected result")
case <-ctx.Done():
timedOut = true
}
cancel()
worker.Wait()

early, stop := context.WithCancel(context.Background())
stop()
cancelled := false
select {
case <-ready:
panic("unexpected cancellation result")
case <-early.Done():
cancelled = true
}
verify(timedOut && cancelled && exited.Load() == 1, "timeout cleanup")
fmt.Println("timeout ready=42 deadline=1 cancellation=1 joined=1")
}
Timeout.gs
package Patterns

import Gsharp.Concurrency
import System
import System.Threading
import System.Threading.Tasks

class TimeoutState {
public var Exited int32
}

func timeoutWork(input in chan[int32], started out chan[bool], state TimeoutState, context Context) {
try {
started <- true
let unexpected = <-input
verify(false, "unexpected timeout work result")
} catch (e OperationCanceledException) {
if !context.IsCancelled {
rethrow
}
} finally {
Interlocked.Increment(ref state.Exited)
}
}

func timeoutExample() {
let ready = chan[int32](1)
ready <- 42
var value = 0
scope {
using let readyDeadline = after(TimeSpan.FromMinutes(1))
select {
case let result = <- ready {
value = result
}
case <- readyDeadline {
verify(false, "ready value timed out")
}
}
}
verify(value == 42, "ready result")
let quiet = chan[int32](1)
let started = chan[bool](1)
let state = TimeoutState()
var timedOut = false
scope {
go timeoutWork(quiet, started, state, ctx)
let (_, ok) = <-started
verify(ok, "timeout start barrier")
using let deadline = after(TimeSpan.FromMilliseconds(5))
select {
case let unexpected = <- ready {
verify(false, "unexpected result")
}
case <- deadline {
timedOut = true
ctx.TryCancel()
}
}
}
var cancelled = false
scope {
ctx.TryCancel()
select {
case let unexpected = <- quiet {
verify(false, "unexpected cancellation result")
}
case cancelled {
cancelled = true
}
}
}
verify(timedOut && cancelled && state.Exited == 1, "timeout cleanup")
using let contextDeadline = Context.None.WithTimeout(TimeSpan.FromMilliseconds(5))
var contextTimedOut = false
try {
await Task.Delay(Timeout.InfiniteTimeSpan, contextDeadline.Token)
} catch (e OperationCanceledException) {
contextTimedOut = contextDeadline.IsCancelled
}
verify(contextTimedOut, "context deadline for a .NET task")
Console.WriteLine("timeout ready=42 deadline=1 cancellation=1 joined=1")
}
Expected summary from either implementation
timeout ready=42 deadline=1 cancellation=1 joined=1

Pattern 09

Channel ownership and routing​

A producer owns sending and closure; the consumer receives through a read-only handle and distinguishes closed state from a legitimate zero value.

Checked cases
Empty and nonempty streams, zero as data, a closed two-value receive, and negative compilation checks for sending through a receive-only handle.
G# choices
in chan[T] and out chan[T] expose capabilities in signatures; channel iteration and the ok flag avoid a nil/zero sentinel protocol.
Go choices
The directional <-chan and chan<- types offer the corresponding capability split and closed-receive check.
Boundaries
Directional handles are not a linear ownership proof. The application must still choose one closer and define multi-writer coordination.
Compare the Go and G# implementations

These pattern files share only the dispatcher/assertion helper shown below. The download contains complete projects, not isolated snippets.

ownership.go
package main

import (
"fmt"
"sync"
)

func ownedProduce(output chan<- int, count int) {
defer close(output)
for value := range count {
output <- value
}
}

func runOwned(count int) int {
channel := make(chan int, 2)
var reader <-chan int = channel
var producer sync.WaitGroup
producer.Go(func() { ownedProduce(channel, count) })
seen, sum := 0, 0
for value := range reader {
seen++
sum += value
}
producer.Wait()
zero, ok := <-reader
verify(seen == count && !ok && zero == 0, "closure is not a data sentinel")
return sum
}

func ownershipExample() {
verify(runOwned(0) == 0 && runOwned(6) == 15, "owned channel output")
fmt.Println("ownership count=6 sum=15 closed-ok=0 empty=0")
}
Ownership.gs
package Patterns

import System

func ownedProduce(output out chan[int32], count int32) {
try {
for value in 0 ... count {
output <- value
}
} finally {
output.Close()
}
}

func runOwned(count int32) int32 {
let channel = chan[int32](2)
let reader in chan[int32] = channel
var seen = 0
var sum = 0
scope {
go ownedProduce(channel, count)
for value in reader {
seen++
sum += value
}
}
let (zero, ok) = <-reader
verify(seen == count && !ok && zero == 0, "closure is not a data sentinel")
return sum
}

func ownershipExample() {
verify(runOwned(0) == 0 && runOwned(6) == 15, "owned channel output")
Console.WriteLine("ownership count=6 sum=15 closed-ok=0 empty=0")
}
Expected summary from either implementation
ownership count=6 sum=15 closed-ok=0 empty=0

Pattern 10

Atomic counter and lazy initialization​

Concurrent increments are not lost, and the lazy factory runs once while every caller observes the initialized value.

Checked cases
No eager initialization, forty concurrent users, exact final count, one factory invocation, and a repeated read.
G# choices
Interlocked and Lazy[T] with ExecutionAndPublication are .NET library tools, not special G# syntax.
Go choices
Typed atomic values and sync.OnceValue supply the corresponding successful-initialization behavior.
Boundaries
An atomic field does not make a compound object invariant atomic. Factory failure/retry policies differ and are outside this successful-factory example.
Compare the Go and G# implementations

These pattern files share only the dispatcher/assertion helper shown below. The download contains complete projects, not isolated snippets.

atomic_lazy.go
package main

import (
"fmt"
"sync"
"sync/atomic"
)

func atomicLazyExample() {
var count, factories atomic.Int32
value := sync.OnceValue(func() int {
factories.Add(1)
return 42
})
verify(factories.Load() == 0, "eager initialization")
var workers sync.WaitGroup
for range 40 {
workers.Go(func() {
verify(value() == 42, "lazy result")
count.Add(1)
})
}
workers.Wait()
verify(count.Load() == 40 && factories.Load() == 1, "atomic count or initialization")
verify(value() == 42 && factories.Load() == 1, "repeated lazy read")
fmt.Println("atomic-lazy count=40 factories=1 value=42")
}
AtomicLazy.gs
package Patterns

import System
import System.Threading

class AtomicState {
public var Count int32
public var FactoryRuns int32
}

func useLazy(value Lazy[int32], state AtomicState) {
verify(value.Value == 42, "lazy result")
Interlocked.Increment(ref state.Count)
}

func atomicLazyExample() {
let state = AtomicState()
let value = Lazy[int32](
() -> {
Interlocked.Increment(ref state.FactoryRuns)
42
},
LazyThreadSafetyMode.ExecutionAndPublication
)
verify(state.FactoryRuns == 0, "eager initialization")
scope {
for id in 0 ... 40 {
go useLazy(value, state)
}
}
verify(state.Count == 40 && state.FactoryRuns == 1, "atomic count or initialization")
verify(value.Value == 42 && state.FactoryRuns == 1, "repeated lazy read")
Console.WriteLine("atomic-lazy count=40 factories=1 value=42")
}
Expected summary from either implementation
atomic-lazy count=40 factories=1 value=42

Run the checks yourself.​

Unzip the bundle and choose a pattern ID from the index. A failed invariant produces a nonzero process exit. The checks demonstrate the listed cases, not a formal proof or a production-ready framework.

From the extracted ConcurrencyPatterns folder
dotnet run --project gsharp -- worker-pool
Go
cd go
go run . worker-pool

From a clone of this repository, run python3 website/tests/verify-concurrency-patterns.py --race. The verifier uses the downloadable bundle, compares all ten summaries, repeats each pair, and checks that both compilers reject a receive-only send. A supported Go/C toolchain is required for the race detector.

Shared runner and assertion helper
main.go
package main

import (
"fmt"
"os"
)

func verify(condition bool, message string) {
if !condition {
panic(message)
}
}

func main() {
patterns := map[string]func(){
"worker-pool": workerPoolExample, "bounded": boundedExample,
"pipeline": pipelineExample, "fan-in": fanInExample,
"ttl-cache": ttlCacheExample, "rate-limit": rateLimitExample,
"all": allExample, "timeout": timeoutExample,
"ownership": ownershipExample, "atomic-lazy": atomicLazyExample,
}
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "Usage: patterns <pattern-id>")
os.Exit(2)
}
run, ok := patterns[os.Args[1]]
if !ok {
fmt.Fprintln(os.Stderr, "Unknown pattern:", os.Args[1])
os.Exit(2)
}
run()
}
Program.gs
package Patterns

import System

func verify(condition bool, message string) {
if !condition {
throw InvalidOperationException(message)
}
}

let argv = Environment.GetCommandLineArgs()
if argv.Length != 2 {
Console.Error.WriteLine("Usage: Patterns <pattern-id>")
Environment.ExitCode = 2
} else {
switch argv[1] {
case "worker-pool" {
workerPoolExample()
}
case "bounded" {
boundedExample()
}
case "pipeline" {
pipelineExample()
}
case "fan-in" {
fanInExample()
}
case "ttl-cache" {
ttlCacheExample()
}
case "rate-limit" {
rateLimitExample()
}
case "all" {
allExample()
}
case "timeout" {
timeoutExample()
}
case "ownership" {
ownershipExample()
}
case "atomic-lazy" {
atomicLazyExample()
}
case _ {
Console.Error.WriteLine("Unknown pattern: " + argv[1])
Environment.ExitCode = 2
}
}
}

Measured separately / not example timings

What the workflow actually measured.​

These are the registered lower-level operations from concurrency-bench.yml run 37889376357, not end-to-end timings of the ten programs above.

Report-only aggregate; not baseline-comparable.
  • the host exposes no observable power-state identity

A shared hosted runner is not a named workstation. Ratios describe this run; they are not a universal language ranking or a regression gate.

Measurement finished (UTC)
Compiler / commit
G# 0.4.1253 / c2b079774f1c
Host
Linux x86_64, 4 visible CPUs; AMD EPYC 7763 64-Core Processor
Toolchains
.NET SDK 10.0.401; JIT 10.0.12; AOT 10.0.12; go1.27.0
Method
3 whole runs × 6 process launches per mode; rotating/interleaved launch order.
Snapshot retrieved (UTC)

Values are ns per counted operation. Each cell shows a median and the range of the three run medians, not a 95% confidence interval. The source field retains a historical interval name; its recorded aggregation method determines the meaning.

OperationG# JITG# NativeAOTGoJIT / GoAOT / Go

buf64​

A bounded channel driven producer-to-consumer: one pipeline stage.
162.78160.97 – 177.81189.32186.82 – 189.4442.1541.95 – 42.23.86×4.49×

rendezvous​

Capacity 0, so the number is the hand-off rather than the buffer. ONE hand-off per operation; `pingpong` is the Go-paired counterpart.
163.84163.45 – 166.35184.36183.45 – 188.63Not paired——

pingpong​

A round trip over two rendezvous channels: TWO hand-offs per counted operation, shaped exactly like Go's.
308.31301.06 – 312.46361.68358.86 – 366.71330.4321.7 – 3310.93×1.09×

closed-recv​

A receive from a closed, drained channel — the exception-based defect D2 removes.
15.2614.99 – 15.5716.2516.23 – 16.2514.5514.5 – 14.61.05×1.12×

spawn​

Spawn cost: `go` is a thread-pool work item, not a Task. Times the spawn and the join only, as Go's WaitGroup row does.
100.7397.85 – 103.5112.65108.11 – 117.63194.3191.3 – 1950.52×0.58×

select-ready​

A select whose arms are ready: no registration, no park. Four channel operations per counted iteration, so G#-only; `select-stream` is the Go-paired row.
194.64194.07 – 196.13210.81209.41 – 214.38Not paired——

select-stream​

One select receive per operation against a producer filling one buffered arm — a mix of ready and parked selects, as Go's row is.
387.62382.08 – 389.88351.59348.51 – 354.0189.188.45 – 89.454.35×3.95×

select-park​

A select with no ready arm: registration on every arm, a park, a hand-off. Wave 1 could not measure this at all.
379.43377.28 – 384.79354.1338.27 – 357.53Not paired——

chunk64​

D10 batching at 64 elements per chunk. G#-only: `chunks()` copies elements where Go passes a slice header.
13.5713.5 – 13.6213.9913.95 – 15.9Not paired——

chunk1k​

D10 batching at 1024 elements per chunk. The curve between this and chunk64 is the point, not either number.
8.328.29 – 8.321110.84 – 11.05Not paired——

chunk64-arrays​

G#-only control: fresh CLR int32 arrays over capacity-64 channels at 64 elements.
6.476.46 – 6.676.816.76 – 6.92Not paired——

chunk1k-arrays​

G#-only control: fresh CLR int32 arrays over capacity-64 channels at 1024 elements with an exact-length tail.
3.093.01 – 3.153.113.09 – 3.13Not paired——

chunk64-slices​

Fresh native G# slices versus fresh Go []int32 slices over capacity-64 channels at 64 elements.
14.9714.87 – 15.7313.8713.85 – 13.893.63.55 – 3.74.16×3.85×

chunk1k-slices​

Fresh native G# slices versus fresh Go []int32 slices over capacity-64 channels at 1024 elements with an exact-length tail.
11.2911.27 – 11.310.0210.01 – 10.052.12.05 – 2.15.38×4.77×

A ratio below 1 means the recorded G# median was lower for that paired operation; above 1 means it was higher. No ratio is shown when the registry deliberately has no equivalent Go workload. In particular, do not substitute the round-trip Go row for a one-handoff G# row, or equate copied chunks with transported array/slice references.

Provenance and interpretation limits

JIT mode: tiered-pgo-steady-state. Aggregate interval: range-of-run-medians. Kernel: 6.17.0-1022-azure.

Comparison key: 82f5f8c6ebb9222d12ecb1b6d2c3d5882207fe028c01bc9d54067da47c7b6ba6
Aggregation key: 184c186b0e6d8043ea8022b5c9f7f13478ab082385ca0a8df16f48d61ca256be
Benchmark definition: 463562a166bebfe391ff6a8d4b70ddce851bf84d7c3f0941e9a0aa7091ff7d76
Source payload: c8549e6f888232f7d431514d1beb0bdbe59427207cf636fe8dc9ba8362f8d62b

The GitHub artifact expires at 2027-01-07T05:36:22Z; this static snapshot retains its provenance. Refreshes select successful main-branch runs, validate the source identity, and use the registry from the measured commit. They do not edit performance baselines.

Read the methodology at the measured commit and the pairing registry before drawing conclusions.

Choose the contract before the syntax.

Go offers a familiar channel/context ecosystem and a mature race detector. G# combines language-level channels and scopes with .NET libraries, exceptions, and tooling. Neither removes the need to define ownership, synchronization, or cancellation boundaries.