Right, so you’ve got channels. They’re for sending data. But sometimes, you don’t actually care about the data. You just care about the signal. You want to control how many of a certain thing can happen at once, like limiting the number of simultaneous database connections or outgoing API calls. This is called bounding concurrency, and it’s a classic job for a semaphore.

A semaphore is just a fancy counter that blocks when it hits zero. You can build one trivially with a buffered channel. The capacity of the channel is your concurrency limit. Instead of sending meaningful data, you’ll just send empty structs (struct{}), which are basically tokens that take up zero bytes of memory. It’s the computational equivalent of a “you may proceed” hand signal.

Here’s the basic pattern. You create a channel with a buffer size equal to your desired limit. Before a goroutine starts the work you want to limit, it tries to send a token into the channel. If the channel is full, the send blocks, effectively telling the goroutine to wait its turn. When the goroutine finishes its work, it reads a token back out of the channel, freeing up a slot for the next one.

The Basic Token Bucket

Let’s make this concrete. Say we want to limit ourselves to three concurrent goroutines.

// Create a semaphore channel with a capacity of 3.
tokens := make(chan struct{}, 3)

// Pre-fill it with tokens. This is the key.
for i := 0; i < 3; i++ {
    tokens <- struct{}{}
}

// Now, let's spin up 10 goroutines that want to do work.
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    wg.Add(1)

    go func(id int) {
        defer wg.Done()

        // Try to acquire a token. This will block until a slot is free.
        <-tokens

        // We got one! Do the expensive work.
        fmt.Printf("Goroutine %d is doing work...\n", id)
        time.Sleep(2 * time.Second) // Simulate work
        fmt.Printf("Goroutine %d is done.\n", id)

        // Release the token back into the pool.
        tokens <- struct{}{}
    }(i)
}

wg.Wait()
close(tokens)

Watch the output. You’ll see three goroutines start immediately, print “doing work,” and then two seconds later, three more will kick off. It works perfectly. The channel’s buffer size is the absolute maximum number of goroutines that can be inside the critical section (between <-tokens and tokens <- struct{}{}) at any given time.

Why Pre-Filling is Non-Negotiable

You might be tempted to create an empty buffered channel and send the tokens in the goroutine after you’ve done the work. Don’t. That’s backwards and breaks the whole mechanism. Think about it: an empty buffered channel has a capacity of three but contains zero tokens. The first send (tokens <- struct{}{}) would succeed immediately, not block, and you’d get no limiting at all until you’d already exceeded your limit. The pattern only works because we start with a full bucket of tokens. Acquiring a token takes one out, and releasing it puts one back. The initial pre-fill is what creates the pool of available permits.

Handling Context Cancellation Gracefully

The simple example above has a massive, real-world problem: what if the work we’re doing gets cancelled via a context? The goroutine will exit, but it will still be holding that token hostage, never to release it. This is a classic way to deadlock an application. We need to make our token acquisition respect the context.

func doWorkWithLimit(ctx context.Context, tokens chan struct{}, id int) error {
    // We need to try and acquire a token, but be able to abort if the context is cancelled.
    select {
    case <-tokens: // Acquired a token!
        // Excellent, proceed.
    case <-ctx.Done():
        return ctx.Err() // Context cancelled before we could even start.
    }

    // Now, we MUST ensure we release the token no matter how we exit this function.
    defer func() {
        tokens <- struct{}{} // Release the token back to the pool.
    }()

    // Now do the work, but make sure it's also context-aware.
    // For example, an HTTP request:
    req, _ := http.NewRequestWithContext(ctx, "GET", "https://example.com", nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    // ... process the response ...
    fmt.Printf("Goroutine %d succeeded\n", id)
    return nil
}

This is the robust way to do it. The select lets us bail on acquisition if the parent says “stop,” and the defer guarantees the token gets released even if the function panics or errors out. Not handling this is like borrowing a friend’s car and then driving it into a lake because your dinner reservation was cancelled. Just don’t.

The Subtle Art of Acquisition Order

Here’s a fun bit of trivia: because we’re using a channel, this semaphore is fair. It provides first-in, first-out (FIFO) ordering for access to the critical section. The goroutines that block waiting on <-tokens will be unblocked in the exact order they called it. This is often what you want, but it’s good to be aware of. Other semaphore implementations might not make this guarantee. It’s one of those nice properties you get for free with channels. So, your limiting isn’t just bounded; it’s orderly. How civilized.