How to use tokio sync Semaphore

Limit concurrent async tasks in Rust by acquiring a permit from a tokio::sync::Semaphore before entering a critical section.

The bouncer at the door

You are building a web scraper. The target site has a strict policy: no more than five requests per second from a single IP. You have a list of a thousand URLs to fetch. If you spawn a task for every URL and let them all run, the site detects the flood and bans your IP within milliseconds. You need a way to throttle the traffic. You need a mechanism that says, "Five tasks can go. The rest, wait in line until a spot opens."

That is exactly what tokio::sync::Semaphore provides. It is a concurrency primitive that limits the number of tasks that can execute a critical section at the same time. It does not protect data from races like a mutex does. It controls flow. It acts as a gatekeeper with a fixed capacity.

How the counter works

A semaphore holds a count of permits. When you create it, you set the initial number of permits. To enter the protected section, a task must acquire a permit. If permits are available, the count decreases and the task proceeds. If the count is zero, the task waits. When a task finishes, it releases the permit. The count increases, and the next waiting task wakes up and takes the permit.

Think of a parking garage with ten spots. The gate has a counter. Cars enter only if the counter is above zero. When a car enters, the counter drops. When a car leaves, the counter rises. If the counter hits zero, cars queue at the gate. The garage never holds more than ten cars. The semaphore enforces that limit automatically.

Minimal example

Here is the basic pattern. You create a semaphore, acquire a permit, do work, and drop the permit.

use tokio::sync::Semaphore;

#[tokio::main]
async fn main() {
    // Create a semaphore with 3 permits.
    // Only 3 tasks can hold a permit simultaneously.
    let semaphore = Semaphore::new(3);

    // Acquire a permit.
    // This returns a SemaphorePermit handle.
    // If no permits are available, this line suspends the task.
    let permit = semaphore.acquire().await.unwrap();

    // Critical section.
    // The permit is held, so the count is reduced by 1.
    println!("Working with permit");

    // Drop the permit to release it.
    // The count increases, and a waiting task can proceed.
    drop(permit);
}

The permit is a small handle. It does not contain the data. It just proves you have the right to proceed. Dropping the permit triggers the release logic. You can also let the permit drop automatically when it goes out of scope, but explicit drop calls make the release point clear to readers.

Convention aside: prefer drop(permit) at the end of the scope rather than relying on implicit drop. It signals intent. Readers see exactly where the permit is released, which helps prevent accidental holding across long scopes.

What happens under the hood

When you call Semaphore::new(3), Tokio allocates a structure on the heap. It stores the permit count and a queue of waiters. The count is atomic, so multiple tasks can check and update it safely.

When a task calls acquire, Tokio checks the count. If the count is positive, it decrements the count and returns a permit immediately. No suspension happens. If the count is zero, Tokio creates a waiter entry, pushes it onto the queue, and suspends the current task. The runtime picks up another ready task to run.

When a permit is dropped, Tokio increments the count. If the count is still zero or negative (which happens if waiters are queued), Tokio pops the next waiter from the queue and wakes that task. The woken task resumes, gets the permit, and continues.

The permit itself is just a marker. SemaphorePermit holds a reference to the semaphore. It implements Drop, which calls the release logic. This design ensures permits are always released, even if the task panics. The permit cleans itself up.

Realistic rate limiter

In real code, you usually share a semaphore across many tasks. You wrap it in an Arc and clone the arc to spawn tasks. Here is a rate-limited fetcher.

use tokio::sync::Semaphore;
use std::sync::Arc;

async fn fetch_url(url: &str, semaphore: Arc<Semaphore>) {
    // Acquire a permit before making the request.
    // This ensures we never exceed the concurrency limit.
    let _permit = semaphore.acquire().await.unwrap();

    // Simulate network request.
    println!("Fetching {}", url);
    // Work happens here. The permit is held during the request.
}

#[tokio::main]
async fn main() {
    // Limit to 5 concurrent requests.
    let semaphore = Arc::new(Semaphore::new(5));

    let urls = vec![
        "https://example.com/1",
        "https://example.com/2",
        "https://example.com/3",
        "https://example.com/4",
        "https://example.com/5",
        "https://example.com/6",
    ];

    let mut handles = vec![];
    for url in urls {
        // Clone the Arc to share the semaphore with the spawned task.
        // Convention: use Arc::clone for clarity.
        let sem = Arc::clone(&semaphore);
        let handle = tokio::spawn(async move {
            fetch_url(url, sem).await;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.await.unwrap();
    }
}

Convention aside: use Arc::clone(&semaphore) instead of semaphore.clone(). Both compile and work identically. The explicit form makes it obvious you are cloning the pointer, not the semaphore internals. It also avoids confusion with types that implement Clone differently. The Rust community treats Arc::clone as the standard way to share references.

Drop the permit as soon as the work is done. Holding a permit across unnecessary await points blocks other tasks. If you hold a permit while waiting for a slow operation that does not require the limit, you waste capacity. Scope the permit tightly.

Dynamic control and shutdown

You are not locked into the initial count. You can add permits at runtime with add_permits. This is useful if you detect the system is underutilized and want to allow more concurrency. You can also check the current count with available_permits.

use tokio::sync::Semaphore;

#[tokio::main]
async fn main() {
    let semaphore = Semaphore::new(2);

    // Check how many permits are free.
    println!("Available: {}", semaphore.available_permits());

    // Add more permits dynamically.
    semaphore.add_permits(3);
    println!("Available after add: {}", semaphore.available_permits());
}

You can also close a semaphore. Closing prevents new acquisitions. Existing permits can still be released. This is a clean way to shut down a system. You close the semaphore, wait for all permits to drop, and then clean up resources.

use tokio::sync::Semaphore;

#[tokio::main]
async fn main() {
    let semaphore = Semaphore::new(2);

    // Close the semaphore.
    semaphore.close();

    // Acquire returns an error after close.
    let result = semaphore.acquire().await;
    assert!(result.is_err());
}

Use close to signal shutdown. It stops new work from starting while allowing in-progress work to finish. This avoids tearing down resources while tasks are still using them.

Pitfalls and errors

Holding a permit too long causes starvation. If a task acquires a permit and then waits for a long time without releasing, other tasks queue up. The semaphore enforces the limit, but it does not enforce fairness or timeouts. You must manage the scope of the permit yourself. If you hold a permit across a network call, the permit is busy during the entire call. If the call is slow, the semaphore capacity is wasted.

If you try to send a SemaphorePermit across threads, the compiler rejects you. SemaphorePermit borrows the semaphore and is not Send. You get E0277 (trait bound not satisfied) if you try to move a permit into a thread or a multi-threaded task. Use acquire_owned instead. It returns an OwnedSemaphorePermit that owns the permit and is Send. The owned variant allocates a small amount of memory to detach the permit from the semaphore reference.

use tokio::sync::Semaphore;

#[tokio::main]
async fn main() {
    let semaphore = Semaphore::new(1);

    // acquire_owned returns a permit that can be sent across threads.
    let permit = semaphore.acquire_owned().await.unwrap();
    // permit is Send and can be moved freely.
}

Convention aside: keep unsafe blocks out of semaphore usage. Semaphores are safe abstractions. If you find yourself reaching for unsafe to manipulate permits, you are likely fighting the design. Stick to the API. The minimum unsafe surface rule applies here too. Do not wrap semaphore logic in unsafe unless you are implementing a custom synchronization primitive from scratch.

If you use try_acquire instead of acquire, you get a Result immediately. try_acquire does not wait. It returns Ok if a permit is available, or Err if not. If you ignore the error, you might skip work silently. Always handle the error. Decide whether to retry, log, or abort.

Treat the permit like a loan. Return it the moment you are done. Holding a permit longer than necessary starves the rest of your system.

When to use a semaphore

Use Semaphore when you need to cap the number of concurrent operations to a specific limit. Use Semaphore for rate-limiting external requests where the provider enforces a maximum number of simultaneous connections. Use Semaphore to guard a pool of resources like database connections or file descriptors. Use Semaphore when you want to implement a bounded concurrency pattern without blocking threads.

Reach for Mutex when only one task can access the data at a time and you need to protect the data itself. A mutex serializes access to a single value. A semaphore allows multiple tasks up to a count.

Reach for Barrier when a group of tasks must wait for each other before proceeding. Barriers synchronize tasks, they do not limit concurrency.

Reach for tokio::sync::Notify when you just need to wake up a single waiter without tracking a count. Notify is lighter weight for simple signaling.

Reach for tokio::sync::watch or broadcast channels when you need to share state changes across tasks. Channels transmit values, semaphores control access counts.

Where to go next