Golang · Sep 8, 2026 · 9 min read

Semaphore in Golang

Understand the semaphore concurrency pattern in Golang through the analogy of a parking lot with a limited number of slots, where a car can only enter when a slot is free.

A semaphore is a concurrency primitive that limits how many goroutines can be inside a section of code at the same time. In Golang you rarely need a dedicated library for it: a buffered channel already behaves like a counting semaphore. You send a token before entering the guarded work and receive it back when you leave, so at most N goroutines can hold a token at once.

1. What is a Semaphore?

A semaphore holds a fixed number of permits. A goroutine must acquire a permit before doing the protected work and release it afterwards. If every permit is taken, the next acquire blocks until someone releases one. A semaphore with a single permit behaves exactly like a mutex; with N permits it lets up to N goroutines run concurrently.

  • Acquire = take one permit; block if none are available
  • Release = give a permit back, waking one goroutine that is waiting to acquire
  • The count never goes below zero or above its maximum, so concurrency is bounded on both ends
  • It limits concurrency without caring which goroutine runs — unlike a worker pool, the goroutines are not pre-created

2. Analogy: A Parking Lot with Limited Slots

Imagine a parking lot with exactly 3 slots and a boom gate at the entrance. Cars arrive continuously, but the lot can never hold more than 3 at a time.

  • Parking slots = the permits; there are a fixed number of them
  • Boom gate at the entrance = the acquire step: the gate only lifts if a slot is free
  • A car parking and later leaving = a goroutine doing its protected work
  • Car exiting and the gate registering a free slot = the release step
  • Cars queued on the street waiting for the gate = goroutines blocked on acquire

How a busy afternoon runs:

  • The first 3 cars drive straight in and take the 3 slots
  • Car #4 arrives and waits at the gate — the lot is full, so the gate stays down
  • When any parked car leaves, exactly one slot opens and the gate lifts for the next car in line
  • It does not matter which car leaves or which waiting car enters; the only rule is "at most 3 inside"
  • At closing time, no new cars are let in, and the lot is empty only once the last parked car has driven out

3. A Semaphore is Just a Buffered Channel

A buffered channel of capacity N is a counting semaphore. Sending an empty struct is acquire (it blocks when the buffer is full), and receiving is release (it frees one slot). The empty struct{} carries no data and takes no memory — we only care about the count.

semaphore.go
// 3 parking slots
var slots = make(chan struct{}, 3)

func park(carID int) {
	slots <- struct{}{}        // drive up to the gate; blocks if the lot is full
	defer func() { <-slots }() // on exit, free the slot for the next car

	fmt.Printf("car %d parked\n", carID)
	time.Sleep(200 * time.Millisecond) // time spent parked
	fmt.Printf("car %d leaving\n", carID)
}

The defer is what makes this safe: even if the protected work panics or returns early, the slot is always given back, so the lot can never deadlock with phantom full slots.

4. Bounding a Burst of Goroutines

The common use case: you have hundreds of items to process and you spawn a goroutine for each, but you must not hammer a database or an API with hundreds of simultaneous calls. The semaphore caps how many run at once while still letting you write the simple "one goroutine per item" loop.

main.go
func main() {
	start := time.Now()

	const maxConcurrent = 3
	gate := make(chan struct{}, maxConcurrent)

	var wg sync.WaitGroup
	for carID := 1; carID <= 12; carID++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()

			gate <- struct{}{}        // acquire a slot
			defer func() { <-gate }() // release it

			fmt.Printf("car %d parked\n", id)
			time.Sleep(200 * time.Millisecond)
		}(carID)
	}

	wg.Wait()
	fmt.Printf("lot cleared in %s\n", time.Since(start))
}

All 12 goroutines are created almost instantly, but only 3 are ever inside the gated section. Twelve cars parking for 200ms each, 3 at a time, finish in roughly 4 batches — about 800ms — instead of 2.4 seconds serially or an uncontrolled stampede.

5. Trying to Enter Without Waiting

Sometimes a car should not queue — if the lot is full, it should drive on and try somewhere else. A select with a default turns a blocking acquire into a non-blocking try-acquire.

tryacquire.go
func tryPark(carID int) bool {
	select {
	case gate <- struct{}{}:
		defer func() { <-gate }()
		fmt.Printf("car %d got a slot\n", carID)
		time.Sleep(200 * time.Millisecond)
		return true
	default:
		fmt.Printf("car %d found the lot full, driving on\n", carID)
		return false
	}
}

The same trick with a time.After case instead of default gives you an acquire that waits, but only up to a timeout.

6. Weighted Semaphore: Cars, Vans, and Buses

A plain channel semaphore assumes every unit of work costs the same. When some tasks are heavier — a bus takes 3 slots, a van takes 2, a car takes 1 — reach for golang.org/x/sync/semaphore, which lets each acquire request a weight.

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

func main() {
	ctx := context.Background()
	lot := semaphore.NewWeighted(10) // 10 slot-units total

	vehicles := []struct {
		id   int
		size int64 // slots this vehicle occupies
	}{
		{1, 1}, {2, 3}, {3, 2}, {4, 1}, {5, 3}, {6, 2},
	}

	var wg sync.WaitGroup
	for _, v := range vehicles {
		wg.Add(1)
		go func(id int, size int64) {
			defer wg.Done()

			if err := lot.Acquire(ctx, size); err != nil {
				return // ctx cancelled while waiting
			}
			defer lot.Release(size)

			fmt.Printf("vehicle %d (%d slots) parked\n", id, size)
			time.Sleep(200 * time.Millisecond)
		}(v.id, v.size)
	}
	wg.Wait()
}

Acquire takes a context, so a blocked acquire can be cancelled or timed out cleanly — something the raw channel version cannot do without wrapping it in a select. Release must pass the same weight that was acquired, or the internal count drifts.

7. When to Use This Pattern

  • You spawn goroutines per item and need to cap how many touch a shared resource (DB pool, rate-limited API, disk) at once
  • The limit is about concurrency, not about reusing a fixed set of workers — if you want long-lived workers pulling from a queue, use a worker pool instead
  • You need try-acquire or acquire-with-timeout semantics (buffered channel + select)
  • Tasks have different costs and the budget is a total capacity, not a count — use a weighted semaphore
  • Avoid it when a single mutex is enough (limit of 1) or when the protected work is so cheap that the channel operations dominate

8. Practice Exercises

Try solving these on your own before checking the reference solutions. Each one uses a semaphore to bound concurrency.

exercise-1.go
// Exercise 1: Photo Upload Processing with a Concurrency Limit
// Build a program that simulates uploading 10 photos to a server, but the
// server can only accept a maximum of 3 uploads at the same time (rate limit).
//
// Requirements:
// 1. Create a semaphore with capacity 3 (sem := make(chan struct{}, 3)).
// 2. Write a function uploadPhoto(id int, sem chan struct{}, wg *sync.WaitGroup)
//    — simulate the upload with time.Sleep(500 * time.Millisecond). Print when
//    the upload starts and when it finishes, including the photo id.
// 3. Spawn 10 goroutines (1 per photo) from main, all calling uploadPhoto.
// 4. Prove that at most 3 run at the same time — the easiest way: add an
//    "active" counter (use sync.Mutex or sync/atomic, since this counter is
//    accessed by many goroutines at once) that increases when an upload
//    starts and decreases when it finishes. Print the counter value every
//    time it changes, and make sure it is never greater than 3 at any point.
// 5. Measure the total execution time. With 10 photos, 3 concurrent slots and
//    500ms per photo — what total time should you expect? (Hint: ceil(10/3)
//    batches of 500ms.)
exercise-2.go
// Exercise 2: API Request Processing with a Rate Limit
// Simulate sending 15 requests to an external API that allows a maximum of 4
// concurrent requests (a rate limit imposed by the server).
//
// Requirements:
// 1. Create a semaphore with capacity 4.
// 2. Result struct:
//        type APIResult struct {
//            RequestID int
//            Success   bool
//            Duration  time.Duration
//        }
// 3. Write a function
//    sendRequest(id int, sem chan struct{}, results chan<- APIResult, wg *sync.WaitGroup):
//    - Acquire a semaphore slot first.
//    - Record the start time (time.Now()).
//    - Simulate the request with time.Sleep(300 * time.Millisecond).
//    - Simulate deterministic failure: use id % 4 == 0 as the rule (not rand,
//      so results stay predictable when debugging) — treat this request as
//      Success: false when id is divisible by 4, otherwise Success: true.
//    - Send an APIResult (fill Duration with time.Since(start)) to results.
//    - Release the semaphore slot.
// 4. In main, run all 15 requests (goroutine + semaphore), collecting results
//    through the results channel (the WaitGroup + goroutine-that-closes
//    pattern you already know).
// 5. At the end, print a summary: total successful requests, total failed
//    requests, and the average Duration across all requests.
exercise-3.go
// Exercise 3: Batch File Processing with a Rate Limit and a Timeout
// Simulate processing 20 file uploads to cloud storage, with a maximum of 5
// concurrent uploads (semaphore), but the whole process must be force-stopped
// if it runs longer than 2 seconds (context timeout).
//
// Requirements:
// 1. Create a semaphore with capacity 5.
// 2. Create a ctx with context.WithTimeout of 2 seconds (defer cancel()).
// 3. Write a function:
//        func uploadFile(ctx context.Context, id int, sem chan struct{},
//                        results chan<- string, wg *sync.WaitGroup)
// 4. Before acquiring a semaphore slot, first check whether ctx is already
//    cancelled — if it was cancelled before this goroutine could start, do not
//    process at all, just return (skip this file). A select on ctx.Done() with
//    a default is the simplest check.
// 5. Acquire the slot with a select (NOT a bare sem <- struct{}{}) — so that
//    if it is waiting for a free slot and ctx times out first, it stops
//    immediately instead of waiting for a slot. On the ctx.Done() branch,
//    return without processing.
// 6. If it does get a slot, simulate the upload with
//    time.Sleep(400 * time.Millisecond).
// 7. Send the message "file<id> uploaded successfully" to results — also using
//    a select with ctx.Done() as the alternative (results is unbuffered, so
//    the send can block).
// 8. Release the semaphore slot at the end (only if a slot was actually
//    acquired) — a deferred <-sem placed right after a successful acquire.
// 9. In main, run 20 uploadFile goroutines, collect results through the
//    results channel (the WaitGroup + goroutine-that-closes pattern), print
//    each result, then at the end print how many files succeeded and the
//    reason for stopping (ctx.Err()).
View the reference solutions on GitHub

Conclusion

A semaphore bounds how many goroutines are inside a critical section at once, and in Golang a buffered channel gives you one for free: send to acquire, receive to release, always with a defer. Keep the parking lot in mind — a fixed number of slots, a gate that only lifts when one is free, and cars that either queue, drive on, or wait a while before giving up.

Share this article