Right, let’s talk about semaphores. You’ve probably heard the term in operating systems or concurrent programming—it’s a fancy word for a counter that controls access to a finite number of resources. In Go, we don’t have a semaphore package in the standard library. Why? Because we can build a perfectly good one, a weighted one at that, in about three lines of code using a buffered channel. It’s one of those elegant “less is more” designs that makes you appreciate Go’s simplicity, even if you occasionally want to throw your keyboard at it.

The core idea is diabolically simple: a buffered channel’s capacity is our maximum concurrent count. Sending an item into the channel acquires a slot, receiving one releases it. The type of the item is irrelevant; we’re just using the channel’s blocking behavior as our lock. We typically use struct{} to minimize memory overhead, because we’re not actually passing data, we’re just counting.

The Basic Implementation

Here’s the entire semaphore implementation. Try not to blink, you might miss it.

type Semaphore chan struct{}

func NewSemaphore(n int) Semaphore {
    return make(chan struct{}, n)
}

func (s Semaphore) Acquire() {
    s <- struct{}{} // Send an empty struct to acquire a slot.
}

func (s Semaphore) Release() {
    <-s // Receive from the channel to release a slot.
}

To use it, you create a semaphore with a specific capacity, say 10. Before a goroutine uses a precious resource (like a database connection, an API call rate limit, or a GPU), it calls Acquire(). If the channel is full, Acquire() will block until someone calls Release(). This is the magic. The channel’s built-in synchronization primitives handle all the nasty lock/unlock and waiting logic for us. It’s brilliantly simple.

A Realistic Example: Limiting Goroutines

Let’s say you’re crawling the web. Spawning 10,000 goroutines simultaneously to fetch URLs is a fantastic way to get your IP address banned, overwhelm your network, or just generally behave like a bad citizen. A semaphore is the perfect tool to limit how many HTTP requests are in flight at once.

func main() {
    urls := []string{...} // A very long list of URLs
    sem := NewSemaphore(20) // Limit to 20 concurrent fetches

    var wg sync.WaitGroup
    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()

            sem.Acquire()       // Block until a slot is free. We have 20 "tickets".
            defer sem.Release() // Critical: release the slot when we're done, no matter what.

            // Now do the risky, rate-limited thing.
            resp, err := http.Get(u)
            if err != nil {
                log.Printf("failed to get %s: %v", u, err)
                return
            }
            defer resp.Body.Close()
            // ... process the response ...
        }(url)
    }
    wg.Wait()
}

Notice the defer sem.Release(). This is non-negotiable. If you Acquire and then panic or return early without Release, you’ve leaked a semaphore slot. Your program will gradually grind to a halt as all your “tickets” are lost forever, leaving goroutines permanently blocked on Acquire(). Always, always use defer to release. It’s the closest thing to a guarantee you get in this line of work.

Why Not Just Use a sync.Mutex and a Counter?

It’s a fair question. You could do this:

var (
    mu    sync.Mutex
    count int
    max   = 10
)

func acquire() {
    for {
        mu.Lock()
        if count < max {
            count++
            mu.Unlock()
            return
        }
        mu.Unlock()
        time.Sleep(10 * time.Millisecond) // Yikes.
    }
}

But look at that mess. You’ve had to implement a busy-wait loop, which is horribly inefficient. The channel-based semaphore is better because it puts waiting goroutines to sleep efficiently at the runtime level. They don’t wake up until a Release() call makes a slot available. It’s the right abstraction.

The context.Context Aware Acquire

Here’s a common pitfall: what if you want to Acquire() a slot, but you might need to cancel the operation if a timeout occurs or a parent request is cancelled? A plain Acquire() will block forever. This is where we level up our basic semaphore.

We can implement a TryAcquire method that works with context.Context.

func (s Semaphore) TryAcquire(ctx context.Context) error {
    select {
    case s <- struct{}{}:
        return nil // Acquired!
    case <-ctx.Done():
        return ctx.Err() // Timed out or cancelled.
    }
}

Now, our URL fetcher can be made much smarter. If acquiring a slot takes too long, we can bail out gracefully.

func fetchWithTimeout(url string, sem Semaphore, ctx context.Context) error {
    err := sem.TryAcquire(ctx)
    if err != nil {
        return fmt.Errorf("failed to acquire semaphore: %w", err)
    }
    defer sem.Release()

    // Proceed with the HTTP request, perhaps also using ctx for the request timeout.
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return err
    }
    resp, err := http.DefaultClient.Do(req)
    // ... etc ...
}

This pattern is essential for building robust, production-grade systems that need to respect deadlines and cancellation signals. The basic semaphore is great, but the context-aware version is what you’ll actually use in the real world to avoid those “mysterious” hangs.

In the end, the buffered channel semaphore is a perfect idiom. It’s concise, efficient, and built from a primitive you already understand. It demonstrates the power of Go’s concurrency model: with a few simple tools, you can build sophisticated patterns yourself, without waiting for the language designers to add a formal implementation.