Skip to main content

Concurrency Patterns and Synchronization

For developers with .NET (C#) experience, this guide explains sync.WaitGroup, Mutex, errgroup, worker pools, and the race detector. In C# you build concurrency from Task and async/await, Parallel, and Channel<T>; in Go, goroutines, channels, the sync package, and the semi-official errgroup library play the same roles. The biggest difference is that there is no Task that carries a return value, so you design how to wait for completion and how to collect results and errors yourself.

1. What This Document Covers​

  • WaitGroup and errgroup
  • Worker pool / fan-out / fan-in
  • Detecting data races with go test -race
  • Shared memory vs message passing

WaitGroup and errgroup​

sync.WaitGroup is the minimal tool for waiting until all started goroutines finish, and a plain counterpart of Task.WhenAll. It cannot carry return values or errors, though, so you receive results through shared variables or channels.

errgroup is provided by the golang.org/x/sync/errgroup package (not the standard library; add it with go get golang.org/x/sync) and adds the following to WaitGroup:

  • g.Go(func() error) starts a goroutine that can return an error.
  • g.Wait() returns the first non-nil error that occurred.
  • With errgroup.WithContext(ctx), the derived ctx is cancelled as soon as any goroutine returns an error, which tells the others to stop.
  • g.SetLimit(n) limits the number of goroutines running at once. While the limit is reached, g.Go blocks the caller.

In practice, for most cases of "run several things concurrently and fail as a whole if any one fails", errgroup fits better than WaitGroup. Note that Go 1.25 added a Go(func()) method to sync.WaitGroup, so you no longer have to hand-write the matching Add and Done. Check which Go version you use.

Worker pool / fan-out / fan-in​

  • Worker pool: a fixed number of goroutines take work from a single channel (the job queue) and process it. This corresponds to Parallel.ForEachAsync with MaxDegreeOfParallelism, or to reading a Channel<T> with several consumers.
  • Fan-out: several workers read one input channel at the same time to distribute the work.
  • Fan-in: the outputs of several channels are merged into one channel.

If you only need to cap the number of workers, errgroup + SetLimit is the simplest. Choose a channel-based worker pool for staged processing (pipelines) or when you want to process results as a stream.

Detecting data races with go test -race​

Go ships with a race detector that detects data races at runtime. It is enabled with go test -race ./..., go run -race, or go build -race. It reports only the execution paths where a race actually occurred (finding nothing is not proof of safety), so the standard practice is to write tests that exercise concurrent code and always run them with -race in CI. Run time and memory use increase, so do not leave it on in production binaries.

C# has no equivalent standard tool, which makes this an especially important habit in Go.

Shared memory vs message passing​

A Go proverb says, "Do not communicate by sharing memory; instead, share memory by communicating." Designs that hand off ownership through channels are encouraged, but this does not mean "always use channels".

SituationSuitable tool
Several goroutines read and write state (counters, caches, settings)sync.Mutex / sync.RWMutex / sync/atomic
Flowing data through stages, distributing workChannels
Waiting for completion, collecting errorsWaitGroup / errgroup
Propagating cancellationcontext.Context

Reaching for channels to protect simple shared state only makes things more complicated. A rule of thumb: if a single goroutine owns the data, use a channel; if several touch it, use a lock.

2. .NET Mapping​

.NET (C#)Go
lock / Monitorsync.Mutex / sync.RWMutex
Task.WhenAllsync.WaitGroup / errgroup.Group
Parallel.ForEachAsyncworker pool pattern
ConcurrentDictionarysync.Map / Mutex + map

Additional correspondences that complement the table above:

.NET (C#)Go
Task.Run(() => ...)go func() { ... }()
Task<T> (async work with a return value)Channels, or shared variables + WaitGroup / errgroup
MaxDegreeOfParallelism of Parallel.ForEachAsyncerrgroup.Group.SetLimit(n)
Channel<T> (System.Threading.Channels)chan T (built in)
SemaphoreSlimBuffered channel, golang.org/x/sync/semaphore
Interlocked.Incrementsync/atomic (atomic.Int64, etc.)
Lazy<T> / initialize oncesync.Once / sync.OnceValue (Go 1.21 and later)
ReaderWriterLockSlimsync.RWMutex
AggregateExceptionerrors.Join (combines multiple errors); errgroup returns only the first error
(no standard data race detection tool)go test -race (race detector)

3. Common Pitfalls for C# Developers​

There is no Task with a return value​

In C#, await Task.WhenAll(tasks) gives you an array of results. A Go goroutine has no return value, so you write results into each element of a pre-allocated slice or send them over a channel. Writing to a slice where each goroutine uses a different element is safe without locks.

Goroutine leaks​

Goroutines are not garbage collected. A goroutine that keeps sending to a channel nobody receives from, or keeps waiting in range on a channel nobody closes, stays alive and consumes memory. For every goroutine you start, decide when and on what trigger it ends before you write it. Combining context cancellation with select to build the exit condition is the standard approach.

Capturing loop variables (changed in Go 1.22)​

Up to Go 1.21, a for loop variable was a single variable shared by all iterations, so capturing it as in go func() { use(v) }() famously made every goroutine see the last value. From Go 1.22 (modules whose go.mod go directive is 1.22 or higher), a new variable is created per iteration, so this problem no longer occurs. With an older go.mod the old behavior remains, so check the go directive in existing projects.

Call WaitGroup Add outside the goroutine​

If you call wg.Add(1) inside the goroutine, wg.Wait() may run first and return without waiting. Call Add right before the go statement. Also, never copy a WaitGroup or Mutex by value; pass pointers or keep them as struct fields. The copylocks check of go vet detects this.

The sender closes the channel​

The sender closes a channel, not the receiver. Sending to a closed channel and closing twice both panic. When there are multiple senders, wait for all of them with a WaitGroup and close in one place. A receiver can keep reading with for v := range ch until the channel is closed.

Concurrent map access is a fatal error​

If several goroutines write to a regular map at the same time (or write and read at the same time), the runtime terminates the process immediately with an error such as fatal error: concurrent map writes. It cannot be caught with recover. There is no built-in concurrent map like ConcurrentDictionary, so the baseline is sync.Mutex / sync.RWMutex + map. sync.Map targets limited uses, such as keys written once and read many times, or goroutines working on disjoint sets of keys. Its type safety is also weaker, so consider Mutex + map first.

Mutex is not reentrant​

C#'s lock allows reentry from the same thread, but Go's sync.Mutex does not. Trying to take a lock you already hold deadlocks. Design so that functions called while holding a lock do not take the same lock again. Releasing with defer mu.Unlock() is the standard idiom.

errgroup returns only the first error​

g.Wait() returns the first non-nil error that occurred. If you want all errors, collect each goroutine's error yourself and combine them with errors.Join. Also, the ctx from WithContext is cancelled when Wait returns, so do not reuse it after Wait.

Do not leave concurrency unbounded​

Starting goroutines in a for loop over many items is cheap in Go, but if each goroutine calls a database or HTTP, the number of connections and the load on the other side become a problem. Set an upper bound with errgroup.SetLimit or a worker pool.

4. Code Examples​

Example 1: Aggregation with WaitGroup and Mutex​

C#:

var total = 0;
var gate = new object();

await Task.WhenAll(Enumerable.Range(1, 5).Select(n => Task.Run(() =>
{
lock (gate)
{
total += n;
}
})));

Console.WriteLine(total);

Go:

package main

import (
"fmt"
"sync"
)

func main() {
var (
mu sync.Mutex
wg sync.WaitGroup
total int
)

for n := 1; n <= 5; n++ {
wg.Add(1) // call Add outside the goroutine
go func() {
defer wg.Done()
// From Go 1.22, n is a per-iteration variable, so it can be captured as is
mu.Lock()
defer mu.Unlock()
total += n
}()
}

wg.Wait()
fmt.Println(total) // 15
}

For a simple counter, atomic.Int64 from sync/atomic removes the need for a lock.

var total atomic.Int64
// inside each goroutine
total.Add(int64(n))
// at the end
fmt.Println(total.Load())

Example 2: Bounded concurrency with errgroup​

This is the scenario of Parallel.ForEachAsync with MaxDegreeOfParallelism.

C#:

var urls = new[] { "a", "b", "c", "d", "e" };
var results = new string[urls.Length];

await Parallel.ForEachAsync(
urls.Select((u, i) => (u, i)),
new ParallelOptions { MaxDegreeOfParallelism = 2, CancellationToken = ct },
async (item, token) =>
{
results[item.i] = await FetchAsync(item.u, token);
});

Go:

package main

import (
"context"
"fmt"
"time"

"golang.org/x/sync/errgroup"
)

// fetch is a dummy implementation standing in for an external call
func fetch(ctx context.Context, url string) (string, error) {
select {
case <-time.After(100 * time.Millisecond):
if url == "d" {
return "", fmt.Errorf("fetch %s: boom", url)
}
return "body of " + url, nil
case <-ctx.Done():
return "", ctx.Err()
}
}

func fetchAll(ctx context.Context, urls []string) ([]string, error) {
g, ctx := errgroup.WithContext(ctx) // ctx is cancelled when one fails
g.SetLimit(2) // at most 2 run at the same time

results := make([]string, len(urls))
for i, u := range urls {
g.Go(func() error {
body, err := fetch(ctx, u)
if err != nil {
return err
}
results[i] = body // each goroutine writes a different element, so no lock is needed
return nil
})
}

if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}

func main() {
res, err := fetchAll(context.Background(), []string{"a", "b", "c", "d", "e"})
fmt.Println(res, err)
}

When you set SetLimit, g.Go blocks the caller while the limit is reached. If you do not want to block, use g.TryGo, which returns a bool indicating whether the goroutine was started.

Example 3: Worker pool (fan-out / fan-in)​

This is close to reading a Channel<T> with several consumers in C#.

package main

import (
"fmt"
"sync"
)

type result struct {
n int
sq int
}

func worker(jobs <-chan int, results chan<- result, wg *sync.WaitGroup) {
defer wg.Done()
for n := range jobs { // receive until jobs is closed
results <- result{n: n, sq: n * n}
}
}

func main() {
jobs := make(chan int)
results := make(chan result)

// fan-out: 3 workers read the same jobs channel
var wg sync.WaitGroup
for range 3 { // range over an integer is available from Go 1.22
wg.Add(1)
go worker(jobs, results, &wg)
}

// sender: send the work, then close jobs (the sender closes)
go func() {
defer close(jobs)
for n := 1; n <= 9; n++ {
jobs <- n
}
}()

// fan-in: close results after all workers have finished
go func() {
wg.Wait()
close(results)
}()

sum := 0
for r := range results {
sum += r.sq
}
fmt.Println("sum of squares:", sum) // 285
}

Three key points:

  • The sender closes jobs, and a dedicated goroutine that waits for all workers to finish closes results.
  • The receiving side keeps reading with range until the channel is closed.
  • Restricting channel direction in the type, as <-chan int (receive-only) and chan<- result (send-only), prevents misuse at compile time.

Example 4: A data race and the race detector​

The following code contains a data race because several goroutines update counter without synchronization.

package main

import (
"fmt"
"sync"
)

func main() {
counter := 0
var wg sync.WaitGroup

for range 1000 {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // race: read, add, and write are not atomic
}()
}

wg.Wait()
fmt.Println(counter) // not guaranteed to be 1000
}

Running it with go run -race main.go prints WARNING: DATA RACE along with the stack traces of the conflicting reads and writes. In tests, run:

go test -race ./...

Fix it by protecting the variable with a sync.Mutex or by switching to atomic.Int64.

Example 5: A thread-safe cache protected by RWMutex​

Instead of ConcurrentDictionary, you hide the map and the lock together in a struct.

package main

import (
"fmt"
"sync"
)

type Cache struct {
mu sync.RWMutex
data map[string]string
}

func NewCache() *Cache {
return &Cache{data: make(map[string]string)}
}

func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // many readers can hold this at once
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}

func (c *Cache) Set(key, value string) {
c.mu.Lock() // writes are exclusive
defer c.mu.Unlock()
c.data[key] = value
}

func main() {
c := NewCache()
c.Set("lang", "go")
v, ok := c.Get("lang")
fmt.Println(v, ok)
}

Because Cache contains a Mutex, handle it through a pointer rather than copying it by value.

5. Summary​

  • Goroutines have no return values, so design waiting and result aggregation with WaitGroup, errgroup, and channels.
  • For concurrent work that returns errors and for limiting concurrency, golang.org/x/sync/errgroup (WithContext and SetLimit) is the simplest.
  • A worker pool distributes work: the sender closes the job channel, and the results channel is closed in one place after all workers finish.
  • Protect shared state with Mutex / RWMutex / atomic, and use channels for handing off ownership and staged processing.
  • A regular map causes a fatal error under concurrent access. Use Mutex + map as the baseline, and remember that Mutex is not reentrant.
  • Give every goroutine an exit condition to avoid leaks. From Go 1.22, loop variables are created per iteration.
  • Keep go test -race always on in CI to find data races early.

References​