1. Semaphore on a buffered channel: theory and the basic template
A beginner’s intuitive idea sounds very convincing: “I have 10 000 tasks? Great, I’ll start 10 000 goroutines!” On small examples it even works, so the brain happily concludes: “that’s how it should be.” But in real code this approach turns into a concurrent version of “let’s order pizza for everyone, and pay later.”
The problem is not that Go “can’t handle lots of goroutines.” It can. The problem is that you stop controlling the system: how many tasks run at the same time, how much memory is consumed, who blocks whom, how many total active operations are “in the air.”
This is exactly where the concept of bounded concurrency appears—limited (controlled) concurrency: we explicitly set a cap, for example “no more than 8 tasks at the same time.”
Two kinds of limits: in-flight and queue
It’s very useful to keep two numbers separate in your head—numbers that beginners often mix up.
The first number is parallelism “in progress” (in-flight): how many tasks are actually executing right now.
The second number is the queue size: how many tasks we’re willing to buffer before the system starts slowing down the producer.
Here we focus on the first one: we limit concurrent execution itself. That is bounded concurrency in the narrow sense: “no more than N tasks at a time”—even if there are a million tasks.
Schematically (simplified), you can think of it like this:
flowchart LR
P[Producer: creates tasks] -->|start| G[Task goroutines]
G --> W[Work]
W --> D[Done]
subgraph Limit[Limit N]
W
end
We need a mechanism that “lets” a task into the W zone only if there is free capacity under the N limit.
A semaphore on a buffered channel: the “token” idea and why it’s Go-like
The word “semaphore” may sound like something from an operating systems textbook, but in Go it’s usually a very simple, practical technique. We create a buffered channel with capacity N and use it as a counter of “permits.” If there’s a permit, the task may start work. If not, the task waits.
Classic form:
sem := make(chan struct{}, N)
Why struct{}? Because we don’t need to carry any data—we only need the fact “busy/free.” An empty struct takes no space for fields (conceptually “zero payload”), and it reads directly as: “I’m sending a token, not meaning.”
Two semaphore operations:
- acquire (take a slot) — sem <- struct{}{}
- release (free a slot) — <-sem
And yes, at this point you are using the channel as a “control structure,” not as a “data pipe.” That’s fine: channels in Go are not only about passing values, but also about synchronization, and standard patterns use this heavily.
Minimal skeleton: limit + WaitGroup
It’s important not to confuse the roles of the tools. The semaphore limits how many tasks are running concurrently, but it doesn’t answer “when are they all finished?” For “wait until all are done,” we still use WaitGroup.
The skeleton looks like this: we start tasks, each task takes a token, does the work, returns the token, and calls wg.Done().
package main
import (
"fmt"
"sync"
)
func main() {
const limit = 3
sem := make(chan struct{}, limit)
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
sem <- struct{}{} // acquire: if the limit is reached, main will wait
go func(id int) {
defer wg.Done()
defer func() { <-sem }() // release
fmt.Println("task", id, "done")
}(i)
}
wg.Wait()
fmt.Println("all done")
}
Notice two things.
First, sem <- struct{}{} is placed before go ...: this is an important design choice, and we’ll discuss it separately.
Second, releasing the semaphore is done via defer, so even on an early return (or an error, if there were one) the slot doesn’t “leak.”
Where to do acquire: before starting the goroutine or inside
You can use the semaphore in two ways, and both “work,” but they behave differently in terms of memory and controllability.
Option A: acquire before starting the goroutine.
When you do acquire in the producer (often main or a function that kicks off processing), you don’t create extra goroutines. If the limit is reached, the current thread simply waits until someone frees a slot.
sem <- struct{}{} // acquire
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }() // release
// work
}()
This means: “we limit not only parallelism, but also the number of goroutines created.” For large task volumes, this is almost always nicer.
Option B: acquire inside the goroutine.
In the second option, you create a goroutine per task, and then inside the goroutine it waits for a token. Parallelism will be limited, but goroutines can pile up by the thousands and just sit “in the queue” on the semaphore.
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{} // acquire (now the goroutine waits)
defer func() { <-sem }() // release
// work
}()
Why can this be bad? Because goroutines are lightweight, but not free. And most importantly, you lose part of your control again: you limited “in flight,” but you didn’t limit “how many total have been started and are waiting.”
A practical rule (no fanaticism): if there can be many tasks and you want to really “keep the system on a leash,” do acquire before go.
Example: processing a batch of tasks with results
So the example isn’t just “print a line,” let’s continue the worker-pool storyline: we have Task, and we want to get Result. But here we don’t keep permanent workers; we start a goroutine “per task,” while limiting parallelism with a semaphore.
Let’s make a simple model: the task multiplies a number by 2, and we put the result into a results channel. Collection (collector) happens in main, in a single goroutine, so there are no races on a shared slice.
package main
import (
"fmt"
"sync"
)
type Task struct {
ID int
N int
}
type Result struct {
ID int
Out int
}
func main() {
tasks := []Task{{1, 10}, {2, 20}, {3, 30}, {4, 40}, {5, 50}}
const limit = 2
sem := make(chan struct{}, limit)
results := make(chan Result)
var wg sync.WaitGroup
for _, t := range tasks {
sem <- struct{}{} // acquire: limit in-flight
wg.Add(1)
go func(task Task) {
defer wg.Done()
defer func() { <-sem }() // release
results <- Result{ID: task.ID, Out: task.N * 2}
}(t)
}
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Println(r.ID, r.Out)
}
}
There’s an important “for health” detail here: we pass task Task as a parameter into the anonymous function. This protects us from the classic trap with the loop variable.
In Go 1.22+ the typical range issue has become less treacherous, but the habit of “passing the needed value as a parameter” still saves you in real variations (for example, if the variable is declared outside the loop, or if you use a different kind of loop). Better to be boring and correct than fun and debugging at night.
Semaphore vs. worker pool: not the same thing
From the outside it can seem like: “both limit parallelism—what’s the difference?” The difference is in the shape and in what your “main structure” is.
A worker pool is an architecture of “a task queue + a fixed number of workers that live for a long time.” The semaphore scheme is “a goroutine per task, but no more than N at the same time.”
It’s convenient to compare as an engineering choice:
| Criterion | Worker pool | Semaphore via buffered channel |
|---|---|---|
| Goroutine lifecycle | Long-lived workers | Goroutine usually = one task |
| Parallelism control | By the number of workers | By the buffer size of sem |
| Task queue | Usually a jobs channel | May be no queue at all (e.g., a loop over a slice) |
| When it’s easier to read | When there’s a stream of tasks and producer/worker/collector roles | When tasks are easier to “be functions,” but you need a limit |
| Main beginner risk | Closing results incorrectly, hanging | Accumulating goroutines if acquire is inside go |
So a semaphore is a great tool when a worker pool feels structurally excessive (too many roles), but limiting parallelism is still required.
How to tell the semaphore works: a thought experiment
To write concurrent code confidently, it helps to be able to “simulate” it in your head. Imagine limit = 2, and you have 5 tasks.
The first two loop iterations will send two tokens into sem and start two goroutines. On the third iteration, sem <- struct{}{} tries to put a third token, but the buffer is full, so the send blocks. And that’s good: the producer stops and waits until one of the tasks frees a slot (<-sem). As soon as one task finishes and frees a token, the producer continues to start the next one.
Thus, “how many tasks are in progress” at any moment does not exceed cap(sem).
You can even temporarily (for understanding) print len(sem)—but it’s important to remember: this is diagnostics, not control logic. In concurrent code, you can’t do “if len(sem) < cap(sem) then …” as the basis of your protocol: between the check and the action, everything may already have changed.
3. Common mistakes when using a semaphore on a buffered channel
Mistake No. 1: make sem unbuffered and wonder why everything hangs.
If you write make(chan struct{}), that’s a channel without a buffer, and sem <- struct{}{} will wait until someone simultaneously does <-sem. That’s no longer a semaphore, but a weird “handing the token from hand to hand.” For a semaphore, you almost always need a buffered channel: make(chan struct{}, N).
Mistake No. 2: forget release and “clog” the system.
The most common breakage: you take a slot and forget to free it. As a result, after some time the producer will hit a full sem forever, and the program will “run forever” (i.e., wait). The cure is discipline: wrap the release in defer func(){ <-sem }() near the acquire so they are visually paired.
Mistake No. 3: release twice and get a hang at an unexpected moment.
A semaphore is strict bookkeeping: one acquire = one release. If you accidentally do two <-sem, the second will wait for a token that nobody will send, and your task will hang. That’s the case where the “bug doesn’t make noise” (no panic), but quietly turns your program into a statue.
Mistake No. 4: do acquire inside the goroutine with a huge number of tasks and not understand where the memory went.
Parallelism really will be limited, but goroutines will be created by the thousands and will sit in a queue on sem <- struct{}{}. On small examples everything looks nice, and on big ones—suddenly “why are there so many goroutines?” If there can be many tasks, it’s often safer to do acquire before go, so the producer itself throttles goroutine creation.
Mistake No. 5: accidentally capture the loop variable and process “the wrong data.”
If you start goroutines inside a loop, carefully pin the value. A reliable way is to pass a parameter into the anonymous function: go func(t Task){ ... }(t). This way the code reads and behaves the same regardless of whether it’s range or another kind of loop.
GO TO FULL VERSION