← Table of contents

7.3 The Lock

The shared shelf solved reading. Adding something to the shelf is a different matter. Two people trying to write in the same ledger at the same time produce not two entries but one corrupted one. A workshop that knows this keeps the key in plain sight: take it - you work; don’t take it - you wait.

What You Need

Of the three problems - passing the store to handlers, sharing it across threads, safely modifying it from any of them - the third remains. Arc gave everyone the same vector, but modifying it through Arc is not allowed.

Mutex<T> wraps data and grants access to it one at a time: .lock() waits until another holder releases, and returns a MutexGuard<T> - a guard. The vector’s methods are called through the guard. While the guard is alive, the lock is held; when it goes out of scope, it is released automatically.

Arc<Mutex<Vec<Task>>> - one vector for all, modifiable in turn. .lock() is always required - for reads and writes alike: without the lock, the data is unreachable.

The Build

Writing Arc<Mutex<Vec<Task>>> in every signature is unnecessary work. A type alias - the same technique as TqResult<T> - resolves this once. At the top of crates/api/src/routes.rs:

pub type SharedStore = Arc<Mutex<Vec<Task>>>;

.lock().unwrap() returns the guard - a value of type MutexGuard<Vec<Task>>. The variable tasks below is that guard: the vector is visible through it, and while it lives, the lock is held.

The full crates/api/src/routes.rs:

// crates/api/src/routes.rs - CHANGED
use axum::{
    Json, Router,
    extract::{Path, State},
    routing::{get, patch, post},
};
use serde::Deserialize;
use std::sync::{Arc, Mutex, MutexGuard};
use tq_core::task::Task;

pub type SharedStore = Arc<Mutex<Vec<Task>>>;

#[derive(Deserialize)]
struct AddRequest {
    title: String,
}

async fn add(State(store): State<SharedStore>, Json(req): Json<AddRequest>) -> Json<Task> {
    let mut tasks: MutexGuard<Vec<Task>> = store.lock().unwrap(); // tasks is the guard
    let id = (tasks.len() + 1) as u64;
    let task = Task::new(id, &req.title).unwrap();
    tasks.push(task.clone());
    Json(task)
}

async fn list(State(store): State<SharedStore>) -> Json<Vec<Task>> {
    let tasks = store.lock().unwrap(); // no mut - methods take &self only
    Json(tasks.to_vec()) // Json requires owned Vec<Task>; to_vec() copies from the guard
}

async fn get_task(State(store): State<SharedStore>, Path(id): Path<u64>) -> Json<Task> {
    let tasks = store.lock().unwrap();
    Json(tasks.iter().find(|t| t.id == id).unwrap().clone())
}

async fn done(State(store): State<SharedStore>, Path(id): Path<u64>) -> Json<Task> {
    let mut tasks = store.lock().unwrap();
    let task = tasks.iter_mut().find(|t| t.id == id).unwrap();
    task.complete();
    Json(task.clone())
}

pub fn router(store: SharedStore) -> Router {
    Router::new()
        .route("/tasks", post(add).get(list))
        .route("/tasks/{id}", get(get_task))
        .route("/tasks/{id}/done", patch(done))
        .with_state(store)
}

In add, the returned task is a variable created inside the function, independent of the guard. In list, the vector itself is needed: Json requires an owned Vec<Task>, while the guard yields only &Vec<Task> - to_vec() makes the copy. In all handlers the lock is released the same way: automatically, when tasks goes out of scope.

In crates/api/src/main.rs:

// crates/api/src/main.rs - CHANGED
mod routes;

use axum::{Router, routing::get};
use routes::SharedStore;
use std::sync::{Arc, Mutex};

async fn health() -> String {
    "tq ok".to_string()
}

#[tokio::main]
async fn main() {
    let store: SharedStore = Arc::new(Mutex::new(vec![]));
    let app = Router::new()
        .route("/", get(health))
        .merge(routes::router(store));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

The Result

Server in one terminal (make serve), four requests in another:

$ curl -s -X POST localhost:3000/tasks -H "Content-Type: application/json" -d '{"title":"buy milk"}'
{"id":1,"title":"buy milk","status":"Todo","created_at":"2026-06-20T08:47:13.382393472Z"}

$ curl -s -X POST localhost:3000/tasks -H "Content-Type: application/json" -d '{"title":"send report"}'
{"id":2,"title":"send report","status":"Todo","created_at":"2026-06-20T08:47:52.408233978Z"}

$ curl -s localhost:3000/tasks
[{"id":1,"title":"buy milk","status":"Todo","created_at":"2026-06-20T08:47:13.382393472Z"},{"id":2,"title":"send report","status":"Todo","created_at":"2026-06-20T08:47:52.408233978Z"}]

$ curl -s -X PATCH localhost:3000/tasks/1/done
{"id":1,"title":"buy milk","status":"Done","created_at":"2026-06-20T08:47:13.382393472Z"}

Tasks survive between requests. Restart the server and they disappear: the store lives in process memory. Persisting across restarts is a separate problem.

The complete tq code for this chapter is in 7-many-hands/03-the-lock/.


Lore: When the Guard Drops

MutexGuard<T> holds the lock while it is alive. When it goes out of scope, the lock is released automatically. In the handlers above, the guard lives until the end of the function: one request holds the store until it is done. Holding a lock longer than necessary means delaying everyone else. In a multithreaded server under heavy load that matters - for now there is no need to worry about it.