La mayoría de los programas async en Rust se basan en Tokio, que se ha ganado una reputación por ser difícil de depurar y analizar su rendimiento. Antes de RustConf, Russell publicó una entrada de blog con principios generales para construir aplicaciones Tokio rápidas, y la publicación está siendo discutida actualmente en Hacker News.
Russell ha elaborado una versión temprana de un documento destinado a convertirse en una colección de mejores prácticas. Ha solicitado a los lectores que planteen inquietudes o realicen cambios directos a través de problemas o solicitudes de extracción. Tiene la intención de incluir una aplicación funcional pronto, para que la gente pueda ver cómo se corresponde el rastreo dial9 con las pautas.
Las Reglas Inquebrantables
Los tiempos de ejecución de Tokio no vienen con muchas reglas fijas para construir cargas de trabajo rápidas, señala Russell. La respuesta a tantas preguntas es «depende», ya que el rendimiento de una carga de trabajo depende totalmente de qué otras tareas también se estén ejecutando en el tiempo de ejecución en ese preciso momento.
Es por eso que tantos problemas no aparecen hasta que el sistema está en funcionamiento. Elaborar software async que funcione sin problemas requiere encontrar un equilibrio entre la justicia y el procesamiento por lotes, mientras también se gestiona la contención y se garantiza el aislamiento.
Dividir para la Latencia, Procesar por Lotes para el Rendimiento
Optimizar para una latencia baja en muchas solicitudes depende de un único principio: ceder el control con más frecuencia. Ese principio exige justicia entre las conexiones, de modo que ninguna solicitud individual puede retrasar el resto.
Tome Redis (o una aplicación similar con soporte para el procesamiento por canalización de solicitudes). Un enfoque simple lee datos directamente de la conexión antes de que todos los datos hayan llegado. Con las solicitudes canalizadas, toda la solicitud canalizada (o casi toda) llega a un búfer en la memoria en lugar. Leer tramas de ese búfer luego produce resultados Poll::Ready sin necesidad de regresar a la red en absoluto.
El resultado son largas esperas y un trato desigual para los usuarios. El rendimiento generalmente se ve menos afectado — el recuento total de solicitudes se mantiene igual. Pero la latencia se desplaza mucho, ya que toda una cadena de operaciones tiene que pausar detrás de una sola operación precedente.
En este ejemplo, ceder el control explícitamente después de cada solicitud reduce la latencia en aproximadamente 10 veces. Se puede obtener una reducción aún mayor esperando a ceder el control solo después de varias lecturas consecutivas que estén listas de inmediato.
Aquí está el ejemplo de código que Russell proporciona:
The handle_conn method runs an endless loop that keeps checking for incoming data on the connection until shutdown is triggered. Inside that loop, it calls tokio::select!, which waits for two possible outcomes at once. One outcome is a call to self.connection.read_frame() returning a result; the other is a signal arriving on self.shutdown. When read_frame completes successfully, its result is stored in frame, and the method moves on to execute the command contained within it using execute_command(&self.db, &mut self.connection, frame).await. If the shutdown signal arrives instead, the loop ends immediately and the method returns success. The comment about fairness suggests adding tokio::task::yield_now().await between iterations, though it isn’t included in the code here.
Agrupación para amortizar costos generales
La equidad tiene un costo, y el segundo principio es la agrupación: cuanto más trabajo útil puede realizar una aplicación por evento de tiempo de ejecución —cambiando tareas, consultando, moviéndose entre trabajadores o cambiando de hilos—, más eficiente se vuelve.
Tokio::fs sirve como la principal ilustración de Russell, y a veces lleva su punto más allá al decir que «tokio::fs se considera perjudicial» —es decir, sin io_uring, Tokio ejecuta cada operación del sistema de archivos en el grupo de bloqueo. Cada llamada a spawn_blocking conlleva su propio costo, y el grupo de bloqueo se comparte en todo el tiempo de ejecución.
Antes de comenzar a realizar una serie de operaciones del sistema de archivos —o cualquier otro tipo de trabajo de bloqueo—, considere agrupar esas operaciones juntas en un solo lote lo más grande posible. A veces, en lugar de intentar manejar todo a la vez dentro de su aplicación, un hilo dedicado del sistema operativo es en realidad el enfoque más eficiente.
La regla se aplica sin importar dónde interactúe con Tokio. Siempre que sepa que una tarea alcanzará la cola global, combinar varias tareas en un solo lote puede aliviar esa carga de coordinación en general.
Generar una tarea tampoco tiene costo, y aún así no es gratis. La creación de una tarea sigue siendo económica, pero cuando genera cientos o miles de tareas, cada instancia demanda la atención del tiempo de ejecución individualmente. Cada una introduce nuevas oportunidades para retrasos en la programación, agrega otra consulta para que el tiempo de ejecución administre y contribuye a la sobrecarga en general.
Antes de generar una tarea, evalúe lo que realmente está programando: poner una unidad de trabajo de 10 microsegundos en su propia tarea probablemente obstaculizará más de lo que ayuda. Puede usar herramientas como dial9 o tokio-metrics para monitorear la duración de sus tareas.
Cómo saber si tiene un problema
En su trabajo, Russell presenta dos conjuntos de marcadores que señalan problemas. El primer conjunto se refiere a la latencia.
- P99 es mucho mayor que P50.
- Las consultas tardan más de lo que deberían requerir las operaciones internas.
- Muchos spans caen dentro de un solo poll.
El segundo trata sobre el rendimiento:
- Las APIs de Tokio, como spawn_blocking, consumen un tiempo notable en flamegraphs.
- Un bucle apretado realiza muchas operaciones individuales y pequeñas del sistema de archivos o bloqueantes.
- El rendimiento mejora cuando el mismo trabajo se agrupa en unidades más grandes.
Las Excepciones y las Métricas
Tokio no siempre es donde está el problema, según Russell, quien señala que dial9 ha arrojado luz sobre la biblioteca a través de una amplia visibilidad. Tan a menudo como dial9 descubre un problema real de Tokio, tan frecuentemente muestra la ausencia de uno, dando a las personas la confianza para buscar en otra parte soluciones.
Por supuesto, a veces es un problema de Tokio.
La métrica de Tokio más útil es el histograma de latencia de programación, que se agregó recientemente. La latencia de programación mide cuánto tiempo tarda Tokio en consultar un futuro después de que su tarea esté lista para ejecutarse —digamos, cuando el socket tiene datos. Esta métrica no revela la causa, pero es la señal más clara de problemas entre Tokio y su código.
Nuestra Opinión
Estas pautas son útiles. Los ejemplos son específicos, y se explica claramente la diferencia entre latencia y rendimiento. Trabajar hacia atrás desde una métrica real que está tratando de mejorar es un buen consejo.
Esta entrada no es una receta para cocinar. En cambio, presenta una colección de ideas orientadoras, y Russell mismo admite que la respuesta a numerosas consultas a menudo se reduce a «depende». Esa modestia es lo que hace que todo el artículo valga la pena leer.
La promesa de Russell de una aplicación de muestra será útil. Tener rastreos de dial9 junto a estos principios en acción facilitará a los lectores comprender cómo ponerlos en práctica.
Cualquier persona que esté construyendo una aplicación Tokio encontrará la publicación un buen punto de partida. No reemplaza un perfilado y pruebas cuidadosos, pero señala hacia qué debe prestar atención.
Recibe El Cuaderno.
Las mejores historias del día y cada veredicto nuevo, en español claro, en tu correo a las siete. Un correo al día, nada más.

