Threads em C
Desde o padrão C11 da linguagem C existe uma padronização na libc para criar e manipular threads, que ficou conhecido como "C11 threads". Nesse artigo vou lhe explicar como funciona e como usar suas funções.
Mas antes vou dar uma pequena base da teoria sobre threads para quem precisar.
A teoria sobre threads
Thread significa literalmente "fio" em Inglês e você pode pensar em uma thread como uma unidade de execução de código dentro de um processo.
Em um sistema operacional multitarefas vários processos (tarefas) são executados simultaneamente onde cada processo começa a execução de seu código no endereço conhecido como entrypoint. Quando um processo é criado pelo dynamic loader do sistema operacional também é criada uma thread principal para o processo onde é nela que o código começa a ser executado.
Esse é o modo como um processo comum, monothread (que tem apenas uma thread), é executado. Só que a partir da thread principal o processo pode criar outras threads se quiser, assim permitindo que vários códigos do processo sejam executados ao mesmo tempo.
Quando o processo cria uma nova thread ele define o endereço onde a thread irá começar sua execução (o "entrypoint" da thread) e daquele endereço em diante o fluxo de execução do código segue normalmente, até a thread invocar a system call do sistema operacional que faz a saída do programa.
Cada thread do processo compartilha a mesma memória, arquivos abertos etc. com todas as outras threads do mesmo processo. Portanto não é como criar um processo filho (com a função fork()
) onde toda a memória do processo é copiada porém não é compartilhada entre os processos. Ou seja, por exemplo, modificar uma variável global X no processo filho não modificaria a mesma variável global no processo pai. Porém em uma thread, como a região de memória virtual é a mesma, as variáveis globais são as mesmas e portanto essa modificação seria visível em todas as threads do processo.
Porém apesar da memória ser compartilhada entre as threads elas usam uma nova região de pilha (stack) para si (de 8 KiB de tamanho no Linux). E também cada thread têm sua própria região de memória estática chamada de Thread-local Storage (TLS).
Concorrência e paralelismo
Como esses conceitos são facilmente confundidos vale reforçar aqui a diferença entre os dois.
Imagine que você esteja executando apenas um processo em um processador com 8 núcleos, e esse processo tem exatamente 8 threads. Cada thread poderia ser executada em cada um dos núcleos literalmente ao mesmo tempo, pois cada núcleo funciona independentemente do outro. E portanto cada núcleo pode executar uma tarefa distinta da outra ao mesmo tempo, ou seja, paralelamente.
Agora imagine que, ao invés de apenas um processo, seu computador esteja executando centenas de processos cada um com variados números de threads. Nesse caso o processador (com 8 núcleos) só pode executar 8 tarefas paralelamente e as outras centenas de tarefas precisam ficar esperando um núcleo ficar disponível para ser executado. Isso acontece quando o escalonador de processos do sistema operacional decide interromper uma tarefa para dar lugar para outra executar.
Ou seja, as tarefas estão concorrendo pelo tempo de execução no núcleo. Isso é chamado de concorrência.
Em um sistema operacional multitarefas rodando em um hardware que tem um processador com múltiplos núcleos (ou até múltiplos processadores) existe um número N de tarefas que podem ser executadas paralelamente. Portanto é impraticável que todas as tarefas sejam executadas paralelamente e, por isso, o sistema operacional implementa um sistema concorrente de execução de tarefas.
Problemas comuns com threads
Vale lembrar que implementar softwares com múltiplas threads pode criar muita dor de cabeça caso você precise que mais de uma thread leia/escreva o mesmo dado na memória. Pois você precisa garantir a sincronia entre essas threads e isso pode implicar em muitos problemas.
Esse tema é extenso demais para ser abordado nesse artigo e portanto será deixado de fora. Mas sugiro ao leitor pesquisar sobre o assunto caso ainda não esteja ciente dos problemas.
E a melhor forma de "resolver" esses problemas é simplesmente evitar eles, garantindo que duas ou mais threads não manipulem o mesmo estado (variáveis, arquivos etc.).
Como usar C11 threads
O C11 thread é implementado na própria biblioteca padrão da linguagem C (libc) e o header file <threads.h> declara macros, funções e os tipos necessários para lidar com as threads.
Um detalhe que C11 threads é um recurso opcional e pode não ser implementado em algumas implementações da libc.
Estou usando o GCC 12.2.0
em um Ubuntu 22.10
. Versões mais antigas do GCC podem exigir a compilação do programa com a flag -pthread
. Pois a glibc implementa C11 threads usando pthread por baixo dos panos e, em versões mais antigas, pthread era implementado em uma biblioteca independente que precisava ser linkada ao compilar.
Se a macro __STDC_NO_THREADS__
for declarada com o valor 1
isso indica que o compilador/libc não suporta o C11 threads. Você pode usar essa macro para validar se seu compilador suporta ou não esse recurso.
Um pequeno exemplo de código “hello world” para que você possa testar na sua máquina:
#include <stdio.h>
#include <stdlib.h>
#include <threads.h>
int thread_handler(void *arg)
{
puts("Secondary thread!");
return 0;
}
int main(void)
{
thrd_t my_thread;
if (thrd_create(&my_thread, thread_handler, NULL) != thrd_success)
{
perror("Error on create thread!");
return EXIT_FAILURE;
}
puts("Main thread!");
thrd_join(my_thread, NULL);
return EXIT_SUCCESS;
}
Thread-local Storage (TLS)
A região de memória TLS é uma região de memória estática única para cada thread do programa. Para declarar uma variável global ou local com o modificador static
dentro da TLS, você pode usar o storage-class modifier _Thread_local
. Assim cada thread usará uma região de memória diferente para a mesma variável. O header file <threads.h> declara o macro thread_local
que expande para _Thread_local
.
Exemplo:
#include <threads.h>
thread_local int my_global_var = 555;
Funções de threads
As seguintes funções são declaradas em <threads.h> que servem para a manipulação de threads.
Tipos:
thrd_t
: Identificador de uma thread usado para manipulá-la.thrd_start_t
: Equivalente aint (*)(void *)
. Passado parathrd_create()
para criar uma nova thread.
Constantes:
thrd_success
: Retornado por uma função para indicar sucesso.thrd_error
: Retornado por uma função para indicar erro.thrd_nomem
: Retornado por uma função para indicar que a operação falhou por falta de memória.
thrd_create
int thrd_create(thrd_t *thr, thrd_start_t func, void *arg);
Essa função cria uma nova thread executando o código da função passada como segundo argumento (func
) e passando o ponteiro do terceiro argumento como argumento para a função func
. O valor inteiro retornado por func
define o código de saída da thread.
O identificador da thread é armazenado no ponteiro passado como primeiro argumento.
Essa função retorna thrd_success
em caso de sucesso, thrd_nomem
em caso de falta de memória e thrd_error
em caso de erro.
thrd_current
thrd_t thrd_current(void);
Essa função retorna o identificador da thread que invocou a função.
thrd_detach
int thrd_detach(thrd_t thr);
Desvincula a thread identificada por thr
do ambiente do processo. Isso significa que quando a thread finalizar os recurso alocados para ela (arquivos, regiões de memória etc.) serão automaticamente liberados quando a thread finalizar. Não se deve invocar essa função após já ter invocado thrd_join()
ou thrd_detach()
para o mesmo identificador anteriormente.
Essa função retorna thrd_success
em caso de sucesso e thrd_error
em caso de erro.
thrd_equal
int thrd_equal(thrd_t thr0, thrd_t thr1);
Essa função retorna zero caso os identificadores thr0
e thr1
sejam identificadores de threads distintas. Caso contrário (se for a mesma thread) retorna um valor diferente de zero.
thrd_exit
_Noreturn void thrd_exit(int res);
Finaliza a execução da thread que invocou a função definindo seu código de saída como o parâmetro res
.
thrd_join
int thrd_join(thrd_t thr, int *res);
Essa função sincroniza a execução da thread atual com a thread identificada por thr
bloqueando a execução da thread atual até a thread thr
finalizar sua execução. Caso o ponteiro res
seja diferente de NULL
o código de saída da thread será armazenado no inteiro apontado pelo mesmo.
Essa função retorna thrd_success
em caso de sucesso e thrd_error
em caso de erro.
thrd_sleep
int thrd_sleep(const struct timespec *duration, struct timespec *remaining);
Essa função suspende a execução da thread atual até o intervalo de tempo especificado por duration
termine ou então caso um sinal seja recebido. Se a suspensão foi interrompida por um sinal e o argumento remaining
for diferente de NULL
, o intervalo de tempo restante será armazenado na estrutura apontada por esse ponteiro. Os dois ponteiros podem apontar para o mesmo objeto na memória.
Essa função retorna 0
caso o tempo especificado tenha passado, -1
caso tenha sido interrompido por um sinal ou um valor negativo caso tenha falhado.
thrd_yield
void thrd_yield(void);
Essa função sugere para o sistema operacional que a thread atual seja interrompida para dar espaço para outra thread ser executada, mesmo quando a thread atual normalmente continuaria sendo executada.
Essa função é útil, por exemplo, em loops muito longos (ou até infinitos). Pois invocando ela a cada iteração do loop garante que não seja consumido tempo de execução exacerbado por uma única thread. Assim evitando lentidões ou travamentos.
Funções de inicialização
Tipos:
once_flag
: Armazena uma flag usada pela funçãocall_once()
.
Macros:
ONCE_FLAG_INIT
: Expande para um valor que pode ser usado para inicializar um objeto do tipoonce_flag
.
call_once
void call_once(once_flag *flag, void (*func)(void));
Essa função usa a flag flag
para garantir que a função func
seja invocada apenas uma vez.
Funções de Thread-specific storage
Um Thread-specific storage (TSS) é uma região de memória específica por thread que armazena um valor.
Tipos:
tss_t
: Identificador único de um TSS.tss_dtor_t
: Equivalente avoid (*)(void *)
. Usado para destrutor de um TSS.
tss_create
int tss_create(tss_t *key, tss_dtor_t dtor);
Cria um novo TSS usando o destrutor dtor
que pode ser NULL
. O identificador do novo TSS é armazenado no endereço apontado por key
.
Essa função retorna thrd_success
em caso de sucesso e thrd_error
em caso de erro.
tss_delete
void tss_delete(tss_t key);
Libera todos os recursos usados pelo TSS identificado por key
.
tss_get
void *tss_get(tss_t key);
Obtém o valor armazenado no TSS identificado por key
. Retorna o valor em caso de sucesso ou NULL
em caso de erro.
tss_set
int tss_set(tss_t key, void *val);
Define o valor armazenado no TSS identificado por key
para val
. Retorna thrd_success
em caso de sucesso e thrd_error
em caso de erro.
Funções de mutex
Um mutex (ou lock) é um mecanismo de sincronização usado em um sistema de execução concorrente. Ele garante que apenas uma tarefa por vez acesse o recurso protegido pelo mutex, onde o mesmo funciona como um semáforo que pode ser trancado ou destrancado para sinalizar se um determinado recurso está livre ou não para ser usado por outras threads.
O C11 threads implementa mutex e abaixo será listado as funções necessárias para lidar com o mesmo.
Tipos:
mtx_t
: Identificador de um mutex.
Constantes:
mtx_plain
: Tipo de mutex simples (sem timeout).mtx_recursive
: Tipo de mutex recursivo (reentrant mutex).mtx_timed
: Tipo de mutex que suporta tempo limite (timeout).thrd_timedout
: Retornado por uma função para indicar que o tempo limite foi esgotado.thrd_busy
: Retornado por uma função para indicar que a operação falhou porque o recurso solicitado já está em uso.
mtx_destroy
void mtx_destroy(mtx_t *mtx);
Libera quaisquer recursos usado pelo mutex identificado por mtx
.
mtx_init
int mtx_init(mtx_t *mtx, int type);
Cria um novo mutex e armazena seu identificador no endereço apontado por mtx
. O argumento type
pode ser um dos seguintes valores:
mtx_plain
: Mutex simples sem recursividade nem timeout.mtx_timed
: Mutex com timeout.mtx_plain | mtx_recursive
: Mutex recursivo.mtx_timed | mtx_recursive
: Mutex recursivo que suporta timeout.
Essa função retorna thrd_success
em caso de sucesso e thrd_error
em caso de erro.
mtx_lock
int mtx_lock(mtx_t *mtx);
Bloqueia a thread atual até que o mutex identificado por mtx
seja trancado.
Essa função retorna thrd_success
em caso de sucesso e thrd_error
em caso de erro.
mtx_timedlock
int mtx_timedlock(mtx_t *restrict mtx, const struct timespec *restrict ts);
Bloqueia a thread atual até que o mutex identificado por mtx
seja trancado ou o tempo limite especificado por ts
seja atingido (que é uma data/hora UTC). O mutex deve suportar timeout.
Essa função retorna thrd_success
em caso de sucesso, thrd_error
em caso de erro e thrd_timedout
caso o tempo limite seja atingido sem conseguir trancar o mutex.
mtx_trylock
int mtx_trylock(mtx_t *mtx);
Tranca o mutex identificado por mtx
caso ele ainda não esteja trancado. Caso já esteja trancado retorna sem bloquear a thread atual.
Essa função retorna thrd_success
em caso de sucesso, thrd_error
em caso de erro e thrd_busy
se o mutex já estava trancado.
mtx_unlock
int mtx_unlock(mtx_t *mtx);
Destranca o mutex identificado por mtx
. Esse mutex deve ter sido trancado anteriormente pela mesma thread que invocou essa função.
Funções de condition variable
Uma condition variable pode ser usada para bloquear uma thread até que determinada condição aconteça e então ela possa continuar sua execução. É um recurso útil para sincronizar threads, por exemplo, quando uma thread precisa esperar um determinado evento ocorrer em outra thread.
Tipos:
cnd_t
: Identificador de uma condition variable.
cnd_broadcast
int cnd_broadcast(cnd_t *cond);
Desbloqueia todas as threads bloqueadas na condition variable cond
no momento da chamada da função. Se nenhuma thread estiver bloqueada no momento da chamada, a função não faz nada e retorna sucesso.
Essa função retorna thrd_success
em caso de sucesso e thrd_error
em caso de erro.
cnd_destroy
void cnd_destroy(cnd_t *cond);
Libera todos os recursos usados pela condition variable. Essa função requer que nenhuma thread esteja bloqueada por essa condition variable.
cnd_init
int cnd_init(cnd_t *cond);
Cria uma condition variable e guarda seu identificado no objeto apontado por cond
.
Essa função retorna thrd_success
em caso de sucesso, thrd_error
em caso de erro e thrd_nomem
caso não seja possível alocar memória para a nova condition variable.
cnd_signal
int cnd_signal(cnd_t *cond);
Desbloqueia uma das threads bloqueada nessa condition variable no momento da chamada da função. Se não houver threads bloqueadas no momento da chamada, a função não faz nada e retorna sucesso.
Essa função retorna thrd_success
em caso de sucesso e thrd_error
em caso de erro.
cnd_timedwait
int cnd_timedwait(cnd_t *restrict cond,
mtx_t *restrict mtx,
const struct timespec *restrict ts);
Atomicamente destranca o mutex apontado por mtx
e bloqueia a thread atual até que a condition variable cond
receba um sinal pelas funções cnd_signal()
ou cnd_broadcast()
, ou o tempo limite especificado por ts
seja atingido (que é uma data/hora UTC).
Quando a thread que invocou a função for desbloqueada o mutex será trancado novamente. É necessário que o mutex mtx
tenha sido trancado pela thread que invocou cnd_timedwait()
.
Essa função retorna thrd_success
em caso de sucesso, thrd_error
em caso de erro ou thrd_timedout
caso o tempo limite tenha se esgotado antes da condition variable receber o sinal.
cnd_wait
int cnd_wait(cnd_t *cond, mtx_t *mtx);
Atomicamente destranca o mutex apontado por mtx
e bloqueia a thread atual até que a condition variable cond
receba um sinal pelas funções cnd_signal()
ou cnd_broadcast()
.
Quando a thread que invocou a função for desbloqueada o mutex será trancado novamente. É necessário que o mutex mtx
tenha sido trancado pela thread que invocou cnd_wait()
.
Essa função retorna thrd_success
em caso de sucesso ou thrd_error
em caso de erro.
Referências
- Andrew S. Tanenbaum. Sistemas Operacionais Modernos. 4° Edição. ISBN: 978–8543005676
- C11 Standard — ISO/IEC 9899:201x draft n1570
- glibc source code — threads.h
- glibc source code — sysdeps/pthread
- thrd(3) — NetBSD Manual Pages
- Lock (computer science) — Wikipedia