El trabajo que no es asíncrono
SQLite, un cálculo intensivo, una biblioteca síncrona. Cómo se sacan del runtime sin agotarlo, la diferencia entre esperar entrada y salida y gastar CPU, y el error de envolver lo que no hacía falta.
La herramienta
#[tauri::command]async fn buscar(db: tauri::State<'_, Db>, termino: String) -> Result<Vec<Fila>, ErrorPayload> { let db = db.inner().clone(); // lo que la tarea necesita, por valor tauri::async_runtime::spawn_blocking(move || { db.consultar(&termino) // síncrono, y aquí no molesta a nadie }) .await .map_err(|_| ErrorPayload::interno())?}Lo que hace: ejecuta esa función en un grupo de hilos aparte, pensado para trabajo que bloquea, y devuelve algo que se puede esperar. El hilo del runtime queda libre mientras tanto.
Dos detalles que hacen falta:
- Lo que entra en el bloque tiene que poder viajar por valor. De ahí el clonado: un préstamo con el tiempo de vida del comando no vale.
- Devuelve dos capas de error: la de la tarea —que puede haber entrado en pánico— y la tuya. Hay que abrir las dos, y por eso hay dos operadores en el ejemplo.
Esperar frente a trabajar
La distinción que casi nadie hace y que decide bien:
La consecuencia práctica: la herramienta del framework es la correcta para esperar, y para CPU intensiva conviene un grupo propio acotado. Mezclar las dos clases en el mismo sitio hace que una consulta rápida a la base espere detrás de una compresión de dos minutos.
Cuándo NO hace falta envolver
El error simétrico, y es frecuente en cuanto alguien aprende la herramienta: envolver todo por si acaso. Tres casos donde no hace falta:
Lo que ya es asíncrono. Una petición HTTP con un cliente asíncrono, una lectura con la API asíncrona. Envolverlas añade un salto de hilo sin ganar nada.
Lo que tarda microsegundos. Leer un campo del estado, un cálculo trivial. El coste de mandar el trabajo a otro hilo y esperar la respuesta es mayor que hacerlo ahí mismo.
Lo que ya está fuera del runtime. Dentro de una tarea lanzada con la herramienta bloqueante, todo es síncrono: no hay que envolver otra vez.
La regla de dedo: envuelve lo que pueda tardar más de un milisegundo o dos y no sea asíncrono. Por debajo, el envoltorio cuesta más que el problema.
El caso de SQLite
Es el más frecuente y merece verlo entero, porque es la fase VII entera en miniatura:
// El estado: una conexión detrás de un cerrojo. Nivel 22.2pub struct Db(pub Arc<Mutex<rusqlite::Connection>>);impl Db { // el método encapsula el cerrojo Y el salto de hilo pub async fn consultar(&self, termino: String) -> Result<Vec<Fila>, DbError> { let conn = self.0.clone(); tauri::async_runtime::spawn_blocking(move || { let c = conn.lock().unwrap(); // el cerrojo, dentro del hilo bloqueante // … la consulta, síncrona, sin ningún await de por medio Ok(vec![]) }) .await .map_err(|_| DbError::Interno)? }}Fíjate en que el cerrojo se toma dentro del bloque bloqueante. Eso resuelve de una vez las dos cosas: el trabajo sale del runtime, y el problema del nivel 16.5 desaparece porque dentro de ese bloque no hay ningún punto de espera que cruzar.
Es el patrón que la fase VII usa en todas partes, y la razón de que SQLite con un cerrojo sea perfectamente razonable en una aplicación de escritorio.