Técnicas de Fabricação Avançadas
Implementação de Multithreads em C Usando Tópicos de Posix (pthreads)
Table of Contents
Introdução aos Tópicos do POSIX
Multithreading é uma técnica de programação que permite que um único processo execute várias tarefas simultaneamente, fazendo assim melhor uso dos recursos da CPU e melhorando a responsividade da aplicação. Na linguagem de programação C, o padrão mais amplamente adotado para multithreading em sistemas do tipo Unix é a biblioteca de threads POSIX, comumente referida como pthreads[. A API de pthreads fornece um conjunto abrangente de funções para criação de threads, sincronização e gerenciamento. É definida pelo padrão IEE POSIX 1003.1c e está disponível em quase todos os sistemas operacionais modernos do Linux, macOS e outros do UNIX.
Usando pthreads, os desenvolvedores podem projetar programas que executam computação de fundo, lidam com várias conexões de clientes simultaneamente ou paralelizam tarefas de processamento de dados. A biblioteca abstrai detalhes de baixo nível do sistema operacional, enquanto dá controle fino sobre o comportamento de threads. Um entendimento sólido de pthreads é essencial para qualquer programador C trabalhando em aplicativos sensíveis ao desempenho ou concorrentes. Este artigo cobre os conceitos fundamentais, fornece exemplos de código práticos e explora as primitivas de sincronização e as melhores práticas.
Conceitos Básicos de Pthreads
Antes de escrever código multithreaded, é importante se familiarizar com os tipos de dados e funções que o pthreads fornece:
- Thread: Uma unidade leve de execução que funciona dentro do espaço de endereço de um processo. Vários threads compartilham a mesma memória, descritores de arquivos e outros recursos, tornando a comunicação entre threads eficiente, mas também introduzindo a necessidade de sincronização cuidadosa.
- [[FLT: 0]]pthread t: Um tipo de dados opaco usado para representar um identificador de thread. Não é um inteiro; você deve tratá- lo como um identificador que é devolvido por [[FLT: 0]] e usado em chamadas como .
- pthread create: A função usada para gerar um novo thread. É preciso quatro argumentos: um ponteiro para uma variável , um ponteiro para um objeto de atributo thread (geralmente ] para os padrões), a função que o thread executará (um ponteiro para uma função que retorna ] e leva um único argumento [], e um argumento para passar para essa função.
- pthread join: Uma chamada de bloqueio que espera um thread específico terminar. Também recupera o valor de retorno da rotina inicial do thread.
- pthread mutex t: O tipo de dados para um bloqueio mutex (exclusão mutual), que é a sincronização fundamental primitiva usada para proteger dados compartilhados de acesso simultâneo.
- pthread cond t: Uma variável de condição usada em conjunto com mutexes para permitir threads esperarem que condições específicas se tornem verdadeiras.
Os tópicos são criados em um detacável ou conjunto[. Por padrão, os tópicos são uníveis, o que significa que existe uma chamada correspondente que limpa os seus recursos. Se você não planeja juntar um tópico, você pode desmontá-lo com para ter seus recursos automaticamente recuperados após o término.
Implementação de um programa simples multithreaded
A demonstração clássica de pthreads envolve a criação de dois threads que executam simultaneamente e imprimem mensagens. O exemplo abaixo ilustra os passos principais: declarando variáveis, chamando com uma rotina inicial, e então esperando que ambos os threads terminem com .
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
void* print_message(void* message) {
char* msg = (char*) message;
printf("%s\n", msg);
return NULL;
}
int main(void) {
pthread_t thread1, thread2;
char* message1 = "Hello from Thread 1!";
char* message2 = "Hello from Thread 2!";
if (pthread_create(&thread1, NULL, print_message, (void*) message1) != 0) {
perror("pthread_create");
exit(EXIT_FAILURE);
}
if (pthread_create(&thread2, NULL, print_message, (void*) message2) != 0) {
perror("pthread_create");
exit(EXIT_FAILURE);
}
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
return 0;
}
Neste programa, cria dois threads, cada um dos quais chama . As chamadas garantem que o thread principal aguarde que ambos os threads infantis terminem antes de sair. Note que a ordem de saída não é garantida: o escalonador do sistema operacional pode interligar a execução dos dois threads, assim você pode ver "Olá do Thread 2!" impresso antes da primeira mensagem.
É uma boa prática sempre verificar o valor de retorno de e outras funções do pthread. Eles retornam zero no sucesso, ou um código de erro positivo na falha (semelhante a ). O programa acima usa e para lidar com falhas graciosamente.
Ciclo de Vida e Atributos do Tópico
Cada thread no pthreads tem um ciclo de vida: criação, execução, terminação e limpeza. Quando um thread é criado, ele herda muitos atributos do processo de chamada, mas você pode ajustar o seu comportamento usando um objeto . Atributos comuns incluem:
- Desconecte o estado: Se o thread é criado como unível (padrão) ou desvinculado.
- Tamanho do stack: Permite que você aloque um tamanho específico da pilha para o thread.
- Política de agendamento e prioridade: Para controle de thread em tempo real (necessita privilégios apropriados).
Para definir atributos, inicialize um com , modifique-o com funções como ou , então passe o ponteiro de atributos como segundo argumento para . Quando terminar, chame . Objetos de atributos não são modificados pelo thread; eles só são usados durante a criação.
Sincronização com Mutexes
Quando vários threads acessam dados compartilhados simultaneamente, existe um risco de corridas de dados — situações em que o resultado depende da ordem imprevisível de execução de threads. Para evitar isso, os pthreads fornecem mutexes. Um mutex garante que apenas um thread de cada vez pode executar uma seção crítica de código. O padrão típico é:
- Inicializar um mutex com (ou estaticamente com ]).
- Bloquear o mutex antes de acessar dados compartilhados com .
- Desbloquear o mutex após a seção crítica com .
- Destrua o mutex quando já não é necessário com .
O exemplo a seguir demonstra um contador compartilhado incrementado por dois threads com proteção mutex adequada:
#include <pthread.h>
#include <stdio.h>
int counter = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void* increment(void* arg) {
for (int i = 0; i < 1000000; i++) {
pthread_mutex_lock(&lock);
counter++;
pthread_mutex_unlock(&lock);
}
return NULL;
}
int main(void) {
pthread_t t1, t2;
pthread_create(&t1, NULL, increment, NULL);
pthread_create(&t2, NULL, increment, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Final counter value: %d\n", counter); // Guaranteed to be 2000000
pthread_mutex_destroy(&lock);
return 0;
}
Sem o mutex, o valor final do contador provavelmente seria inferior a 2.000.000 devido às condições de corrida. O mutex serializa as operações de incremento, garantindo segurança do thread. No entanto, o bloqueio excessivo pode degradar o desempenho; a arte da programação multithreaded é minimizar o tamanho e a duração das seções críticas.
Tipos de Mutex e Tratamento de Erros
O POSIX define vários tipos de mutex controlados pelo atributo :
- Normal (): Um simples mutex que não detecta o impasse. Tentando bloqueá-lo novamente do mesmo thread resulta em ] deadlock.
- Error-checking (): Fornece detecção de erros; se o mesmo tópico tentar rebloquear um mutex já existente, ele retorna .
- Recursivo (): Permite que o thread proprietário bloqueie o mutex várias vezes sem bloqueio. Cada bloqueio deve ser emparelhado com um desbloqueio.
- Padrão (): A implementação pode mapear para qualquer uma das acima. No Linux (glibc) é equivalente a .
Verifique sempre o valor de retorno das funções de bloqueio/desbloqueamento do mutex. No código de produção, você deve lidar com erros potenciais (por exemplo, ] para um mutex inválido, se o thread de chamada não possui o mutex).
Variáveis de Condição
Variáveis de condição permitem que os threads aguardem que uma condição específica se torne verdadeira. Eles são sempre usados com um mutex. O padrão típico é: um thread bloqueia o mutex, verifica um predicado (uma variável compartilhada), e se o predicado for falso, ele chama ] que atomicamente libera o mutex e coloca o thread para dormir. Quando outro thread sinaliza a condição (com ] ou ], o thread que espera reajusta o mutex e verifica novamente o predicado.
Aqui está um exemplo clássico de produtor-consumidor usando uma única variável de condição e um mutex:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int data_ready = 0;
void* producer(void* arg) {
sleep(1); // Simulate work
pthread_mutex_lock(&mutex);
data_ready = 1;
printf("Producer: data ready\n");
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
return NULL;
}
void* consumer(void* arg) {
pthread_mutex_lock(&mutex);
while (!data_ready) {
pthread_cond_wait(&cond, &mutex);
}
printf("Consumer: processing data\n");
pthread_mutex_unlock(&mutex);
return NULL;
}
int main(void) {
pthread_t prod, cons;
pthread_create(&cons, NULL, consumer, NULL);
pthread_create(&prod, NULL, producer, NULL);
pthread_join(prod, NULL);
pthread_join(cons, NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}
Observe o loop: uma variável de condição pode sofrer de despertares espúrios (um thread pode retornar de ] sem realmente ser sinalizado). Verifique sempre o predicado após acordar, não ].
Transmissão vs. Sinal
Use quando apenas um tópico de espera precisa de acordar (por exemplo, um único recurso fica disponível); use quando todos os tópicos de espera devem acordar (por exemplo, uma bandeira de desligamento). O uso inadequado do sinal pode levar a despertares perdidos e à fome de thread.
Fechaduras de leitura- escrita
Para estruturas de dados que são lidas frequentemente, mas raramente escritas, um bloqueio de leitura () pode melhorar a concorrência. Vários leitores podem manter o bloqueio simultaneamente, mas um escritor requer acesso exclusivo. A API é semelhante a mutexes: , , e . Os bloqueios de leitura são particularmente úteis em cenários como tabelas de cache ou configuração que são atualizadas com pouca frequência.
Exemplo:
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
void* reader(void* arg) {
pthread_rwlock_rdlock(&rwlock);
// read shared data
pthread_rwlock_unlock(&rwlock);
return NULL;
}
void* writer(void* arg) {
pthread_rwlock_wrlock(&rwlock);
// modify shared data
pthread_rwlock_unlock(&rwlock);
return NULL;
}
Esteja ciente de que as fechaduras de leitura-escrita podem ser menos eficientes do que os mutexes quando a seção crítica é muito curta, e eles podem causar fome de escritor se os leitores estão continuamente chegando.
Pistas e melhores práticas comuns
A programação multithreaded em C é poderosa, mas propensa a erros. Abaixo estão problemas frequentes e como evitá-los.
Corridas de dados e Deadlocks
Uma corrida de dados ocorre quando dois threads acessam o mesmo local de memória sem sincronização e pelo menos um registro. C11 adicionou o qualificador , mas os pthreads não fornecem operações atômicas diretamente; você deve usar os bloqueios. A ] deadlock[ acontece quando dois ou mais threads esperam que um outro solte um bloqueio. Para evitar bloqueios:
- Adquira sempre fechaduras em uma ordem global consistente.
- Utilizar ou se não puder garantir a encomenda.
- Mantenha as secções críticas o mais breve possível.
Segurança de Tópicos das Funções da Biblioteca
Muitas funções de biblioteca padrão C não são seguras para thread. Por exemplo, ] usa estado estático interno. Use as versões reentrantes (por exemplo, ]) ou proteja chamadas com um mutex. Funções como também não são seguras para thread; prefira com uma semente per-thread.
Tamanho da pilha e limites de recursos
Cada thread tem sua própria pilha. O tamanho padrão da pilha pode ser grande (por exemplo, 8 MB no Linux). Criar milhares de threads com pilhas padrão pode esgotar a memória. Use para ajustar os tamanhos da pilha quando você conhece os requisitos de um thread. Também esteja ciente dos limites de processo por: ] limita o número de threads que um único processo pode criar.
Cancelamento e Limpeza do Tópico
Você pode cancelar um tópico com ]. O cancelamento pode ser assíncrono (imediato) ou deferido[ (até que o tópico atinja um ponto de cancelamento como ] ou ). O cancelamento diferido é mais seguro porque permite que o tópico libere bloqueios e limpe recursos. Use e ] para registrar manipuladores de limpeza. Evite cancelar threads a menos que absolutamente necessário; é muitas vezes melhor usar uma bandeira compartilhada que a linha verifique periodicamente.
Tópicos Avançados: Grupos de Tópicos e Filas de Trabalho
Criar e destruir tópicos para cada tarefa pequena é caro. Um grupo de threads pré- cria um número fixo de tópicos de worker que ficam em estado de espera até que as tarefas sejam enviadas. As tarefas são normalmente armazenadas em uma fila de trabalho (uma estrutura de dados sincronizada). O pool de threads é uma pedra angular de aplicações de servidor de alto desempenho. A implementação de um a partir do zero é um grande exercício: use um mutex, uma variável de condição e uma lista de ponteiros de funções conectadas. Muitos sistemas de produção usam bibliotecas como o libpthread (parte de dispositivos de glibc) ou de frameworks externos, mas os princípios são idênticos.
Depuração e Ajuste de Desempenho
Os erros multithreaded são notoriamente difíceis de reproduzir e corrigir. Use ferramentas como O Helgrind ou ThreadSanitizer[ (apto para compilar com ) para detectar corridas de dados e bloqueios de dados. O GDB suporta depuração multithreaded com comandos como ] e . Para a profilização de desempenho, o Linux’s ] ou podem ajudar a identificar gargalos de contenção. Use [ apenas em sistemas multi-core com secções críticas muito curtas, como o spinlocks deswaste CPU se a espera for longa.
Conclusão
POSIX Threads (pthreads) provide a robust, standard interface for multithreading in C. By mastering thread creation, mutexes, condition variables, and read‑write locks, you can build concurrent applications that are both efficient and correct. Start with simple examples, always handle errors, and gradually incorporate advanced patterns like thread pools. For further reading, consult the IEEE POSIX specification, the Linux pthreads man page, and the book Programming with POSIX Threads by David R. Butenhof. With careful design and testing, pthreads can dramatically improve the performance and responsiveness of your C programs while remaining portable across Unix‑like systems.