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

Pthreads (POSIX Threads)

O padrão POSIX (Portable Operating System Interface) é uma coleção de interfaces de programação (APIs) que um sistema operacional deve implementar para ser considerado POSIX-compliant. Essas APIs são acessíveis por meio da linguagem de programação C1.

Esse padrão foi criado para que quando um programador escrevesse código que utilizasse as funcionalidades comuns de um sistema operacional (system calls, gerenciamento de processos e threads, utilitários de sistema, shells, etc), esse código funcionasse em qualquer sistema operacional POSIX-compliant.

Um dos componentes da POSIX é a API para criar e gerenciar threads do SO, chamada de Pthreads. Essa API é acessível em C via <pthread.h>.

Criando uma única thread com pthread_t e pthread_create

Criar uma thread é simples:

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

void *greet(void *arg) {
  char *name = (char *)arg;
  printf("Hello from %s!\n", name);
  return NULL;
}

int main() {
  pthread_t my_thread;
  pthread_create(&my_thread, NULL, greet, "thread");
}

Código 4-1: Criando uma thread com a Pthreads.

Vamos entender o código por partes:

  1. Primeiro, para podermos usar as funções da API Pthreads, incluímos o header da biblioteca fazendo #include <pthread.h>.

  2. Toda thread precisa executar algum código, e isso é feito por meio de uma função. Quando a thread inicia, ela executa uma função que o programador especifica. Nesse caso, a função greet será executada pela nossa thread.

    Nós chamaremos essas funções que são executadas pelas threads de “funções iniciais”.

    O primeiro e único argumento de greet é arg, cujo tipo é void *. Esse tipo é usado em C para representar um “ponteiro genérico”. Isto é, um ponteiro que pode estar apontando para um valor de qualquer tipo. Porém, não podemos usar um ponteiro desse tipo, apenas passar ele entre funções. O motivo pelo qual não podemos usar um void * (ponteiro para void) é porque void é um tipo que não possui valores. Portanto, não faz sentido tentar acessar/dereferenciar o valor apontado.

    Para podermos usar esse ponteiro, precisamos converter ele para um ponteiro para algum tipo válido. É o que fazemos em char *name = (char *)arg;. Nós convertemos um ponteiro para void num ponteiro para char, e salvamos ele numa variável name. Depois, imprimos name na saída padrão e retornamos NULL para indicar que essa função não retorna nada de útil.

    Toda função inicial tem a mesma assinatura: recebe um void * e retorna um void *.

  3. Começamos o código da main declarando uma variável my_thread cujo tipo é pthread_t. Esse tipo representa uma thread, mas por enquanto ela ainda não foi criada pelo sistema operacional.

    Para pedir que o SO crie a thread, chamamos pthread_create. Precisamos passar:

    1. um ponteiro para uma pthread_t
    2. um ponteiro para uma pthread_attr_t, um tipo que armazena os atributos da nossa thread. Quando passamos NULL, a thread receberá os atributos padrões.
    3. um ponteiro para a função inicial que será executada. Nesse caso, passamos a função que criamos, greet. Note que em C, &greet é equivalente a greet, pois o nome de uma função automaticamente decai para um ponteiro para ela mesma.
    4. Um valor que será convertido num void * e passado como o argumento arg de greet. Note que passamos a string literal "thread", que é do tipo char[7]. Arrays em C decaem para um ponteiro do mesmo tipo, então o tipo decai de char[7] para char *, que é implicitamente convertido para um void *.

Vamos executar o programa e vê-lo em ação. Para isso, compilamos o programa usando a flag -pthread do compilador, que inclui o binário que implementa a biblioteca:

$ cc main.c -o main -pthread
$ ./main
$

Epa! O que aconteceu?! O esperado era que o programa printasse Hello from thread! na saída padrão.

Aguardando uma thread concluir com pthread_join

O motivo de nada ter sido printado é que o nosso programa principal criou uma nova thread e chegou ao seu fim. O programa principal não ficou esperando até a thread ser de fato executada e encerrada.

Felizmente, podemos forçar que nosso programa principal (ou thread principal, pois lembre-se que todo programa inicia com pelo menos uma thread, a principal) fique aguardando a conclusão da sua irmã, usando a função pthread_join. É o que fazemos no Código 4-2.

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

void *greet(void *arg) {
  char *name = (char *)arg;
  printf("Hello from %s!\n", name);
  return NULL;
}
// --restante omitido--

int main() {
  pthread_t my_thread;
  pthread_create(&my_thread, NULL, greet, "thread");
  pthread_join(my_thread, NULL);
}

Código 4-2: Usando pthread_join para aguardar a thread terminar de rodar.

$ cc main.c -o main -pthread
$ ./main
Hello from thread!

Agora sim! pthread_join trabalha com dois parâmetros:

  1. O primeiro é a variável que representa a thread que você deseja esperar.

  2. O segundo é um void ** (um ponteiro para um ponteiro para void) que, após a thread sendo esperada finalizar, é atualizado para apontar para o valor retornado pela função inicial que acabou de ser executada (Lembre-se que o tipo do valor retornado por funções iniciais é sempre void *).

    Como passamos NULL, estamos indicando que não queremos salvar esse valor. Depois veremos exemplos onde é útil para o programa obter o resultado retornado pela thread.

Criando múltiplas threads

Esse não seria um livro de concorrência se criássemos apenas uma thread! O Código 4-3 cria 10 threads num loop, cada uma executando greet com um argumento diferente.

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

void *greet(void *arg) {
  char *name = (char *)arg;
  printf("Hello from %s!\n", name);
  return NULL;
}
// --restante omitido--

int main() {
  int n = 10;
  pthread_t threads[n];
  char names[n][32];

  for (int i = 0; i < n; i++) {
    snprintf(names[i], sizeof(names[i]), "thread %d", i);
    pthread_create(&threads[i], NULL, greet, names[i]);
  }

  for (int i = 0; i < n; i++) {
    pthread_join(threads[i], NULL);
  }
}

Código 4-3: Criando múltiplas threads.

Vamos compilar e executar o programa. Para demonstrar o não-determinismo, iremos executar o programa 3 vezes.

$ cc main.c -o main -pthread
$ ./main
Hello from thread 0!
Hello from thread 2!
Hello from thread 1!
Hello from thread 4!
Hello from thread 3!
Hello from thread 5!
Hello from thread 7!
Hello from thread 6!
Hello from thread 9!
Hello from thread 8!
$ ./main
Hello from thread 0!
Hello from thread 1!
Hello from thread 4!
Hello from thread 3!
Hello from thread 2!
Hello from thread 7!
Hello from thread 8!
Hello from thread 9!
Hello from thread 5!
Hello from thread 6!
$ ./main
Hello from thread 0!
Hello from thread 1!
Hello from thread 2!
Hello from thread 3!
Hello from thread 4!
Hello from thread 5!
Hello from thread 6!
Hello from thread 7!
Hello from thread 8!
Hello from thread 9!

Vamos prestar atenção em cada execução:

  1. Na primeira execução, vemos que a ordem que as mensagens aparecem não vai linearmente de 0 até 9, apesar de nós termos criado as threads (e feito o join delas) nessa ordem.

    Logo após o Hello da thread 0, vem o Hello da thread 2! O Hello da thread 1 vem atrás do da thread 2.

  2. Na segunda execução, vemos uma nova ordem de mensagens, totalmente diferente da primeira.

  3. Na terceira execução, algo interessante acontece. Demos a sorte da ordem das mensagens terem seguido linearmente de 0 a 9! Mesmo após duas execuções não-lineares!

    Isso é um exemplo simples de como, muitas vezes, programas concorrentes podem “enganar” o programador, dando a falsa impressão de que alguma ordem está sendo seguida quando, na verdade, é apenas uma intercalação possível (e talvez mais provável) ocorrendo, em meio a tantas outras.

Retornando valores de uma thread

Muitas vezes, quando uma thread A cria outra thread B, a thread B realiza algum trabalho e deve retornar um valor para A. Esse tipo de colaboração entre threads é muito comum nos seguintes casos:

  • Uma thread pai precisa coletar e combinar resultados gerados por \( N \) threads filhas.
  • Uma thread precisa retornar um erro/status para outra thread.
  • Threads colaboram usando o padrão de fan-out/fan-in, que estudaremos mais à frente.

O mecanismo: return + pthread_join

Vamos criar um programa simples, que não precisaria de threads normalmente, mas que é simples o suficiente para mostrarmos como o mecanismo para retornar valores de uma thread funciona.

O Código 4-4 cria uma thread que calcula \( 7^2 \), e retorna o resultado para a thread principal.

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

void *square(void *arg) {
  int input = *(int *)arg;
  int *result = malloc(sizeof(int));
  *result = input * input;
  return result;
}

int main() {
  int val = 7;
  pthread_t tid;
  pthread_create(&tid, NULL, square, &val);

  int *answer;
  pthread_join(tid, (void **)&answer);
  printf("7² = %d\n", *answer);
  free(answer);
}

Código 4-4: Retornando um valor para a thread principal.

Vamos entender o código:

  1. Uma thread é criada e inicializada com a função inicial square. Como de praxe, toda função inicial precisa receber um void * e retornar um void *.

  2. Dentro da função square, convertemos o ponteiro genérico void * em int *, dereferenciamos/acessamos o valor que ele está apontando e guardamos na variável local input.

  3. Alocamos memória para um inteiro com malloc(sizeof(int)) e guardamos o ponteiro retornado em result.

    O motivo pelo qual precisamos alocar memória no heap para guardar o resultado é que a função inicial de uma thread só retorna void *. Se simplesmente usássemos uma variável local para guardar o resultado, precisaríamos retornar um ponteiro para ela. Porém, em C, retornar ponteiros para variáveis locais é considerado undefined behavior, pois a memória do stack alocada para a função não sobrevive após o retorno da função2.

    Mas ponteiros que apontam para variáveis no heap permanecem válidos mesmo após o retorno da função em que eles foram criados, então não há problema em utilizar esses ponteiros como valor de retorno de uma função.

  4. Calculamos o quadrado do argumento via input * input e armazenamos dentro do endereço de memória apontado por result.

  5. Retornamos o ponteiro result da função inicial. Note que, apesar de result ser do tipo int * (ponteiro para int), ele decai automaticamente para um void * pois esse é o tipo de retorno de square e ponteiros para qualquer tipo podem decair para void *.

  6. A thread principal cria um ponteiro para int answer, que irá armazenar o valor retornado pela thread. Ao chamar pthread_join, um ponteiro para esse ponteiro (&answer) é passado e convertido para void **.

    Dessa forma, após a thread finalizar, a implementação de pthread_join pode fazer algo como *(&answer) = result, onde result é o ponteiro retornado por square.

  7. Após a thread finalizar, seus recursos serem liberados e pthread_join retornar, a expressão *answer retorna o valor 49. Em seguida, esse valor é exibido na tela e a memória apontada por answer é liberada.

Retornando structs

Como a função inicial de uma thread sempre retorna um void *, podemos alocar memória para qualquer tipo, inclusive um struct complexo. É o que o Código 4-5 faz.

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

struct stats {
  int min, max, sum;
};

void *compute_stats(void *arg) {
  int *nums = arg;
  int count = nums[0];
  int *data = &nums[1];

  struct stats *s = malloc(sizeof(struct stats));
  s->min = s->max = s->sum = data[0];

  for (int i = 1; i < count; i++) {
    if (data[i] < s->min) s->min = data[i];
    if (data[i] > s->max) s->max = data[i];
    s->sum += data[i];
  }

  return s;
}

int main() {
  int nums[] = {5, 3, 17, 8, 1, 12};
  pthread_t tid;

  pthread_create(&tid, NULL, compute_stats, nums);

  struct stats *s;
  pthread_join(tid, (void **)&s);

  printf("min=%d max=%d sum=%d\n", s->min, s->max, s->sum);
  free(s);
}

Código 4-5: Retornando um struct de uma thread.

Vamos entender o código:

  1. Declaramos um struct stats que irá armazenar estatísticas sobre uma sequência de ints.
  2. Declaramos a função inicial compute_stats. O parâmetro arg é convertido num int * que será usado como uma array. Por convenção, o primeiro elemento guarda a quantidade de elementos count, e do segundo elemento para frente é a sequência de números em si. Para facilitar, criamos um novo ponteiro data apontando para o começo dos dados.
  3. Alocamos memória para um valor do tipo stats e armazenamos o endereço de memória retornado no ponteiro s. Em seguida, um simples laço for é usado para calcular as estatísticas. Retornamos o valor do ponteiro s.
  4. O main é trivial. Os dados são inicializados, a thread é criada e um ponteiro stats * é declarado. Ele será inicializado com o valor retornado pela thread.

Usando pthread_exit em chamadas de função aninhadas

Só podemos usar return para retornar valores de uma thread se ele for utilizado dentro da função inicial (top-level) da thread. Caso essa função inicial chame uma função A, que por sua vez chama outra função B, como faríamos para terminar a thread e enviar um resultado dentro de B?

A resposta é a função pthread_exit, que pode ser chamada em qualquer ponto na pilha de chamadas de uma thread. Um exemplo de uso dessa função está no código abaixo.

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

static void check_positive(int n, int *result) {
    if (n < 0) {
        *result = -1;
        pthread_exit(result);
    }
}

void *double_it(void *arg) {
    int n = *(int *)arg;
    int *result = malloc(sizeof(*result));

    check_positive(n, result);

    *result = n * 2;
    return result;
}

int main() {
    int inputs[] = {5, -3, 10};
    pthread_t tids[3];

    for (int i = 0; i < 3; i++)
        pthread_create(&tids[i], NULL, double_it, &inputs[i]);

    for (int i = 0; i < 3; i++) {
        int *r;
        pthread_join(tids[i], (void **)&r);
        if (*r == -1)
            printf("input %d: rejected\n", inputs[i]);
        else
            printf("input %d: result = %d\n", inputs[i], *r);
        free(r);
    }
}

(Avançado) Threads joinable vs detached

Toda pthread tem um atributo chamado join state. A depender desse atributo, uma thread pode ser classificada em:

  • Joinable: após terminar sua execução, a thread espera outra thread chamar pthread_join nela. Esse é o padrão.
  • Detached: a thread nunca será passada como argumento de pthread_join, e o sistema automaticamente fará a limpeza dos seus recursos quando ela terminar de executar.

Esse atributo é relevante pois, mesmo após terminar sua execução, uma joinable thread ainda consome recursos, principalmente memória, até que ela seja passada como argumento de pthread_join.

Cada thread joinable que terminou sua execução e está aguardando ser passada de argumento pra pthread_join está associada a três recursos principais: o valor de retorno da sua função inicial, um stack de chamadas (não pode ser desalocado, pois o valor de retorno pode depender dele) e os metadados usados pela implementação da biblioteca <pthread.h>. Esses recursos totalizam pouco mais de 8 MB por thread.

pthread_join não faz apenas uma thread esperar outra terminar de executar. Quando a thread passada para o join finaliza, pthread_join limpa todos os recursos associados com a thread finalizada.

Isso nos leva ao principal bug de memória em programas baseados em <pthread.h>: o programador esquecer de chamar pthread_join em threads joinable, provocando vazamento de recursos (resource leak), como memória. Como de praxe na linguagem C, o gerenciamento de memória é uma responsabilidade exclusiva do programador. C não oferece APIs ou runtimes que facilitem essa responsabilidade.

Um programa com resource leak

Vamos demonstrar um bug de resource leak com um programa. O Código 4-6 irá criar threads joinable num loop while. Essas threads irão executar uma função que não faz praticamente nada, fazendo com que elas terminem de executar quase que imediatamente. Para causar o resource leak, o programa não irá chamar pthread_join nelas. Chegará um momento em que a tentativa de criar novas threads usando pthread_create irá falhar, pois toda a memória virtual disponível para o programa já terá sido consumida pelas threads já criadas, encerrando o loop.

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

void *do_nothing(void *arg) {
    return NULL;
}

int main(void) {
    pthread_t tid;
    int i = 0;

    while (pthread_create(&tid, NULL, do_nothing, NULL) == 0) {
        i++;
    }

    printf("created %d threads before failure\n", i);
    return 0;
}

Código 4-6: Criando joinable threads até a exaustão.

Antes de executar, precisamos limitar a memória virtual do processo para até ~2 GB via ulimit. Caso deixássemos sem limite, o OOM Killer (Out-of-Memory Killer, um processo do kernel-space que mata processos quando a memória livre chega perto de zero) entraria em ação, matando o processo e impedindo que o último printf fosse executado.

$ ulimit -Sv 2000000
$ cc main.c -o main -pthread
$ ./main
created 243 threads before failure

O programa conseguiu criar 243 threads antes de falhar devido a falta de recursos. Todos os 2 GB de memória virtual foram consumidos pois as threads criadas nunca foram passadas para pthread_join. Mesmo após terminarem de executar, elas continuaram consumindo memória.

Esse número de 243 faz sentido: 2 GB é equivalente a 2048 MB. 2048 MB / 8 MB = 256 threads, na teoria. O restante da memória foi consumido por outros atores (o próprio processo pai, a thread principal, bibliotecas, entre outras alocações).

A versão com threads detached e sem leaks

Vamos ver agora como esse programa se comporta se criarmos threads detached ao invés de joinable, implementado no Código 4-7.

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

void *do_nothing(void *arg) {
    return NULL;
}
// --restante omitido--

int main(void) {
    pthread_t tid;
    pthread_attr_t attr;
    pthread_attr_init(&attr);
    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);

    for (int i = 0; i < 100000; i++) {
        pthread_create(&tid, &attr, do_nothing, NULL);
    }
    pthread_attr_destroy(&attr);
    printf("100k threads created and self-cleaning\n");

    // Pequeno sleep (0.5 s) para que as threads detached tenham tempo de
    // finalizar e liberar seus recursos.
    usleep(500000);

    return 0;
}

Código 4-7: Criando 100 mil threads detached.

$ cc main.c -o main -pthread
$ ./main
100k threads created and self-cleaning

Para definir uma thread como detached, podemos chamar pthread_detach numa thread joinable ou, de maneira mais eficiente, criamos ela como detached desde o princípio, usando os atributos da thread.

Criamos uma variável do tipo pthread_attr_t para guardar os atributos das threads. Configuramos ela como detached usando pthread_attr_setdetachstate.

Enquanto tínhamos criado no máximo 243 threads joinable simultâneas no Código 4-6, o Código 4-7 conseguiu criar 100.000 threads detached sem problema. Isso faz sentido: como os recursos das threads detached são liberados quando elas finalizam, e a função do_nothing termina quase que imediatamente, podemos criar um número imenso dessas threads sem se preocupar com esgotamento dos recursos.

Note que chamar pthread_join numa thread detached é considerado Undefined Behavior pela especificação POSIX.

Uma observação

Apesar de threads detached terem seus benefícios, elas são geralmente consideradas um anti-padrão ou code smell, sendo preferível o uso de outros construtos (thread pools, executors, etc).

A motivação para isso é abordada no capítulo Anti-padrões de Concorrência.


  1. C também possui uma outra API para criar threads, introduzida no padrão C11 e acessível via <threads.h>. Esta, no entanto, possui uma superfície de API muito menor, não oferecendo suporte para cancelamento e tipos avançados de locks, sendo preterida para programas não-triviais. ↩

  2. Em C, variáveis locais de uma função são alocadas no stack do programa (mais especificamente, no frame do stack referente àquela função). ↩