Skip to main content

Semaphores for Concurrency Control in Golang — A Practical Example

Shekhar PatilShekhar Patil
7 min read

What is a Semaphore?

A semaphore is a concurrency-control mechanism that limits how many goroutines can access a resource or perform an operation at the same time.

Think of a semaphore with a capacity of 3 as having 3 permits:

Semaphore capacity = 3

┌─────────┐
│ Permit 1│
├─────────┤
│ Permit 2│
├─────────┤
│ Permit 3│
└─────────┘

A goroutine must acquire a permit before doing the work.

When the work is complete, it releases the permit.

So if 10 goroutines are trying to perform work and the semaphore has only 3 permits:

Task 1 ──→ Acquire ──→ Running
Task 2 ──→ Acquire ──→ Running
Task 3 ──→ Acquire ──→ Running

Task 4 ──→ Waiting
Task 5 ──→ Waiting
Task 6 ──→ Waiting
...
Task 10 ─→ Waiting

Only 3 tasks can run the protected work at the same time.

Once one of the running tasks finishes and releases its permit, another waiting task can continue.

The basic pattern is:

Acquire → Do the work → Release

This simple pattern is useful whenever you want to put a limit on concurrent access to something.


1. Building a Semaphore with a Buffered Channel

Go's buffered channels make it surprisingly simple to implement a semaphore.

We start with:

ch := make(chan struct{}, 3)

The channel capacity becomes our concurrency limit.

Let's wrap it in a small Semaphore type:

type Semaphore struct {
ch chan struct{}
}

func NewSemaphore(maxConcurrency int) *Semaphore {
return &Semaphore{
ch: make(chan struct{}, maxConcurrency),
}
}

func (s *Semaphore) Acquire() {
s.ch <- struct{}{}
}

func (s *Semaphore) Release() {
<-s.ch
}

Now let's use it with 10 tasks and allow only 3 to run concurrently:

package main

import (
"fmt"
"sync"
"time"
)

type Semaphore struct {
ch chan struct{}
}

func NewSemaphore(maxConcurrency int) *Semaphore {
return &Semaphore{
ch: make(chan struct{}, maxConcurrency),
}
}

func (s *Semaphore) Acquire() {
s.ch <- struct{}{}
}

func (s *Semaphore) Release() {
<-s.ch
}

func main() {
sem := NewSemaphore(3)

var wg sync.WaitGroup

active := 0
var mu sync.Mutex

for i := 0; i < 10; i++ {
wg.Add(1)

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

sem.Acquire()
defer sem.Release()

mu.Lock()
active++
fmt.Printf("Task %d started | Active: %d\n", id, active)
mu.Unlock()

// Simulate work
time.Sleep(2 * time.Second)

mu.Lock()
active--
fmt.Printf("Task %d completed | Active: %d\n", id, active)
mu.Unlock()
}(i)
}

wg.Wait()
}

The important part is:

sem.Acquire()
defer sem.Release()

Everything between these two lines is limited by the semaphore.

How does the channel actually limit concurrency?

We created:

make(chan struct{}, 3)

which gives us a buffered channel with a capacity of 3.

When a goroutine calls:

s.ch <- struct{}{}

it puts a value into the channel.

The first three goroutines can do this immediately:

Task 1 → channel ✓
Task 2 → channel ✓
Task 3 → channel ✓

Channel is now full.

When the fourth goroutine tries:

s.ch <- struct{}{}

the channel is full, so the goroutine blocks.

It stays blocked until another goroutine releases its permit:

<-s.ch

That removes one value from the channel, creating space for another goroutine.

So the channel is effectively acting as our pool of permits.

Acquire

Put value into channel

Channel full? → Wait

Do the work

Release

Remove value from channel

Next waiting goroutine can proceed

What does the output tell us?

You might see something like:

Task 0 started   | Active: 1
Task 2 started | Active: 2
Task 1 started | Active: 3

Task 2 completed | Active: 2
Task 9 started | Active: 3

Task 0 completed | Active: 2
Task 6 started | Active: 3

The exact order will change because goroutine scheduling is not deterministic.

But one thing will always be true:

Active <= 3

We created 10 goroutines, but only 3 can execute the protected work at the same time.

That's the actual value of a semaphore.


2. Using golang.org/x/sync/semaphore

The channel implementation is great when you need a simple concurrency limit.

But Go also has a commonly used semaphore implementation in the golang.org/x/sync module.

It provides a Weighted semaphore:

import "golang.org/x/sync/semaphore"

The same example becomes:

package main

import (
"context"
"fmt"
"sync"
"time"

"golang.org/x/sync/semaphore"
)

func main() {
sem := semaphore.NewWeighted(3)
ctx := context.Background()

var wg sync.WaitGroup

active := 0
var mu sync.Mutex

for i := 0; i < 10; i++ {
wg.Add(1)

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

if err := sem.Acquire(ctx, 1); err != nil {
return
}
defer sem.Release(1)

mu.Lock()
active++
fmt.Printf("Task %d started | Active: %d\n", id, active)
mu.Unlock()

// Simulate work
time.Sleep(2 * time.Second)

mu.Lock()
active--
fmt.Printf("Task %d completed | Active: %d\n", id, active)
mu.Unlock()
}(i)
}

wg.Wait()
}

The idea hasn't changed:

sem.Acquire(ctx, 1)
defer sem.Release(1)

The 1 means this task needs one permit.

Since the semaphore was created with:

semaphore.NewWeighted(3)

only three permits can be acquired at once.


Why does Acquire take a Context?

This is one of the useful differences between the simple channel implementation and the library implementation.

Imagine all three permits are currently being used:

Task 1 → Running
Task 2 → Running
Task 3 → Running

Task 4 → Waiting

Normally, Task 4 will keep waiting until a permit becomes available.

But what if Task 4 belongs to an HTTP request that has already timed out?

We don't want the goroutine to wait forever for a resource that the caller no longer needs.

That's where context.Context helps.

For example:

ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()

if err := sem.Acquire(ctx, 1); err != nil {
return err
}

Now the goroutine will wait for at most 2 seconds to acquire a permit.

If no permit becomes available:

Task 4

Acquire()

Waiting...

2 seconds

Context cancelled

Acquire() returns error

An important detail:

Context cancellation does not release a semaphore permit that has already been acquired.

It only allows a goroutine waiting inside Acquire() to stop waiting.

For an HTTP server, you would typically use the request's context:

func handler(w http.ResponseWriter, r *http.Request) {
if err := sem.Acquire(r.Context(), 1); err != nil {
return
}
defer sem.Release(1)

// Do the work
}

Now if the request is cancelled while waiting for the semaphore, Acquire can return instead of keeping the goroutine blocked.


Which One Should You Use?

For a simple concurrency limit, a buffered channel is often enough:

sem := make(chan struct{}, 10)

It's simple, idiomatic, and doesn't require another dependency.

If you need features such as:

  • Context-aware acquisition
  • Weighted permits
  • More advanced semaphore behavior

then golang.org/x/sync/semaphore is a better choice.

The important idea is not the implementation itself.

It's understanding the pattern:

Acquire

Do the work

Release

Once you understand that, you'll start seeing practical places to use semaphores everywhere—limiting database queries, controlling external API calls, processing files, managing expensive background jobs, or protecting any resource where unlimited concurrency can become a problem.

A semaphore doesn't stop you from creating goroutines.

It simply puts a limit on how many of them can proceed at the same time.