Embora não exista uma panaceia para corrigir as condições de corrida, muitas podem ser corrigidas com as estratégias certas. Obtenha exemplos de duas categorias de condições de corrida e três maneiras de resolvê-las.
O que é uma condição de corrida?
Procurei uma boa definição de condição de corrida e esta é a melhor que encontrei:
Uma condição de corrida é um comportamento imprevisto causado por vários processos interagindo com recursos compartilhados em uma ordem diferente da esperada.
Isso é bastante complicado e ainda não está muito claro como as condições de corrida aparecem no Rails .
Usando Rails, estamos sempre trabalhando com múltiplos processos — cada solicitação ou trabalho em segundo plano é um processo individual que pode operar principalmente de forma independente de outros processos.
Também estamos sempre trabalhando com recursos compartilhados. A aplicação utiliza um banco de dados relacional? Esse é um recurso compartilhado. O aplicativo usa algum tipo de servidor de cache? Sim, esse é um recurso compartilhado. Você usa algum tipo de API externa? Você adivinhou: é um recurso compartilhado.
Há dois exemplos de categorias de condições de corrida sobre as quais eu gostaria de falar e, em seguida, abordar como abordá-las.
Ler-modificar-escrever
A categoria leitura-modificação-gravação é um tipo de condição de corrida em que um processo lê valores de um recurso compartilhado, modifica o valor na memória e, em seguida, tenta gravá-lo de volta no recurso compartilhado. Isso parece muito simples quando olhamos através das lentes de um único processo. Mas quando surge um segundo processo, pode resultar em algum comportamento inesperado.
Considere um código parecido com este:
class IdeasController < ActionController::Base def vote @idea = Idea.find(params[:id]) @idea.votes += 1 @idea.save! end end
Aqui estamos lendo ( Idea.find(params[:id]) ), modificando ( @idea.votes += 1 ) e depois escrevendo ( @idea.save! ).
Podemos ver que isso aumentaria em um o número de votos em uma ideia. Se houvesse uma ideia com zero votos, terminaria com um voto. No entanto, se uma segunda solicitação chegasse e lesse a ideia do banco de dados enquanto ainda tinha zero votos e incrementasse esse valor na memória, poderíamos ter uma situação em que dois votos chegassem simultaneamente - mas o resultado final é que o número de votos no banco de dados é apenas um.
Isso também é conhecido como condição de corrida de atualização perdida .
Verifique e aja
A categoria verificar e agir é um tipo de condição de corrida em que os dados são carregados de um recurso compartilhado e, dependendo do valor presente, determinamos se uma ação precisa ser executada.
Um dos exemplos clássicos de como isso aparece está na validação validates_uniqueness_of no Rails, assim:
class User < ActiveRecord::Base validates_uniqueness_of :email end
Com a validação implementada, o Rails irá verificar se existe algum usuário com aquele email. Se não houver outro, ele atuará persistindo o usuário no banco de dados. No entanto, o que aconteceria se uma segunda solicitação executasse o mesmo código ao mesmo tempo? Poderíamos acabar em uma situação em que ambas as solicitações verificam se há dados duplicados (e não há nenhum) — então ambas agirão salvando os dados, resultando em um usuário duplicado no banco de dados.
Lidando com condições de corrida
Não existe solução mágica para corrigir as condições de corrida, mas existem algumas estratégias que podem ser utilizadas para qualquer problema específico. Existem três categorias principais para remover condições de corrida:
1. Remova a seção crítica
Embora isso possa ser visto como uma exclusão do código incorreto, às vezes você pode refatorar o código para que ele não fique vulnerável a condições de corrida. Outras vezes, você pode examinar operações atômicas. Uma operação atômica é aquela em que nenhum outro processo pode interromper a operação, então você sabe que ela sempre será executada como uma única unidade.
Para o exemplo de leitura-modificação-gravação, em vez de incrementar os votos da ideia na memória, eles poderiam ser incrementados no banco de dados:
@ideas.increment!(:votes)
Isso executará um sql semelhante a este:
UPDATE "ideas" SET "votes" = COALESCE("votes", 0) + 1 WHERE "ideas"."id" = 123
Utilizar isso não estaria sujeito às mesmas condições de corrida.
Para o exemplo check-then-act, em vez de permitir que o Rails valide o modelo, poderíamos inserir o registro diretamente no banco de dados com um upsert:
Isso irá inserir o registro no banco de dados. Se houver um conflito no email (o que exigiria um índice exclusivo no email), ele simplesmente ignorará a inserção.
2. Detecte e recupere
Às vezes você não consegue remover a seção crítica. É possível que haja uma ação atômica, mas ela não funciona da maneira que o código exige. Nessas situações, você pode tentar uma abordagem de detecção e recuperação. Com esta abordagem, são configuradas salvaguardas que irão informá-lo se ocorrer uma condição de corrida. Você pode abortar normalmente ou tentar novamente a operação.
Para o exemplo de leitura-modificação-gravação, isso poderia ser feito com . O bloqueio otimista está embutido no Rails e pode permitir a detecção de quando vários processos estão operando no mesmo registro ao mesmo tempo. Para habilitar o bloqueio otimista, você só precisa adicionar uma coluna lock_version à sua tabela e o Rails irá habilitá-la automaticamente.
change_table :ideas do |t| t.integer :lock_version, default: 0 end
Então, quando você tentar atualizar um registro, o Rails só irá atualizá-lo se lock_version for a mesma versão que estava na memória. Caso contrário, gerará uma exceção ActiveRecord::StaleObjectError , que pode ser resgatada para lidar com isso. Lidar com isso pode ser uma retry ou apenas uma mensagem de erro relatada ao usuário.
Para o exemplo de verificar e agir, isso poderia ser feito com um índice exclusivo na coluna e, em seguida, resgatando a exceção ao persistir os dados.
add_index :users, [:email], unique: true
Com um índice exclusivo instalado, se já existirem dados no banco de dados com aquele email , o Rails irá gerar um erro ActiveRecord::RecordNotUnique e isso pode ser resgatado e tratado adequadamente.
begin user = User.create(email: "[email protected]") rescue ActiveRecord::RecordNotUnique user = User.find_by(email: "[email protected]") end
Idempotência
Para repetir ações, é importante que toda a operação seja idempotente. Isso significa que se uma operação for executada várias vezes, o resultado será o mesmo de se ela fosse aplicada apenas uma vez.
Por exemplo, imagine se um trabalho enviasse um e-mail e fosse executado sempre que os votos de uma ideia fossem alterados. Seria muito ruim se um e-mail fosse enviado a cada nova tentativa. Para tornar a operação idempotente, você poderia adiar o envio do e-mail até que toda a operação de votação fosse concluída. Alternativamente, você pode atualizar a implementação do processo que envia o e-mail para enviá-lo apenas se os votos tiverem mudado desde a última vez que foi enviado. Se ocorrer uma condição de corrida e você precisar tentar novamente, a primeira tentativa de enviar um e-mail poderá resultar em inatividade e é seguro acioná-lo novamente.
Muitas operações podem não ser idempotentes, como colocar um trabalho em segundo plano na fila, enviar um email ou chamar uma API de terceiros.
3. Proteja o código
Se você não conseguir detectar e recuperar, você pode tentar proteger o código. O objetivo aqui é criar um contrato onde apenas um processo possa acessar o recurso compartilhado por vez. Efetivamente, você está removendo a simultaneidade — como apenas um processo pode ter acesso a um recurso compartilhado, podemos evitar a maioria das condições de corrida. A desvantagem, porém, é que quanto mais simultaneidade for removida, mais lento o aplicativo poderá ser, pois outros processos aguardarão até que tenham acesso permitido.
Isso pode ser resolvido usando bloqueio pessimista incorporado ao Rails. Para usar , você pode adicionar lock às consultas que estão sendo construídas, e o Rails dirá ao banco de dados para manter um bloqueio de linha nesses registros. O banco de dados impedirá que qualquer outro processo obtenha o bloqueio até que isso seja feito. Certifique-se de agrupar o código em uma transaction para que o banco de dados saiba quando liberar o bloqueio.
Idea.transaction do @idea = Idea.lock.find(params[:id]) @idea.votes += 1 @idea.save! end
Se o bloqueio em nível de linha não for possível, existem outras ferramentas, como Redlock ou with_advisory_lock, que podem ser usadas. Isso permitirá bloquear um bloco arbitrário de código. Usar isso pode ser tão simples quanto algo assim:
email = "[email protected]" User.with_advisory_lock("user_uniqueness_#{email}"} do User.find_or_create_by(email: email) end
Essas estratégias farão com que os processos esperem até que um bloqueio seja obtido. Portanto, eles também desejarão ter algum tipo de tempo limite para evitar que um processo espere para sempre — bem como algum tratamento sobre o que fazer no caso de um tempo limite.
Embora não exista uma panaceia para corrigir as condições de corrida, muitas condições de corrida podem ser corrigidas através destas estratégias. Porém, cada problema é um pouco diferente, portanto os detalhes das soluções podem variar. Você pode dar uma olhada na , que detalha mais as condições da corrida.
Sobre o autor
Kyle de Oliveira
Kyle é apaixonado por transformar ideias abstratas em softwares funcionais. Ele é engenheiro de software principal da . Quando não está em desenvolvimento, Kyle gosta de comidas incríveis e cervejarias artesanais perto de sua casa em Vancouver, Canadá.