Introdução
Bem-vindo à Concorrência de Estado Compartilhado (Shared-State Concurrency). Este laboratório faz parte do Livro do Rust. Você pode praticar suas habilidades em Rust no LabEx.
Neste laboratório, exploramos o conceito de concorrência de memória compartilhada e por que os entusiastas da passagem de mensagens alertam contra ela.
Concorrência de Estado Compartilhado (Shared-State Concurrency)
A passagem de mensagens é uma ótima maneira de lidar com a concorrência, mas não é a única. Outro método seria para múltiplos threads acessarem 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 os entusiastas da passagem de mensagens alertariam para não usar o compartilhamento de memória?
De certa forma, os canais em qualquer linguagem de programação são semelhantes à propriedade única, porque, uma vez que você transfere um valor por um canal, você não deve mais usar esse valor. A concorrência de memória compartilhada é como a propriedade múltipla: múltiplos threads podem acessar o mesmo local de memória ao mesmo tempo. Como você viu no Capítulo 15, onde os smart pointers tornaram possível a propriedade múltipla, a propriedade múltipla pode adicionar complexidade porque esses diferentes proprietários precisam ser gerenciados. O sistema de tipos e as regras de propriedade do Rust ajudam muito a obter esse gerenciamento corretamente. Como exemplo, vamos analisar os mutexes, uma das primitivas de concorrência mais comuns para memória compartilhada.
Usando Mutexes para Permitir Acesso aos Dados de Um Thread por Vez
Mutex é uma abreviação de exclusão mútua (mutual exclusion), pois um mutex permite que apenas um thread acesse alguns dados em um determinado momento. Para acessar os dados em um mutex, um thread deve primeiro sinalizar que deseja acesso, solicitando a aquisição do lock (bloqueio) do mutex. O bloqueio é uma estrutura de dados que faz parte do mutex e que acompanha quem atualmente tem acesso exclusivo aos dados. Portanto, o mutex é descrito como guardando os dados que ele contém por meio do sistema de bloqueio.
Mutexes têm a reputação de serem difíceis de usar porque você precisa se lembrar de duas regras:
- Você deve tentar adquirir o bloqueio antes de usar os dados.
- Quando você terminar com os dados que o mutex protege, você deve desbloquear os dados para que outros threads possam adquirir o bloqueio.
Para uma metáfora do mundo real para um mutex, imagine uma mesa redonda em uma conferência com apenas um microfone. Antes que um painelista possa falar, ele precisa pedir ou sinalizar que deseja usar o microfone. Quando ele recebe o microfone, ele pode falar o tempo que quiser e, em seguida, entregar o microfone ao próximo painelista que solicitar para falar. Se um painelista esquecer de entregar o microfone quando terminar com ele, ninguém mais poderá falar. Se o gerenciamento do microfone compartilhado der errado, a mesa redonda não funcionará como planejado!
O gerenciamento de mutexes pode ser incrivelmente complicado de acertar, e é por isso que tantas pessoas são entusiastas de canais. No entanto, graças ao sistema de tipos e às regras de propriedade do Rust, você não pode errar o bloqueio e o desbloqueio.
A API de Mutex<T>
Como exemplo de como usar um mutex, vamos começar usando um mutex em um contexto de single-threaded (única thread), conforme mostrado na Listagem 16-12.
Nome do arquivo: src/main.rs
use std::sync::Mutex;
fn main() {
1 let m = Mutex::new(5);
{
2 let mut num = m.lock().unwrap();
3 *num = 6;
4 }
5 println!("m = {:?}", m);
}
Listagem 16-12: Explorando a API de Mutex<T> em um contexto de single-threaded para simplificar
Como acontece com muitos tipos, criamos um Mutex<T> usando a função associada new [1]. Para acessar os dados dentro do mutex, usamos o método lock para adquirir o bloqueio [2]. Essa chamada bloqueará a thread atual para que ela não possa fazer nenhum trabalho até que seja nossa vez de ter o bloqueio.
A chamada para lock falharia se outra thread que detém o bloqueio entrasse em pânico. Nesse caso, ninguém seria capaz de obter o bloqueio, então escolhemos unwrap e fazemos com que esta thread entre em pânico se estivermos nessa situação.
Depois de adquirir o bloqueio, 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 bloqueio antes de usar o valor em m. O tipo de m é Mutex<i32>, não i32, então devemos chamar lock para poder usar o valor i32. Não podemos esquecer; o sistema de tipos não nos permitirá acessar o i32 interno de outra forma.
Como você pode suspeitar, Mutex<T> é um smart pointer (ponteiro inteligente). Mais precisamente, a chamada para lock retorna um smart pointer chamado MutexGuard, encapsulado em um LockResult que tratamos com a chamada para unwrap. O smart pointer MutexGuard implementa Deref para apontar para nossos dados internos; o smart pointer também possui uma implementação Drop que libera o bloqueio automaticamente quando um MutexGuard sai do escopo, o que acontece no final do escopo interno [4]. Como resultado, não corremos o risco de esquecer de liberar o bloqueio e bloquear o mutex de ser usado por outras threads porque a liberação do bloqueio acontece automaticamente.
Depois de descartar o bloqueio, podemos imprimir o valor do mutex e ver que fomos capazes de alterar o i32 interno para 6 [5].
Compartilhando um Mutex<T> Entre Múltiplas Threads
Agora, vamos tentar compartilhar um valor entre múltiplas threads usando Mutex<T>. Vamos iniciar 10 threads e fazer com que cada uma incremente um valor de contador em 1, para que o contador vá de 0 a 10. O exemplo na Listagem 16-13 terá um erro de compilação, e usaremos esse erro para aprender mais sobre como usar Mutex<T> e como o Rust nos ajuda a usá-lo corretamente.
Nome do arquivo: src/main.rs
use std::sync::Mutex;
use std::thread;
fn main() {
1 let counter = Mutex::new(0);
let mut handles = vec![];
2 for _ in 0..10 {
3 let handle = thread::spawn(move || {
4 let mut num = counter.lock().unwrap();
5 *num += 1;
});
6 handles.push(handle);
}
for handle in handles {
7 handle.join().unwrap();
}
8 println!("Result: {}", *counter.lock().unwrap());
}
Listagem 16-13: Dez threads, cada uma incrementando um contador guardado por um Mutex<T>
Criamos uma variável counter para armazenar um i32 dentro de um Mutex<T> [1], como fizemos na Listagem 16-12. Em seguida, criamos 10 threads iterando sobre uma faixa de números [2]. Usamos thread::spawn e damos a todas as threads a mesma closure: uma que move o contador para a thread [3], adquire um bloqueio no Mutex<T> chamando o método lock [4] e, em seguida, adiciona 1 ao valor no mutex [5]. Quando uma thread termina de executar sua closure, num sairá do escopo e liberará o bloqueio para que outra thread possa adquiri-lo.
Na thread principal, coletamos todos os join handles [6]. Então, como fizemos na Listagem 16-2, chamamos join em cada handle para garantir que todas as threads terminem [7]. Nesse ponto, a thread principal adquirirá o bloqueio e imprimirá o resultado deste programa [8].
Sugerimos que este exemplo não compilaria. Agora vamos descobrir o porquê!
error[E0382]: use of moved value: `counter`
--> src/main.rs:9:36
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `Mutex<i32>`, which
does not implement the `Copy` trait
...
9 | let handle = thread::spawn(move || {
| ^^^^^^^ value moved into closure here,
in previous iteration of loop
10 | let mut num = counter.lock().unwrap();
| ------- use occurs due to use in closure
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 a propriedade do bloqueio counter para múltiplas threads. Vamos corrigir o erro de compilação com o método de propriedade múltipla que discutimos no Capítulo 15.
Propriedade Múltipla com Múltiplas Threads
No Capítulo 15, demos um valor a múltiplos proprietários usando o smart pointer Rc<T> para criar um valor com contagem de referência. Vamos fazer o mesmo aqui e ver o que acontece. Vamos encapsular o Mutex<T> em Rc<T> na Listagem 16-14 e clonar o Rc<T> antes de mover a propriedade para a thread.
Nome do arquivo: 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());
}
Listagem 16-14: Tentando usar Rc<T> para permitir que múltiplas threads possuam o Mutex<T>
Mais uma vez, compilamos e obtemos... erros diferentes! O compilador está nos ensinando muito.
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely 1
--> src/main.rs:11:22
|
11 | let handle = thread::spawn(move || {
| ______________________^^^^^^^^^^^^^_-
| | |
| | `Rc<Mutex<i32>>` cannot be sent between threads
safely
12 | | let mut num = counter.lock().unwrap();
13 | |
14 | | *num += 1;
15 | | });
| |_________- within this `[closure@src/main.rs:11:36: 15:10]`
|
= help: within `[closure@src/main.rs:11:36: 15:10]`, the trait `Send` is not
implemented for `Rc<Mutex<i32>>` 2
= note: required because it appears within the type
`[closure@src/main.rs:11:36: 15:10]`
note: required by a bound in `spawn`
Uau, essa mensagem de erro é muito longa! Aqui está a parte importante para focar: Rc<Mutex<i32>>` não pode ser enviado entre *threads* com segurança` \[1]. O compilador também está nos dizendo o motivo: `a trait `Send` não é implementada para `Rc<Mutex<i32>> [2]. Falaremos sobre Send na próxima seção: é uma das traits que garante que os tipos que usamos com threads são destinados ao uso em situações concorrentes.
Infelizmente, Rc<T> não é seguro para compartilhar entre threads. Quando Rc<T> gerencia a contagem de referência, ele adiciona à contagem para cada chamada para clone e subtrai da contagem quando cada clone é descartado. Mas ele não usa nenhuma primitiva de concorrência para garantir que as alterações na contagem não possam ser interrompidas por outra thread. Isso pode levar a contagens erradas - bugs sutis que podem, por sua vez, levar a vazamentos de memória ou a um valor sendo descartado antes de terminarmos de usá-lo. O que precisamos é de um tipo exatamente como Rc<T>, mas um que faça alterações na contagem de referência de forma thread-safe.
Contagem de Referência Atômica com Arc<T>
Felizmente, Arc<T> é um tipo como Rc<T> que é seguro para usar em situações concorrentes. O a significa atômico (atomic), o que significa que é um tipo com contagem de referência atômica (atomically reference counted). Atômicos são um tipo adicional 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 os atômicos funcionam como tipos primitivos, mas são seguros para compartilhar entre threads.
Você pode então se perguntar por que nem todos os tipos primitivos 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 a segurança de thread vem com uma penalidade de desempenho que você só quer pagar quando realmente precisa. Se você estiver apenas realizando operações em valores dentro de uma única thread, seu código pode ser executado mais rápido se não precisar impor as garantias que os atômicos fornecem.
Vamos retornar ao nosso exemplo: Arc<T> e Rc<T> têm a mesma API, então corrigimos nosso programa alterando a linha use, a chamada para new e a chamada para clone. O código na Listagem 16-15 finalmente compilará e será executado.
Nome do arquivo: 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());
}
Listagem 16-15: Usando um Arc<T> para encapsular o Mutex<T> para poder compartilhar a propriedade entre múltiplas threads
Este código imprimirá o seguinte:
Result: 10
Conseguimos! Contamos de 0 a 10, o que pode não parecer muito impressionante, mas nos ensinou muito sobre Mutex<T> e segurança de thread. Você também pode usar a estrutura deste 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, dividir essas partes entre threads e, em seguida, usar um Mutex<T> para que cada thread atualize o resultado final com sua parte.
Observe que, se você estiver fazendo operações numéricas simples, existem tipos mais simples do que os tipos 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 para este exemplo para que pudéssemos nos concentrar em como Mutex<T> funciona.
Semelhanças entre RefCell<T>/Rc<T> e Mutex<T>/Arc<T>
Você pode ter notado que counter é imutável, mas pudemos obter uma referência mutável ao valor dentro dele; isso significa que Mutex<T> fornece mutabilidade interior, como a família Cell faz. Da mesma forma que usamos RefCell<T> no Capítulo 15 para nos 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 ser observado é que o Rust não pode protegê-lo de todos os tipos de erros lógicos quando você usa Mutex<T>. Recorde no Capítulo 15 que o uso de Rc<T> veio com o risco de criar ciclos de referência, onde dois valores Rc<T> se referem um ao outro, causando vazamentos de memória. Da mesma forma, Mutex<T> vem com o risco de criar deadlocks (impasse). Estes ocorrem quando uma operação precisa bloquear dois recursos e duas threads adquiriram cada uma das locks (bloqueios), fazendo com que esperem uma pela outra para sempre. Se você estiver interessado em deadlocks, tente criar um programa Rust que tenha um deadlock; então, 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.
Resumo
Parabéns! Você concluiu o laboratório de Concorrência de Estado Compartilhado. Você pode praticar mais laboratórios no LabEx para aprimorar suas habilidades.