Executando verificação de segurança...
27
Silva97
12 min de leitura ·

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 a int (*)(void *). Passado para thrd_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ção call_once().

Macros:

  • ONCE_FLAG_INIT: Expande para um valor que pode ser usado para inicializar um objeto do tipo once_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 a void (*)(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_plainMutex simples sem recursividade nem timeout.
  • mtx_timedMutex com timeout.
  • mtx_plain | mtx_recursiveMutex recursivo.
  • mtx_timed | mtx_recursiveMutex 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

Carregando publicação patrocinada...
3

Cara sensacional sua publicação. Estou pensando em começar a publicar coisas sobre sistemas embarcados em C, não sei se o pessoal daqui acharia interessante. Mas parabéns pela publicação.

1
1

Cara, há anos estou em busca de consumir fóruns ou sites com posts na pegada do Embedded C, ainda mais em português e alimentado por pessoas que trabalham na área. Seria muito legal ter pessoas criando e postando conteúdo de sistemas embarcados e baixo nível :)

1
1
1
1
1

Só pela explicação do que é uma Thread já valeu meu TabCoin. Parabéns pelo artigo, é ótimo, não só ao nível de conteúdo mas também de organização, os conteúdos daqui deveriam no mínimo ser assim.

Se puder trazer mais conceitos sobre, seria muito massa! Uma coisa que eu não vejo ninguém falando que você poderia abordar é requisições HTTP com C ou requisições de forma geral.

2
1
1
0