How to Use Channels as Semaphores in Go

Use a buffered channel with a capacity equal to the desired concurrency limit, where acquiring a permit means sending a value into the channel and releasing it means receiving a value.

The problem with unlimited goroutines

You are writing a scraper. You find 100 URLs. You spawn a goroutine for every URL. The target server sees a flood of requests. It bans your IP. Or you connect to a database that caps connections at five. You launch fifty tasks. The database rejects forty-five of them. You need a way to throttle the goroutines so only five run at a time. The rest wait until a slot opens.

Go does not have a built-in semaphore type. It has channels. A buffered channel tracks how many values are inside it. The capacity is the maximum. Sending adds a value. Receiving removes one. If you send to a full channel, the goroutine blocks. If you receive from an empty channel, the goroutine blocks. This blocking behavior creates a semaphore.

Use a buffered channel with capacity N as a semaphore with N permits. Acquiring a permit means sending a value. Releasing means receiving. The channel fills up to N. The N+1 send blocks. When a receiver runs, the channel drops below N, and the blocked sender proceeds.

The channel capacity is the law. The runtime enforces it.

A channel is a counter

Think of the channel as a parking garage with a fixed number of spots. The capacity is the number of spots. A car entering the garage is a send operation. A car leaving is a receive operation. If the garage is full, cars wait at the gate. They cannot enter until a spot opens.

In Go, the "car" is a value. The value itself does not matter. Only the presence of the value matters. The channel counts the values. The count cannot exceed the capacity. This count is the semaphore state.

When a goroutine sends to the channel, it claims a spot. The count increases. If the count equals the capacity, the send blocks. The goroutine pauses. It consumes no CPU. It waits in the runtime's scheduler queue. When another goroutine receives from the channel, it frees a spot. The count decreases. The runtime wakes one of the blocked senders. That sender proceeds.

This pattern limits concurrency without explicit locks. The channel handles the blocking and waking automatically. You do not need to check a counter and sleep. The runtime does the work.

Minimal semaphore

Here is the simplest semaphore: a channel with capacity two, five goroutines, and a wait group to keep main alive.

package main

import (
	"fmt"
	"sync"
)

func main() {
	// Capacity 2 means only two goroutines run the critical section at once.
	sem := make(chan struct{}, 2)
	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()

			// Send blocks if the channel is full, enforcing the limit.
			sem <- struct{}{}
			// Defer ensures the token returns even if the work panics.
			defer func() { <-sem }()

			fmt.Printf("Task %d is running\n", id)
		}(i)
	}

	wg.Wait()
}

The output shows tasks running in pairs. Two tasks start. They finish. Two more start. The last one starts. The order depends on the scheduler, but the concurrency never exceeds two.

Defer is your safety net. Always release the permit.

How the runtime enforces the limit

Walk through the execution. The channel sem has capacity two. It starts empty.

Goroutine 1 sends struct{}{}. The channel has one value. The send succeeds. Goroutine 1 prints "Task 1 is running".

Goroutine 2 sends struct{}{}. The channel has two values. The send succeeds. Goroutine 2 prints "Task 2 is running".

Goroutine 3 sends struct{}{}. The channel is full. The send blocks. Goroutine 3 pauses.

Goroutine 4 sends struct{}{}. The channel is full. The send blocks. Goroutine 4 pauses.

Goroutine 5 sends struct{}{}. The channel is full. The send blocks. Goroutine 5 pauses.

Now three goroutines are blocked. Two are running. Goroutine 1 finishes. The deferred receive runs. It removes a value from the channel. The channel has one value. The runtime wakes one of the blocked senders. Goroutine 3 proceeds. It prints "Task 3 is running".

The pattern repeats. The channel capacity caps the number of active goroutines. The blocking send enforces the cap. The deferred receive restores the capacity.

Convention: the zero-byte token

The community uses struct{}{} as the semaphore token. An empty struct takes zero bytes of memory. An int takes eight bytes. If your semaphore has a large capacity, struct{}{} saves memory. It also signals intent: the value itself does not matter, only the presence of a value.

Using struct{}{} is the standard convention. It appears in the standard library and in most Go codebases. It tells other developers that the channel is a control mechanism, not a data stream.

Do not pass a *string or a large struct as the token. The token is copied on send and receive. A zero-byte struct copies instantly. A large struct copies slowly. Keep the token cheap.

Realistic pattern: bounded worker function

In production code, you wrap the semaphore logic in a function. This keeps the boilerplate out of your business logic. The function takes a slice of items, a concurrency limit, and a worker function.

Here is a reusable bounded worker function that processes items with a fixed concurrency limit.

// ProcessItems runs jobs with a bounded concurrency limit.
func ProcessItems(items []string, limit int, fn func(string)) {
	// The channel capacity defines the maximum concurrent workers.
	sem := make(chan struct{}, limit)
	var wg sync.WaitGroup

	for _, item := range items {
		wg.Add(1)
		go func(val string) {
			defer wg.Done()

			// Acquire a permit; blocks if limit is reached.
			sem <- struct{}{}
			// Release the permit when the goroutine exits.
			defer func() { <-sem }()

			fn(val)
		}(item)
	}

	wg.Wait()
}

The function creates the semaphore channel. It iterates over the items. It spawns a goroutine for each item. Each goroutine acquires a permit, runs the function, and releases the permit. The wait group ensures ProcessItems returns only when all items are done.

The caller passes the limit and the work function. The caller does not see the channel or the defer. The pattern is encapsulated.

Wrap the pattern in a function. Don't repeat the boilerplate.

Adding context for cancellation

Real code has deadlines. If a request cancels, the goroutine must stop. The permit must return. The defer statement returns the permit automatically. The work must check the context to stop early.

Here is how to integrate context into the bounded worker.

// ProcessItemsWithContext runs jobs with a bounded concurrency limit and cancellation support.
func ProcessItemsWithContext(ctx context.Context, items []string, limit int, fn func(context.Context, string)) {
	sem := make(chan struct{}, limit)
	var wg sync.WaitGroup

	for _, item := range items {
		wg.Add(1)
		go func(val string) {
			defer wg.Done()

			// Acquire permit; blocks until a slot is available.
			sem <- struct{}{}
			// Release permit on exit, regardless of cancellation.
			defer func() { <-sem }()

			// Check context before starting work.
			select {
			case <-ctx.Done():
				return
			default:
				fn(ctx, val)
			}
		}(item)
	}

	wg.Wait()
}

The goroutine acquires the permit first. Then it checks the context. If the context is done, it returns immediately. The defer releases the permit. The work does not run. If the context is alive, it calls the function. The function should also check the context periodically.

The context always goes as the first parameter. This is the Go convention. Functions that take a context should respect cancellation and deadlines.

Pitfalls and runtime panics

Closing a semaphore channel causes a panic. The runtime crashes with panic: send on closed channel. A semaphore channel is a control mechanism, not a data stream. You never close it. If you close it, every goroutine waiting to acquire a permit panics.

The compiler does not catch this error. It is a runtime panic. The error message is clear, but the crash is not. Never close a semaphore channel.

You cannot resize a channel. make(chan struct{}, 5) creates a channel with capacity five. That capacity is fixed. If you need a dynamic limit, you need a different approach. The standard library does not provide a dynamic semaphore. You can use golang.org/x/sync/semaphore for weighted permits and dynamic limits.

Goroutine leaks are rare with this pattern. The defer ensures the permit returns. The channel never stays full forever. If the program exits, the goroutines terminate. If the wait group is used correctly, the program waits for all goroutines. The worst goroutine bug is the one that never logs. Always use a wait group to track completion.

The compiler rejects the program with loop variable i captured by func literal if you forget to pass the loop variable to the goroutine. In Go 1.22+, this is a hard error. Always capture the loop variable by passing it as an argument to the closure.

Never close a semaphore channel. It is not a data stream.

Decision matrix

Use a buffered channel as a semaphore when you need to limit concurrent operations to a fixed count. Use sync.Mutex when you need exclusive access to a single shared resource. Use sync.WaitGroup when you need to wait for goroutines to finish but do not need to limit concurrency. Use a worker pool with a job channel when tasks are independent and you want to distribute work across a fixed set of workers. Use golang.org/x/sync/semaphore when you need weighted permits or dynamic limits. Use a ticker or rate limiter when you need to control the rate of operations over time, not just the concurrency count.

Pick the tool that matches the constraint. Concurrency limit needs a semaphore.

Where to go next