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:
-
Primeiro, para podermos usar as funções da API Pthreads, incluímos o header da biblioteca fazendo
#include <pthread.h>. -
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
greetserá 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 umvoid *(ponteiro paravoid) é porquevoidé 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 paravoidnum ponteiro parachar, e salvamos ele numa variávelname. Depois, imprimosnamena saída padrão e retornamosNULLpara indicar que essa função não retorna nada de útil.Toda função inicial tem a mesma assinatura: recebe um
void *e retorna umvoid *. -
Começamos o código da
maindeclarando uma variávelmy_threadcujo 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:- um ponteiro para uma
pthread_t - um ponteiro para uma
pthread_attr_t, um tipo que armazena os atributos da nossa thread. Quando passamosNULL, a thread receberá os atributos padrões. - 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 agreet, pois o nome de uma função automaticamente decai para um ponteiro para ela mesma. - Um valor que será convertido num
void *e passado como o argumentoargdegreet. Note que passamos a string literal"thread", que é do tipochar[7]. Arrays em C decaem para um ponteiro do mesmo tipo, então o tipo decai dechar[7]parachar *, que é implicitamente convertido para umvoid *.
- um ponteiro para uma
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:
-
O primeiro é a variável que representa a thread que você deseja esperar.
-
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 é semprevoid *).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:
-
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
Helloda thread 0, vem oHelloda thread 2! OHelloda thread 1 vem atrás do da thread 2. -
Na segunda execução, vemos uma nova ordem de mensagens, totalmente diferente da primeira.
-
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:
-
Uma thread é criada e inicializada com a função inicial
square. Como de praxe, toda função inicial precisa receber umvoid *e retornar umvoid *. -
Dentro da função
square, convertemos o ponteiro genéricovoid *emint *, dereferenciamos/acessamos o valor que ele está apontando e guardamos na variável localinput. -
Alocamos memória para um inteiro com
malloc(sizeof(int))e guardamos o ponteiro retornado emresult.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.
-
Calculamos o quadrado do argumento via
input * inpute armazenamos dentro do endereço de memória apontado porresult. -
Retornamos o ponteiro
resultda função inicial. Note que, apesar deresultser do tipoint *(ponteiro paraint), ele decai automaticamente para umvoid *pois esse é o tipo de retorno desquaree ponteiros para qualquer tipo podem decair paravoid *. -
A thread principal cria um ponteiro para int
answer, que irá armazenar o valor retornado pela thread. Ao chamarpthread_join, um ponteiro para esse ponteiro (&answer) é passado e convertido paravoid **.Dessa forma, após a thread finalizar, a implementação de
pthread_joinpode fazer algo como*(&answer) = result, onderesulté o ponteiro retornado porsquare. -
Após a thread finalizar, seus recursos serem liberados e
pthread_joinretornar, a expressão*answerretorna o valor49. Em seguida, esse valor é exibido na tela e a memória apontada poransweré 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:
- Declaramos um struct
statsque irá armazenar estatísticas sobre uma sequência deints. - Declaramos a função inicial
compute_stats. O parâmetroargé convertido numint *que será usado como uma array. Por convenção, o primeiro elemento guarda a quantidade de elementoscount, e do segundo elemento para frente é a sequência de números em si. Para facilitar, criamos um novo ponteirodataapontando para o começo dos dados. - Alocamos memória para um valor do tipo
statse armazenamos o endereço de memória retornado no ponteiros. Em seguida, um simples laçoforé usado para calcular as estatísticas. Retornamos o valor do ponteiros. - 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_joinnela. 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.
-
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. ↩ -
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). ↩