Skip to Content
Course content

155: Tokio Sync Primitives: Mutex, RwLock, Semaphore

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Why can't I just use the standard library Mutex in an async function?

This is probably the most common point of confusion when people move from synchronous Rust to Tokio. On the surface, std::sync::Mutex works. It compiles, and for tiny critical sections, it might even feel faster. But here is the danger: if you hold a std::sync::MutexGuard across an .await point, you are essentially hijacking the OS thread that the Tokio runtime is using to manage other tasks.

If your task is suspended at an .await while holding a standard mutex, no other task assigned to that thread can progress if they need that lock. Worse, you can end up with a deadlock that is incredibly hard to debug because the runtime's scheduler is now stuck. Tokio's Mutex is designed to be "async-aware." When you call .lock().await, if the lock is held, the task yields back to the executor so other work can get done. I always tell people: if the lock needs to be held while you're doing I/O or calling another async function, use tokio::sync::Mutex. If the lock is only held for a few nanoseconds to update a counter, the standard library one is actually fine—and faster.

use tokio::sync::Mutex;
use std::sync::Arc;

async fn update_shared_state(state: Arc<Mutex<Vec<String>>>, val: String) {
    // This lock is held across an await point, which is exactly 
    // why we use the Tokio version here.
    let mut guard = state.lock().await;
    
    // Imagine this is a database call or a network request
    tokio::time::sleep(std::time::Duration::from_millis(10)).await; 
    
    guard.push(val);
}

When does an RwLock actually make sense over a Mutex?

I see a lot of developers reach for Mutex by default because it's simpler. But if you have a data structure that is read constantly but updated rarely—think of a configuration object or a routing table—a Mutex becomes a massive bottleneck. Every single reader has to wait in line, even though they aren't changing anything.

That's where RwLock (Read-Write Lock) comes in. It allows an unlimited number of concurrent readers, as long as no one is writing. The moment someone wants to write, they get exclusive access. I usually suggest benchmarking first, but as a rule of thumb: if your read-to-write ratio is higher than 10:1, RwLock will almost certainly give you a performance boost in a highly concurrent system.

use tokio::sync::RwLock;
use std::sync::Arc;

struct AppConfig {
    api_key: String,
}

async fn handle_request(config: Arc<RwLock<AppConfig>>) {
    // Multiple tasks can hold a read lock simultaneously
    let conf = config.read().await;
    println!("Using API key: {}", conf.api_key);
}

async fn rotate_key(config: Arc<RwLock<AppConfig>>, new_key: String) {
    // This will wait until all active readers are finished
    let mut conf = config.write().await;
    conf.api_key = new_key;
}

How do I use a Semaphore to stop my app from crashing an external API?

Mutexes and RwLocks are about exclusive access to data. Semaphores are different; they are about concurrency limiting. I've had plenty of projects where we accidentally DDOSed our own internal microservices because we spawned 10,000 Tokio tasks that all tried to hit an HTTP endpoint at the exact same millisecond.

A Semaphore acts like a bowl of permits. To do work, a task must acquire a permit. If the bowl is empty, the task waits. Once the task is done, it drops the permit, putting it back in the bowl for the next task. It's the cleanest way to implement a "max concurrent requests" limit without building a complex queue system.

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

async fn fetch_url(url: String, semaphore: Arc<Semaphore>) {
    // Acquire a permit before starting the request
    let _permit = semaphore.acquire().await.unwrap();
    
    println!("Fetching {}...", url);
    // Simulate a network request
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
    
    // Permit is automatically returned to the semaphore when _permit goes out of scope
}

#[tokio::main]
async fn main() {
    // Only 3 concurrent requests allowed at any given time
    let semaphore = Arc::new(Semaphore::new(3));
    let mut handles = vec![];

    for i in 0..10 {
        let sem = Arc::clone(&semaphore);
        handles.push(tokio::spawn(fetch_url(format!("https://api.example.com/{}", i), sem)));
    }

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



📋 Practical Task

Exercise: Building a Concurrent API Request Throttler

You are building a tool that processes a list of 50 IDs by fetching data for each from a remote API. However, the API has a strict limit: if you make more than 5 concurrent requests, it will return a 429 Too Many Requests error and ban your IP for an hour.

Your Task:

  • Create a Semaphore with a capacity of 5.
  • Spawn 50 asynchronous tasks using tokio::spawn.
  • Inside each task, use the semaphore to ensure that no more than 5 tasks are "fetching" (simulated by tokio::time::sleep) at the same time.
  • Use an Arc<Mutex<Vec<u32>>> to collect the IDs of the successfully processed requests.
  • Print the final list of processed IDs to verify all 50 were handled.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.