Skip to main content

Goroutines and Channels

For developers with .NET (C#) experience, this guide covers goroutines, channels, select, and how the model differs from async / await.

Concurrency is built into the Go language. The big difference from C# is that you write ordinary synchronous code and run it concurrently with the go keyword.

1. What Is a Goroutine?​

A goroutine is a lightweight unit of execution managed by the Go runtime. Put go before a function call and it runs in a new goroutine.

func main() {
go fmt.Println("runs in another goroutine")
fmt.Println("main")
time.Sleep(100 * time.Millisecond) // normally you would wait with a WaitGroup (see below)
}

Scheduling (M:N model)​

The Go runtime multiplexes many goroutines (G) onto a small number of OS threads (M).

AspectgoroutineOS thread (.NET Thread)
Initial stack sizeAbout 2 KB (grows and shrinks)Usually 1 MB or more (depends on OS and settings)
Creation costVery smallLarge
SchedulerGo runtime (cooperative + preemptive)OS
Blocking I/OThe runtime switches to another goroutineOccupies the thread

When a goroutine blocks on I/O, the runtime runs another goroutine, so there is no need for async/await just to avoid blocking calls.

2. .NET Mapping​

.NET (C#)Go
Task / async / awaitgoroutine (runs concurrently while staying synchronous in style)
Task.Run(() => ...)go func() { ... }()
Getting a Task<T> resultReceive via a channel / sync.WaitGroup
Task.WhenAllsync.WaitGroup / errgroup.Group
Task.WhenAnyselect
Channel<T> (System.Threading.Channels)chan T
lock / Monitorsync.Mutex
ReaderWriterLockSlimsync.RWMutex
Interlockedsync/atomic
CancellationTokencontext.Context
Task.Delaytime.Sleep / time.After / time.NewTimer
Parallel.ForEachgoroutines + WaitGroup (or errgroup)

3. Channels​

A channel is a typed pipe for passing values between goroutines. The Go proverb says:

Do not communicate by sharing memory; instead, share memory by communicating.

Basic operations​

ch := make(chan int) // unbuffered channel

go func() {
ch <- 42 // send
}()

v := <-ch // receive
fmt.Println(v) // 42
OperationSyntax
Createmake(chan T) / make(chan T, n)
Sendch <- v
Receivev := <-ch / v, ok := <-ch
Closeclose(ch)
Loop until closedfor v := range ch { ... }

Unbuffered vs buffered​

KindCreationBehavior
Unbufferedmake(chan T)Both sides block until a sender and receiver meet (acts as a synchronization point)
Bufferedmake(chan T, n)Sends do not block while the buffer has room. The sender blocks when full, the receiver when empty
ch := make(chan string, 2)
ch <- "a" // does not block
ch <- "b" // does not block
// ch <- "c" // buffer is full, so this blocks until someone receives

A buffered channel is similar to Channel.CreateBounded<T>(n) in System.Threading.Channels.

Closing rules​

  • The sender closes the channel, not the receiver.
  • Sending to a closed channel panics.
  • Receiving from a closed channel returns the remaining values, then the zero value with ok == false.
  • With multiple senders, wait for all of them with a WaitGroup and close in exactly one place.
  • Closing is not mandatory. Use it when you need range to finish, for example.

Channel direction​

Restricting channel direction in function parameters catches misuse at compile time.

func producer(out chan<- int) { /* send-only */ }
func consumer(in <-chan int) { /* receive-only */ }

4. select​

select runs one of several channel operations that is ready. It resembles Task.WhenAny, but it handles sends, receives, timeouts, and cancellation in a uniform way.

select {
case msg := <-ch1:
fmt.Println("ch1:", msg)
case ch2 <- 10:
fmt.Println("sent to ch2")
case <-time.After(1 * time.Second):
fmt.Println("timeout")
case <-ctx.Done():
fmt.Println("canceled:", ctx.Err())
default:
// runs immediately if nothing else is ready (non-blocking)
}
  • If several cases are ready at once, one is chosen at random.
  • With a default case, select never blocks.
  • Sending to or receiving from a nil channel blocks forever, so you can use that to disable a particular case inside select.

Timeout and cancellation​

func fetch(ctx context.Context, ch <-chan string) (string, error) {
select {
case s := <-ch:
return s, nil
case <-ctx.Done():
return "", ctx.Err()
}
}

Calling time.After on every loop iteration creates many timers (since Go 1.23, unreferenced timers are garbage collected, but the wasted allocations remain). Inside loops, reuse a time.NewTimer or use context.WithTimeout. See context for details.

5. Waiting and Mutual Exclusion​

sync.WaitGroup​

The equivalent of Task.WhenAll.

var wg sync.WaitGroup
for i := range 3 {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("worker", i)
}()
}
wg.Wait()
  • Call Add before starting the goroutine. Calling it inside the goroutine can let Wait return too early.
  • Since Go 1.22, the loop variable i is a separate variable per iteration, so capturing it directly in a closure as above is safe. Before that, you needed i := i.
  • In newer Go versions (1.25 and later), wg.Go(func() { ... }) removes the need for Add / Done.

sync.Mutex​

Protects shared state. The equivalent of the lock statement.

type Counter struct {
mu sync.Mutex
n int
}

func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
  • A sync.Mutex must not be copied (use pointer receivers).
  • Locks are not reentrant. Locking twice in the same goroutine deadlocks (note that C#'s lock is reentrant).

errgroup​

To run several tasks concurrently and cancel everything on the first error, golang.org/x/sync/errgroup is convenient.

g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
g.Go(func() error {
return fetchURL(ctx, url)
})
}
if err := g.Wait(); err != nil {
return err
}

6. Common Pitfalls for C# Developers​

  1. Goroutines have no return value: There is no Task<T>-like type that holds a result. Receive results through channels or shared variables (with synchronization).
  2. Goroutines are not awaited automatically: When main returns, running goroutines are killed immediately. Wait explicitly with a WaitGroup or similar.
  3. Exceptions do not cross goroutines: A panic in a goroutine takes down the whole process unless recovered there. Aggregate errors through channels or errgroup.
  4. Goroutine leaks: A goroutine that keeps sending to a channel with no receiver, or waits on a channel that is never closed, never ends. Unlike a .NET Task, it is not garbage collected.
  5. Deadlock detection: If all goroutines are blocked, the runtime aborts with fatal error: all goroutines are asleep - deadlock!. It does not detect this when other goroutines (such as an HTTP server) are still running.
  6. Loop variable capture: Up to Go 1.21, all iterations shared one variable, causing frequent bugs where closures saw unexpected values. This is fixed when the go directive in go.mod is 1.22 or later.
  7. Data races: Reading and writing from several goroutines without synchronization is undefined behavior. Make it a habit to detect them with go test -race / go run -race.
  8. No async infection: Functions have no color, so you can call a synchronous function from a goroutine directly. Libraries also do not need to offer an "async version."
  9. Cancellation is cooperative: There is no way to forcibly stop a goroutine from outside. Watch context.Context or a dedicated done channel and exit yourself.

Preventing goroutine leaks​

// Bad: even if ctx is canceled, the goroutine stays if the receiver never reads
func leak(ctx context.Context, out chan<- int) {
go func() {
out <- compute() // blocks forever without a receiver
}()
}

// Good: make the send respond to cancellation through select
func noLeak(ctx context.Context, out chan<- int) {
go func() {
select {
case out <- compute():
case <-ctx.Done():
}
}()
}

Guidelines:

  • When you start a goroutine, always decide when and how it ends
  • Watch the exit condition (closing a channel, ctx.Done()) with select
  • When returning a single result, use a channel with buffer size 1 so the sender never gets stuck

7. Code Examples​

C# async/await​

public async Task<IReadOnlyList<string>> FetchAllAsync(
IEnumerable<string> urls, CancellationToken ct)
{
var tasks = urls.Select(url => _http.GetStringAsync(url, ct));
return await Task.WhenAll(tasks);
}

Go: WaitGroup and collecting results​

func fetchAll(ctx context.Context, urls []string) ([]string, error) {
results := make([]string, len(urls))
g, ctx := errgroup.WithContext(ctx)

for i, url := range urls {
g.Go(func() error {
body, err := fetch(ctx, url)
if err != nil {
return fmt.Errorf("fetch %s: %w", url, err)
}
results[i] = body // each goroutine writes only its own index, so there is no race
return nil
})
}

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

func fetch(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()

b, err := io.ReadAll(resp.Body)
return string(b), err
}

Go: A channel pipeline (producer/consumer)​

func generate(ctx context.Context, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // the sender closes
for _, n := range nums {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
}

func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}

func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

for v := range square(ctx, generate(ctx, 1, 2, 3, 4)) {
fmt.Println(v)
}
}

This is the same structure you would build in C# with Channel.CreateUnbounded<int>() and await foreach (var x in reader.ReadAllAsync()). In Go there is no async or await, and a plain for ... range works.

Comparison with C# Channel<T>​

var channel = Channel.CreateBounded<int>(10);

var producer = Task.Run(async () =>
{
for (var i = 0; i < 5; i++)
await channel.Writer.WriteAsync(i);
channel.Writer.Complete();
});

await foreach (var item in channel.Reader.ReadAllAsync())
Console.WriteLine(item);
ch := make(chan int, 10)

go func() {
defer close(ch)
for i := range 5 {
ch <- i
}
}()

for item := range ch {
fmt.Println(item)
}

8. Choosing the Right Tool​

SituationRecommended
Protecting simple state such as counters or cachessync.Mutex / sync/atomic
Passing values, pipelines, coordination between workersChannels
Waiting for several taskssync.WaitGroup / errgroup
Timeout and cancellationcontext + select
Run exactly oncesync.Once

You do not have to use channels everywhere. If you only need to protect state, a Mutex is often simpler. For more practical patterns, see Concurrency Patterns.

9. Summary​

  • A goroutine is a lightweight unit of execution started with just go, and tens of thousands are fine. No async / await is needed, and synchronous-style code can run concurrently.
  • Channels are the means of communication between goroutines. Unbuffered channels act as synchronization points, buffered channels as queues. The sender closes.
  • select handles multiple channel operations, timeouts, and cancellation through ctx.Done() in one construct.
  • Goroutines are not awaited automatically and cannot be stopped from outside. Design the exit condition, wait with WaitGroup / errgroup, and detect data races with -race.

References​