Múltiples hilos

Para ejecutar diferentes tareas al mismo tiempo, se usan los hilos (threads). Los ordenadores modernos suelen tener más de un núcleo de proceso por lo que pueden ejecutar más de una cosa a la vez. Rust permite aprovechar esto. Para ello, Rust utiliza hilos, llamados hilos de sistema operativo. Esto significa que el sistema operativo crea este hilo y lo asigna a un núcleo de proceso. Otros lenguajes de programación utilizan hilos verdes (green threads) que son menos potentes.

Se pueden crear hilos con std::thread::spawn al que se le pasa un cierre para indicarle qué tiene que hacer. Los hilos son interesantes porque se ejecutan a la vez. Se puede comprobar con el siguiente ejemplo.

fn main() {
    std::thread::spawn(|| {
        println!("I am printing something");
    });
}

Si se ejecuta este código, en ocasiones se imprimirá algo y otras veces no. Dependerá también de la velocidad del ordenador en que se ejecute. Esto sucede porque main() se ejecuta en el hilo principal del programa y el cierre en un hilo secundario. Cuando el hilo principal, main(), finaliza, el programa se para.

Para verlo mejor, un bucle for resulta más práctico:

fn main() {
    for _ in 0..10 { // lanzará 10 hilos
        std::thread::spawn(|| {
            println!("I am printing something");
        });
    }   // Se inicia un hilo.
}       // ¿Cuántos hilos pueden terminar antes de que main() finalice aquí?

Variará en cada caso, unas veces 1, otras 4, otras 5. Si el ordenador es muy rápido, podría no llegarse a imprimir nada. A veces, pdoría darse este error:

#![allow(unused)]
fn main() {
thread 'thread 'I am printing something
thread '<unnamed><unnamed>thread '' panicked at '<unnamed>I am printing something
' panicked at 'thread '<unnamed>cannot access stdout during shutdown' panicked at '<unnamed>thread 'cannot access stdout during
shutdown
}

Que sucede cuando el hilo intenta ejecutar algo mientras el programa está finalizando.

Se le puede pedir al hilo principal (el que está ejecutando la función main()) que ejecute algo que lo entretenga mientras se ejecutan los hilos:

fn main() {
    for _ in 0..10 {
        std::thread::spawn(|| {
            println!("I am printing something");
        });
    }
    for _ in 0..1_000_000 { // declarar "let x = 9" un millón de veces
                            // Tiene que hacerlo antes de poder acabar la función main()
        let _x = 9;
    }
}

Pero este código anterior es una mala práctica. Para dar tiempo a acabar a los hilos, lo que se debe hacer es conservarlas en una variable. Si se añade let se asigna un valor de tipo JoinHandle. Esto se ve claramente en la definición de la función spawn:

#![allow(unused)]
fn main() {
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T,
    F: Send + 'static,
    T: Send + 'static,
}

f es el cierre que se ejecuta por este hilo. JoinHandle es el tipo de retorno.

Ahora se puede escribir:

fn main() {
    for i in 0..10 {
        let handle = std::thread::spawn(|| {
            println!("Hilo, imprimo algo");
        });
        println!("Hilo {} creado.", i);
    }
}

handle es de tipo JoinHandle. ¿Qué se hace con él? Se puede usar el método .join() que hace que el hilo en el que se ejecute (el principal), se pare para esperar a que este hilo haya terminado. Así:

fn main() {
    for i in 0..10 {
        let handle = std::thread::spawn(|| {
            println!("Hilo, imprimo algo");
        });
        println!("Hilo {} creado.", i);
        handle.join(); // Espera a que acabe este hilo 
    }
}

El código anterior no es muy correcto ya que su orden de ejecución es: crea un hilo, espera a que termine, luego crea otro, etc. El resultado siempre es:

Hilo 0 creado.
Hilo, imprimo algo
Hilo 1 creado.
Hilo, imprimo algo
Hilo 2 creado.
Hilo, imprimo algo
Hilo 3 creado.
Hilo, imprimo algo
Hilo 4 creado.
Hilo, imprimo algo
Hilo 5 creado.
Hilo, imprimo algo
Hilo 6 creado.
Hilo, imprimo algo
Hilo 7 creado.
Hilo, imprimo algo
Hilo 8 creado.
Hilo, imprimo algo
Hilo 9 creado.
Hilo, imprimo algo

Con lo que no se aprovecha para paralelizar todo lo que sea posible por los nucleos de proceso que tenga el ordenador. Lo correcto sería lanzar todos los hilos y luego esperar a que acaben todos, así:

fn main() {
    let mut handles = Vec::new();

    for i in 0..10 {
        handles.push(std::thread::spawn(|| {
            println!("Hilo, imprimo algo");
        }));
        println!("Hilo {} creado.", i);
    }

    for handle in handles {
        handle.join();
    }
}

De esta forma, se crean los hilos y la ejecución puede producirse en diversos ordenes. Pero de forma simultánea puede haber hasta un máximo de 10 hilos.

A continuación se explican los tres tipos de cierres que existen:

  • FnOnce: que toma el cierre completo.
  • FnMut: que toma una referencia modificable.
  • Fn: que toma una referencia.

Un cierre intentará usar Fn, si es posible. Pero si necesita modificar algún valor utilizará FnMut. Y si necesita apropiarse del valor, usará FnOnce. Este último es un buen nombre, porque explica lo que hace: tomar el valor una vez y luego ya no puede volver a usarlo.

A continuación se observa un ejemplo:

fn main() {
    let my_string = String::from("I will go into the closure");
    let my_closure = || println!("{}", my_string);
    my_closure();
    my_closure();
}

Que imprime:

I will go into the closure
I will go into the closure

String no es de tipo Copy, por lo que el cierre es Fn y Rust crea una referencia al valor.

Si se modificara el valor de la variable, el cierre pasaría a ser de tipo FnMut.

fn main() {
    let mut my_string = String::from("I will go into the closure");
    let mut my_closure = || {
        my_string.push_str(" now");
        println!("{}", my_string);
    };
    my_closure();
    my_closure();
}

Que imprime:

I will go into the closure now
I will go into the closure now now

Si la variable se pasa por valor, entonces el cierre será FnOnce.

fn main() {
    let my_vec: Vec<i32> = vec![8, 9, 10];
    let my_closure = || {
        my_vec
            .into_iter() // into_iter toma la propiedad
            .map(|x| x as u8) // lo convierte en u8
            .map(|x| x * 2) // lo multiplica por 2
            .collect::<Vec<u8>>() // y lo guarda en un Vec
    };
    let new_vec = my_closure();
    println!("{:?}", new_vec);
}

En este último caso, solo se puede ejecutar una vez este cierre ya que my_vec se pasa por valor.

De vuelta a los hilos. Si se intenta usar un valor así:

fn main() {
    let mut my_string = String::from("¿Puede pasarlo a un hilo?");

    let handle = std::thread::spawn(|| {
        println!("{}", my_string); // ⚠️
    });

    handle.join();
}

El compilador dice que esto no es posible:

error[E0373]: closure may outlive the current function, but it borrows `my_string`, which is owned by the current function
 --> src/main.rs:4:37
  |
4 |     let handle = std::thread::spawn(|| {
  |                                     ^^ may outlive borrowed value `my_string`
5 |         println!("{}", my_string); // ⚠️
  |                        --------- `my_string` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:4:18
  |
4 |       let handle = std::thread::spawn(|| {
  |  __________________^
5 | |         println!("{}", my_string); // ⚠️
6 | |     });
  | |______^
help: to force the closure to take ownership of `my_string` (and any other referenced variables), use the `move` keyword
  |
4 |     let handle = std::thread::spawn(move || {
  |                                     ++++

Es un mensaje muy largo, pero explicativo: dice que es necesario usar la palabra move. El problema es que en el hilo principal la variable es mut y, por lo tanto, se puede modificar mientras los demás hilos tienen acceso a ella. Esto no es seguro.

Se puede intentar algo más que tampoco funciona:

fn main() {
    let mut my_string = String::from("Can I go inside the thread?");

    let handle = std::thread::spawn(|| {
        println!("{}", my_string); // now my_string is being used as a reference
    });

    std::mem::drop(my_string);  // ⚠️ We try to drop it here. But the thread still needs it.

    handle.join();
}

Lo correcto, para poder usarlo, es pasar la variable con move para hacer al cierreo propietario de tipo FnOnce.

fn main() {
    let mut my_string = String::from("Can I go inside the thread?");

    let handle = std::thread::spawn(move|| {
        println!("{}", my_string);
    });

    std::mem::drop(my_string);  // ⚠️ No se puede hacer drop, ya que se ha transferido al hilo anterior.

    handle.join();
}

Si se quita el std::mem::drop funciona correctamente ya que el código es seguro:

fn main() {
    let my_string = String::from("Can I go inside the thread?");

    let handle = std::thread::spawn(move|| {
        println!("{}", my_string);
    });

    handle.join().unwrap();
}

Es necesario recordar: si se necesita pasar por valor un elemento, es necesario usar move.