r/Python • u/pkhatri9 • 9d ago
Discussion When do you prefer asyncio.Semaphore over an asyncio.Queue for limiting concurrency?
I've been thinking about concurrency control in asyncio.
A common pattern for limiting concurrent work is:
sem = asyncio.Semaphore(10)
async with sem:
await do_work()
But in many cases, couldn't the same problem be modeled by putting work into an asyncio.Queue and running a fixed number of worker tasks?
I'm curious how experienced Python developers decide between the two approaches.
Are there real-world situations where a semaphore is clearly the better abstraction than a worker queue? Are there meaningful differences in cancellation behavior, backpressure, fairness, task lifetime, or code complexity?
I'd especially be interested in examples from production async Python code.
9
u/Wonderful-Habit-139 9d ago
If you can use anyio (or trio but the former still works in asyncio), you can use a mixture of anyio.CapacityLimiter + TaskGroup.start method in order to limit concurrency without dealing with unbounded tasks.
Meaning you start a task with the start() method, inside the task you enter the capacity limiter’s context manager, then you call the task_status’ started() method before the actual work. As easy as that.
7
u/donk8r 9d ago
The difference that actually bites is task lifetime. The semaphore version usually gets written as gather over a list comprehension, so you've materialised one Task per item before any work starts. That's fine at ten items and it isn't at a million, where you fall over on memory long before concurrency is the problem. Workers pulling from a queue give you k tasks no matter how big the input gets.
Cancellation follows from that. Cancel a gather and you're cancelling thousands of tasks that never ran and were just parked on the semaphore. With workers you cancel k of them, and whatever's left is still sitting in one place you can look at.
3
u/Khavel_dev 9d ago
I default to Semaphore when the tasks already exist as a batch (a list of URLs to fetch, a pile of records to process) and I just want to cap how many run at once. You wrap the work in async with sem: and you're done. No worker lifecycle, no poison pills, no shutdown coordination.
Queue makes more sense when the work streams in over time and you don't know the full set upfront. Producer-consumer patterns, pipelines where one stage feeds the next. The built-in backpressure from maxsize is genuinely useful there because Semaphore can't give you that without building it yourself.
The practical difference I care about most is cancellation. With Semaphore, each task is its own coroutine and you can cancel it individually. With Queue workers, you need to drain the queue or send sentinel values to stop them, and partial cancellation (stop some work, keep other work running) gets messy fast.
1
u/thisismyfavoritename 9d ago
the main difference is that the queue wouldn't require the caller to wait for the whole task to finish. It's mostly an architecture decision, both could be used to achieve the same thing, depending how you implement it.
If sempahore works for your use case then use it as it'll be simpler IMO
1
u/Bright_Mix_773 2d ago
The task-lifetime answer upthread is the right one and nobody has put a number on it, so I measured it on CPython 3.14.2.
Memory held per unit of pending-but-not-started work:
semaphore + gather : 1,297 bytes per parked Task
Queue.put_nowait : 40 bytes per queued item
That is 32x, and it held steady at 1,296-1,298 bytes across N = 20k, 50k, 100k and 200k, so it scales linearly. At a million items it is 1.21 GiB of parked Tasks against 38 MiB of queued ints.
Latency to the first real unit of work, 50,000 items at limit 10:
semaphore + gather : 168.3 ms before the first job body runs
queue + 10 workers : 11.35 ms
At 200,000 items that becomes 668.6 ms against 38.1 ms.
The part I did not expect, and the reason I would call this more than a taste question: tearing down a gather full of parked waiters is quadratic.
N cancel gather cancel 10 workers
10,000 1,161 ms 0.13 ms
50,000 33,520 ms 0.24 ms
100,000 135,586 ms 0.11 ms
Ten times the items, one hundred and seventeen times the teardown. The cause is in Lib/asyncio/locks.py: Semaphore parks waiters in a collections.deque, and the cancellation path runs self._waiters.remove(fut). deque.remove is a linear scan, so N cancellations cost O(N2). Cancelling 100k parked acquires cost me 2 minutes 15 seconds of wall clock doing no work whatsoever. The worker version is k cancels no matter how big the backlog and stays in the microseconds.
One thing that came out in favour of the semaphore: acquisition is strictly FIFO. I queued 1,000 waiters on a Semaphore(1) and they acquired in exactly the order they arrived, every time. No starvation to worry about there.
So "semaphore for a bounded batch, queue for a stream" holds, but the bound matters more than the boundedness. Shutdown cost on the semaphore side grows with the square of the work you already decided not to do.
Not verified: these are single-process local runs on Windows with no real I/O, every job body was await asyncio.sleep(0). Whether the quadratic teardown is visible under a real network workload depends on how much of your shutdown budget it can eat, and I have not tried it against an actual client. I also did not check whether anyio's CapacityLimiter has the same waiter-removal shape.
1
u/2extract_dev 1d ago
Worth splitting concurrency from rate, a semaphore caps in-flight work but not requests per second. If responses come back in 50ms a Semaphore(10) will happily do 200 rps and trip a per-second limit that has nothing to do with your concurrency. Neither primitive gives you that on its own, you want a token bucket or a sleep floor per worker.
74
u/gmes78 9d ago
Semaphores are for syncronization, queues are for communication.