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
tqcode for this chapter is in7-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.