Isso é melhor ilustrado com um exemplo.
Suponha que tenhamos uma tarefa simples que queremos executar várias vezes em paralelo e que desejemos acompanhar globalmente o número de vezes que a tarefa foi executada, por exemplo, contando ocorrências em uma página da web.
Quando cada encadeamento chega ao ponto em que está incrementando a contagem, sua execução terá a seguinte aparência:
- Leia o número de ocorrências da memória em um registro do processador
- Incremente esse número.
- Escreva esse número de volta à memória
Lembre-se de que todo encadeamento pode ser suspenso a qualquer momento deste processo. Portanto, se o segmento A executar a etapa 1 e for suspenso, seguido pelo segmento B, executando as três etapas, quando o segmento A for reiniciado, seus registros terão o número errado de ocorrências: seus registros serão restaurados e incrementarão felizmente o número antigo de hits e armazene esse número incrementado.
Além disso, qualquer número de outros encadeamentos poderia ter sido executado durante o tempo em que o encadeamento A foi suspenso; portanto, o encadeamento de contagem A gravado no final pode estar bem abaixo da contagem correta.
Por esse motivo, é necessário garantir que, se um encadeamento executa a etapa 1, ele deve executar a etapa 3 antes que qualquer outro encadeamento possa executar a etapa 1, o que pode ser realizado por todos os encadeamentos que esperam obter um único bloqueio antes de iniciar esse processo e liberando o bloqueio somente após a conclusão do processo, para que esta "seção crítica" do código não possa ser intercalada incorretamente, resultando em uma contagem incorreta.
Mas e se a operação fosse atômica?
Sim, na terra de unicórnios mágicos e arco-íris, onde a operação de incremento é atômica, o bloqueio não seria necessário para o exemplo acima.
É importante perceber, no entanto, que passamos muito pouco tempo no mundo dos unicórnios mágicos e arco-íris. Em quase todas as linguagens de programação, a operação de incremento é dividida nas três etapas acima. Isso porque, mesmo que o processador suporte uma operação de incremento atômico, essa operação é significativamente mais cara: ela precisa ler da memória, modificar o número e gravá-lo na memória ... e geralmente a operação de incremento atômico é uma operação que pode falhar, o que significa que a sequência simples acima deve ser substituída por um loop (como veremos abaixo).
Como, mesmo no código multithread, muitas variáveis são mantidas locais em um único thread, os programas são muito mais eficientes se eles assumem que cada variável é local em um único thread e permitem que os programadores cuidem da proteção do estado compartilhado entre os threads. Especialmente porque as operações atômicas geralmente não são suficientes para resolver problemas de encadeamento, como veremos mais adiante.
Variáveis voláteis
Se quisermos evitar bloqueios para esse problema em particular, primeiro precisamos entender que as etapas descritas em nosso primeiro exemplo não são realmente o que acontece no código compilado moderno. Como os compiladores assumem que apenas um thread está modificando a variável, cada thread manterá sua própria cópia em cache da variável, até que o registro do processador seja necessário para outra coisa. Contanto que tenha a cópia em cache, ela pressupõe que não precisa voltar à memória e lê-la novamente (o que seria caro). Eles também não gravam a variável na memória, desde que ela seja mantida em um registro.
Podemos voltar à situação que fornecemos no primeiro exemplo (com os mesmos problemas de encadeamento identificados acima), marcando a variável como volátil , o que informa ao compilador que essa variável está sendo modificada por outras pessoas e, portanto, deve ser lida em ou gravado na memória sempre que for acessado ou modificado.
Portanto, uma variável marcada como volátil não nos levará à terra das operações de incremento atômico, apenas nos aproxima tão perto como pensávamos que já estávamos.
Tornando o incremento atômico
Uma vez que estamos usando uma variável volátil, podemos tornar nossa operação de incremento atômica usando uma operação de conjunto condicional de baixo nível que a maioria das CPUs modernas suporta (geralmente chamadas de comparar e configurar ou comparar e trocar ). Essa abordagem é adotada, por exemplo, na classe AtomicInteger do Java :
197 /**
198 * Atomically increments by one the current value.
199 *
200 * @return the updated value
201 */
202 public final int incrementAndGet() {
203 for (;;) {
204 int current = get();
205 int next = current + 1;
206 if (compareAndSet(current, next))
207 return next;
208 }
209 }
O loop acima executa repetidamente as seguintes etapas, até que a etapa 3 seja bem-sucedida:
- Leia o valor de uma variável volátil diretamente da memória.
- Incremente esse valor.
- Altere o valor (na memória principal) se, e somente se, o valor atual na memória principal for o mesmo que lemos inicialmente, usando uma operação atômica especial.
Se a etapa 3 falhar (porque o valor foi alterado por um thread diferente após a etapa 1), ela lê novamente a variável diretamente da memória principal e tenta novamente.
Embora a operação de comparação e troca seja cara, é um pouco melhor do que usar o bloqueio nesse caso, porque se um encadeamento for suspenso após a etapa 1, outros encadeamentos que atingem a etapa 1 não precisarão bloquear e aguardar o primeiro encadeamento, que pode impedir a troca de contexto dispendiosa. Quando o primeiro encadeamento continuar, ele falhará em sua primeira tentativa de gravar a variável, mas poderá continuar relendo a variável, o que provavelmente é mais barato do que a troca de contexto que seria necessária com o bloqueio.
Assim, podemos chegar a terra de incrementos atômicos (ou outras operações em uma única variável) sem usar bloqueios reais, via comparação e troca.
Então, quando o bloqueio é estritamente necessário?
Se você precisar modificar mais de uma variável em uma operação atômica, o bloqueio será necessário, você não encontrará uma instrução especial do processador para isso.
Contanto que você esteja trabalhando em uma única variável e esteja preparado para qualquer trabalho que tenha falhado e que precise ler a variável e começar de novo, a comparação e troca será boa o suficiente.
Vamos considerar um exemplo em que cada thread adiciona primeiro 2 à variável X e depois multiplica X por dois.
Se X é inicialmente um e dois threads são executados, esperamos que o resultado seja (((1 + 2) * 2) + 2) * 2 = 16.
No entanto, se os encadeamentos se intercalarem, poderíamos, mesmo com todas as operações atômicas, fazer com que ambas as adições ocorram primeiro e as multiplicações ocorram depois, resultando em (1 + 2 + 2) * 2 * 2 = 20.
Isso acontece porque multiplicação e adição não são operações comutativas.
Portanto, as próprias operações sendo atômicas não são suficientes, precisamos tornar a combinação de operações atômica.
Podemos fazer isso usando o bloqueio para serializar o processo ou podemos usar uma variável local para armazenar o valor de X quando iniciamos nosso cálculo, uma segunda variável local para as etapas intermediárias e, em seguida, comparar e trocar para defina um novo valor apenas se o valor atual de X for igual ao valor original de X. Se falharmos, teríamos que começar novamente lendo X e realizando os cálculos novamente.
Existem várias compensações envolvidas: à medida que os cálculos se tornam mais longos, é muito mais provável que o encadeamento em execução seja suspenso e o valor seja modificado por outro encadeamento antes de retomarmos, o que significa que as falhas se tornam muito mais prováveis, levando ao desperdício. tempo do processador. No caso extremo de um grande número de threads com cálculos de execução muito longos, podemos ter 100 threads lendo a variável e participar de cálculos; nesse caso, apenas o primeiro a concluir terá sucesso ao escrever o novo valor, os outros 99 ainda conclua seus cálculos, mas descubra que eles não podem atualizar o valor ... nesse ponto, cada um lerá o valor e iniciará o cálculo novamente. Provavelmente, os 99 threads restantes repetem o mesmo problema, desperdiçando grandes quantidades de tempo do processador.
A serialização completa da seção crítica via bloqueios seria muito melhor nessa situação: 99 threads seriam suspensos quando não obtivessem o bloqueio, e executaríamos cada thread na ordem de chegada ao ponto de bloqueio.
Se a serialização não for crítica (como no nosso caso de incremento), e os cálculos que seriam perdidos se a atualização do número falhar forem mínimos, pode haver uma vantagem significativa a ser obtida com o uso da operação de comparação e troca, porque essa operação é mais barato que travar.