Saltar al contenido

La tarea dueña del estado

El patrón que elimina los cerrojos: una tarea posee el dato y todo el mundo le habla por mensajes. Cuándo compensa, qué cuesta, y por qué convierte una clase de fallos de concurrencia en imposibles.

El modelo

Las tres señales de que compensa

1 · Necesitas varios cerrojos para una operación. Es la señal del nivel 22.2, y con este modelo desaparece: la tarea posee todo lo suyo y una operación es un mensaje.

2 · Las secciones críticas son largas. Si mantener la consistencia exige hacer varias cosas seguidas, un cerrojo bloquea a todo el mundo mientras tanto. La tarea las hace en su bucle, y quien esperaba respuesta espera; quien no, sigue.

3 · Hay trabajo de fondo que también toca el estado. Una sincronización, un indexado, un vigilante de ficheros. Con cerrojos, ese trabajo compite con los comandos; con este modelo, es un emisor más de mensajes.

Si ninguna de las tres aplica —un contador, una caché, una conexión— el cerrojo con métodos del nivel 22.3 es más simple y más rápido, y cambiar sería complicar por gusto.

El patrón

Rust
// 1 · los mensajes: una variante por operaciónpub enum Msg {    Guardar { id: ProyectoId, contenido: String, resp: oneshot::Sender<Result<(), Error>> },    Listar { resp: oneshot::Sender<Vec<ProyectoResumen>> },    Invalidar { id: ProyectoId },          // sin respuesta: no lleva canal}// 2 · la tarea: posee el dato y atiende en buclepub fn arrancar(mut rx: mpsc::Receiver<Msg>) {    tauri::async_runtime::spawn(async move {        let mut estado = Estado::cargar().await;   // solo esta tarea lo ve        while let Some(msg) = rx.recv().await {            match msg {                Msg::Guardar { id, contenido, resp } => {                    let r = estado.guardar(id, &contenido).await;                    let _ = resp.send(r);          // si nadie espera, da igual                }                Msg::Listar { resp } => { let _ = resp.send(estado.listar()); }                Msg::Invalidar { id } => estado.invalidar(id),            }        }    });}// 3 · el estado registrado es solo el emisorpub struct Proyectos(pub mpsc::Sender<Msg>);

Y un comando queda así:

Rust
#[tauri::command]async fn listar_proyectos(p: tauri::State<'_, Proyectos>) -> Result<Vec<ProyectoResumen>, ErrorPayload> {    let (tx, rx) = oneshot::channel();    p.0.send(Msg::Listar { resp: tx }).await.map_err(|_| ErrorPayload::interno())?;    rx.await.map_err(|_| ErrorPayload::interno())}

Fíjate en que no hay ningún cerrojo en el comando. Y en que el emisor se puede clonar libremente, así que llevarlo a una tarea de fondo es gratis.

Los dos tipos de mensaje

  • Con respuesta. Lleva dentro un canal de un solo uso. El que envía espera por él, y ese await es el que sustituye a la espera del cerrojo — con la diferencia de que aquí nadie más está bloqueado.
  • Sin respuesta. Se manda y se olvida. Sirve para invalidar, notificar, encolar trabajo. Es más rápido y cambia la semántica: el que envía no sabe si se hizo. Conviene que sea una decisión y no un descuido.

Lo que cuesta, dicho claro