Critical Section
Nos dois problemas anteriores, existia sempre um trecho de código que não podia
ter duas threads dentro dele ao mesmo tempo. No contador, era o counter++. No
saque, era o par checa-então-debita. Esse trecho tem um nome: seção crítica
(critical section). É a parte do código que acessa um recurso compartilhado e
que precisa rodar sem interferência de outras threads pra manter o programa
correto.
O termo é de Dijkstra, dos anos 60, e já apareceu aqui de forma prática. Quando
protegemos o counter++ com um mutex, a seção crítica era exatamente o trecho
entre o lock e o unlock. As três propriedades que uma solução de exclusão
mútua precisa satisfazer, exclusão mútua, progresso e espera limitada, foram
enunciadas em torno justamente desse conceito de seção crítica, e estão no
capítulo sobre o problema da exclusão mútua.
O que falta aqui não é repetir aquilo, é desenvolver a habilidade de reconhecer
uma seção crítica no seu próprio código, antes de sair aplicando mutex em tudo.
A janela de leitura-modificação-escrita
O caso mais comum de seção crítica é a sequência read-modify-write: uma thread
lê um estado compartilhado, calcula algo a partir dele, e escreve o resultado de
volta. O counter++ é o exemplo mínimo. Uma única linha, mas três instruções: lê
counter num registrador, incrementa o registrador, escreve de volta.
O perigo mora na janela entre a leitura e a escrita. Enquanto uma thread está com
o valor no registrador, incrementando, o counter na memória ainda tem o valor
antigo. Se outra thread ler counter nesse intervalo, ela lê o valor
desatualizado, e as duas acabam gravando o mesmo resultado. É por isso que a
sequência inteira precisa se comportar como uma operação indivisível, do começo da
leitura até o fim da escrita. Exclusão mútua sobre essa janela é o que transforma
o 809976 de volta em 1000000.
O saque do capítulo sobre race condition é a mesma janela, só que mais espalhada:
lê o saldo (if conta.saldo() >= valor), decide (passou no teste), escreve
(conta.debita(valor)). Entre a leitura e a escrita, o saldo pode ter mudado. Que
a leitura e a escrita estejam em linhas diferentes, ou até em funções diferentes,
não muda a natureza do problema.
Reconhecendo seções críticas no seu código
O padrão a procurar é sempre o mesmo: uma decisão ou um cálculo baseado num estado compartilhado, seguido de uma modificação desse estado, com a suposição implícita de que o estado não mudou no meio do caminho. Sempre que você vir esse formato, tem uma seção crítica ali, e ela precisa rodar de forma atômica em relação às outras threads.
Reconhecer essas janelas é o que separa quem protege o programa certo de quem sai espalhando lock aleatoriamente (o que, além de não garantir corretude, ainda mata a performance, porque cada lock desnecessário serializa threads que poderiam rodar em paralelo). O olho pra identificar seções críticas vem com prática, e é uma habilidade que vale mais do que decorar qual primitiva usar: a primitiva você escolhe depois de saber o que precisa proteger, nunca antes.