In this lesson, you will learn how to use asynchronous semaphores to prevent resource exhaustion by strictly bounding the number of concurrent operations allowed to access a shared service.
The Problem: Unbounded Concurrency
When writing asynchronous code, it is tempting to spawn a new task for every incoming request. However, if your code interacts with a limited resource—such as a database pool, a third-party API with strict rate limits, or a local network socket—spinning up thousands of concurrent tasks can trigger connection timeouts or 429 "Too Many Requests" errors. While channels are great for message passing, they don't natively "block" the caller from spawning more work. This is where a Semaphore shines.
Managing Access with Semaphores
A semaphore maintains a set of "permits." Before an asynchronous task can proceed, it must acquire a permit. If no permits are available, the task is suspended (without blocking the thread), effectively creating a queue. Once a task finishes and drops the permit, the next waiting task is notified.
Using the popular tokio::sync::Semaphore implementation, the pattern looks like this:
use std::sync::Arc;
use tokio::sync::Semaphore;
async fn access_limited_resource(semaphore: Arc<Semaphore>, id: u32) {
// Acquire a permit. This will asynchronously wait if the limit is reached.
let _permit = semaphore.acquire_owned().await.unwrap();
println!("Task {} is accessing the resource", id);
// Simulate work that consumes the resource
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
#[tokio::main]
async fn main() {
// Limit to 3 concurrent operations
let semaphore = Arc::new(Semaphore::new(3));
for i in 0..10 {
let sem = semaphore.clone();
tokio::spawn(access_limited_resource(sem, i));
}
}
Why this works
By using acquire_owned().await, the permit is tied to the scope of the task. When the permit object goes out of scope (or is explicitly dropped), the semaphore counter is incremented, allowing another task to wake up. This provides a clean, non-blocking way to implement backpressure at the application level, ensuring that your system's throughput matches the capacity of its dependencies.
Key Takeaway: Semaphores act as a concurrency gate, forcing tasks to wait asynchronously for a permit before accessing shared resources, thereby ensuring stability under high load.