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).
| Aspect | goroutine | OS thread (.NET Thread) |
|---|---|---|
| Initial stack size | About 2 KB (grows and shrinks) | Usually 1 MB or more (depends on OS and settings) |
| Creation cost | Very small | Large |
| Scheduler | Go runtime (cooperative + preemptive) | OS |
| Blocking I/O | The runtime switches to another goroutine | Occupies 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 / await | goroutine (runs concurrently while staying synchronous in style) |
Task.Run(() => ...) | go func() { ... }() |
Getting a Task<T> result | Receive via a channel / sync.WaitGroup |
Task.WhenAll | sync.WaitGroup / errgroup.Group |
Task.WhenAny | select |
Channel<T> (System.Threading.Channels) | chan T |
lock / Monitor | sync.Mutex |
ReaderWriterLockSlim | sync.RWMutex |
Interlocked | sync/atomic |
CancellationToken | context.Context |
Task.Delay | time.Sleep / time.After / time.NewTimer |
Parallel.ForEach | goroutines + 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
| Operation | Syntax |
|---|---|
| Create | make(chan T) / make(chan T, n) |
| Send | ch <- v |
| Receive | v := <-ch / v, ok := <-ch |
| Close | close(ch) |
| Loop until closed | for v := range ch { ... } |
Unbuffered vs buffered
| Kind | Creation | Behavior |
|---|---|---|
| Unbuffered | make(chan T) | Both sides block until a sender and receiver meet (acts as a synchronization point) |
| Buffered | make(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
WaitGroupand close in exactly one place. - Closing is not mandatory. Use it when you need
rangeto 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
defaultcase,selectnever 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
Addbefore starting the goroutine. Calling it inside the goroutine can letWaitreturn too early. - Since Go 1.22, the loop variable
iis a separate variable per iteration, so capturing it directly in a closure as above is safe. Before that, you neededi := i. - In newer Go versions (1.25 and later),
wg.Go(func() { ... })removes the need forAdd/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.Mutexmust not be copied (use pointer receivers). - Locks are not reentrant. Locking twice in the same goroutine deadlocks (note that C#'s
lockis 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
- 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). - Goroutines are not awaited automatically: When
mainreturns, running goroutines are killed immediately. Wait explicitly with aWaitGroupor similar. - Exceptions do not cross goroutines: A panic in a goroutine takes down the whole process unless recovered there. Aggregate errors through channels or
errgroup. - 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. - 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. - 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
godirective ingo.modis 1.22 or later. - 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. - No
asyncinfection: 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." - Cancellation is cooperative: There is no way to forcibly stop a goroutine from outside. Watch
context.Contextor a dedicateddonechannel 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()) withselect - 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
| Situation | Recommended |
|---|---|
| Protecting simple state such as counters or caches | sync.Mutex / sync/atomic |
| Passing values, pipelines, coordination between workers | Channels |
| Waiting for several tasks | sync.WaitGroup / errgroup |
| Timeout and cancellation | context + select |
| Run exactly once | sync.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. Noasync/awaitis 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.
selecthandles multiple channel operations, timeouts, and cancellation throughctx.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.