Concorrência em C (visão geral)
LidoEsta aula oferece uma visão geral introdutória sobre concorrência em C, abordando threads com pthreads, o problema das race conditions, o conceito de mutex e quando faz sentido usar concorrência. O aluno aprenderá os fundamentos teóricos e práticos para começar a escrever programas concorrentes em C, com exemplos de código e exercícios.
Bem-vindo à aula 89 do nosso curso de Linguagem C. Hoje vamos explorar um tópico avançado e extremamente relevante: concorrência. Em um mundo onde processadores multi-core são a norma, saber escrever programas que executam múltiplas tarefas simultaneamente é uma habilidade valiosa. Nesta aula, você terá uma visão geral sobre como implementar concorrência em C usando threads, entenderá os riscos como race conditions, aprenderá a usar mutex para sincronização e saberá quando faz sentido aplicar esses conceitos.
Concorrência em C é alcançada principalmente através da biblioteca POSIX threads (pthreads), que fornece funções para criar e gerenciar threads. Vamos começar com os fundamentos e, ao longo da aula, você verá exemplos práticos que ilustram cada conceito. Ao final, você terá uma base sólida para aprofundar seus conhecimentos em programação concorrente.
Threads (pthreads, visão geral)
Uma thread é a menor unidade de execução que pode ser gerenciada de forma independente pelo escalonador do sistema operacional. Em um programa tradicional, temos apenas uma thread principal (a função main). Com threads, podemos executar várias funções simultaneamente, compartilhando o mesmo espaço de memória do processo. Isso permite que tarefas sejam divididas em partes menores que rodam em paralelo, aproveitando múltiplos núcleos da CPU.
Em C, a biblioteca pthreads (POSIX threads) é o padrão para trabalhar com threads em sistemas Unix-like. Ela fornece funções como pthread_create, pthread_join e pthread_exit. Para usar pthreads, é necessário incluir a biblioteca <pthread.h> e compilar com a flag -pthread.
Vejamos um exemplo simples: criar uma thread que imprime uma mensagem.
#include <stdio.h>
#include <pthread.h>
void* mensagem(void* arg) {
printf("Olá da thread!\n");
return NULL;
}
int main() {
pthread_t thread_id;
printf("Criando thread...\n");
pthread_create(&thread_id, NULL, mensagem, NULL);
pthread_join(thread_id, NULL);
printf("Thread terminou.\n");
return 0;
}
Neste código, pthread_create cria uma nova thread que executa a função mensagem. O quarto argumento é um ponteiro para dados que podem ser passados à função. A função pthread_join faz com que a thread principal espere até que a thread criada termine, evitando que o programa finalize antes. Sem o pthread_join, o programa pode terminar abruptamente, matando as threads.
É importante notar que as threads compartilham o mesmo espaço de memória, o que facilita a comunicação, mas também introduz riscos, como veremos a seguir.
Race conditions
Uma race condition ocorre quando duas ou mais threads acessam dados compartilhados simultaneamente e pelo menos uma delas está escrevendo, sem controle de ordem. O resultado final pode variar a cada execução, dependendo da ordem em que as threads são escalonadas pelo sistema operacional. Isso leva a bugs intermitentes e difíceis de depurar.
Considere um exemplo clássico: várias threads incrementando uma variável global. O incremento em C não é uma operação atômica; ele é composto por várias instruções de máquina (ler, somar, escrever). Se duas threads executam essas instruções intercaladas, o valor final pode ser menor que o esperado.
#include <stdio.h>
#include <pthread.h>
int contador = 0;
#define N 1000
void* incrementar(void* arg) {
for (int i = 0; i < N; i++) {
contador++; // Não é atômico!
}
return NULL;
}
int main() {
pthread_t threads[4];
for (int i = 0; i < 4; i++) {
pthread_create(&threads[i], NULL, incrementar, NULL);
}
for (int i = 0; i < 4; i++) {
pthread_join(threads[i], NULL);
}
printf("Valor final: %d (esperado: %d)\n", contador, 4 * N);
return 0;
}
Ao executar esse programa várias vezes, você provavelmente verá valores como 2000, 3000, etc., mas raramente 4000. Isso ocorre porque as operações de incremento se sobrepõem. Para evitar race conditions, precisamos de mecanismos de sincronização, como mutex.
Mutex (conceito)
Um mutex (abreviação de "mutual exclusion") é um objeto de sincronização que garante que apenas uma thread por vez possa acessar um recurso crítico. É como uma chave: a thread que quer entrar na seção crítica deve primeiro "adquirir" o mutex; quando termina, "libera" o mutex, permitindo que outra thread o adquira. Isso impede que múltiplas threads executem o código protegido simultaneamente, eliminando race conditions.
Na biblioteca pthreads, usamos pthread_mutex_t e as funções pthread_mutex_lock e pthread_mutex_unlock. O mutex deve ser inicializado antes do uso, geralmente com PTHREAD_MUTEX_INITIALIZER ou pthread_mutex_init.
Vamos corrigir o exemplo anterior usando um mutex:
#include <stdio.h>
#include <pthread.h>
int contador = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
#define N 1000
void* incrementar(void* arg) {
for (int i = 0; i < N; i++) {
pthread_mutex_lock(&mutex);
contador++;
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main() {
pthread_t threads[4];
for (int i = 0; i < 4; i++) {
pthread_create(&threads[i], NULL, incrementar, NULL);
}
for (int i = 0; i < 4; i++) {
pthread_join(threads[i], NULL);
}
printf("Valor final: %d (esperado: %d)\n", contador, 4 * N);
return 0;
}
Agora, o incremento é protegido pelo mutex, garantindo que apenas uma thread por vez execute a operação crítica. O resultado será sempre 4000. Note que usar mutex introduz overhead, então devemos proteger apenas as seções críticas, não todo o código.
Quando usar
Concorrência não é uma solução universal; ela deve ser usada com critério. Os principais cenários em que faz sentido usar threads em C incluem: tarefas que podem ser paralelizadas de forma independente, operações de I/O que podem ser sobrepostas (como leitura de arquivos), e processamento de dados em grandes volumes (como gráficos ou cálculos numéricos).
No entanto, evite usar threads quando: a tarefa é trivial e o overhead de criação e sincronização supera os benefícios; quando há dependências complexas entre tarefas que exigem sincronização intensa, o que pode levar a deadlocks; ou quando o programa deve ser simples e previsível. Além disso, threads compartilham memória, então é essencial garantir acesso seguro aos dados compartilhados.
Em sistemas com múltiplos núcleos, threads podem melhorar o desempenho, mas a comunicação entre threads pode ser um gargalo. É importante medir e testar para ver se a concorrência realmente traz ganhos. Uma boa prática é começar com uma versão sequencial e depois identificar partes que podem ser paralelizadas com segurança.
Boas práticas em concorrência
- Identifique e proteja todas as seções críticas (onde dados compartilhados são modificados).
- Use mutex somente quando necessário; minimize o tempo dentro da seção crítica.
- Evite segurar um mutex enquanto chama funções que podem bloquear (como I/O).
- Prefira variáveis locais quando possível; compartilhe apenas o que for essencial.
- Teste exaustivamente, pois race conditions são intermitentes e difíceis de reproduzir.
- Considere o uso de ferramentas como ThreadSanitizer para detectar corridas.
Exercícios
Exercício 1: Escreva um programa que cria 5 threads, cada uma imprimindo seu ID (passado como argumento) e uma mensagem. Use
pthread_createepthread_join.✓ Resposta:#include <stdio.h> #include <pthread.h> void* imprimir(void* arg) { int id = *(int*)arg; printf("Thread %d executando\n", id); return NULL; } int main() { pthread_t threads[5]; int ids[5] = {1, 2, 3, 4, 5}; for (int i = 0; i < 5; i++) { pthread_create(&threads[i], NULL, imprimir, &ids[i]); } for (int i = 0; i < 5; i++) { pthread_join(threads[i], NULL); } return 0; }Exercício 2: Crie um programa que simule um contador compartilhado entre 10 threads, cada uma incrementando 1000 vezes. Use um mutex para proteger o incremento. Verifique se o resultado é 10000.
✓ Resposta:#include <stdio.h> #include <pthread.h> int contador = 0; pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; void* incrementar(void* arg) { for (int i = 0; i < 1000; i++) { pthread_mutex_lock(&mutex); contador++; pthread_mutex_unlock(&mutex); } return NULL; } int main() { pthread_t threads[10]; for (int i = 0; i < 10; i++) { pthread_create(&threads[i], NULL, incrementar, NULL); } for (int i = 0; i < 10; i++) { pthread_join(threads[i], NULL); } printf("Contador: %d\n", contador); return 0; }Exercício 3: Explique por que a operação
contador++não é atômica em C e como o mutex resolve esse problema.✓ Resposta: A operaçãocontador++é composta por múltiplas instruções de máquina: ler o valor da memória, somar 1, e escrever o novo valor. Se duas threads executam essas instruções intercaladas, uma pode sobrescrever o valor que a outra acabou de escrever, resultando em uma perda de incremento. O mutex garante que apenas uma thread execute o bloco protegido por vez, tornando a sequência de operações indivisível do ponto de vista de outras threads.Exercício 4: Dado o código a seguir, identifique a race condition e sugira uma correção usando mutex. O programa tenta calcular a soma de um array com duas threads, cada uma somando metade.
#include <stdio.h> #include <pthread.h> #define TAM 1000 int soma = 0; int arr[TAM]; void* somar(void* arg) { int inicio = *(int*)arg; int fim = inicio + TAM/2; for (int i = inicio; i < fim; i++) { soma += arr[i]; } return NULL; } int main() { for (int i = 0; i < TAM; i++) arr[i] = 1; pthread_t t1, t2; int a = 0, b = TAM/2; pthread_create(&t1, NULL, somar, &a); pthread_create(&t2, NULL, somar, &b); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("Soma: %d\n", soma); return 0; }✓ Resposta: A race condition ocorre porque ambas as threads modificam a variável globalsomasem sincronização. A correção é proteger o acesso asomacom um mutex, como mostrado:#include <stdio.h> #include <pthread.h> #define TAM 1000 int soma = 0; int arr[TAM]; pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; void* somar(void* arg) { int inicio = *(int*)arg; int fim = inicio + TAM/2; for (int i = inicio; i < fim; i++) { pthread_mutex_lock(&mutex); soma += arr[i]; pthread_mutex_unlock(&mutex); } return NULL; } int main() { for (int i = 0; i < TAM; i++) arr[i] = 1; pthread_t t1, t2; int a = 0, b = TAM/2; pthread_create(&t1, NULL, somar, &a); pthread_create(&t2, NULL, somar, &b); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("Soma: %d\n", soma); return 0; }Exercício 5: Pesquise e descreva uma situação prática em que o uso de threads em C seja mais eficiente do que um programa sequencial. Cite um exemplo concreto (por exemplo, processamento de imagens, servidores web, etc.) e explique por que a concorrência ajuda.
✓ Resposta: Um exemplo é o processamento de imagens em lote. Se você tem várias imagens para aplicar um filtro, cada thread pode processar uma imagem independentemente, aproveitando múltiplos núcleos da CPU. Em um programa sequencial, as imagens seriam processadas uma após a outra, desperdiçando capacidade de processamento. Com threads, o tempo total pode ser drasticamente reduzido. Outro exemplo é um servidor web que atende múltiplas requisições simultâneas: cada requisição pode ser tratada em uma thread, permitindo que o servidor responda a vários clientes ao mesmo tempo, melhorando a capacidade de resposta.