Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.