DEV Community

Amrit Singh
Amrit Singh

Posted on

Mastering Tokio Semaphores in Rust

The Two Faces of Tokio's Semaphore: A Tale of Patience and Ruthlessness

Why choosing between acquire_owned and try_acquire_owned might be the most important decision your async Rust code ever makes


The Night Everything Broke (A bit dramatic? I know! but we need to build a background)

Picture this: It's 2 AM. Your chat server has been humming along beautifully for months. Then someone posts a link on Hacker News, and suddenly 10,000 users are hammering your connection handler. Your database pool—sized for a sensible 200 connections—starts choking. Queries pile up. Memory balloons. The OOM killer arrives like the grim reaper.

You stare at your terminal, coffee growing cold, wondering: How did we get here?

The answer, more often than not, is that you didn't understand semaphores. Specifically, you didn't understand which semaphore method to use—and that choice makes all the difference between a server that gracefully says "please wait" and one that tries to be a hero, takes on the whole world, and dies trying.

This is a story about two methods. One is patient. One is ruthless. Both are essential.


First, a Quick Primer: What Even Is a Semaphore?

If a mutex is a bouncer who lets in exactly one person at a time, a semaphore is a bouncer with a clicker counter. "I can let in 5 people. You're number 6? Wait in line."

pub struct Semaphore { /* private fields */ }
Enter fullscreen mode Exit fullscreen mode

Simple concept, profound implications. Tokio's semaphore maintains a pool of permits. Want to do something? Grab a permit. Done? Release it (or in Rust's case, just drop it—RAII handles the rest).

Here's the beautiful part: Tokio's semaphore is fair. First come, first served. No cutting in line. If someone ahead of you is waiting for 3 permits and only 2 are available, you wait too—even if you only need 1. Democracy in action.

But here's where it gets interesting...


The 'static Problem (Or: Why Rust Makes You Earn Your Concurrency)

Before we talk about the difference between "try" and "wait," we need to address the elephant in the room: that 'static lifetime bound that makes newcomers want to flip their desks.

When you spawn a task—tokio::spawn, std::thread::spawn, doesn't matter—Rust demands your data satisfies 'static:

// tokio::spawn's signature (simplified)
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
    F: Future + Send + 'static,  // <- There it is
Enter fullscreen mode Exit fullscreen mode

"Wait," you say, "'static? You mean it has to live forever? That's insane!"

Here's the thing—and this is one of the most misunderstood concepts in Rust—T: 'static does not mean "lives forever." It means "doesn't contain any borrowed references that could become dangling."

Think about it:

  • A String is 'static. You can drop it whenever you want.
  • A Vec<u8> is 'static. Create it, mutate it, destroy it—all at runtime.
  • An Arc<Mutex<Whatever>> is 'static. Reference-counted, heap-allocated, mortal as anything.

The Tokio tutorial puts it perfectly: "When we say that a value is 'static, all that means is that it would not be incorrect to keep that value around forever."

The value could live forever. It doesn't have to. It just needs to own its data instead of borrowing it from somewhere that might disappear.

Type 'static? Why
String, Vec<u8>, i32 Owns its data
Arc<Mutex<Data>> Reference-counted ownership
&'a str (where 'a isn't 'static) Borrowed from somewhere

Now, here's the problem. The regular acquire() method returns a SemaphorePermit<'a> that borrows from the semaphore:

// ❌ This will haunt your dreams
let sem = Semaphore::new(5);
let permit = sem.acquire().await.unwrap();
tokio::spawn(async move {
    drop(permit);  // Error: permit doesn't live long enough!
});
Enter fullscreen mode Exit fullscreen mode

The compiler screams. The permit holds a reference to sem, but sem lives on the stack. The spawned task might outlive main(). Rust cannot allow this.

Enter acquire_owned().


The Patient One: acquire_owned

acquire_owned() solves the lifetime puzzle through ownership. Wrap your semaphore in an Arc, and the method clones that Arc into the returned permit. The permit owns its reference to the semaphore. No borrowing. No lifetime issues.

// ✅ This compiles, runs, and lets you sleep at night
let sem = Arc::new(Semaphore::new(5));
let permit = sem.clone().acquire_owned().await.unwrap();
tokio::spawn(async move {
    // permit owns a ref-counted pointer to the semaphore
    do_work().await;
    // permit dropped here, slot released
});
Enter fullscreen mode Exit fullscreen mode

But here's the crucial behavioral trait: acquire_owned is patient. It's the polite friend who says, "No worries, I'll wait."

If all permits are taken, it doesn't complain. It doesn't error. It parks your task in a perfectly fair FIFO queue and waits. Could be milliseconds. Could be minutes. Could be... forever, if you're not careful.

Trait acquire_owned
Sync/Async Async (returns a Future)
When no permits? Waits patiently
Fairness Strict FIFO queue
Returns Result<OwnedSemaphorePermit, AcquireError>
Errors when Semaphore is closed (rare)

When to Use It

This is your workhorse for mandatory operations. Database writes that must complete. Payment processing. Anything where dropping the work is not an option.

let db_semaphore = Arc::new(Semaphore::new(200)); // Match your pool size

async fn execute_query(query: &str) {
    let permit = db_semaphore.clone().acquire_owned().await.unwrap();

    // We WILL get here eventually. Maybe not immediately, but we'll get here.
    let result = db.execute(query).await;

    // permit dropped, slot freed for the next query
}
Enter fullscreen mode Exit fullscreen mode

The caller slows down. Backpressure propagates naturally. The system breathes.


The Ruthless One: try_acquire_owned

Now meet the other sibling: try_acquire_owned.

This one has no patience. No chill. It checks if a permit is available, and if not—instant rejection. No waiting. No queue. Just a cold, efficient "no."

// Synchronous. Immediate. Merciless.
match sem.clone().try_acquire_owned() {
    Ok(permit) => { /* You're in. Do the work. */ }
    Err(TryAcquireError::NoPermits) => { /* Sorry, system's full. */ }
    Err(TryAcquireError::Closed) => { /* Semaphore shut down entirely. */ }
}
Enter fullscreen mode Exit fullscreen mode

Notice something? No .await. This method is synchronous. It doesn't return a Future. It returns immediately, right now, in this exact moment.

Trait try_acquire_owned
Sync/Async Synchronous (immediate)
When no permits? Returns Err instantly
Fairness None—no queue exists
Returns Result<OwnedSemaphorePermit, TryAcquireError>
Errors when No permits OR semaphore closed

When to Use It

This is your tool for load shedding—the deliberate, intelligent refusal to accept work you can't handle.

Imagine you're building a telemetry pipeline. Thousands of metrics pour in every second. Missing a few is fine. Crashing is not.

fn ingest_metric(sem: Arc<Semaphore>, metric: Metric) {
    if let Ok(permit) = sem.clone().try_acquire_owned() {
        tokio::spawn(async move {
            let _permit = permit;  // Hold the slot
            process_metric(metric).await;
        });
    } else {
        // System's at capacity. Drop this metric. Log it. Move on.
        metrics::counter!("telemetry.dropped").increment(1);
    }
}
Enter fullscreen mode Exit fullscreen mode

No queues growing unbounded. No memory ballooning. The metrics that do get processed are handled promptly, and the ones that don't... well, they weren't that important anyway.

This is the philosophy of graceful degradation: better to serve some users well than to serve all users poorly.


The Showdown: When to Choose What

Let's make this concrete:

Scenario Method Why
Database transaction acquire_owned Must complete. Data integrity matters.
Sending a payment acquire_owned You really don't want to drop this.
Processing telemetry try_acquire_owned Missing some is fine. Crashing isn't.
Cache warming try_acquire_owned Nice to have, not essential.
Rate-limiting API requests acquire_owned Client should wait, not lose data.
Health check endpoint try_acquire_owned Fast response matters more than accuracy under load.

The mental model:

  • acquire_owned: "This work is sacred. I will wait as long as necessary."
  • try_acquire_owned: "This work is expendable. If there's no room, throw it away."

For the Go Refugees: A Familiar Dance

Coming from Go? You've probably implemented semaphores with buffered channels:

sem := make(chan struct{}, 5)
sem <- struct{}{}  // Acquire (blocks if full)
<-sem              // Release
Enter fullscreen mode Exit fullscreen mode

The Rust translation is conceptually identical—just with explicit ownership:

Go Rust
make(chan struct{}, 5) Arc::new(Semaphore::new(5))
sem <- struct{}{} (blocking) sem.acquire_owned().await
select { case sem <- x: ... default: ... } sem.try_acquire_owned()
<-sem drop(permit) — automatic!

The biggest difference? Rust's RAII. In Go, you need defer or careful manual cleanup. In Rust, the permit is released when it goes out of scope. Forget it, and Rust forgets about releasing automatically. No ceremony required.


The Traps (Learn From Others' Pain)

Trap #1: Using acquire() Instead of acquire_owned()

Every week on Stack Overflow, someone posts this:

let permit = sem.acquire().await?;
tokio::spawn(async move {
    drop(permit);  // Compiler: "Excuse me?"
});
Enter fullscreen mode Exit fullscreen mode
error: future cannot be sent between threads safely
Enter fullscreen mode Exit fullscreen mode

The fix is now burned into your memory: If it goes into spawn, use Arc and acquire_owned.

Trap #2: Holding std::sync::Mutex Across .await

This one's subtle and deadly:

let guard = mutex.lock().unwrap();
semaphore.acquire_owned().await;  // Deadlock risk
Enter fullscreen mode Exit fullscreen mode

std::sync::Mutex blocks the OS thread. Tokio's worker thread is now stuck. Other tasks on that thread can't run—including the one that might release the semaphore permit you're waiting for.

Use tokio::sync::Mutex for locks held across .await points. Or better yet, restructure to avoid it.

Trap #3: Acquiring Outside, Dropping Inside

let permit = sem.acquire_owned().await?;  // Acquired here...
tokio::spawn(async move {
    let _p = permit;  // ...but compiler might drop it early
    do_work().await;
});
Enter fullscreen mode Exit fullscreen mode

If you're not careful, the permit might be dropped before do_work() completes. Always bind the permit visibly inside the spawned block.


Production Wisdom

A few patterns from the trenches:

1. Match Your Semaphore to Your Bottleneck

// 200-connection database pool? 200-permit semaphore.
let db_sem = Arc::new(Semaphore::new(200));
Enter fullscreen mode Exit fullscreen mode

2. Add Timeouts to Prevent Infinite Waits

use tokio::time::{timeout, Duration};

match timeout(Duration::from_secs(10), sem.acquire_owned()).await {
    Ok(Ok(permit)) => { /* Got it in time */ }
    Ok(Err(_)) => { /* Semaphore closed */ }
    Err(_) => { /* Timeout. System probably overloaded. */ }
}
Enter fullscreen mode Exit fullscreen mode

3. Pair With Bounded Channels

Semaphores control concurrency. Bounded channels control queue size. Together, they're unstoppable:

let (tx, rx) = mpsc::channel(1000);  // Max 1000 queued
let sem = Arc::new(Semaphore::new(10));  // Max 10 concurrent

// Now you control both *waiting* and *working*
Enter fullscreen mode Exit fullscreen mode

The Takeaway

Semaphores in Tokio aren't complicated. But they demand a choice:

Will you wait, or will you walk away?

acquire_owned waits. It queues. It believes in eventual success. It's for the work that matters.

try_acquire_owned walks away. It sheds load. It believes in protecting the system over completing every task. It's for the work that's nice to have.

Both are correct. Both are essential. The art is knowing which one fits your problem.

Now go build something that doesn't fall over.

Top comments (0)