← Índice

7.3 El cerrojo

Esta traducción fue asistida por IA y revisada superficialmente. Si encuentras errores o algo no suena natural, escríbeme — lo agradezco.

El estante compartido resolvió la lectura. Añadir algo al estante es otra cuestión. Dos personas que intentan escribir en el mismo libro de cuentas al mismo tiempo no producen dos entradas sino una corrompida. Un taller que sabe esto mantiene la llave a la vista: la tomas - trabajas; no la tomas - esperas.

Lo que necesitas

De los tres problemas - pasar el almacén a los handlers, compartirlo entre hilos, modificarlo con seguridad desde cualquiera de ellos - queda el tercero. Arc dio a todos el mismo vector, pero modificarlo a través de Arc no está permitido.

Mutex<T> envuelve los datos y concede acceso a ellos de uno en uno: .lock() espera hasta que otro titular lo libere, y devuelve un MutexGuard<T> - un guardián. Los métodos del vector se llaman a través del guardián. Mientras el guardián está vivo, el cerrojo está tomado; cuando sale del alcance, se libera automáticamente.

Arc<Mutex<Vec<Task>>> - un vector para todos, modificable en turnos. .lock() es siempre necesario - tanto para lecturas como para escrituras: sin el cerrojo, los datos son inaccesibles.

La construcción

Escribir Arc<Mutex<Vec<Task>>> en cada firma es trabajo innecesario. Un alias de tipo - la misma técnica que TqResult<T> - lo resuelve de una vez. Al principio de crates/api/src/routes.rs:

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

.lock().unwrap() devuelve el guardián - un valor de tipo MutexGuard<Vec<Task>>. La variable tasks a continuación es ese guardián: el vector es visible a través de él, y mientras viva, el cerrojo está tomado.

El crates/api/src/routes.rs completo:

// crates/api/src/routes.rs - CAMBIADO
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 es el guardián
    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(); // sin mut - los métodos solo toman &self
    Json(tasks.to_vec()) // Json requiere Vec<Task> con posesión; to_vec() copia desde el guardián
}

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)
}

En add, la task devuelta es una variable creada dentro de la función, independiente del guardián. En list, se necesita el vector en sí: Json requiere un Vec<Task> con posesión, mientras que el guardián solo cede &Vec<Task> - to_vec() hace la copia. En todos los handlers el cerrojo se libera del mismo modo: automáticamente, cuando tasks sale del alcance.

En crates/api/src/main.rs:

// crates/api/src/main.rs - CAMBIADO
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();
}

El resultado

El servidor en un terminal (make serve), cuatro peticiones en otro:

$ 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"}

Las tareas sobreviven entre peticiones. Reinicia el servidor y desaparecen: el almacén vive en la memoria del proceso. La persistencia entre reinicios es un problema aparte.

El código completo de tq para este capítulo está en 7-many-hands/03-the-lock/.


Lore: Cuando cae el guardián

MutexGuard<T> mantiene el cerrojo mientras está vivo. Cuando sale del alcance, el cerrojo se libera automáticamente. En los handlers anteriores, el guardián vive hasta el final de la función: una petición retiene el almacén hasta que termina. Retener un cerrojo más tiempo del necesario significa retrasar a todos los demás. En un servidor multihilo bajo carga elevada eso importa - por ahora no hay que preocuparse por ello.