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

Spinlock

Já sabemos o que é um spinlock: o lock que, em vez de dormir esperando, fica girando num loop testando o lock até ele liberar. A pergunta que ficou em aberto é quando isso vale a pena. A resposta é um número, então vamos medir.

O experimento é o mesmo dos dois lados: threads incrementando um contador compartilhado dois milhões de vezes cada, com uma seção crítica curtíssima (um counter++). Uma rodada protege o contador com um mutex, a outra com um spinlock, e cronometramos as duas. Com só duas threads:

mutex     2 threads: 0.181s
spinlock  2 threads: 0.076s

O spinlock terminou em menos da metade do tempo. Com só duas threads e uma seção crítica de um incremento, o lock quase nunca está ocupado quando a outra thread chega, e quando está, é por pouquíssimo tempo. Dormir e ser acordado, nesse cenário, é puro desperdício: o custo de envolver o sistema operacional pra tirar e recolocar a thread em execução é maior que a espera em si. O spinlock corta esse custo: gira umas poucas voltas e pega o lock, sem nunca chamar o sistema operacional para dormir.

Quando o spinlock desanda

Agora o mesmo programa, sem mexer em nada além do número de threads. Primeiro com uma thread por core (a máquina do teste tem 16), depois com quatro vezes mais threads que cores:

mutex    16 threads: 1.724s
spinlock 16 threads: 5.401s

mutex    64 threads: 5.623s
spinlock 64 threads: 94.340s

Com 16 threads o spinlock já perdeu, gastando três vezes o tempo do mutex. Com 64, ele não é só mais lento: é catástrofe, dezessete vezes pior. O mesmo lock que ganhava por mais de 2x virou perda por 17x, sem mudar uma linha de código, só o número de threads.

Duas coisas apodrecem ao mesmo tempo. A primeira é a disputa: com dezesseis threads girando na mesma posição de memória, cada core que quer testar o lock precisa puxar aquela linha de cache pra si, invalidando a cópia dos outros. O lock vira um ping-pong de coerência de cache entre os cores, e as threads gastam mais tempo brigando pela linha do que fazendo trabalho.

A segunda é pior e é a razão de fundo pra evitar spinlock fora de nichos. Quando há mais threads que cores, o sistema operacional preempta threads no meio da execução pra dar vez às outras. Se ele preempta uma thread enquanto ela segura o spinlock, todas as outras que querem o lock ficam girando, queimando CPU a plena carga, esperando um lock cuja dona nem está executando pra poder liberar. Elas giram uma fatia de tempo inteira à toa, até o escalonador finalmente rodar a dona de novo e ela soltar. Um mutex não sofre disso: quem não consegue o lock dorme, o core fica livre pra rodar a thread que segura o lock, ela termina e libera mais rápido.

A API da Pthreads

O tipo é o pthread_spinlock_t, e as operações espelham as do mutex, com spin no lugar de mutex:

#include <stdio.h>
#include <pthread.h>

long counter = 0;
pthread_spinlock_t lock;

void *increment(void *arg) {
    for (int i = 0; i < 1000000; i++) {
        pthread_spin_lock(&lock);
        counter++;
        pthread_spin_unlock(&lock);
    }
    return NULL;
}

int main(void) {
    pthread_spin_init(&lock, 0);          // 0: compartilhado só entre threads deste processo
    pthread_t t[2];
    pthread_create(&t[0], NULL, increment, NULL);
    pthread_create(&t[1], NULL, increment, NULL);
    pthread_join(t[0], NULL);
    pthread_join(t[1], NULL);
    pthread_spin_destroy(&lock);
    printf("counter = %ld\n", counter);
}

Código 9-1: Contador protegido por spinlock.

$ cc main.c -o main -pthread
$ ./main
counter = 2000000

A diferença pro mutex é só de tipo e nome de função. Não existe inicializador estático análogo ao PTHREAD_MUTEX_INITIALIZER, então o spinlock é sempre montado em runtime com pthread_spin_init, e o primeiro argumento diz se ele é compartilhado entre processos ou só entre threads do mesmo processo. O pthread_spin_destroy no fim libera os recursos dele.

Repare que o spinlock não oferece nada análogo ao contraste que separava dormir de girar: ele só sabe girar. É por isso que ele é uma ferramenta de nicho, e não o padrão. O lugar dele é dentro do próprio sistema operacional e de estruturas de dados de baixíssima latência, onde as seções críticas são medidas em nanossegundos e o código roda com a preempção sob controle. Fora desse nicho, o mutex é a escolha certa, e o custo de dormir que ele paga se paga de volta na primeira vez que uma thread é preemptada segurando o lock.