Mutex
O mutex (de mutual exclusion) é a encarnação mais comum do lock. É o que
você vai usar em 90% dos casos em que precisa proteger uma seção crítica. Em vez
de mais teoria, vamos direto consertar o contador. O Código
6-1 é o mesmo de sempre, com um mutex protegendo o
counter++:
#include <stdio.h>
#include <pthread.h>
int counter = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void *increment(void *arg) {
for (int i = 0; i < 100000; i++) {
pthread_mutex_lock(&lock);
counter++;
pthread_mutex_unlock(&lock);
}
return NULL;
}
int main(void) {
pthread_t tids[10];
for (int i = 0; i < 10; i++)
pthread_create(&tids[i], NULL, increment, NULL);
for (int i = 0; i < 10; i++)
pthread_join(tids[i], NULL);
printf("counter = %d\n", counter);
}
Código 6-1: O contador protegido por um mutex.
$ cc main.c -o main -pthread
$ ./main
counter = 1000000
$ ./main
counter = 1000000
$ ./main
counter = 1000000
1000000, três vezes seguidas, e vai continuar dando 1000000 em qualquer
execução. Deixou de ser uma questão de sorte com o escalonamento: não importa
como as dez threads se intercalam, o resultado é o mesmo. O não-determinismo
continua ali (a ordem em que as threads pegam o lock ainda varia a cada execução),
mas ele deixou de afetar a corretude. É isso que a sincronização compra: você abre
mão do controle sobre a ordem, mas garante que qualquer ordem produz o resultado
certo.
A API da Pthreads
Foram três mudanças em relação ao código quebrado.
Declaramos o mutex com pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER. Esse
PTHREAD_MUTEX_INITIALIZER é uma macro que dá ao mutex o estado inicial correto
(destravado). Pra mutexes globais ou estáticos, é a forma mais simples de
inicializar. Existe também pthread_mutex_init, usada quando o mutex é alocado
dinamicamente ou precisa de atributos especiais.
Antes do counter++, chamamos pthread_mutex_lock(&lock). Se o mutex está livre,
a thread o adquire e segue. Se está com outra thread, essa chamada bloqueia: a
thread é retirada de execução pelo sistema operacional e só volta quando o mutex
for liberado. Ela não fica queimando CPU num loop, o que é a diferença entre um
mutex e um spinlock.
Depois do counter++, chamamos pthread_mutex_unlock(&lock), liberando o mutex
pra próxima thread. Esse par lock/unlock delimita a seção crítica. Tudo que está
entre eles roda com exclusão mútua garantida.
O trecho protegido aqui é minúsculo, só um incremento, e é assim que deve ser. O mutex serializa tudo que está dentro dele, então quanto mais código você põe entre o lock e o unlock, mais tempo as outras threads passam bloqueadas esperando. A regra é segurar o mutex pelo menor tempo possível: proteja o acesso ao dado compartilhado, e nada mais.
Esquecer o unlock
O erro mais clássico com mutex é adquirir e esquecer de liberar, ou liberar só em alguns caminhos do código. Considere:
pthread_mutex_lock(&lock);
if (algo_deu_errado) {
return -1; // saiu com o mutex ainda travado!
}
counter++;
pthread_mutex_unlock(&lock);
Se algo_deu_errado for verdadeiro, a função retorna sem chamar unlock. O mutex
fica travado pra sempre, e a próxima thread que tentar pthread_mutex_lock vai
bloquear eternamente. Todo caminho de saída da seção crítica (returns, breaks,
gotos de erro) precisa liberar o mutex. Em C isso é responsabilidade sua e é fácil
escorregar. Linguagens com destrutores ou blocos protegidos resolvem isso de forma
mais segura, como veremos no Kotlin logo abaixo.
Mutex em Kotlin: synchronized
Lá em Criando Threads
ficou uma promessa por cumprir: um var counter capturado por várias threads em
Kotlin daria uma race, e o compilador não avisaria nada. A correção é a
palavra-chave synchronized.
Na JVM, o mecanismo embutido de exclusão mútua é justamente essa palavra-chave.
Todo objeto em Kotlin (e Java) tem um lock associado, chamado de monitor, e um
bloco synchronized(obj) adquire o monitor de obj na entrada e o libera na
saída. O contador protegido fica assim:
import kotlin.concurrent.thread
var counter = 0
val lock = Any()
fun main() {
val threads = (0 until 10).map {
thread {
repeat(100_000) {
synchronized(lock) {
counter++
}
}
}
}
threads.forEach { it.join() }
println("counter = $counter")
}
Código 6-2: O mesmo contador protegido com synchronized em Kotlin.
$ kotlinc main.kt -include-runtime -d main.jar && java -jar main.jar
counter = 1000000
O val lock = Any() cria um objeto qualquer só pra servir de lock. Poderia ser
qualquer objeto; o que importa é que todas as threads sincronizem no mesmo
objeto, senão cada uma adquire um monitor diferente e a exclusão mútua não
acontece.
A vantagem do synchronized sobre o par lock/unlock manual é que ele libera o
monitor automaticamente ao sair do bloco, por qualquer caminho, inclusive se
uma exceção for lançada lá dentro. O problema do “esquecer o unlock” simplesmente
não existe: não tem unlock pra esquecer. O fim do bloco { } é o unlock.
Quando o dado compartilhado vive dentro de um objeto, dá pra ir além e marcar
métodos inteiros com @Synchronized:
class Contador {
private var valor = 0
@Synchronized fun incrementa() { valor++ }
@Synchronized fun valor(): Int = valor
}
Um método @Synchronized sincroniza no próprio objeto (this). Enquanto uma
thread executa incrementa(), nenhuma outra consegue executar incrementa() nem
valor() na mesma instância, porque ambos disputam o mesmo monitor. Isso mantém a
proteção junto do dado que ela protege, que é um jeito mais organizado de pensar
do que espalhar lock/unlock pelo código.
Uma ressalva importante, porém: @Synchronized torna cada método atômico, mas não
uma sequência de chamadas a esses métodos. Proteger cada acesso individual não é
a mesma coisa que proteger a lógica que combina vários acessos. Um objeto com
todos os métodos @Synchronized ainda pode ser usado de forma incorreta se o
código que o chama assume que o estado não muda entre uma chamada e a próxima.