Rust

Rust

Tutorial Rust: Fundamentos asíncronos y futures

Aprende los conceptos clave de asincronía, futures y ejecutores en Rust para desarrollar aplicaciones concurrentes eficientes y escalables.

Aprende Rust y certifícate

Qué es la asincronía

La asincronía es un paradigma de programación que permite a nuestro código realizar múltiples operaciones sin bloquear la ejecución principal del programa. A diferencia de la programación secuencial tradicional, donde cada instrucción debe completarse antes de pasar a la siguiente, la programación asíncrona nos permite iniciar una operación y continuar con otras tareas mientras esperamos que la primera finalice.

En el contexto de sistemas informáticos, muchas operaciones dependen de recursos externos que pueden tomar tiempo considerable en responder:

  • Lectura y escritura de archivos en disco
  • Solicitudes a servidores remotos a través de la red
  • Consultas a bases de datos
  • Espera de entrada del usuario

Estas operaciones se caracterizan por ser I/O bound (limitadas por entrada/salida), lo que significa que el tiempo de espera no está relacionado con la capacidad de procesamiento de la CPU, sino con factores externos.

Diferencia entre concurrencia con hilos y asincronía

Aunque ya conocemos la concurrencia basada en hilos, la asincronía ofrece un enfoque fundamentalmente distinto:

  • Concurrencia con hilos: Cada hilo representa una secuencia de ejecución independiente que el sistema operativo programa en los núcleos de CPU disponibles. Los hilos son recursos del sistema relativamente costosos, con un tamaño de pila fijo y sobrecarga de cambio de contexto.

  • Concurrencia asíncrona: Utiliza un número reducido de hilos (a menudo solo uno) para gestionar muchas tareas concurrentes. Las tareas pueden suspenderse cuando esperan recursos externos y reactivarse cuando los datos están disponibles, sin bloquear el hilo subyacente.

Veamos un ejemplo conceptual de la diferencia:

// Enfoque con hilos (bloqueante)
fn main() {
    // Cada conexión requiere un hilo completo
    for _ in 0..10_000 {
        std::thread::spawn(|| {
            // Este hilo queda bloqueado durante la espera
            let datos = leer_de_red_bloqueante();
            procesar(datos);
        });
    }
    // Problema: 10,000 hilos consumen muchos recursos del sistema
}
// Enfoque asíncrono (no bloqueante)
fn main() {
    // Un solo hilo puede manejar miles de tareas
    let runtime = crear_ejecutor();
    
    for _ in 0..10_000 {
        runtime.spawn(async {
            // Esta tarea se suspende durante la espera, liberando el hilo
            let datos = leer_de_red_asincrono().await;
            procesar(datos);
        });
    }
    // Ventaja: Mucho más eficiente en recursos
}

Modelo de ejecución asíncrona

La asincronía en Rust se basa en un modelo de suspensión y reanudación de tareas. Cuando una operación asíncrona necesita esperar por un recurso externo:

  1. La tarea se suspende y guarda su estado actual
  2. El hilo queda libre para trabajar en otras tareas
  3. Cuando los datos están disponibles, la tarea se reanuda desde donde se quedó

Este modelo es particularmente eficiente para aplicaciones que manejan muchas operaciones de I/O simultáneas, como servidores web, aplicaciones de red o sistemas que procesan muchas solicitudes concurrentes.

Ventajas de la programación asíncrona

  • Escalabilidad mejorada: Un solo hilo puede manejar miles de tareas concurrentes, lo que permite crear sistemas que escalan mejor con recursos limitados.

  • Menor consumo de memoria: Al no necesitar un hilo completo por tarea, se reduce significativamente el uso de memoria del sistema.

  • Menos cambios de contexto: Los cambios entre tareas asíncronas son más ligeros que los cambios de contexto entre hilos del sistema operativo.

  • Mayor rendimiento en escenarios I/O bound: Para aplicaciones que esperan constantemente por operaciones de entrada/salida, la asincronía puede mejorar drásticamente el rendimiento.

Cuándo usar asincronía

La programación asíncrona no es una solución universal. Es especialmente adecuada para:

  • Aplicaciones con muchas operaciones de I/O concurrentes
  • Servidores que manejan miles de conexiones simultáneas
  • Sistemas que requieren alta eficiencia en recursos
  • Aplicaciones que necesitan mantener responsividad mientras esperan recursos externos

Sin embargo, para tareas CPU bound (limitadas por procesamiento) que requieren cálculos intensivos, la concurrencia basada en hilos o paralelismo puede ser más apropiada, ya que aprovecha múltiples núcleos de CPU.

Desafíos de la programación asíncrona

Aunque la asincronía ofrece ventajas significativas, también presenta algunos desafíos:

  • Complejidad conceptual: El flujo de ejecución no lineal puede ser más difícil de seguir y razonar.

  • Propagación de asincronía: Una vez que introduces asincronía en parte de tu código, tiende a "propagarse" a través de la base de código.

  • Depuración más compleja: Seguir el flujo de ejecución y diagnosticar problemas puede ser más difícil en código asíncrono.

  • Sobrecarga de abstracción: Existe cierta sobrecarga asociada con la maquinaria que permite la suspensión y reanudación de tareas.

Modelo mental para la asincronía en Rust

En Rust, podemos visualizar la asincronía como un sistema donde:

  • Las tareas asíncronas representan unidades de trabajo que pueden suspenderse
  • Un ejecutor (o runtime) es responsable de avanzar estas tareas cuando pueden progresar
  • Las tareas se suspenden en puntos de espera cuando necesitan recursos externos
  • El ejecutor puede multiplexar muchas tareas en un número reducido de hilos

Este modelo es fundamentalmente diferente del enfoque basado en callbacks común en otros lenguajes, y ofrece un código más legible y mantenible a largo plazo.

// Ejemplo conceptual (simplificado) de una tarea asíncrona
fn tarea_asincrona() -> impl Future<Output = String> {
    async {
        // Punto 1: Código que se ejecuta inmediatamente
        let id = generar_id();
        
        // Punto 2: Aquí la tarea se suspenderá, liberando el hilo
        let respuesta = obtener_datos_de_red(id).await;
        
        // Punto 3: Cuando los datos lleguen, la ejecución continúa aquí
        procesar_respuesta(respuesta)
    }
}

En este ejemplo conceptual, la tarea puede suspenderse mientras espera datos de la red, permitiendo que el hilo trabaje en otras tareas. Cuando los datos están disponibles, la tarea se reanuda exactamente desde donde se quedó.

La asincronía en Rust proporciona una forma elegante y eficiente de manejar operaciones concurrentes, especialmente aquellas que involucran esperas por recursos externos, sin la sobrecarga asociada con la creación y gestión de múltiples hilos.

Futures en Rust

Los futures son el componente fundamental del sistema de asincronía en Rust. Un future representa una operación asíncrona que puede no haber completado su ejecución todavía. Podemos pensar en ellos como una promesa de que eventualmente tendremos un valor resultante.

A diferencia de otros lenguajes que implementan promesas o futuros basados en callbacks, Rust utiliza un enfoque basado en polling (sondeo), lo que permite un control más preciso sobre la ejecución y evita algunos problemas comunes como la inversión de control.

Trait Future

En Rust, los futures se definen mediante el trait Future de la biblioteca estándar:

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

Este trait contiene:

  • Output: El tipo de valor que el future eventualmente producirá
  • poll: El método que intenta avanzar el future hacia su finalización

El tipo de retorno Poll es una enumeración con dos variantes:

enum Poll<T> {
    Ready(T),      // El future ha completado con un valor T
    Pending,       // El future aún no está listo y necesita ser sondeado más tarde
}

Modelo de ejecución basado en polling

El modelo de polling de Rust funciona de la siguiente manera:

  1. El ejecutor llama al método poll() del future
  2. Si el future puede completarse, devuelve Poll::Ready(valor)
  3. Si el future necesita esperar, devuelve Poll::Pending y registra un waker para notificar cuando pueda avanzar
  4. Cuando el recurso externo está listo, el waker es invocado
  5. El ejecutor vuelve a llamar a poll() en el future

Este enfoque tiene varias ventajas:

  • Evita la creación de callbacks anidados
  • Permite un control más fino sobre la ejecución
  • Facilita la composición de operaciones asíncronas
  • Reduce la sobrecarga de memoria

Creación manual de un Future simple

Para entender mejor cómo funcionan los futures internamente, veamos un ejemplo simplificado de un future que se completa después de cierto tiempo:

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};

struct Delay {
    when: Instant,
}

impl Future for Delay {
    type Output = ();
    
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        if Instant::now() >= self.when {
            println!("¡Future completado!");
            Poll::Ready(())
        } else {
            // Programamos ser despertados cuando sea el momento
            let waker = cx.waker().clone();
            let when = self.when;
            
            // En un caso real, esto se haría con un temporizador adecuado
            std::thread::spawn(move || {
                let now = Instant::now();
                if now < when {
                    std::thread::sleep(when - now);
                }
                waker.wake();
            });
            
            Poll::Pending
        }
    }
}

// Crear un future que se completará después de 2 segundos
fn delay(duration: Duration) -> Delay {
    Delay {
        when: Instant::now() + duration,
    }
}

Este ejemplo muestra los componentes clave de un future:

  • Implementación del trait Future
  • Lógica para determinar si está listo (Poll::Ready) o no (Poll::Pending)
  • Mecanismo para notificar al ejecutor cuando el future puede avanzar (usando el waker)

Composición de Futures

Una de las grandes fortalezas de los futures es su capacidad de composición. Podemos combinar futures más simples para crear operaciones asíncronas más complejas:

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

// Un future que ejecuta dos futures en secuencia
struct ThenFuture<FutA, FutB, FnT> {
    first: Option<FutA>,      // El primer future a ejecutar
    second: Option<FutB>,     // El segundo future (puede ser None inicialmente)
    transition: FnT,          // Función para crear el segundo future
}

impl<FutA, FutB, FnT, A, B> Future for ThenFuture<FutA, FutB, FnT>
where
    FutA: Future<Output = A>,
    FutB: Future<Output = B>,
    FnT: FnOnce(A) -> FutB,
{
    type Output = B;
    
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        // Nota: Esta implementación es simplificada y no maneja correctamente Pin
        
        // Si tenemos el primer future, intentamos avanzarlo
        if let Some(first) = &mut self.first {
            match Future::poll(Pin::new(first), cx) {
                Poll::Ready(value) => {
                    // El primer future está listo, creamos el segundo
                    let second = (self.transition)(value);
                    self.first = None;
                    self.second = Some(second);
                }
                Poll::Pending => return Poll::Pending,
            }
        }
        
        // Si tenemos el segundo future, intentamos avanzarlo
        if let Some(second) = &mut self.second {
            return Future::poll(Pin::new(second), cx);
        }
        
        unreachable!("No debería llegar aquí");
    }
}

// Función auxiliar para crear un ThenFuture
fn then<FutA, FnT, FutB>(future: FutA, f: FnT) -> ThenFuture<FutA, FutB, FnT>
where
    FutA: Future,
    FnT: FnOnce(FutA::Output) -> FutB,
    FutB: Future,
{
    ThenFuture {
        first: Some(future),
        second: None,
        transition: f,
    }
}

Este ejemplo muestra cómo podríamos implementar una versión simplificada de la funcionalidad then para encadenar futures. En la práctica, la biblioteca estándar y crates como futures proporcionan implementaciones más robustas y optimizadas.

Futures y estado de la máquina

Internamente, los futures en Rust se implementan como máquinas de estado. Cada vez que un future se suspende (devuelve Poll::Pending), su estado actual se guarda para poder reanudar la ejecución más tarde desde ese punto.

Cuando usamos la sintaxis async/await (que veremos en lecciones posteriores), el compilador de Rust transforma nuestro código en una máquina de estado que implementa el trait Future. Esto nos permite escribir código asíncrono de forma secuencial y legible, mientras que por debajo se genera código eficiente basado en futures.

Futures vs Threads: Cuándo usar cada uno

Los futures y los hilos tienen diferentes casos de uso:

  • Futures (asincronía): Ideal para operaciones de I/O concurrentes, como manejar miles de conexiones de red simultáneas con recursos limitados.

  • Threads (paralelismo): Mejor para tareas intensivas de CPU que pueden ejecutarse en paralelo, aprovechando múltiples núcleos.

Comparativa de rendimiento:

Escenario Futures Threads
Muchas operaciones I/O Excelente (bajo consumo de recursos) Deficiente (alto consumo de memoria)
Tareas CPU-intensivas Limitado (un solo hilo) Excelente (paralelismo real)
Sobrecarga por tarea Muy baja (~100 bytes) Alta (~1-8 MB por hilo)
Cambio de contexto Muy ligero Más costoso

Limitaciones de los Futures

Los futures en Rust tienen algunas limitaciones importantes:

  • No se ejecutan por sí solos: Un future no hace nada hasta que algo llama a su método poll(). Necesitan un ejecutor.

  • No son thread-safe por defecto: Los futures básicos no implementan Send ni Sync automáticamente, aunque muchas implementaciones concretas sí lo hacen.

  • Complejidad de implementación manual: Implementar futures manualmente requiere entender conceptos avanzados como Pin y Context.

// Ejemplo: Un future que no hace nada por sí solo
fn main() {
    let future = async {
        println!("¡Hola desde un future!");
    };
    
    // Este código NO imprimirá nada, porque nadie está ejecutando el future
    println!("Fin del programa");
}

Futures en la biblioteca estándar vs crate futures

Rust proporciona el trait Future en su biblioteca estándar, pero muchas utilidades adicionales están disponibles en el crate futures:

// Usando futures de la biblioteca estándar
use std::future::Future;

// Usando el crate futures para funcionalidades adicionales
use futures::future::{self, FutureExt, TryFutureExt};

El crate futures proporciona:

  • Combinadores adicionales como join, select, try_join
  • Primitivas de sincronización asíncrona
  • Adaptadores entre streams y futures
  • Utilidades para trabajar con futures de forma más conveniente

Resumen

Los futures en Rust representan operaciones asíncronas que pueden completarse en el futuro. A diferencia de otros lenguajes, Rust utiliza un modelo basado en polling que ofrece mayor control y eficiencia. Los futures por sí solos no hacen nada; necesitan un ejecutor que los avance llamando a su método poll().

La verdadera potencia de los futures viene de su capacidad de composición, permitiendo construir operaciones asíncronas complejas a partir de otras más simples. Aunque implementar futures manualmente puede ser complejo, las herramientas de Rust como la sintaxis async/await (que veremos más adelante) simplifican enormemente este proceso.

En la siguiente sección, exploraremos los ejecutores (runtimes) que son necesarios para hacer que los futures realmente funcionen.

Ejecutores (runtimes)

Los ejecutores (también conocidos como runtimes) son el componente esencial que permite que los futures en Rust cobren vida. Como vimos anteriormente, un future por sí solo es simplemente una descripción de una tarea asíncrona, pero no realiza ningún trabajo hasta que algo llama repetidamente a su método poll(). Aquí es donde entran en juego los ejecutores.

Un ejecutor asíncrono es responsable de:

  • Gestionar una colección de futures pendientes
  • Llamar a poll() en los futures cuando pueden avanzar
  • Manejar las notificaciones de los wakers cuando un future está listo para progresar
  • Distribuir eficientemente el trabajo entre los hilos disponibles

Funcionamiento básico de un ejecutor

El ciclo de vida típico de un future dentro de un ejecutor sigue este patrón:

  1. El future se registra con el ejecutor (generalmente mediante un método como spawn)
  2. El ejecutor llama a poll() en el future
  3. Si el future devuelve Poll::Ready, se completa y se elimina del ejecutor
  4. Si devuelve Poll::Pending, el ejecutor lo guarda y registra su waker
  5. Cuando el waker es activado, el ejecutor vuelve a poner el future en la cola para ser sondeado nuevamente
// Ejemplo conceptual simplificado de un ejecutor básico
struct MiniExecutor {
    // Cola de futures listos para ser sondeados
    ready_queue: Vec<Box<dyn Future<Output = ()> + Send>>,
}

impl MiniExecutor {
    fn new() -> Self {
        MiniExecutor {
            ready_queue: Vec::new(),
        }
    }
    
    // Añade un future al ejecutor
    fn spawn<F>(&mut self, future: F)
    where
        F: Future<Output = ()> + Send + 'static,
    {
        self.ready_queue.push(Box::new(future));
    }
    
    // Ejecuta todos los futures hasta que se completen
    fn run(&mut self) {
        // Nota: Esta implementación es extremadamente simplificada
        // y no maneja correctamente wakers ni Pin
        
        while !self.ready_queue.is_empty() {
            // Tomamos un future de la cola
            let mut future = self.ready_queue.remove(0);
            
            // Creamos un contexto con un waker que volvería a poner
            // el future en la cola cuando sea activado
            let waker = /* ... */;
            let mut context = Context::from_waker(&waker);
            
            // Sondeamos el future
            match Pin::new(&mut future).poll(&mut context) {
                Poll::Ready(()) => {
                    // El future ha completado, no hacemos nada más con él
                }
                Poll::Pending => {
                    // El future no está listo, lo guardamos para más tarde
                    // (en un ejecutor real, esto sería manejado por el waker)
                    self.ready_queue.push(future);
                }
            }
        }
    }
}

Este ejemplo es extremadamente simplificado y no es práctico para uso real, pero ilustra el concepto básico de cómo funciona un ejecutor.

Tipos de ejecutores

En el ecosistema de Rust existen varios tipos de ejecutores, cada uno con características y optimizaciones diferentes:

  • Ejecutores de un solo hilo: Procesan todos los futures en un único hilo. Son simples y tienen menos sobrecarga, pero no aprovechan múltiples núcleos.

  • Ejecutores multi-hilo: Distribuyen los futures entre varios hilos de trabajo, permitiendo paralelismo real. Son ideales para aplicaciones que necesitan alto rendimiento.

  • Ejecutores con I/O integrado: Incluyen integraciones optimizadas con sistemas de I/O asíncrono del sistema operativo (como epoll, kqueue, IOCP).

  • Ejecutores especializados: Diseñados para casos de uso específicos, como procesamiento de señales, aplicaciones embebidas o entornos con restricciones.

Principales ejecutores en el ecosistema Rust

Aunque Rust no incluye un ejecutor en su biblioteca estándar, existen varias implementaciones maduras y ampliamente utilizadas:

  • Tokio: El ejecutor más popular y completo, optimizado para aplicaciones de red. Incluye primitivas para I/O asíncrono, temporizadores, canales y más.

  • async-std: Una biblioteca que proporciona una API similar a la biblioteca estándar de Rust, pero con operaciones asíncronas.

  • smol: Un ejecutor minimalista y ligero que prioriza la simplicidad y el tamaño reducido.

  • futures: Incluye un ejecutor básico útil para casos simples o pruebas.

// Ejemplo usando Tokio
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    println!("Iniciando tarea asíncrona...");
    
    // Spawn crea una nueva tarea asíncrona gestionada por el ejecutor
    tokio::spawn(async {
        sleep(Duration::from_millis(100)).await;
        println!("Tarea en segundo plano completada");
    });
    
    sleep(Duration::from_millis(200)).await;
    println!("Tarea principal completada");
}
// Ejemplo usando async-std
use async_std::task;
use std::time::Duration;

#[async_std::main]
async fn main() {
    println!("Iniciando tarea asíncrona...");
    
    // Spawn crea una nueva tarea asíncrona
    task::spawn(async {
        task::sleep(Duration::from_millis(100)).await;
        println!("Tarea en segundo plano completada");
    });
    
    task::sleep(Duration::from_millis(200)).await;
    println!("Tarea principal completada");
}

Características importantes de los ejecutores

Al elegir o trabajar con un ejecutor, hay varias características importantes a considerar:

  • Modelo de concurrencia: Algunos ejecutores utilizan un modelo cooperativo donde las tareas deben ceder explícitamente el control, mientras que otros manejan esto automáticamente.

  • Estrategia de trabajo: Cómo se distribuyen las tareas entre los hilos de trabajo disponibles.

  • Manejo de bloqueo: Cómo maneja el ejecutor las operaciones que bloquean un hilo (como llamadas a sistema bloqueantes).

  • Integración con I/O: Qué tan eficientemente se integra con las APIs de I/O asíncrono del sistema operativo.

  • Soporte para cancelación: Si proporciona mecanismos para cancelar tareas en ejecución.

Ejecutor local vs global

Algunos ejecutores permiten dos modos de operación:

  • Ejecutor local: Existe solo dentro de un ámbito específico y se destruye cuando ese ámbito termina.

  • Ejecutor global: Disponible durante toda la vida de la aplicación, a menudo implementado como un singleton.

// Ejemplo de ejecutor local con Tokio
use tokio::runtime::Runtime;
use std::time::Duration;

fn main() {
    // Creamos un ejecutor local
    let rt = Runtime::new().unwrap();
    
    // Ejecutamos un bloque asíncrono en este ejecutor
    rt.block_on(async {
        println!("Ejecutando en un runtime local");
        tokio::time::sleep(Duration::from_millis(100)).await;
        println!("Completado");
    });
    
    // El runtime se destruye cuando rt sale del ámbito
}

Trabajo bloqueante en ejecutores

Un desafío común al trabajar con ejecutores asíncronos es manejar operaciones que bloquean el hilo, como operaciones intensivas de CPU o llamadas a bibliotecas sincrónicas. Los ejecutores suelen proporcionar mecanismos específicos para esto:

// Manejo de trabajo bloqueante en Tokio
#[tokio::main]
async fn main() {
    // Esta operación bloqueante se ejecutará en un pool de hilos separado
    // para no bloquear el ejecutor principal
    let resultado = tokio::task::spawn_blocking(|| {
        // Operación intensiva de CPU o llamada bloqueante
        std::thread::sleep(std::time::Duration::from_secs(1));
        "Resultado de operación bloqueante"
    }).await.unwrap();
    
    println!("{}", resultado);
}

Implementación manual de un ejecutor simple

Para entender mejor cómo funciona un ejecutor, veamos una implementación muy simplificada que puede manejar futures básicos:

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, Wake};
use std::sync::{Arc, Mutex};
use std::collections::VecDeque;
use std::thread;

// Una implementación simple de Waker
struct SimpleWaker {
    queue: Arc<Mutex<VecDeque<Arc<Task>>>>,
    task: Arc<Task>,
}

impl Wake for SimpleWaker {
    fn wake(self: Arc<Self>) {
        // Cuando se activa el waker, ponemos la tarea de vuelta en la cola
        self.queue.lock().unwrap().push_back(self.task.clone());
    }
}

// Representa una tarea asíncrona
struct Task {
    future: Mutex<Pin<Box<dyn Future<Output = ()> + Send>>>,
}

// Nuestro ejecutor simple
struct SimpleExecutor {
    task_queue: Arc<Mutex<VecDeque<Arc<Task>>>>,
}

impl SimpleExecutor {
    fn new() -> Self {
        SimpleExecutor {
            task_queue: Arc::new(Mutex::new(VecDeque::new())),
        }
    }
    
    // Añade un future al ejecutor
    fn spawn<F>(&self, future: F)
    where
        F: Future<Output = ()> + Send + 'static,
    {
        let task = Arc::new(Task {
            future: Mutex::new(Box::pin(future)),
        });
        
        self.task_queue.lock().unwrap().push_back(task);
    }
    
    // Ejecuta todas las tareas hasta que se completen
    fn run(&self) {
        while let Some(task) = self.task_queue.lock().unwrap().pop_front() {
            // Creamos un waker para esta tarea
            let waker = Arc::new(SimpleWaker {
                queue: self.task_queue.clone(),
                task: task.clone(),
            });
            let waker = std::task::Waker::from(waker);
            let mut context = Context::from_waker(&waker);
            
            // Sondeamos el future
            let mut future_lock = task.future.lock().unwrap();
            if let Poll::Ready(()) = future_lock.as_mut().poll(&mut context) {
                // La tarea ha completado, no hacemos nada más
            }
            // Si devuelve Pending, el waker se encargará de volver a ponerla en la cola
        }
    }
}

Este ejecutor es extremadamente básico y carece de muchas optimizaciones y características que tendría un ejecutor real, pero ilustra los conceptos fundamentales.

Consideraciones de rendimiento

Los ejecutores modernos implementan numerosas optimizaciones para maximizar el rendimiento:

  • Work stealing: Permite que los hilos inactivos "roben" trabajo de otros hilos ocupados, mejorando la utilización de recursos.

  • Colas de tareas optimizadas: Utilizan estructuras de datos sin bloqueo para minimizar la contención entre hilos.

  • Integración con I/O asíncrono del sistema: Se integran directamente con mecanismos como epoll (Linux), kqueue (BSD/macOS) o IOCP (Windows).

  • Estrategias de planificación inteligentes: Priorizan tareas basándose en diversos factores como tiempo de espera o dependencias.

Cuándo usar cada ejecutor

La elección del ejecutor depende de las necesidades específicas de tu aplicación:

  • Tokio: Ideal para aplicaciones de red de alto rendimiento, servidores web, o cualquier sistema que requiera escalabilidad y características completas.

  • async-std: Buena opción si prefieres una API familiar similar a la biblioteca estándar de Rust.

  • smol: Excelente para aplicaciones más pequeñas donde el tamaño del binario y la simplicidad son prioritarios.

  • Ejecutor personalizado: Puede ser necesario para entornos muy específicos como sistemas embebidos o cuando tienes requisitos únicos.

Interoperabilidad entre ejecutores

Un desafío importante en el ecosistema asíncrono de Rust es la interoperabilidad entre diferentes ejecutores. En general:

  • Los futures son independientes del ejecutor y pueden transferirse entre diferentes implementaciones.

  • Las primitivas específicas de cada ejecutor (como temporizadores o canales) generalmente no son transferibles.

  • Mezclar ejecutores en la misma aplicación puede llevar a problemas de rendimiento o comportamientos inesperados.

La mejor práctica es elegir un único ejecutor para toda tu aplicación siempre que sea posible.

Resumen

Los ejecutores son el motor que impulsa la asincronía en Rust, proporcionando el entorno necesario para que los futures se ejecuten eficientemente. Aunque Rust no incluye un ejecutor estándar, el ecosistema ofrece varias implementaciones maduras que satisfacen diferentes necesidades.

Al trabajar con asincronía en Rust, es importante entender que los futures y los ejecutores son conceptos separados pero complementarios: los futures describen "qué" trabajo asíncrono debe realizarse, mientras que los ejecutores determinan "cómo" y "cuándo" se realiza ese trabajo.

En las próximas lecciones, exploraremos cómo la sintaxis async/await simplifica enormemente la escritura de código asíncrono, ocultando gran parte de la complejidad de los futures y ejecutores que hemos visto hasta ahora.

Aprende Rust online

Otras lecciones de Rust

Accede a todas las lecciones de Rust y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Accede GRATIS a Rust y certifícate

Ejercicios de programación de Rust

Evalúa tus conocimientos de esta lección Fundamentos asíncronos y futures con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.

En esta lección

Objetivos de aprendizaje de esta lección

  • Comprender el concepto de asincronía y su diferencia con la concurrencia basada en hilos.
  • Entender el modelo de ejecución asíncrona en Rust mediante suspensión y reanudación de tareas.
  • Conocer el trait Future, su método poll y el modelo de polling para avanzar tareas asíncronas.
  • Aprender cómo funcionan los ejecutores (runtimes) para gestionar y ejecutar futures.
  • Identificar las ventajas, desafíos y casos de uso adecuados para la programación asíncrona en Rust.