select: Multiplexing Like a Pro

The select statement is where Go’s channel system goes from “neat” to “powerful.” It lets you wait on multiple channel operations simultaneously, handle timeouts, and do non-blocking checks. All with clean syntax.

C# doesn’t have a direct equivalent. The closest is Task.WhenAny, but select is more flexible and more deeply integrated.

Basic select

select waits on multiple channel operations and executes whichever is ready first:

select {
case msg := <-ch1:
    fmt.Println("received from ch1:", msg)
case msg := <-ch2:
    fmt.Println("received from ch2:", msg)
}

If both channels have data, one is chosen randomly (fair scheduling). If neither has data, select blocks until one does.

Timeouts

This is where select shines. Implementing a timeout:

select {
case result := <-ch:
    fmt.Println("got result:", result)
case <-time.After(3 * time.Second):
    fmt.Println("timeout!")
}

time.After returns a channel that receives a value after the duration. If your main channel doesn’t deliver in 3 seconds, the timeout case fires.

Compare to C#:

var task = GetResultAsync();
var completed = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(3)));

if (completed == task)
{
    var result = await task;
    Console.WriteLine($"got result: {result}");
}
else
{
    Console.WriteLine("timeout!");
}

More verbose, and the result handling is awkward.

Non-Blocking Operations

Add a default case to make select non-blocking:

select {
case msg := <-ch:
    fmt.Println("received:", msg)
default:
    fmt.Println("no message available")
}

If no channel is ready, default executes immediately. This is how you poll without blocking.

Non-Blocking Send

select {
case ch <- msg:
    fmt.Println("sent")
default:
    fmt.Println("channel full, dropping message")
}

Try to send; if the channel is full (or unbuffered with no receiver), execute default instead of blocking.

Handling Multiple Sources

Real-world example: a worker that handles requests, ticks, and shutdown signals:

func worker(requests <-chan Request, done <-chan struct{}) {
    ticker := time.NewTicker(time.Minute)
    defer ticker.Stop()

    for {
        select {
        case req := <-requests:
            handleRequest(req)
        case <-ticker.C:
            doPeriodicWork()
        case <-done:
            fmt.Println("shutting down")
            return
        }
    }
}

Three different event sources, one clean loop. In C#, you’d need Task.WhenAny with careful task management, or an Rx observable merge.

The Empty select

A select with no cases blocks forever:

select {}  // blocks forever

Useful for keeping a main function alive while goroutines do work:

func main() {
    go server()
    select {}  // wait forever
}

Not common, but occasionally handy.

Priority with Nested select

select chooses randomly among ready cases. If you need priority, nest them:

for {
    // First, drain high priority
    select {
    case msg := <-highPriority:
        handle(msg)
        continue
    default:
    }
    
    // Then check both
    select {
    case msg := <-highPriority:
        handle(msg)
    case msg := <-lowPriority:
        handle(msg)
    }
}

The first select with default is non-blocking. It handles high-priority if available, otherwise falls through. This ensures high-priority messages are processed first.

Cancellation Pattern

Using select with a done channel for cancellation:

func doWork(done <-chan struct{}) error {
    for {
        select {
        case <-done:
            return errors.New("cancelled")
        default:
        }
        
        // Do a chunk of work
        if finished := processChunk(); finished {
            return nil
        }
    }
}

Check for cancellation at the top of each iteration. We’ll see a better way with context later.

Collecting Results with Timeout

Common pattern: gather results from multiple goroutines with an overall timeout:

func fetchAll(urls []string, timeout time.Duration) []Result {
    results := make(chan Result, len(urls))
    
    for _, url := range urls {
        go func(u string) {
            resp, err := fetch(u)
            results <- Result{URL: u, Response: resp, Error: err}
        }(url)
    }
    
    var collected []Result
    deadline := time.After(timeout)
    
    for i := 0; i < len(urls); i++ {
        select {
        case r := <-results:
            collected = append(collected, r)
        case <-deadline:
            return collected  // return what we have
        }
    }
    
    return collected
}

We get as many results as complete before the timeout, then return whatever we have.

Compare to C#:

async Task<List<Result>> FetchAll(string[] urls, TimeSpan timeout)
{
    var cts = new CancellationTokenSource(timeout);
    var tasks = urls.Select(url => FetchAsync(url, cts.Token));
    
    try
    {
        return (await Task.WhenAll(tasks)).ToList();
    }
    catch (OperationCanceledException)
    {
        // WhenAll throws if any task cancels - harder to get partial results
        return tasks
            .Where(t => t.IsCompletedSuccessfully)
            .Select(t => t.Result)
            .ToList();
    }
}

The C# version is more awkward for partial results because Task.WhenAll is all-or-nothing.

select with Send and Receive

You can mix sends and receives in one select:

select {
case ch1 <- value:
    fmt.Println("sent to ch1")
case msg := <-ch2:
    fmt.Println("received from ch2:", msg)
}

Whichever operation can proceed, does. Useful for bidirectional communication.

The C# Comparison

Go selectC# EquivalentNotes
select on multiple channelsTask.WhenAnyLess elegant syntax
Timeout with time.AfterTask.WhenAny + Task.DelayMore verbose
Non-blocking with defaultTask.IsCompleted checkManual polling
Empty select {}Task.Delay(Timeout.Infinite)Rare in both
Priority handlingManual with loopsNo direct equivalent

C# has System.Threading.Channels which gets closer:

var reader = channel.Reader;
while (await reader.WaitToReadAsync())
{
    while (reader.TryRead(out var item))
    {
        Process(item);
    }
}

But multiplexing multiple channels still requires Task.WhenAny gymnastics.

The Honest Take

select is one of Go’s rather splendid features. It makes patterns that are awkward in other languages (timeouts, multiplexing, non-blocking checks) into one clean construct.

What Go does better:

  • Clean syntax for multiplexing
  • Timeouts are trivial
  • Non-blocking operations with default
  • Fair random selection among ready cases
  • First-class language support

What C# does better:

  • Task.WhenAll for collecting all results
  • Better exception propagation
  • Richer LINQ-style composition with Rx

The verdict: If you’re doing event-loop style programming (handling messages from multiple sources, implementing timeouts, coordinating shutdown) select is wonderful. It’s one of those features you miss when you go back to languages without it.


Next up: mutexes and WaitGroups. Because sometimes shared memory with locks is simpler than channels.