Usando Threads para Executar Código Simultaneamente

Beginner

This tutorial is from open-source community. Access the source code

Introdução

Bem-vindo(a) a Usando Threads para Executar Código Simultaneamente. Este laboratório faz parte do Livro de Rust. Você pode praticar suas habilidades em Rust no LabEx.

Neste laboratório, exploraremos o conceito de threads em programação e como elas podem ser usadas para executar código simultaneamente, melhorando o desempenho, mas adicionando complexidade e potenciais problemas, como condições de corrida (race conditions), deadlocks e bugs difíceis de reproduzir.

Usando Threads para Executar Código Simultaneamente

Na maioria dos sistemas operacionais atuais, o código de um programa executado é executado em um processo, e o sistema operacional gerenciará múltiplos processos simultaneamente. Dentro de um programa, você também pode ter partes independentes que são executadas simultaneamente. Os recursos que executam essas partes independentes são chamados de threads (threads). Por exemplo, um servidor web pode ter múltiplas threads para que possa responder a mais de uma requisição ao mesmo tempo.

Dividir a computação em seu programa em múltiplas threads para executar múltiplas tarefas ao mesmo tempo pode melhorar o desempenho, mas também adiciona complexidade. Como as threads podem ser executadas simultaneamente, não há garantia inerente sobre a ordem em que as partes do seu código em diferentes threads serão executadas. Isso pode levar a problemas, como:

  • Condições de corrida (race conditions), onde as threads estão acessando dados ou recursos em uma ordem inconsistente
  • Deadlocks, onde duas threads estão esperando uma pela outra, impedindo que ambas as threads continuem
  • Bugs que acontecem apenas em certas situações e são difíceis de reproduzir e corrigir de forma confiável

Rust tenta mitigar os efeitos negativos do uso de threads, mas programar em um contexto multithreaded ainda exige pensamento cuidadoso e requer uma estrutura de código diferente daquela em programas executados em uma única thread.

Linguagens de programação implementam threads de algumas maneiras diferentes, e muitos sistemas operacionais fornecem uma API que a linguagem pode chamar para criar novas threads. A biblioteca padrão do Rust usa um modelo 1:1 de implementação de thread, pelo qual um programa usa uma thread do sistema operacional por cada thread da linguagem. Existem crates que implementam outros modelos de threading que fazem diferentes trade-offs em relação ao modelo 1:1.

Criando uma Nova Thread com spawn

Para criar uma nova thread, chamamos a função thread::spawn e passamos a ela um closure (falamos sobre closures no Capítulo 13) contendo o código que queremos executar na nova thread. O exemplo na Listagem 16-1 imprime algum texto de uma thread principal e outro texto de uma nova thread.

Nome do arquivo: 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));
    }
}

Listagem 16-1: Criando uma nova thread para imprimir uma coisa enquanto a thread principal imprime outra

Observe que quando a thread principal de um programa Rust é concluída, todas as threads geradas são encerradas, tenham ou não terminado a execução. A saída deste programa pode ser um pouco diferente a cada vez, mas será semelhante ao seguinte:

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!

As chamadas para thread::sleep forçam uma thread a interromper sua execução por uma curta duração, permitindo que uma thread diferente seja executada. As threads provavelmente se revezarão, mas isso não é garantido: depende de como seu sistema operacional agenda as threads. Nesta execução, a thread principal imprimiu primeiro, embora a instrução de impressão da thread gerada apareça primeiro no código. E, embora tenhamos dito à thread gerada para imprimir até que i seja 9, ela só chegou a 5 antes que a thread principal fosse encerrada.

Se você executar este código e vir apenas a saída da thread principal, ou não vir nenhuma sobreposição, tente aumentar os números nos intervalos para criar mais oportunidades para o sistema operacional alternar entre as threads.

Esperando Todas as Threads Terminarem Usando Join Handles

O código na Listagem 16-1 não apenas interrompe a thread gerada prematuramente na maioria das vezes devido ao término da thread principal, mas, como não há garantia sobre a ordem em que as threads são executadas, também não podemos garantir que a thread gerada será executada!

Podemos corrigir o problema da thread gerada não ser executada ou de terminar prematuramente salvando o valor de retorno de thread::spawn em uma variável. O tipo de retorno de thread::spawn é JoinHandle<T>. Um JoinHandle<T> é um valor próprio que, quando chamamos o método join nele, esperará que sua thread termine. A Listagem 16-2 mostra como usar o JoinHandle<T> da thread que criamos na Listagem 16-1 e chamar join para garantir que a thread gerada termine antes que main saia.

Nome do arquivo: 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();
}

Listagem 16-2: Salvando um JoinHandle<T> de thread::spawn para garantir que a thread seja executada até a conclusão

Chamar join no handle bloqueia a thread atualmente em execução até que a thread representada pelo handle termine. Bloquear (Blocking) uma thread significa que essa thread é impedida de realizar trabalho ou sair. Como colocamos a chamada para join após o loop for da thread principal, a execução da Listagem 16-2 deve produzir uma saída semelhante 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!

As duas threads continuam alternando, mas a thread principal espera por causa da chamada para handle.join() e não termina até que a thread gerada seja finalizada.

Mas vamos ver o que acontece quando, em vez disso, movemos handle.join() antes do loop for em main, assim:

Nome do arquivo: 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));
    }
}

A thread principal esperará que a thread gerada termine e, em seguida, executará seu loop for, portanto, a saída não será mais intercalada, como mostrado aqui:

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!

Pequenos detalhes, como onde join é chamado, podem afetar se suas threads são executadas ou não ao mesmo tempo.

Usando Closures move com Threads

Frequentemente usaremos a palavra-chave move com closures passados para thread::spawn porque o closure então assumirá a propriedade dos valores que usa do ambiente, transferindo assim a propriedade desses valores de uma thread para outra. Em "Capturando o Ambiente com Closures", discutimos move no contexto de closures. Agora, vamos nos concentrar mais na interação entre move e thread::spawn.

Observe na Listagem 16-1 que o closure que passamos para thread::spawn não recebe argumentos: não estamos usando nenhum dado da thread principal no código da thread gerada. Para usar dados da thread principal na thread gerada, o closure da thread gerada deve capturar os valores de que precisa. A Listagem 16-3 mostra uma tentativa de criar um vetor na thread principal e usá-lo na thread gerada. No entanto, isso ainda não funcionará, como você verá em um momento.

Nome do arquivo: 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();
}

Listagem 16-3: Tentando usar um vetor criado pela thread principal em outra thread

O closure usa v, então ele capturará v e o tornará parte do ambiente do closure. Como thread::spawn executa este closure em uma nova thread, devemos ser capazes de acessar v dentro dessa nova thread. Mas quando compilamos este exemplo, obtemos o seguinte erro:

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 || {
  |                                ++++

Rust infere como capturar v e, como println! só precisa de uma referência a v, o closure tenta emprestar v. No entanto, há um problema: Rust não pode dizer por quanto tempo a thread gerada será executada, então não sabe se a referência a v sempre será válida.

A Listagem 16-4 fornece um cenário que é mais provável de ter uma referência a v que não será válida.

Nome do arquivo: 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();
}

Listagem 16-4: Uma thread com um closure que tenta capturar uma referência a v de uma thread principal que descarta v

Se Rust nos permitisse executar este código, haveria a possibilidade de que a thread gerada fosse imediatamente colocada em segundo plano sem ser executada. A thread gerada tem uma referência a v dentro, mas a thread principal descarta imediatamente v, usando a função drop que discutimos no Capítulo 15. Então, quando a thread gerada começa a executar, v não é mais válido, então uma referência a ele também é inválida. Oh não!

Para corrigir o erro do compilador na Listagem 16-3, podemos usar o conselho da mensagem de erro:

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 || {
  |                                ++++

Adicionando a palavra-chave move antes do closure, forçamos o closure a assumir a propriedade dos valores que está usando, em vez de permitir que Rust infira que ele deve emprestar os valores. A modificação na Listagem 16-3 mostrada na Listagem 16-5 compilará e será executada como pretendemos.

Nome do arquivo: 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();
}

Listagem 16-5: Usando a palavra-chave move para forçar um closure a assumir a propriedade dos valores que usa

Podemos ser tentados a tentar a mesma coisa para corrigir o código na Listagem 16-4, onde a thread principal chamou drop, usando um closure move. No entanto, esta correção não funcionará porque o que a Listagem 16-4 está tentando fazer é proibido por um motivo diferente. Se adicionássemos move ao closure, moveríamos v para o ambiente do closure, e não poderíamos mais chamar drop nele na thread principal. Em vez disso, obteríamos este erro do compilador:

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

As regras de propriedade do Rust nos salvaram novamente! Recebemos um erro do código na Listagem 16-3 porque Rust estava sendo conservador e apenas emprestando v para a thread, o que significava que a thread principal poderia, teoricamente, invalidar a referência da thread gerada. Ao dizer a Rust para mover a propriedade de v para a thread gerada, estamos garantindo a Rust que a thread principal não usará mais v. Se mudarmos a Listagem 16-4 da mesma forma, estaremos violando as regras de propriedade quando tentarmos usar v na thread principal. A palavra-chave move substitui o padrão conservador de empréstimo do Rust; ela não nos permite violar as regras de propriedade.

Agora que cobrimos o que são threads e os métodos fornecidos pela API de threads, vamos analisar algumas situações em que podemos usar threads.

Resumo

Parabéns! Você concluiu o laboratório Usando Threads para Executar Código Simultaneamente. Você pode praticar mais laboratórios no LabEx para aprimorar suas habilidades.