Limiting goroutines, chapter 2

September 17, 2025

Last time I introduced the SetLimit feature of the errgroup package. But this feature is not sufficient if you ever ant to set some sort of shared or global limit on the number of jobs doing a task.

For a more general-purpose rate limiting mechanism, I often reach for the x/sync/semaphore package.

Let’s modify the example from last time, to introduce a global limit using this library:

import (
	// other impots
	"golang.org/x/sync/semaphore"
)

var limit = semaphore.NewWeighted(10)

func findAllOrders(ctx context.Context, orderIDs []int) ([]*Order, error) {
	g, ctx := errgroup.WithContext(ctx)

	orders := make([]*Order, len(orderIDs))

	for i, orderID := range orderIDs{
		if err := limit.Acquire(ctx, 1); err != nil {
			return nil, err
		}
		g.Go(func() error {
			defer limit.Release(1)
			order, err := fetchSingleOrder(ctx, orderID)
			if err != nil {
				return err
			}
			orders[i] = order
		})
	}

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

We’ve added a few lines of code to the previous example, so it is a bit more complex, on the surface. But let’s see what it does now.

First, we create a global variable limit, in the package scope. (Note that I don’t advocatae for global variables—this would be much better as a field in an instantiated struct, but I want to keep the example as short as possible for illustrative purposes.) We set the limit to 10 in this case.

Then, in the findAllOrders function, before launching each goroutine, we “acquire” an item from the limit semaphore object. The Acquire method will block until there is availability, so this is effectively the same behavior as using errgroup’s SetLimit.

Then, we have to release that “acquired” token with the defer limit.Release(1) call, inside the anonymous function launched in the goroutine.

Why the 1 argument to both Acquire() and Release()? Because the weighted semaphore supports arbitrarily weights. In the case of a worker pool, using 1 per task generally makes sense. But you can use any value for the size of pool, and the amount you acquire or release. As an example, you could set the maximum weight to 100, then acquire and release arbitrary percentages (Acquire(ctx, 3.15) and Release(1.23), etc). You don’t even need to match the Acquire and Release calls, if you want to release partial capacity at a time. I’ve never done this, and I imagine it’s a rare requirement, but the flexibility is there!


Share this

Direct to your inbox, daily. I respect your privacy .

Unsure? Browse the archive .

Get daily content like this in your inbox!

Subscribe