Como testar corretamente estruturas de dados concorrentes
(matklad.github.io)- Usando como exemplo um contador concorrente quebrado em Rust, o texto revela problemas que testes comuns de carga com threads deixam passar por meio de controle de ordem de execução reproduzível e minimizável
- Um wrapper de
AtomicU32para testes inserepause(), e uma managed thread para antes e depois das operações atômicas, sendo retomada na ordem escolhida pelo teste - Um teste simples pode fazer
100threads incrementarem100vezes cada uma e produzir falhas como9598em vez do valor esperado10000, mas isso depende de timing, o que dificulta reproduzir, depurar e reduzir o caso - Um teste baseado em propriedades com
arbtestreproduz o mesmo interleaving com a mesma seed e minimiza o caso de falha até0: increment,1: increment,0: unpause,1: unpause - Expandindo a mesma estrutura com
exhaustigen, é possível enumerar todos os interleavings até5incrementos, e após corrigir comfetch_add,81133interleavings passam no teste
Contador concorrente não atômico
- O exemplo usa
AtomicU32do Rust, masincrement()fazloadseguido destore(value + 1), então a operação de incremento em si não é atômica - A estrutura
Counteré simplesvalue: AtomicU32increment()lê o valor comSeqCst, soma1ao valor lido e grava de voltaget()lê o valor atual comSeqCst
- Duas threads podem ler o mesmo valor e depois gravar o mesmo resultado incrementado, fazendo uma atualização se perder
Por que testes comuns com threads não bastam
- A verificação mais simples é fazer várias threads incrementarem repetidamente o mesmo contador e checar o valor final
thread_count = 100increment_count = 100- o valor esperado é
10000
- Um exemplo de execução falha com
left: 9598,right: 10000 - Esse método depende fortemente do timing de escalonamento
- é difícil reproduzir de forma determinística a mesma falha
- é difícil depurar
- se o número de threads ou de incrementos for reduzido, o teste pode passar por sorte, dificultando minimizar o caso de falha
Lidando com interleavings com testes baseados em propriedades
- Testes baseados em propriedades (PBT) combinam bem com testes de máquina de estados
- é fácil gerar entradas arbitrárias
- pode-se definir a propriedade de que o resultado da execução concorrente deve ser igual ao de um modelo sequencial
- isso também combina com a necessidade de minimizar entradas que falham
- A dificuldade é que não é fácil fazer threads reais do sistema operacional avançarem um passo por vez no momento desejado
- A solução é estruturar o teste para escolher uma thread arbitrária a cada iteração e avançá-la um passo
- é preciso conseguir inserir outra thread entre o
loade ostorede uma mesma thread - para isso, é criada uma API de managed thread que permite controlar diretamente as threads
- é preciso conseguir inserir outra thread entre o
AtomicU32 de teste e inserção de pause
- Em builds de teste, usa-se
managed_thread::AtomicU32em vez destd::sync::atomic::AtomicU32#[cfg(test)] use managed_thread::AtomicU32#[cfg(not(test))] use std::sync::atomic::AtomicU32
- O wrapper
AtomicU32chamapause()antes e depois deload()estore()load:pause()→loadreal →pause()store:pause()→storereal →pause()
- Graças a esses pontos de inserção, o teste pode parar e retomar threads ao redor das operações atômicas, controlando a ordem de execução
Formato da API de managed thread
- O teste cria duas managed threads dentro de
std::thread::scope- como são scoped threads, elas podem pegar emprestados dados locais da stack
- o estado é passado como referência ao contador, como em
spawn(scope, &counter)
- A managed thread não executa uma função
mainespecífica desde o início; ela executa closures enviadas pela thread de controle viasubmit()t.submit(|c| c.increment())- a thread executa a closure sobre seu estado
T
- O loop de teste realiza ações aleatórias em cada thread enquanto ainda houver entropia
- se a thread estiver parada, chama
unpause() - se não estiver parada, executa
increment()viasubmit() - o modelo sequencial
counter_modeltambém é incrementado o mesmo número de vezes
- se a thread estiver parada, chama
- No final, todas as threads são finalizadas com
join()e compara-secounter_modelcom o valor real decounter.get()
Implementação de pause e unpause
pause()usathread_local!para encontrar o contexto da managed thread atual sem alterar a API deCounterem teste- o contexto é compartilhado como
Arc<SharedContext> SharedContextcontémMutex<State>eCondvar
- o contexto é compartilhado como
- Os estados são
Ready,RunningePausedReady: aguardando a próxima closureRunning: a managed thread está em execuçãoPaused: a execução está parada em um ponto depause()
- Quando a managed thread chega a
pause(), ela muda o estado deRunningparaPausede avisa a thread de controle pela condition variable unpause()muda o estado dePausedparaRunning, acorda a managed thread e depois espera até que o estado deixe de serRunning- isso impede que a thread de controle e a managed thread continuem executando ao mesmo tempo
- em qualquer momento, apenas uma das duas executa, reduzindo a não determinismo
Reprodução e minimização da falha
- A execução com
arbtestencontra uma falha no contador quebrado- no exemplo, o valor do modelo é
4e o valor real é3 - a seed da falha é
0x4fd7ddff00000020
- no exemplo, o valor do modelo é
- Ao informar a mesma seed, é possível obter novamente o mesmo interleaving, o que facilita reproduzir a falha
- Com
.minimize(), o caso de falha é reduzido a uma execução mais curta- a seed mínima final é
0x9c2a13a600000001 - o trace mínimo tem quatro passos
0: increment1: increment0: unpause1: unpause
- a seed mínima final é
- Nesse caso mínimo, o valor esperado é
2, mas o valor real é1, expondo o defeito do incremento baseado emload/store
Expansão para enumerar todos os interleavings
- A mesma estrutura pode ser adaptada para usar enumeração em vez de interleavings aleatórios
- Com
exhaustigen, é escrito um teste que explora todos os interleavings até5incrementos- o teste evita iterações inúteis e é estruturado para sempre fazer
unpauseou submeter umincrement
- o teste evita iterações inúteis e é estruturado para sempre fazer
- A implementação quebrada encontra o mesmo bug
- um exemplo de falha é
left: 2,right: 1
- um exemplo de falha é
- Ao corrigir
Counter::increment()parafetch_add(1, SeqCst), o teste passa- o wrapper
AtomicU32também recebepause()antes e depois defetch_add() - o resultado da execução é
all 81133 interleavings are fine! - o tempo de execução é
real 8.65s, CPU8.16se RSS63.91mb
- o wrapper
Expansão para modelo de memória fraca e model checking
- Na implementação de brinquedo atual,
AtomicU32delega para um atomic real - Uma ideia de expansão é fazer cada atomic manter o conjunto de valores já escritos e, ao ler, retornar um valor arbitrário consistente com um modelo de memória fraca
- A exploração de interleavings também pode ficar mais inteligente do que uma busca aleatória
- com uma abordagem de model checking, é possível verificar se todos os interleavings significativamente diferentes foram considerados
- como na abordagem de Generate All The Things, dá para enumerar todos os interleavings dentro de um pequeno limite
Por que é possível minimizar sem shrinking
- O
arbtestusado parece uma interface de PRNG familiar, mas usa um PRNG finito- se você continuar pedindo valores aleatórios, em algum momento ele retorna
Err(OutOfEntropy) - por isso o código de teste usa
?ewhile !rng.is_empty()
- se você continuar pedindo valores aleatórios, em algum momento ele retorna
- Quando o teste consome toda a entropia, ele termina mais cedo; assim, reduzir a entropia disponível também encurta a execução do teste
- A implementação interna é conceitualmente parecida com
&mut &[u8]- cada solicitação de número aleatório encurta o slice de bytes
- quanto menor o slice inicial, mais simples tende a ser o teste
- Graças a isso, os casos de falha podem ficar mais curtos sem precisar implementar manualmente uma lógica separada de shrinking
- O código-fonte do exemplo está em properly-concurrent
1 comentários
Opiniões no Hacker News
Estou criando uma biblioteca chamada Temper em Rust com uma abordagem parecida: https://github.com/reitzensteinm/temper/tree/main
Mas, para modelar as implicações estranhas criadas pelo modelo de memória completo do Rust, é preciso ir muito além: é necessário um livro-razão que rastreie quais escritas cada thread percebeu. Dependendo da ordem de memória atômica, fences de leitura/escrita etc., podem surgir garantias do tipo: se você percebeu a escrita X, então também precisa necessariamente perceber a escrita Y
Acredito que seja uma das coleções mais completas de casos de teste para os modelos de memória de C++/Rust, reunindo quase tudo o que pude encontrar em livros, no padrão C++, no Stack Overflow, em blogs etc. Por exemplo, o arquivo para Rust Atomics and Locks, da Mara Bos, está aqui: https://github.com/reitzensteinm/temper/blob/main/memlog/tes...
Loom, mencionado no texto, é uma biblioteca parecida, mas muito mais madura, que permite testar exaustivamente componentes de nível mais alto, como mutexes ou filas: https://github.com/tokio-rs/loom No entanto, ela não modela o próprio modelo de memória de forma tão detalhada quanto o Temper, e eu estava pensando em portar os casos de teste para o Loom
Fui inspirado por uma apresentação de Will Wilson sobre testes no FoundationDB; hoje ele está na Antithesis criando uma solução baseada em hipervisor que realiza esse tipo de teste em contêineres Docker arbitrários: https://www.youtube.com/watch?v=4fFDFbi3toc, https://antithesis.com/
Acredito firmemente que essa área vai crescer muito nos próximos 10 anos. WebAssembly está em um ponto ideal: completo o bastante para compilar software arbitrário, mas simples o suficiente para que criar algo como a Antithesis não precise ser um projeto de 5 anos para uma equipe de elite que já lançou um banco de dados
Implementei um snapshot atômico de memória compartilhada em Rust e levei os testes automatizados o mais a sério que consegui: https://github.com/kaymanb/todc/tree/main/todc-mem
No começo usei o Loom citado no texto, mas depois troquei para o shuttle: https://github.com/tokio-rs/loom, https://github.com/awslabs/shuttle
O shuttle usa uma abordagem randomizada em vez de exploração exaustiva como o Loom, mas o escalonador ainda oferece garantias probabilísticas para encontrar bugs. Na prática, achei o shuttle mais rápido e capaz de escalar para cenários de teste mais complexos
De forma parecida com a abordagem do texto, se uma determinada agenda de execução causar falha no teste, é possível salvar a semente aleatória. A capacidade de reproduzir rapidamente um teste com falha é muito importante e permite escrever casos de teste explícitos para bugs que já foram encontrados e corrigidos: https://github.com/kaymanb/todc/blob/0e2874a70ec8beed8fae773...
No lado Kotlin/Java, o Lincheck da JetBrains é uma boa biblioteca para esse tipo de trabalho: https://github.com/JetBrains/lincheck
Gosto especialmente do fato de ser declarativa e da forma como exibe os resultados de linearizabilidade
Fico curioso se existe alguma biblioteca tipo Loom para C++. Tenho algumas estruturas de dados lock-free que gostaria de testar
É uma ferramenta bem antiga e fácil de lidar. Foi criada por Dmitry Vyukov, especialista na área de concorrência
https://github.com/facebook/folly/blob/main/folly/test/Deter...
Se entendi corretamente, esta abordagem tem limitações em relação a garantias fracas de progresso
Embora o cálculo no texto não seja totalmente trivial, podemos pensar em um loop de
cmpxchgque, em hardware real e com um escalonador real, tem uma probabilidade extremamente baixa de ser interrompido em uma CPU específica. Se o número de CPUs forn, no pior caso a probabilidade de progredir é1/n, mas neste método de teste ela se torna1/t^p. Aqui,té o número de tarefas, que pode ser muito maior que o número de CPUs, epé o número de pausas dentro do corpo desse loop, que facilmente chega a 3 ou mais. Isso já é suficiente para fazer um algoritmo que funciona na prática parecer quebradoPor outro lado, mesmo quando se quer tratar progresso fraco como bug e exigir progresso forte, este método não parece oferecer uma ferramenta útil
Ainda assim, ele claramente é útil para muitos problemas de concorrência
1/t^pnão está certo; vejo isso simplesmente como1/t. Afinal, depois de um tempot, alguma tarefa certamente terá progredido e, se houverttarefas, a probabilidade de que tenha sido a minha é1/tA confusão principal parece estar no fato de que ser interrompido não significa necessariamente perder no CAS
Sobre o trecho “Para ser sincero, há um pouco de conhecimento prévio aqui. A menos que você faça algo muito amaldiçoado com assembly inline, não acho que seja possível evitar a criação de threads reais. Se algo chama a função
pause()e queremos fazê-lo parar até receber instruções futuras, isso precisa acontecer dentro de uma thread que mantenha uma pilha separada da pilha do teste”, fico curioso se não daria para usar algum tipo de runtime assíncronoParece que se está instrumentando operações atômicas para obter multitarefa cooperativa. Talvez eu precise tomar mais café, mas parece mais simples fazer isso sem threads
Uma desvantagem desta abordagem é que o próprio código em teste precisa ser modificado para se adequar ao código de teste
Parece que daria para fazer a mesma coisa iniciando duas threads e usando
ptracepara executá-las passo a passo, inserindo “aleatoriamente” a execução de instruções. Algo como o modo chaos do rrNo entanto, algumas instruções podem não ser atômicas; então, se é que isso é possível sem emulação, talvez fosse necessário executar passo a passo na unidade de “microcódigo atômico”
Para usar o Loom, parece que é necessária compilação condicional e, embora isso seja aceitável ao testar uma biblioteca, é bastante intrusivo
#[cfg(loom)]pub(crate) use loom::sync::atomic::AtomicUsize;#[cfg(not(loom))]pub(crate) use std::sync::atomic::AtomicUsize;Fico curioso se existe alguma linguagem que permita usar melhor um escalonador próprio
Se quisermos ser realmente minuciosos, parece que poderíamos executar o teste com
ptracee avançar as threads passo a passo para criar diferentes interleavings no nível de instruções. Fico curioso se alguém já viu isso na práticaEm casos como este, em que não é possível instrumentar o código, haveria uma alternativa para testes de caixa-preta?
ninstruções, são necessárias apenasnexecuções, rodando de 0 aténinstruções antes de inserir o sinal; depois disso, o manipulador de sinal roda até o fim e a thread principal também roda até o fim. O tempo total éO(n^2)Mas, se houver
tthreads, cada uma executandoninstruções, e todas puderem interromper umas às outras em cada fronteira, isso fica impraticável para valores realistas den. Acho que seria preciso reduzir o problema selecionando e simulando apenas as operações com comportamento interessanteParece bem interessante, então vou experimentar. Ainda assim, não deve capturar todos os tipos de erro. Cada chamada a
pause()cria sincronização entre as threads, então será que alguns problemas de corrida de dados não ficariam ocultos? Em Rust, talvez isso não seja um problema