Usando Threads para Ejecutar Código Simultáneamente

En la mayoría de los sistemas operativos actuales, el código de un programa ejecutado se ejecuta en un proceso, y el sistema operativo administrará múltiples procesos a la vez. Dentro de un programa, también puede tener partes independientes que se ejecutan simultáneamente. Las características que ejecutan estas partes independientes se llaman threads. Por ejemplo, un servidor web podría tener múltiples hilos para que pudiera responder a más de una solicitud al mismo tiempo.

Dividir la computación en su programa en múltiples hilos para ejecutar múltiples tareas al mismo tiempo puede mejorar el rendimiento, pero también agrega complejidad. Debido a que los hilos pueden ejecutarse simultáneamente, no hay ninguna garantía inherente sobre el orden en que las partes de su código en diferentes hilos se ejecutarán. Esto puede conducir a problemas, como:

  • Race conditions, donde los hilos están accediendo a datos o recursos en un orden inconsistente
  • Deadlocks, donde dos hilos están esperando el uno al otro, evitando que ambos hilos continúen
  • Bugs que ocurren solo en ciertas situaciones y son difíciles de reproducir y arreglar de manera confiable

Rust intenta mitigar los efectos negativos de usar hilos, pero la programación en un contexto multihilo aún requiere un pensamiento cuidadoso y requiere una estructura de código que sea diferente de la de los programas que se ejecutan en un solo hilo.

Los lenguajes de programación implementan hilos de varias maneras diferentes, y muchos sistemas operativos proporcionan una API que el lenguaje puede llamar para crear nuevos hilos. La biblioteca estándar de Rust utiliza un modelo 1:1 de implementación de hilos, mediante el cual un programa utiliza un hilo del sistema operativo por un hilo de lenguaje. Hay crates que implementan otros modelos de enhebrado que hacen diferentes compensaciones al modelo 1:1.

Creando un Nuevo Hilo con spawn

Para crear un nuevo hilo, llamamos a la función thread::spawn y pasamos un closure (hablamos sobre closures en el Capítulo 13) que contiene el código que queremos ejecutar en el nuevo hilo. El ejemplo en el Listado 16-1 imprime algunos textos desde un hilo principal y otros textos desde un nuevo hilo:

Filename: src/main.rs

use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

Listing 16-1: Creando un nuevo hilo para imprimir una cosa mientras el hilo principal imprime algo más

Nota que cuando el hilo principal de un programa Rust se completa, todos los hilos creados se apagan, independientemente de si han terminado de ejecutarse o no. La salida de este programa podría ser un poco diferente cada vez, pero se verá similar a lo siguiente:

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

Las llamadas a thread::sleep fuerzan a un hilo a detener su ejecución durante una corta duración, permitiendo que se ejecute un hilo diferente. Los hilos probablemente se turnarán, pero eso no está garantizado: depende de cómo su sistema operativo programe los hilos. En esta ejecución, el hilo principal imprimió primero, a pesar de que la instrucción de impresión del hilo creado aparece primero en el código. Y aunque le dijimos al hilo creado que imprimiera hasta que i sea 9, solo llegó a 5 antes de que el hilo principal se apagara.

Si ejecutas este código y solo ves el output del hilo principal, o no ves ninguna superposición, intenta aumentar los números en los rangos para crear más oportunidades para que el sistema operativo cambie entre los hilos.

Esperando a que todos los hilos terminen usando join Handles

El código en el Listado 16-1 no solo detiene el hilo creado prematuramente la mayoría de las veces debido a que el hilo principal termina, sino que debido a que no hay garantía sobre el orden en que se ejecutan los hilos, ¡tampoco podemos garantizar que el hilo creado se ejecute en absoluto!

Podemos solucionar el problema de que el hilo creado no se ejecute o termine prematuramente guardando el valor de retorno de thread::spawn en una variable. El tipo de retorno de thread::spawn es JoinHandle. Un JoinHandle es un valor de propiedad que, cuando llamamos al método join en él, esperará a que su hilo termine. El Listado 16-2 muestra cómo usar el JoinHandle del hilo que creamos en el Listado 16-1 y llamar a join para asegurarnos de que el hilo creado termine antes de que main salga:

Filename: src/main.rs

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

Listing 16-2: Guardando un JoinHandle devuelto por thread::spawn para garantizar que el hilo se ejecute hasta completarse

Llamar a join en el handle bloquea el hilo que está actualmente en ejecución hasta que el hilo representado por el handle termine. Bloquear un hilo significa que ese hilo se impide realizar un trabajo o salir. Debido a que hemos puesto la llamada a join después del bucle for del hilo principal, ejecutar el Listado 16-2 debería producir una salida similar a esta:

hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

Los dos hilos continúan alternándose, pero el hilo principal espera debido a la llamada a handle.join() y no termina hasta que el hilo creado haya terminado.

Pero veamos que sucede cuando movemos la llamada a handle.join() antes del bucle for en main, como esto:

Filename: src/main.rs

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

El hilo principal ahora espera a que el hilo creado termine antes de comenzar su bucle for, para que el output no se intercale más. La salida ahora se verá así:

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

Pequeños detalles, como dónde se llama a join, pueden afectar si sus hilos se ejecutan al mismo tiempo.

Usando move Closures con Threads

A menudo usamos la keyword move con closures pasadas a thread::spawn porque el closure tomará posesión de los valores que usa del entorno, transfiriendo así el ownership de esos valores de un hilo a otro. En la sección "Capturando referencias o moviendo la propiedad" del Capítulo 13, discutimos move en el contexto de las closures. Ahora, nos concentraremos más en la interacción entre move y thread::spawn.

Observa en el Listado 16-1 que el closure que pasamos a thread::spawn no tiene argumentos: no estamos usando ningún dato del hilo principal en el código del hilo creado. Para usar datos del hilo principal en el hilo creado, el closure del hilo creado debe capturar los valores que necesita. El Listado 16-3 muestra un intento de crear un vector en el hilo principal y usarlo en el hilo creado. Sin embargo, esto aún no funcionará, como verás en un momento.

Filename: src/main.rs

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}

Listing 16-3: Intentando usar un vector creado por el hilo principal en otro hilo

El closure usa v, por lo que capturará v y lo hará parte del entorno del closure. Debido a que thread::spawn ejecuta este closure en un nuevo hilo, deberíamos poder acceder a v dentro de ese nuevo hilo. Pero cuando compilamos este ejemplo, obtenemos el siguiente error:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {v:?}");
  |                                     - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Here's a vector: {v:?}");
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Rust infiere cómo capturar v, y porque println! solo necesita una referencia a v, el closure intenta pedir prestado v. Sin embargo, hay un problema: Rust no puede decir cuánto tiempo se ejecutará el hilo creado, por lo que no sabe si la referencia a v siempre será válida.

El Listado 16-4 proporciona un escenario que es más probable que tenga una referencia a v que no sea válida:

Filename: src/main.rs

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    drop(v); // oh no!

    handle.join().unwrap();
}

Listing 16-4: Un hilo con un closure que intenta capturar una referencia a v desde un hilo principal que deja de tener v

Si Rust nos permitiera ejecutar este código, existe la posibilidad de que el hilo creado se ponga inmediatamente en segundo plano sin ejecutarse en absoluto. El hilo creado tiene una referencia a v dentro, pero el hilo principal inmediatamente deja caer v, usando la función drop que discutimos en el Capítulo 15. Luego, cuando el hilo creado comienza a ejecutarse, v ya no es válido, por lo que una referencia a él también es inválida. ¡Oh no!

Para solucionar el error en el Listado 16-3, podemos seguir el consejo del mensaje de error:

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

Al agregar la keyword move antes del closure, forzamos al closure a tomar ownership de los valores que está usando en lugar de permitir que Rust infiera que debería pedir prestado los valores. La modificación al Listado 16-3 que se muestra en el Listado 16-5 se compilará y ejecutará como lo pretendemos:

Filename: src/main.rs

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}

Listing 16-5: Usando la keyword move para forzar a un closure a tomar ownership de los valores que utiliza

Podríamos sentir la tentación de intentar lo mismo para arreglar el código en el Listado 16-4 donde el hilo principal llamó a drop usando un closure move. Sin embargo, esta solución no funcionará porque lo que el Listado 16-4 está intentando hacer está prohibido por una razón diferente. Si agregáramos move al closure, moveríamos v al entorno del closure, y ya no podríamos llamar a drop en el hilo principal. En su lugar, obtendríamos este error del compilador:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
4  |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5  |
6  |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
7  |         println!("Here's a vector: {v:?}");
   |                                     - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Las reglas de ownership de Rust nos han salvado de nuevo! Obtenemos un error del código en el Listado 16-3 porque Rust es conservador y solo pide prestado v para el hilo, lo que significa que el hilo principal podría teóricamente invalidar la referencia del hilo creado. Al decirle a Rust que mueva la propiedad de v al hilo creado, le garantizamos a Rust que el hilo principal no usará v nunca más. Si cambiamos el Listado 16-4 de la misma manera, entonces estamos violando las reglas de ownership cuando intentamos usar v en el hilo principal. La keyword move anula la conservadora predeterminada de Rust de pedir prestado; no nos permite violar las reglas de ownership.

Con una comprensión básica de los hilos y la API de hilos, veamos qué podemos hacer con los hilos.