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 derivedctxis 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.Goblocks 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.ForEachAsyncwithMaxDegreeOfParallelism, or to reading aChannel<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".
| Situation | Suitable tool |
|---|---|
| Several goroutines read and write state (counters, caches, settings) | sync.Mutex / sync.RWMutex / sync/atomic |
| Flowing data through stages, distributing work | Channels |
| Waiting for completion, collecting errors | WaitGroup / errgroup |
| Propagating cancellation | context.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 / Monitor | sync.Mutex / sync.RWMutex |
| Task.WhenAll | sync.WaitGroup / errgroup.Group |
| Parallel.ForEachAsync | worker pool pattern |
| ConcurrentDictionary | sync.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.ForEachAsync | errgroup.Group.SetLimit(n) |
Channel<T> (System.Threading.Channels) | chan T (built in) |
SemaphoreSlim | Buffered channel, golang.org/x/sync/semaphore |
Interlocked.Increment | sync/atomic (atomic.Int64, etc.) |
Lazy<T> / initialize once | sync.Once / sync.OnceValue (Go 1.21 and later) |
ReaderWriterLockSlim | sync.RWMutex |
AggregateException | errors.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 closesresults. - The receiving side keeps reading with
rangeuntil the channel is closed. - Restricting channel direction in the type, as
<-chan int(receive-only) andchan<- 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(WithContextandSetLimit) 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
mapcauses a fatal error under concurrent access. UseMutex+mapas the baseline, and remember thatMutexis not reentrant. - Give every goroutine an exit condition to avoid leaks. From Go 1.22, loop variables are created per iteration.
- Keep
go test -racealways on in CI to find data races early.