Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Concorrência com Estado Compartilhado

A passagem de mensagens é uma ótima maneira de lidar com concorrência, mas não é a única. Outro método seria permitir que várias threads acessassem os mesmos dados compartilhados. Considere novamente esta parte do slogan da documentação da linguagem Go: “Não se comunique compartilhando memória.”

Como seria a comunicação por meio do compartilhamento de memória? Além disso, por que entusiastas da passagem de mensagens alertariam contra o uso de memória compartilhada?

De certa forma, canais em qualquer linguagem de programação são semelhantes ao ownership único, porque, depois que você transfere um valor por um canal, não deve mais usar esse valor. Concorrência com memória compartilhada é como ownership múltiplo: várias threads podem acessar o mesmo local de memória ao mesmo tempo. Como você viu no Capítulo 15, em que smart pointers tornaram possível o ownership múltiplo, o ownership múltiplo pode adicionar complexidade, porque esses diferentes owners precisam ser gerenciados. O sistema de tipos e as regras de ownership de Rust ajudam muito a fazer esse gerenciamento corretamente. Como exemplo, vejamos mutexes, uma das primitivas de concorrência mais comuns para memória compartilhada.

Controlando Acesso com Mutexes

Mutex é uma abreviação de mutual exclusion (exclusão mútua), no sentido de que um mutex permite que apenas uma thread acesse determinados dados em um dado momento. Para acessar os dados em um mutex, uma thread deve primeiro sinalizar que quer acesso solicitando a aquisição do lock do mutex. O lock é uma estrutura de dados que faz parte do mutex e mantém registro de quem tem acesso exclusivo aos dados naquele momento. Portanto, dizemos que o mutex protege os dados que contém por meio do sistema de locking.

Mutexes têm a reputação de serem difíceis de usar porque você precisa se lembrar de duas regras:

  1. Você deve tentar adquirir o lock antes de usar os dados.
  2. Quando terminar de usar os dados protegidos pelo mutex, você deve liberar o lock para que outras threads possam adquiri-lo.

Como metáfora do mundo real para um mutex, imagine uma mesa-redonda em uma conferência com apenas um microfone. Antes de uma pessoa no painel poder falar, ela precisa pedir ou sinalizar que quer usar o microfone. Quando recebe o microfone, pode falar pelo tempo que quiser e depois entregá-lo à próxima pessoa que solicitar a palavra. Se alguém se esquecer de entregar o microfone quando terminar, ninguém mais conseguirá falar. Se o gerenciamento do microfone compartilhado der errado, o painel não funcionará como planejado!

Gerenciar mutexes corretamente pode ser incrivelmente complicado, e é por isso que tantas pessoas se animam com canais. No entanto, graças ao sistema de tipos e às regras de ownership de Rust, você não consegue errar ao adquirir e liberar locks.

A API de Mutex<T>

Como exemplo de como usar um mutex, vamos começar usando um mutex em um contexto single-threaded, como mostrado na Listagem 16-12.

Filename: src/main.rs
use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {m:?}");
}
Listing 16-12: Explorando a API de Mutex<T> em um contexto single-threaded, por simplicidade

Como acontece com muitos tipos, criamos um Mutex<T> usando a função associada new. Para acessar os dados dentro do mutex, usamos o método lock para adquirir o lock. Essa chamada bloqueará a thread atual para que ela não possa fazer nenhum trabalho até chegar nossa vez de ter o lock.

A chamada a lock falharia se outra thread que estava segurando o lock tivesse entrado em pânico. Nesse caso, ninguém jamais conseguiria obter o lock, então escolhemos chamar unwrap e fazer esta thread entrar em pânico se estivermos nessa situação.

Depois de adquirir o lock, podemos tratar o valor de retorno, chamado num neste caso, como uma referência mutável aos dados internos. O sistema de tipos garante que adquirimos um lock antes de usar o valor em m. O tipo de m é Mutex<i32>, não i32, então precisamos chamar lock para poder usar o valor i32. Não podemos esquecer; caso contrário, o sistema de tipos não nos deixará acessar o i32 interno.

A chamada a lock retorna um tipo chamado MutexGuard, envolvido em um LockResult que tratamos com a chamada a unwrap. O tipo MutexGuard implementa Deref para apontar para nossos dados internos; esse tipo também tem uma implementação de Drop que libera o lock automaticamente quando um MutexGuard sai de escopo, o que acontece no fim do escopo interno. Como resultado, não corremos o risco de esquecer de liberar o lock e impedir que o mutex seja usado por outras threads, porque a liberação do lock acontece automaticamente.

Depois de liberar o lock, podemos imprimir o valor do mutex e ver que conseguimos alterar o i32 interno para 6.

Acesso Compartilhado a Mutex<T>

Agora vamos tentar compartilhar um valor entre várias threads usando Mutex<T>. Vamos iniciar 10 threads e fazer cada uma incrementar um contador em 1, de modo que o contador vá de 0 a 10. O exemplo da Listagem 16-13 terá um erro de compilação, e usaremos esse erro para aprender mais sobre como usar Mutex<T> e como Rust nos ajuda a usá-lo corretamente.

Filename: src/main.rs
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

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

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-13: Dez threads, cada uma incrementando um contador protegido por um Mutex<T>

Criamos uma variável counter para armazenar um i32 dentro de um Mutex<T>, como fizemos na Listagem 16-12. Em seguida, criamos 10 threads iterando sobre um intervalo de números. Usamos thread::spawn e damos a todas as threads a mesma closure: uma closure que move o contador para dentro da thread, adquire um lock no Mutex<T> chamando o método lock e então adiciona 1 ao valor dentro do mutex. Quando uma thread termina de executar sua closure, num sai de escopo e libera o lock para que outra thread possa adquiri-lo.

Na thread principal, coletamos todos os join handles. Então, como fizemos na Listagem 16-2, chamamos join em cada handle para garantir que todas as threads terminem. Nesse ponto, a thread principal adquirirá o lock e imprimirá o resultado deste programa.

Indicamos que este exemplo não compilaria. Agora vamos descobrir por quê!

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: borrow of moved value: `counter`
  --> src/main.rs:21:29
   |
 5 |     let counter = Mutex::new(0);
   |         ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
 8 |     for _ in 0..10 {
   |     -------------- inside of this loop
 9 |         let handle = thread::spawn(move || {
   |                                    ------- value moved into closure here, in previous iteration of loop
...
21 |     println!("Result: {}", *counter.lock().unwrap());
   |                             ^^^^^^^ value borrowed here after move
   |
help: consider moving the expression out of the loop so it is only moved once
   |
 8 ~     let mut value = counter.lock();
 9 ~     for _ in 0..10 {
10 |         let handle = thread::spawn(move || {
11 ~             let mut num = value.unwrap();
   |

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

A mensagem de erro afirma que o valor counter foi movido na iteração anterior do loop. Rust está nos dizendo que não podemos mover o ownership do lock counter para várias threads. Vamos corrigir o erro do compilador com o método de ownership múltiplo que discutimos no Capítulo 15.

Ownership Múltiplo com Múltiplas Threads

No Capítulo 15, demos um valor a vários owners usando o smart pointer Rc<T> para criar um valor com contagem de referências. Vamos fazer o mesmo aqui e ver o que acontece. Envolveremos o Mutex<T> em Rc<T> na Listagem 16-14 e clonaremos o Rc<T> antes de mover o ownership para a thread.

Filename: src/main.rs
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

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

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-14: Tentando usar Rc<T> para permitir que múltiplas threads tenham ownership do Mutex<T>

Mais uma vez, compilamos e obtemos… erros diferentes! O compilador está nos ensinando bastante:

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
  --> src/main.rs:11:36
   |
11 |           let handle = thread::spawn(move || {
   |                        ------------- ^------
   |                        |             |
   |  ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
   | |                      |
   | |                      required by a bound introduced by this call
12 | |             let mut num = counter.lock().unwrap();
13 | |
14 | |             *num += 1;
15 | |         });
   | |_________^ `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
   |
   = help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>`
note: required because it's used within this closure
  --> src/main.rs:11:36
   |
11 |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^
note: required by a bound in `spawn`
  --> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/std/src/thread/mod.rs:723:1

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

Uau, essa mensagem de erro é bem verbosa! Aqui está a parte importante em que devemos focar: `Rc<Mutex<i32>>` cannot be sent between threads safely. O compilador também nos diz o motivo: the trait `Send` is not implemented for `Rc<Mutex<i32>>`. Falaremos sobre Send na próxima seção: essa é uma das traits que garante que os tipos que usamos com threads sejam adequados para uso em situações concorrentes.

Infelizmente, Rc<T> não é seguro para ser compartilhado entre threads. Quando Rc<T> gerencia a contagem de referências, ele incrementa a contagem a cada chamada a clone e decrementa a contagem quando cada clone é descartado. Mas ele não usa nenhuma primitiva de concorrência para garantir que alterações na contagem não possam ser interrompidas por outra thread. Isso poderia levar a contagens incorretas, bugs sutis que por sua vez poderiam causar vazamentos de memória ou fazer um valor ser descartado antes de terminarmos de usá-lo. O que precisamos é de um tipo exatamente como Rc<T>, mas que faça alterações na contagem de referências de uma forma thread-safe.

Contagem de Referências Atômica com Arc<T>

Felizmente, Arc<T> é um tipo como Rc<T> que é seguro para uso em situações concorrentes. O a significa atomic, ou seja, é um tipo com contagem de referências atômica. Atômicos são outro tipo de primitiva de concorrência que não abordaremos em detalhes aqui: consulte a documentação da biblioteca padrão para std::sync::atomic para mais detalhes. Neste ponto, você só precisa saber que atômicos funcionam como tipos primitivos, mas são seguros para compartilhar entre threads.

Você poderia se perguntar então por que todos os tipos primitivos não são atômicos e por que os tipos da biblioteca padrão não são implementados para usar Arc<T> por padrão. A razão é que thread safety vem com uma penalidade de desempenho que você só quer pagar quando realmente precisa. Se você está apenas realizando operações em valores dentro de uma única thread, seu código pode rodar mais rápido se não precisar impor as garantias que atômicos fornecem.

Vamos voltar ao nosso exemplo: Arc<T> e Rc<T> têm a mesma API, então corrigimos nosso programa alterando a linha use, a chamada a new e a chamada a clone. O código da Listagem 16-15 finalmente compilará e rodará.

Filename: src/main.rs
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

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

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-15: Usando Arc<T> para envolver o Mutex<T> e compartilhar ownership entre múltiplas threads

Esse código imprimirá o seguinte:

Result: 10

Conseguimos! Contamos de 0 a 10, o que pode não parecer muito impressionante, mas nos ensinou bastante sobre Mutex<T> e thread safety. Você também poderia usar a estrutura desse programa para fazer operações mais complicadas do que apenas incrementar um contador. Usando essa estratégia, você pode dividir um cálculo em partes independentes, distribuir essas partes entre threads e então usar um Mutex<T> para fazer cada thread atualizar o resultado final com sua parte.

Observe que, se você estiver fazendo operações numéricas simples, existem tipos mais simples que Mutex<T> fornecidos pelo módulo std::sync::atomic da biblioteca padrão. Esses tipos fornecem acesso atômico, concorrente e seguro a tipos primitivos. Escolhemos usar Mutex<T> com um tipo primitivo neste exemplo para que pudéssemos nos concentrar em como Mutex<T> funciona.

Comparando RefCell<T>/Rc<T> e Mutex<T>/Arc<T>

Você deve ter notado que counter é imutável, mas que conseguimos obter uma referência mutável ao valor dentro dele; isso significa que Mutex<T> fornece mutabilidade interior, assim como a família Cell. Da mesma forma que usamos RefCell<T> no Capítulo 15 para permitir mutar o conteúdo dentro de um Rc<T>, usamos Mutex<T> para mutar o conteúdo dentro de um Arc<T>.

Outro detalhe a observar é que Rust não consegue proteger você de todos os tipos de erro de lógica ao usar Mutex<T>. Lembre-se do Capítulo 15: usar Rc<T> vinha com o risco de criar ciclos de referência, em que dois valores Rc<T> apontam um para o outro, causando vazamentos de memória. De modo semelhante, Mutex<T> vem com o risco de criar deadlocks. Eles ocorrem quando uma operação precisa travar dois recursos e duas threads adquiriram, cada uma, um dos locks, fazendo com que esperem uma pela outra para sempre. Se você tiver interesse em deadlocks, tente criar um programa Rust que tenha um deadlock; depois, pesquise estratégias de mitigação de deadlocks para mutexes em qualquer linguagem e tente implementá-las em Rust. A documentação da API da biblioteca padrão para Mutex<T> e MutexGuard oferece informações úteis.

Concluiremos este capítulo falando sobre as traits Send e Sync e como podemos usá-las com tipos personalizados.