Use the Rust `sema` Crate to Throttle Async Tasks with a Counting Semaphore
When dozens of async tasks compete for a limited resource, a counting semaphore can serialize access without blocking the executor. This guide walks through the Rust `sema` crate, shows a minimal example, explains fairness, and warns about common pitfalls.
18 Aug 2026, 20:12 UTC

Concrete Problem: Too Many Async Tasks, Not Enough Resources
In a high‑traffic service you often need to limit how many concurrent network calls, database queries, or file reads a process can perform. If you fire off hundreds of async tasks without restraint, you can exhaust file descriptors, overwhelm a remote API, or saturate a thread pool. The naive solution is to block the task until a resource is free, but blocking in an async context defeats the purpose of non‑blocking executors and can lead to starvation.
Enter the `sema` Counting Semaphore
The sema crate provides a lightweight, async‑aware counting semaphore. It exposes a Semaphore type that works with any executor that supports std::sync::atomic (Tokio, async‑std, etc.). A semaphore holds a number of permits. A task calls acquire() to request a permit. If a permit is available, the call resolves immediately; otherwise the task is parked until one becomes free. When the task finishes, it releases the permit by dropping the guard returned from acquire() or by calling release() explicitly.
Key API points:
Semaphore::new(count: usize) -> Self– create a non‑fair semaphore withcountpermits.Semaphore::new_fair(count: usize) -> Self– same, but enables FIFO fairness.async fn acquire(&self) -> AcquireGuard– await a permit; the guard releases on drop.fn release(&self)– manually release a permit.
Minimal Working Example
Below is a self‑contained example that demonstrates a semaphore guarding max_concurrent tasks. The example uses Tokio; the same code will run on any async runtime that supports atomics.
// Cargo.toml
[dependencies]
sema = "0.3"
tokio = { version = "1", features = ["full"] }
// src/main.rs
use sema::Semaphore;
use std::sync::Arc;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
// Allow at most 2 concurrent tasks
let sem = Arc::new(Semaphore::new(2));
let mut handles = Vec::new();
for i in 0..5 {
let sem_clone = sem.clone();
handles.push(tokio::spawn(async move {
// Acquire a permit
let _guard = sem_clone.acquire().await;
println!("Task {} started", i);
// Simulate work
sleep(Duration::from_secs(1)).await;
println!("Task {} finished", i);
// Permit automatically released when _guard is dropped
}));
}
// Wait for all tasks to finish
for h in handles {
let _ = h.await;
}
}
When you run this program you’ll notice that only two tasks run at the same time; the others wait until a permit is released.
Fairness: FIFO Ordering vs. Throughput
The new_fair constructor adds a FIFO queue to the semaphore. Waiting tasks are served in the order they requested a permit. This guarantees no starvation but can slightly reduce throughput due to the extra bookkeeping.
let fair_sem = Arc::new(Semaphore::new_fair(2));
To decide whether fairness is worth the overhead, benchmark your workload. In tight loops with high contention, the default non‑fair semaphore often achieves higher throughput.
Common Pitfalls and Best Practices
- Do not clone the guard. Cloning a
AcquireGuardbypasses the permit counter and can corrupt the semaphore state. - Guard lifetime matters. The permit is released when the guard is dropped, so keep the guard in scope for the duration of the critical section.
- Beware of leaks. If you store the semaphore in an
Arcthat lives forever and never drop the guard, the permits are effectively lost, causing future tasks to block indefinitely. - Runtime compatibility. The semaphore uses atomics; if you run it on a custom async executor that doesn’t support
std::sync::atomic, you may get undefined behavior.
Actionable Next Steps
- Wrap your semaphore in an
Arcand share it across tasks that need to coordinate. - Use the guard pattern:
let _guard = sem.acquire().await;and keep the guard alive only for the critical section. - If you need strict ordering, switch to
Semaphore::new_fairand run a quick benchmark to confirm the impact on latency. - Monitor permit usage in production. A simple counter or logging inside
acquire/releasecan surface deadlocks early. - Consider combining
semawith other concurrency primitives (e.g.,Mutex,RwLock) when you need more complex coordination.
By integrating sema into your async codebase you can cleanly throttle access to limited resources, keep your executor healthy, and avoid subtle deadlocks—all while keeping your code readable and maintainable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.