menu_book Índice

Logo UFPR

Sincronização em Sistemas Operacionais

Logo Licenciatura

DEE355 – Sistemas Operacionais

Prof. Jéferjefer@ufpr.br

docs.ufpr.br/~jefer

EAD – Moodle: ava.ufpr.br

Este deck aborda os fundamentos e técnicas avançadas de sincronização, essenciais para evitar condições de corrida, gerenciar regiões críticas e coordenar processos e threads em sistemas operacionais.

Logo UFPR

Objetivos

Logo Licenciatura
  • Atender múltiplos usuários simultâneos (ex.: servidores – Maziero, Cap. 8);
  • Aproveitar multiprocessadores com tarefas cooperantes (Tanenbaum, Cap. 2.3);
  • Sincronizar processos, evitando deadlocks (Silberschatz, Cap. 5).

Organização dos Tópicos: Sincronização e Concorrência

A sequência de tópicos apresentada foi cuidadosamente organizada para refletir uma progressão didática, do problema à aplicação prática e soluções modernas:

  • Condição de Corrida → Região Crítica → Exclusão Mútua: Iniciamos com a definição do problema (condição de corrida), passamos pela abstração teórica (região crítica) e então exploramos as ferramentas concretas para resolvê-lo (exclusão mútua).
  • Semáforos e Mutexes antes de Dormir & Acordar: Esses mecanismos são implementações diretas dos conceitos de bloqueio e liberação, e por isso devem ser compreendidos antes de explorar o comportamento de dormir e acordar processos.
  • Produtor-Consumidor: Apresentado como um estudo de caso clássico que combina semáforos, mutexes e controle de fluxo entre threads, demonstrando na prática os conceitos anteriores.
  • Monitores e Troca de Mensagens: São abordagens mais abstratas e de alto nível para resolver problemas de concorrência, muitas vezes encapsulando a lógica de sincronização.
  • Barreiras, RCU e OpenMP: Técnicas mais modernas, geralmente associadas à sincronização em sistemas paralelos e leitura eficiente sem bloqueios.
  • Prática de Sincronização: Finalizamos com atividades práticas e laboratoriais, reforçando o aprendizado com exercícios e simulações.
Logo UFPR

Condição de Corrida

Logo Licenciatura

Definição: Ocorre quando dois ou mais processos/threads acessam um recurso compartilhado simultaneamente, sem sincronização, gerando resultados imprevisíveis (Tanenbaum, 2.3.1).

Impacto: Comum em sistemas concorrentes como bancos de dados (ex.: saldo duplicado) ou servidores web (ex.: contagem errada de acessos).

Exemplo Simples:

  • Variável compartilhada: contador = 0
  • Processo 1: Lê contador (0), calcula 0 + 1, escreve 1
  • Processo 2: Lê contador (0), calcula 0 + 1, escreve 1
  • Resultado final: contador = 1 (esperado: 2)

Exemplo em C: Dois threads incrementando uma variável compartilhada contador sem qualquer forma de sincronização (como mutexes).

#include <pthread.h>
#include <stdio.h>

int contador = 0;

void* incrementar(void* arg) {
    int temp = contador;           // Lê o valor atual
    temp = temp + 1;               // Incrementa localmente
    contador = temp;               // Escreve de volta
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, incrementar, NULL);
    pthread_create(&t2, NULL, incrementar, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("Contador final: %d\\n", contador); // Pode ser 1 ou 2!
    return 0;
}

Explicação detalhada:

  • Duas threads são criadas: t1 e t2. Ambas executam a função incrementar(), que deve incrementar o valor da variável global contador.
  • A função incrementar() executa três passos:
    1. Lê o valor atual da variável contador para uma variável local temp.
    2. Incrementa temp.
    3. Grava o novo valor de temp de volta em contador.
  • O problema ocorre porque esses três passos não são atômicos — ou seja, podem ser interrompidos por outra thread no meio da execução.
  • Se as duas threads executarem int temp = contador; quase ao mesmo tempo, ambas podem obter o mesmo valor (ex: 0), incrementá-lo localmente (ficando com 1 em temp), e então sobrescrever contador com o mesmo valor 1. O resultado final será contador = 1, mesmo que duas operações de incremento tenham sido feitas.

Resultado esperado: O valor correto de contador após a execução deveria ser 2, mas devido à condição de corrida, ele pode ser 1 ou 2, dependendo da ordem de execução dos threads.

Esse exemplo ilustra:

  • A condição de corrida (race condition): quando múltiplas threads acessam e manipulam dados compartilhados ao mesmo tempo e o resultado depende da ordem de execução.
  • A necessidade de exclusão mútua (como o uso de mutexes) para proteger seções críticas do código em ambientes concorrentes.
Diagrama de Condição de Corrida Diagrama de Condição de Corrida2
Logo UFPR

Regiões Críticas

Logo Licenciatura

Definição: Seção do código que acessa recursos compartilhados (ex.: variáveis, arquivos) e deve ser executada de forma exclusiva por apenas um processo/thread por vez, evitando condições de corrida (Tanenbaum, 2.3.2).

Importância: Garante consistência em sistemas concorrentes como bancos de dados, servidores e sistemas de arquivos (Maziero, Cap. 8).

Requisitos (Silberschatz, Cap. 5):

  • Exclusão Mútua: Apenas um processo na região crítica por vez.
  • Progresso: Processos fora da região não podem bloquear os que querem entrar.
  • Espera Limitada: Nenhum processo espera indefinidamente para acessar.

Exemplo Prático: Controle de uma fila de impressão em um servidor.

  • Sem controle: Dois processos adicionam jobs simultaneamente, corrompendo a fila.
  • Com região crítica: Apenas um processo manipula a fila por vez.

Código em C: Incremento protegido por um lock (uso de pthread_mutex_t para garantir exclusão mútua).

#include <pthread.h>
#include <stdio.h>

int contador = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void* incrementar(void* arg) {
    pthread_mutex_lock(&lock);    // Início da região crítica
    contador++;                   // Acesso ao recurso compartilhado
    printf("Contador: %d\\n", contador);
    pthread_mutex_unlock(&lock);  // Fim da região crítica
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, incrementar, NULL);
    pthread_create(&t2, NULL, incrementar, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}

Explicação detalhada:

  • Esse código é similar ao exemplo anterior, mas agora utiliza um mutex para proteger a variável contador contra acessos simultâneos de múltiplas threads.
  • Antes de acessar o recurso compartilhado, a thread executa pthread_mutex_lock(&lock);, que garante que apenas uma thread por vez poderá entrar na região crítica.
  • Dentro da região crítica, a variável contador é incrementada com segurança e seu valor é exibido.
  • Após a operação, a thread libera o bloqueio com pthread_mutex_unlock(&lock);, permitindo que outra thread possa entrar na região crítica.

Comportamento esperado:

  • Ao executar o programa, ambas as threads conseguem incrementar contador com segurança, e o valor final será sempre 2.
  • Isso acontece porque não há mais condição de corrida: o acesso à variável foi serializado via mutex.

Conceitos ilustrados:

  • Região crítica: Parte do código que acessa recursos compartilhados e deve ser executada por apenas uma thread por vez.
  • Mutex (Mutual Exclusion): Um mecanismo de sincronização usado para proteger regiões críticas em ambientes com múltiplas threads.
  • Sincronização: Coordenação do acesso a recursos compartilhados para evitar conflitos ou resultados inconsistentes.

Resultado: Executar várias vezes sempre imprime dois valores e garante que contador = 2, validando o uso correto do mutex.

Diagrama de Região Crítica
Logo UFPR

Exclusão Mútua

Logo Licenciatura

Definição: Garante que apenas um processo ou thread acesse uma região crítica por vez, evitando condições de corrida (Tanenbaum, 2.3.3).

Técnica 1 – Espera Ocupada (Busy Waiting): Um processo verifica continuamente (em um laço) o estado de uma variável de controle para saber se pode entrar na região crítica.

#include <stdio.h>

volatile int lock = 0; // Variável de controle compartilhada

void entrar_regiao_critica(int id) {
    while (lock == 1); // Espera ocupada: o processo fica preso aqui enquanto o lock estiver ativo
    lock = 1;          // Entra na região crítica e "tranca" o acesso para os outros
    printf("Processo %d na região crítica\\n", id);
    lock = 0;          // Libera o lock após sair da região crítica
}

int main() {
    entrar_regiao_critica(1);
    entrar_regiao_critica(2);
    return 0;
}

Explicação detalhada:

  • A variável lock atua como uma *flag* de controle: quando vale 0, a região crítica está livre; quando vale 1, ela está ocupada.
  • O modificador volatile é usado para indicar ao compilador que essa variável pode mudar de forma imprevisível (por exemplo, por outro processo ou thread), evitando otimizações indesejadas.
  • Quando a função entrar_regiao_critica() é chamada, ela entra em um laço while que fica testando a variável lock até que ela seja 0. Isso é o que chamamos de espera ocupada.
  • Assim que lock vale 0, o processo o define como 1 (bloqueando a entrada de outros), executa a região crítica (impressão), e em seguida libera a região crítica definindo lock = 0.

Comportamento: Embora os dois processos pareçam acessar a função um após o outro no exemplo, a técnica em si demonstra o princípio da espera ocupada em sistemas com concorrência real (várias threads/processos paralelos).

Vantagens:

  • Extremamente simples de implementar.
  • Funciona em sistemas embarcados ou ambientes com poucos recursos, onde não há suporte a semáforos ou mutexes.

Desvantagens:

  • Consome muito tempo de CPU: enquanto o processo espera, ele continua executando instruções (verificando a variável), o que é ineficiente.
  • Não é escalável: em sistemas com múltiplos processos concorrentes, a espera ocupada pode causar gargalos e desperdício de recursos.
  • Não garante ordem de acesso: pode haver problemas de justiça (um processo pode ficar esperando indefinidamente).

Resumo: Essa técnica serve como uma introdução aos mecanismos de controle de concorrência, mas na prática, é substituída por mecanismos mais eficientes como semáforos, mutexes e monitores.

Técnica 2 – Test-and-Set: Utiliza uma instrução atômica (não interrompível) para testar e definir o bloqueio simultaneamente. Essa operação é comumente implementada em hardware para garantir que múltiplos processos não acessem uma região crítica ao mesmo tempo.

function test_and_set(lock) {
    old = lock;
    lock = 1;
    return old;
}

while (test_and_set(lock) == 1); // Espera até lock ser 0
// Região crítica
lock = 0; // Libera

Explicação detalhada:

  • A função test_and_set lê o valor atual da variável lock e, ao mesmo tempo, define seu valor para 1.
  • Ela retorna o valor antigo da variável. Se o valor retornado for 1, significa que a região crítica já estava sendo utilizada por outro processo/thread.
  • O processo entra em um laço de espera até que test_and_set retorne 0, indicando que a região crítica está livre.
  • Após acessar a região crítica, o processo libera o bloqueio definindo lock = 0.

Vantagens:

  • Evita a condição de corrida por meio de uma operação atômica única.
  • Mais eficiente e segura do que a espera ocupada simples, pois garante acesso exclusivo.

Desvantagens:

  • Ainda utiliza espera ocupada, consumindo CPU enquanto aguarda a liberação do bloqueio.
  • Não garante justiça (alguns processos podem nunca obter acesso à região crítica).

Alternativa Moderna: Uso de pthread_mutex em C para controle eficiente de concorrência. Essa abordagem é recomendada por ser mais eficiente, segura e suportada por bibliotecas de threads modernas (como POSIX).

#include <pthread.h>
#include <stdio.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void* regiao_critica(void* arg) {
    int id = *(int*)arg;
    pthread_mutex_lock(&mutex);            // Início da região crítica
    printf("Thread %d na região crítica\\n", id);
    pthread_mutex_unlock(&mutex);          // Liberação do mutex
    return NULL;
}

Explicação:

  • pthread_mutex_lock bloqueia o acesso à região crítica para todas as outras threads.
  • Somente uma thread pode entrar na região protegida por vez, evitando qualquer condição de corrida.
  • pthread_mutex_unlock libera o acesso após o uso, permitindo que outra thread entre.
  • É uma forma moderna, segura e eficiente de garantir exclusão mútua, como recomendado no capítulo 5 do livro do Silberschatz (Operating System Concepts).

Conclusão: Enquanto test-and-set é uma solução mais próxima do hardware e útil para estudo, o uso de mutex é a prática recomendada em sistemas reais e modernos.

Exemplo de Deadlock entre Dois Processos e Dois Recursos

A figura acima representa uma situação clássica de deadlock, onde dois processos estão bloqueados mutuamente esperando por recursos que nunca serão liberados.

  • Processo A possui o Recurso 1 e está aguardando o Recurso 2.
  • Processo B possui o Recurso 2 e está aguardando o Recurso 1.
Fluxo de Espera Ocupada

Explicação do Ciclo:

  1. O Recurso 1 está atualmente alocado ao Processo A.
  2. O Processo A então solicita o Recurso 2, que está em uso pelo Processo B.
  3. Simultaneamente, o Recurso 2 está alocado ao Processo B.
  4. O Processo B solicita o Recurso 1, que está sendo usado pelo Processo A.

Esse ciclo gera um impasse circular, onde:

  • Ambos os processos estão em espera bloqueada, esperando por recursos que não serão liberados;
  • Nenhum dos dois consegue prosseguir, pois cada um aguarda o recurso que o outro possui;
  • Essa situação representa um deadlock, ou seja, uma paralisação indefinida do sistema.

Condições necessárias para o deadlock:

  1. Exclusão mútua: Os recursos não podem ser compartilhados.
  2. Posse e espera: Um processo mantém um recurso enquanto espera por outro.
  3. Não-preempção: O recurso só pode ser liberado voluntariamente pelo processo.
  4. Espera circular: Há um ciclo de processos, onde cada um espera pelo recurso que o próximo possui.

Conclusão: Esta representação gráfica ajuda a compreender a origem e o mecanismo do deadlock em sistemas operacionais, sendo essencial para o estudo de estratégias de prevenção e detecção de impasses.

Comparação entre Estratégias de Gerenciamento de Deadlock

Estratégia Descrição Vantagens Desvantagens
Prevenção Elimina uma ou mais das condições necessárias para que o deadlock ocorra. Evita deadlock completamente. Pode ser ineficiente; reduz paralelismo e flexibilidade do sistema.
Evitação Permite que o sistema entre em estados apenas se forem seguros (ex: algoritmo do banqueiro). Mais flexível que a prevenção. Requer conhecimento prévio sobre recursos futuros; difícil de aplicar na prática.
Detecção e Recuperação Permite que o deadlock ocorra, detecta e então toma medidas para corrigir. Evita restrições desnecessárias durante a execução. Gera sobrecarga com algoritmos de detecção; recuperação pode ser complexa ou custosa.
Ignorar o problema Assume que deadlocks são raros e não toma medidas específicas (estratégia do "avestruz"). Simples de implementar; bom desempenho na maioria dos casos. Se o deadlock ocorrer, o sistema pode travar indefinidamente.

Observação: A escolha da estratégia depende do tipo de sistema, dos recursos envolvidos e do nível de criticidade da aplicação. Sistemas em tempo real, por exemplo, geralmente não podem ignorar deadlocks.

Algoritmo do Banqueiro

O Algoritmo do Banqueiro, proposto por Dijkstra, é uma técnica de evitação de deadlock que decide se uma solicitação de recurso pode ser atendida com segurança. Ele simula a alocação para verificar se o sistema permanecerá em estado seguro.

O sistema só aceita uma nova alocação se, após essa operação, existe pelo menos uma sequência segura de execução dos processos.

Conceitos-chave:

  • Max: Quantidade máxima de recursos que um processo pode solicitar.
  • Allocation: Quantidade de recursos que o processo já possui.
  • Need: Need = Max - Allocation.
  • Available: Recursos disponíveis no sistema.

Passos principais:

  1. Verifica se Request ≤ Need. Caso contrário, erro.
  2. Verifica se Request ≤ Available. Caso contrário, o processo espera.
  3. Executa a alocação "temporária".
  4. Testa se o sistema está em estado seguro.
  5. Se sim, mantém a alocação. Se não, desfaz e o processo espera.

Diagrama conceitual:

[ Processo P1 ]
Max:       [7 5 3]
Alloc:     [0 1 0]
Need:      [7 4 3]

[ Processo P2 ]
Max:       [3 2 2]
Alloc:     [2 0 0]
Need:      [1 2 2]

[ Processo P3 ]
Max:       [9 0 2]
Alloc:     [3 0 2]
Need:      [6 0 0]

[ Processo P4 ]
Max:       [2 2 2]
Alloc:     [2 1 1]
Need:      [0 1 1]

Available: [3 3 2]

→ Existe uma sequência segura? Exemplo: P2 → P4 → P1 → P3 → ...

Exercício: Simulação do Algoritmo do Banqueiro

Considere os seguintes dados:

Processo Max Allocation Need
P1[7 5 3][0 1 0][7 4 3]
P2[3 2 2][2 0 0][1 2 2]
P3[9 0 2][3 0 2][6 0 0]
P4[2 2 2][2 1 1][0 1 1]

Available: [3 3 2]

Questões:

  1. Existe uma sequência segura de execução? Se sim, qual?
  2. O processo P2 solicita [1 0 2]. O sistema pode conceder esse pedido?
Logo UFPR

Dormir e Acordar

Logo Licenciatura

Mecanismo: Um processo suspende sua execução (dorme) ao encontrar um recurso indisponível e é despertado (acordado) por outro processo quando o recurso está pronto (Tanenbaum, 2.3.4). Substitui a espera ocupada, economizando CPU.

Funcionamento: Usa sinais (ex.: sleep() e wakeup()) para coordenar processos.

Exemplo – Produtor-Consumidor: O consumidor dorme se o buffer estiver vazio; o produtor o acorda ao adicionar itens.

#include stdio.h
#include pthread.h
#include unistd.h

int itens = 0;
int consumidor_dormindo = 0;

void* produtor(void* arg) {
    itens++;                   // Produz um item
    if (consumidor_dormindo) {
        printf("Produtor acorda consumidor\\n");
        consumidor_dormindo = 0; // Acorda manualmente (simulação)
    }
    return NULL;
}

void* consumidor(void* arg) {
    if (itens == 0) {
        printf("Consumidor dorme\\n");
        consumidor_dormindo = 1;
        sleep(1);              // Dorme até ser acordado
    }
    itens--;                   // Consome
    printf("Consumidor consome: %d\\n", itens);
    return NULL;
}

int main() {
    pthread_t prod, cons;
    pthread_create(&cons, NULL, consumidor, NULL);
    sleep(1); // Dá tempo para consumidor dormir
    pthread_create(&prod, NULL, produtor, NULL);
    pthread_join(cons, NULL);
    pthread_join(prod, NULL);
    return 0;
}
    

Limitação: Perda de sinais pode ocorrer se o wakeup() for enviado antes do sleep() (Maziero, Cap. 8). Solução: Usar semáforos ou monitores.

Fluxo de Dormir e Acordar Produtor-Consumidor
Logo UFPR

Semáforos e Mutexes

Logo Licenciatura

Semáforos: Variável de controle para sincronização entre processos/threads (Tanenbaum, 2.3.5).

  • Binário: Valores 0 ou 1, usado para exclusão mútua (similar a um lock).
  • Contador: Valores ≥ 0, gerencia múltiplos recursos (ex.: vagas em um buffer – Silberschatz, Cap. 5).
  • Operações: P()/down() (decrementa, bloqueia se 0), V()/up() (incrementa, desbloqueia).

Mutexes: Semáforo binário otimizado para exclusão mútua, com posse explícita (Tanenbaum, 2.3.6).

  • Característica: Apenas o dono do mutex pode liberá-lo, evitando liberações acidentais.

Funcionamento de Semáforo Binário – Controle de Acesso à Região Crítica

A imagem representa o ciclo de controle de concorrência utilizando semáforos para garantir a exclusão mútua entre processos que desejam acessar uma região crítica.

  • Processo deseja entrar na região crítica:
    • O processo tenta executar a operação DOWN(S) no semáforo S.
    • Se S = 1, ele é decrementado para S = 0 e o processo entra na região crítica.
    • Se S = 0, o processo é bloqueado e colocado na fila de espera.
  • Processo acessa a região crítica:
    • Somente um processo por vez pode estar nessa etapa.
    • O valor do semáforo S permanece 0 enquanto a região crítica está ocupada.
  • Processo sai da região crítica:
    • Executa a operação UP(S), que define S = 1 e sinaliza que a região crítica está livre.
    • Se houver processos na fila de espera, o semáforo libera um processo da fila automaticamente para acessar a região crítica.
  • Fila de espera de processos:
    • Contém os processos que tentaram executar DOWN(S) quando S = 0.
    • Esses processos aguardam até que o semáforo seja incrementado por outro processo que saiu da região crítica.

Resumo: O semáforo atua como um mecanismo de bloqueio que controla o acesso à região crítica de forma sincronizada. Ele impede que dois ou mais processos entrem na região crítica simultaneamente, promovendo a exclusão mútua através de operações atômicas DOWN e UP.

Espera Circular na Exclusão Mútua com Semáforo

A figura acima ilustra o funcionamento de um semáforo binário no controle de acesso à região crítica, utilizando as operações DOWN(S) e UP(S) como mecanismos de dormir e acordar processos.

Explicação do Fluxo:

  • Processo deseja entrar na região crítica:
    • Executa a operação DOWN(S).
    • Se S == 1, o processo entra na região crítica e S passa a ser 0.
    • Se S == 0, o processo é colocado na fila de espera e entra em estado de dormência.
  • Processo acessa a região crítica:
    • Executa a tarefa que requer exclusão mútua.
    • Durante esse período, nenhum outro processo pode entrar.
  • Processo sai da região crítica:
    • Executa a operação UP(S), liberando o recurso (S = 1).
    • Um dos processos na fila de espera é acordado para tentar novamente o DOWN(S).

Conceitos ilustrados:

  • Semáforo: Variável usada para controlar acesso a recursos compartilhados. Aqui, está no modo binário (0 ou 1).
  • DOWN(S): Decrementa o semáforo. Se S == 0, o processo dorme.
  • UP(S): Incrementa o semáforo. Acorda um processo da fila (se houver).
  • Fila de espera: Armazena processos bloqueados aguardando o recurso.

Onde usar essa explicação:

Essa figura e explicação se encaixam nas seções de:

  • Exclusão Mútua
  • Sincronização com Semáforos
  • Operações de dormir e acordar processos
  • Controle de concorrência com regiões críticas

Resumo: A espera circular representa como processos tentam acessar a região crítica e, se não puderem, entram em uma fila de espera. Ao liberar o recurso com UP(S), um novo processo tem chance de acessar a região protegida, garantindo exclusão mútua.

Figura Semáforo Binário

Semáforo Binário

Exemplo – Produtor-Consumidor com Semáforo e Mutex:

#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>

#define BUFFER_SIZE 2
int buffer[BUFFER_SIZE];
int count = 0;
sem_t sem_vagas, sem_itens; // Semáforos contadores
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void* produtor(void* arg) {
    sem_wait(&sem_vagas);         // Espera por uma vaga no buffer
    pthread_mutex_lock(&mutex);   // Entra na região crítica
    buffer[count++] = 1;          // Adiciona item ao buffer
    printf("Produziu, count: %d\\n", count);
    pthread_mutex_unlock(&mutex); // Sai da região crítica
    sem_post(&sem_itens);         // Sinaliza que há um novo item
    return NULL;
}

void* consumidor(void* arg) {
    sem_wait(&sem_itens);         // Espera por um item disponível
    pthread_mutex_lock(&mutex);   // Entra na região crítica
    count--;                      // Remove item do buffer
    printf("Consumiu, count: %d\\n", count);
    pthread_mutex_unlock(&mutex); // Sai da região crítica
    sem_post(&sem_vagas);         // Sinaliza que uma vaga foi liberada
    return NULL;
}

int main() {
    sem_init(&sem_vagas, 0, BUFFER_SIZE); // Inicializa semáforo com número de vagas disponíveis
    sem_init(&sem_itens, 0, 0);           // Inicializa semáforo com 0 itens
    pthread_t prod, cons;
    pthread_create(&prod, NULL, produtor, NULL);
    pthread_create(&cons, NULL, consumidor, NULL);
    pthread_join(prod, NULL);
    pthread_join(cons, NULL);
    sem_destroy(&sem_vagas);
    sem_destroy(&sem_itens);
    return 0;
}

Explicação detalhada:

  • Objetivo: Controlar o acesso concorrente a um buffer limitado, onde um produtor insere dados e um consumidor retira dados.
  • Semáforo sem_vagas: Controla o número de posições livres no buffer. O produtor só pode inserir quando houver vaga.
  • Semáforo sem_itens: Controla o número de itens disponíveis no buffer. O consumidor só pode retirar se houver pelo menos um item.
  • Mutex: Garante que apenas uma thread (produtor ou consumidor) acesse e modifique a variável count por vez, evitando condição de corrida.
  • Quando o produtor é executado:
    • Espera por uma vaga com sem_wait(&sem_vagas).
    • Tranca o mutex para acessar a região crítica e incrementa o contador.
    • Libera o mutex e sinaliza que há um novo item com sem_post(&sem_itens).
  • Quando o consumidor é executado:
    • Espera por um item com sem_wait(&sem_itens).
    • Tranca o mutex para acessar a região crítica e decrementa o contador.
    • Libera o mutex e sinaliza que há uma nova vaga com sem_post(&sem_vagas).

Funcionamento: O sistema funciona mesmo com múltiplos produtores e consumidores, respeitando os limites do buffer e garantindo exclusão mútua no acesso à variável compartilhada count.

Comparação:

  • Semáforo: Permite controlar múltiplos recursos ou slots; ideal para gerenciar disponibilidade (vagas/itens).
  • Mutex: Focado na proteção de uma região crítica única; é leve e eficiente quando há apenas uma variável compartilhada.

Resumo: Essa solução híbrida com semáforo + mutex é uma abordagem clássica para resolver problemas de sincronização e coordenação entre threads, garantindo tanto a integridade dos dados quanto o respeito à capacidade do buffer.

Fluxo de Semáforos

Ilustração – Produtor-Consumidor com Semáforo e Mutex

A figura acima representa visualmente o funcionamento da solução clássica do problema Produtor-Consumidor utilizando semáforos e mutex.

  • Produtor: Responsável por gerar itens e colocá-los no buffer. Representado na figura à esquerda, ele:
    • Espera por uma vaga disponível no buffer utilizando o semáforo (sem_wait(&sem_vagas));
    • Adquire o mutex para entrar na região crítica e acessar o buffer;
    • Produz um item e incrementa o contador;
    • Libera o mutex e sinaliza que há um novo item com sem_post(&sem_itens).
  • Consumidor: Responsável por consumir os itens do buffer. Representado na figura à direita, ele:
    • Espera por um item disponível no buffer utilizando o semáforo (sem_wait(&sem_itens));
    • Adquire o mutex para entrar na região crítica e acessar o buffer;
    • Consome o item e decrementa o contador;
    • Libera o mutex e sinaliza que uma vaga foi liberada com sem_post(&sem_vagas).
  • Buffer: Local onde os dados produzidos são armazenados temporariamente. Tem tamanho fixo (no exemplo, 2 posições) e é protegido contra acessos simultâneos.
  • Semáforos:
    • Um controla o número de vagas (sem_vagas) no buffer;
    • Outro controla o número de itens disponíveis (sem_itens);
    • São utilizados para sincronização entre produtor e consumidor.
  • Mutex: Controla o acesso exclusivo à região crítica (acesso e modificação da variável count), garantindo que apenas uma thread modifique o buffer por vez.

Elementos da imagem:

  • Buffer: Armazena temporariamente os dados produzidos até serem consumidos.
  • Semáforo (cima):
    • sem_vagas: controlado pelo produtor, decrementado antes de produzir, incrementado após o consumidor consumir.
    • sem_itens: controlado pelo consumidor, decrementado antes de consumir, incrementado após o produtor produzir.
  • Mutex: Garante que apenas uma thread (produtor ou consumidor) acesse o buffer por vez.
  • Setas: Indicam o fluxo de execução e as interações com os semáforos e mutex.

Resumo da dinâmica: O produtor só produz quando há espaço disponível, e o consumidor só consome quando há itens no buffer. Ambos usam o mutex para garantir exclusão mútua no acesso à estrutura compartilhada.

Logo UFPR

Estudo de Caso: Produtor-Consumidor com Semáforos

Logo Licenciatura

Contexto: Após explorarmos os conceitos de exclusão mútua, dormir/acordar e semáforos, aplicamos agora esses mecanismos em um problema clássico de sincronização: o Produtor-Consumidor.

Descrição do problema: Um produtor insere dados em um buffer limitado, enquanto um consumidor os remove. O desafio está em garantir que:

  • O produtor espere caso o buffer esteja cheio.
  • O consumidor espere caso o buffer esteja vazio.
  • Ambos evitem acessar o buffer simultaneamente.

Solução: Usa pthread e três semáforos:

  • empty: Conta o número de posições livres no buffer.
  • full: Conta o número de itens disponíveis para consumo.
  • mutex: Garante que apenas uma thread acesse o buffer por vez.

Exemplo em C:


#include stdio.h
#include pthread.h
#include semaphore.h
#include unistd.h

#define BUFFER_SIZE 5
int buffer[BUFFER_SIZE];
int count = 0; // Índice de itens no buffer
sem_t empty, full, mutex;

void* producer(void* arg) {
    for (int i = 0; i < 10; i++) {
        sem_wait(&empty);         // Espera uma vaga no buffer
        sem_wait(&mutex);         // Entra na região crítica
        if (count < BUFFER_SIZE) { // Verifica overflow (segurança)
            buffer[count++] = i;
            printf("Produziu: %d, Buffer: %d/%d\\n", i, count, BUFFER_SIZE);
        }
        sem_post(&mutex);         // Sai da região crítica
        sem_post(&full);          // Sinaliza item disponível
        sleep(1);                 // Simula tempo de produção
    }
    return NULL;
}

void* consumer(void* arg) {
    for (int i = 0; i < 10; i++) {
        sem_wait(&full);          // Espera um item no buffer
        sem_wait(&mutex);         // Entra na região crítica
        if (count > 0) {          // Verifica underflow (segurança)
            int item = buffer[--count];
            printf("Consumiu: %d, Buffer: %d/%d\\n", item, count, BUFFER_SIZE);
        }
        sem_post(&mutex);         // Sai da região crítica
        sem_post(&empty);         // Sinaliza vaga liberada
        sleep(2);                 // Simula tempo de consumo
    }
    return NULL;
}

int main() {
    // Inicializa semáforos
    sem_init(&empty, 0, BUFFER_SIZE); // BUFFER_SIZE vagas iniciais
    sem_init(&full, 0, 0);            // 0 itens iniciais
    sem_init(&mutex, 0, 1);           // Mutex para exclusão mútua

    pthread_t prod, cons;
    pthread_create(&prod, NULL, producer, NULL);
    pthread_create(&cons, NULL, consumer, NULL);
    pthread_join(prod, NULL);
    pthread_join(cons, NULL);

    // Limpeza
    sem_destroy(&empty);
    sem_destroy(&full);
    sem_destroy(&mutex);
    return 0;
}
    

Explicação do fluxo do código:

  • O produtor espera por empty antes de adicionar um item.
  • O consumidor espera por full antes de retirar um item.
  • Ambos usam mutex para garantir acesso exclusivo ao buffer.
  • Após cada operação, os semáforos são atualizados para acordar o processo correspondente.

Visualização: Abaixo, o fluxo de preenchimento e consumo do buffer ao longo da execução:

Simulação Produtor-Consumidor

Produtor
Consumidor

Fonte: Adaptado de Silberschatz, Cap. 5

Produtor-Consumidor com Semáforos em Python

Este exemplo implementa o clássico problema do Produtor-Consumidor usando threading e Semaphore do Python. Um produtor insere itens em um buffer compartilhado, enquanto um consumidor retira. O objetivo é garantir que o acesso ao buffer seja sincronizado, evitando condição de corrida, overflow e underflow.

Código em Python:


import threading
import time
import random

BUFFER_SIZE = 5
buffer = []
mutex = threading.Semaphore(1)
empty = threading.Semaphore(BUFFER_SIZE)
full = threading.Semaphore(0)

def produtor(id):
    for _ in range(10):
        item = random.randint(10, 99)
        empty.acquire()          # Espera por espaço
        mutex.acquire()          # Entra na região crítica
        buffer.append(item)
        print(f"🛠️ Produtor {id} produziu {item} | Buffer: {buffer}")
        mutex.release()          # Sai da região crítica
        full.release()           # Sinaliza item disponível
        time.sleep(random.uniform(0.5, 1.5))

def consumidor(id):
    for _ in range(10):
        full.acquire()           # Espera por item
        mutex.acquire()          # Entra na região crítica
        item = buffer.pop(0)
        print(f"🗑️ Consumidor {id} consumiu {item} | Buffer: {buffer}")
        mutex.release()          # Sai da região crítica
        empty.release()          # Sinaliza espaço livre
        time.sleep(random.uniform(1.0, 2.0))

prod_thread = threading.Thread(target=produtor, args=(1,))
cons_thread = threading.Thread(target=consumidor, args=(1,))

prod_thread.start()
cons_thread.start()
prod_thread.join()
cons_thread.join()

print("✅ Execução encerrada.")


Explicação do Código:

  • buffer: lista compartilhada entre produtor e consumidor.
  • mutex: semáforo que garante exclusão mútua no acesso ao buffer.
  • empty: semáforo que conta as vagas disponíveis no buffer.
  • full: semáforo que conta os itens disponíveis para consumo.
  • O produtor:
    • Espera por uma vaga (semáforo empty).
    • Adquire mutex, insere o item e libera mutex.
    • Sinaliza ao consumidor que há item (full.release()).
  • O consumidor:
    • Espera por item (full.acquire()).
    • Adquire mutex, consome o item e libera mutex.
    • Sinaliza ao produtor que há espaço (empty.release()).

Esse modelo garante que produtor e consumidor operem em harmonia, sem interferências e respeitando os limites do buffer.


Explicação do Código:


  • BUFFER_SIZE define o tamanho máximo do buffer compartilhado.
  • buffer é uma lista global que armazena os itens produzidos.
  • Semáforos:
    • mutex: garante exclusão mútua ao acessar o buffer.
    • empty: conta as posições vazias disponíveis no buffer.
    • full: conta os itens disponíveis para o consumidor.
  • App é a classe principal que define a interface e os comportamentos.
  • create_widgets() constrói a interface com:
    • Caixas visuais representando o buffer;
    • Mensagem explicativa de status;
    • Botões: Produzir, Consumir e Reiniciar.
  • update_buffer_display() atualiza visualmente o conteúdo do buffer a cada ação.
  • produzir():
    • Espera uma vaga (empty.acquire());
    • Garante acesso exclusivo ao buffer com mutex.acquire();
    • Adiciona um item e atualiza a interface;
    • Libera os semáforos mutex e full após a produção.
  • consumir():
    • Espera item disponível (full.acquire());
    • Garante acesso exclusivo ao buffer com mutex.acquire();
    • Remove o item mais antigo e atualiza a interface;
    • Libera mutex e empty após o consumo.
  • reiniciar(): limpa o buffer, reinicializa os semáforos e atualiza a interface.

Essa versão permite ao usuário interagir visualmente com o fluxo de execução, compreendendo como o controle de concorrência atua em tempo real em um ambiente gráfico.

Problema dos Filósofos Comensais

Cinco filósofos estão sentados em uma mesa circular. Cada um alterna entre pensar e comer. Há um garfo entre cada par de filósofos e cada um precisa de dois garfos para comer. O problema é garantir que todos consigam comer sem causar deadlock (bloqueio circular).


Acessar animação Jantar dos Filosofos

Exemplo de Código em Python com Streamlit


import threading
import time
import random

NUM = 5
estados = ["PENSANDO"] * NUM
mutex = threading.Lock()
garfos = [threading.Semaphore(1) for _ in range(NUM)]

def pegar_garfos(i):
    garfos[i].acquire()
    garfos[(i+1)%NUM].acquire()

def largar_garfos(i):
    garfos[i].release()
    garfos[(i+1)%NUM].release()

def filosofo(i):
    while True:
        print(f"Filósofo {i} está pensando")
        time.sleep(random.uniform(1,3))
        print(f"Filósofo {i} quer comer")
        pegar_garfos(i)
        print(f"Filósofo {i} está comendo")
        time.sleep(random.uniform(1,2))
        largar_garfos(i)

threads = []
for i in range(NUM):
    t = threading.Thread(target=filosofo, args=(i,))
    threads.append(t)
    t.start()
Logo UFPR

Monitores

Logo Licenciatura

Definição: Estrutura de alto nível que encapsula dados compartilhados e métodos, garantindo exclusão mútua automática (Tanenbaum, 2.3.7). Usa variáveis de condição (wait() e signal()) para sincronizar processos/threads (Silberschatz, Cap. 5).

Funcionamento: Um monitor combina dados compartilhados (ex.: buffers, contadores) e métodos (ex.: inserir, remover) em uma entidade protegida, permitindo que apenas um processo/thread a acesse por vez, evitando condições de corrida. A exclusão mútua é gerenciada automaticamente, sem necessidade de locks explícitos. Variáveis de condição controlam a sincronização: wait() suspende um processo/thread, liberando o monitor, enquanto signal() desperta um processo/thread em espera, como no problema produtor-consumidor, onde produtores esperam por espaço e consumidores por dados.

Vantagens: Simplifica a programação concorrente com exclusão mútua automática, reduz erros como deadlocks e condições de corrida, e centraliza o controle de acesso aos dados, facilitando a manutenção. As variáveis de condição permitem sincronização precisa, ideal para problemas como produtor-consumidor ou leitores-escritores.
Limitações: Nem todas as linguagens suportam monitores nativamente (ex.: C requer bibliotecas), o que pode complicar a implementação. Menos flexível que semáforos para cenários complexos, e o uso incorreto de wait() e signal() pode causar problemas como starvation, onde um thread nunca é acordado.

Exemplo em Java – Produtor-Consumidor com Monitor (synchronized + wait/notify)

Este exemplo utiliza os recursos nativos da linguagem Java para implementar uma solução ao problema clássico do produtor-consumidor, empregando um monitor com os métodos synchronized, wait() e notifyAll().

class MonitorBuffer {
    private int[] buffer;
    private int count = 0;
    public MonitorBuffer(int size) {
        buffer = new int[size];
    }
    public synchronized void produce(int item) throws InterruptedException {
        while (count == buffer.length) // Buffer cheio
            wait();                    // Dorme
        buffer[count++] = item;
        System.out.println("Produziu: " + item);
        notifyAll();                   // Acorda consumidores
    }
    public synchronized int consume() throws InterruptedException {
        while (count == 0)             // Buffer vazio
            wait();                    // Dorme
        int item = buffer[--count];
        System.out.println("Consumiu: " + item);
        notifyAll();                   // Acorda produtores
        return item;
    }
}

Explicação detalhada:

  • A classe MonitorBuffer representa um buffer limitado, compartilhado entre produtores e consumidores.
  • A variável count indica quantos itens há atualmente no buffer.
  • O método synchronized garante exclusão mútua: apenas uma thread executa produce() ou consume() por vez.

Método produce(int item)

  • Se o buffer estiver cheio (count == buffer.length), a thread produtora entra em espera com wait().
  • Assim que houver espaço, a thread é acordada e pode inserir o item no buffer.
  • Após inserir, o método chama notifyAll() para acordar as threads consumidoras que estejam esperando por itens.

Método consume()

  • Se o buffer estiver vazio (count == 0), a thread consumidora entra em espera com wait().
  • Quando um item for produzido, a thread é acordada e pode consumir (remover) um item do buffer.
  • Após consumir, o método chama notifyAll() para acordar os produtores que estejam esperando espaço no buffer.

Vantagens da abordagem:

  • Utiliza os mecanismos de sincronização embutidos da linguagem Java, evitando a necessidade de semáforos externos.
  • Fácil de implementar com os blocos synchronized e os métodos wait()/notifyAll().
  • Evita condição de corrida e deadlocks se bem utilizado.

Resumo: Este monitor Java garante a sincronização entre produtores e consumidores de forma segura e eficiente, controlando o acesso ao buffer e coordenando as esperas e notificações usando recursos nativos da linguagem.

Exemplo em C com PThreads – Produtor e Consumidor com Variáveis de Condição

Este exemplo implementa uma versão simplificada do problema Produtor-Consumidor utilizando pthread_mutex_t para exclusão mútua e pthread_cond_t para sincronização entre as threads.

#include <pthread.h>
#include <stdio.h>

#define BUFFER_SIZE 2
int buffer[BUFFER_SIZE];
int count = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond_full = PTHREAD_COND_INITIALIZER;
pthread_cond_t cond_empty = PTHREAD_COND_INITIALIZER;

void produce(int item) {
    pthread_mutex_lock(&mutex);
    while (count == BUFFER_SIZE)
        pthread_cond_wait(&cond_full, &mutex); // Dorme se cheio
    buffer[count++] = item;
    printf("Produziu: %d\\n", item);
    pthread_cond_signal(&cond_empty);          // Acorda consumidor
    pthread_mutex_unlock(&mutex);
}

void* consume(void* arg) {
    pthread_mutex_lock(&mutex);
    while (count == 0)
        pthread_cond_wait(&cond_empty, &mutex); // Dorme se vazio
    int item = buffer[--count];
    printf("Consumiu: %d\\n", item);
    pthread_cond_signal(&cond_full);            // Acorda produtor
    pthread_mutex_unlock(&mutex);
    return NULL;
}

int main() {
    pthread_t cons;
    pthread_create(&cons, NULL, consume, NULL);
    produce(42);
    pthread_join(cons, NULL);
    return 0;
}

Explicação detalhada:

  • Buffer: Um array de inteiros com capacidade definida por BUFFER_SIZE. A variável count controla quantos itens há no buffer no momento.
  • Mutex (pthread_mutex_t): Garante que apenas uma thread (produtor ou consumidor) possa acessar o buffer por vez, evitando condições de corrida.
  • Variáveis de condição:
    • cond_full: sinaliza quando o buffer deixou de estar cheio (usada pelo consumidor para acordar o produtor).
    • cond_empty: sinaliza quando o buffer deixou de estar vazio (usada pelo produtor para acordar o consumidor).

Função produce(int item):

  • Tenta inserir um item no buffer.
  • Se o buffer estiver cheio (count == BUFFER_SIZE), o produtor dorme (bloqueia) com pthread_cond_wait até ser acordado por um consumidor.
  • Quando há espaço, insere o item, imprime uma mensagem e acorda um consumidor usando pthread_cond_signal(&cond_empty).

Função consume(void* arg):

  • Tenta remover um item do buffer.
  • Se o buffer estiver vazio (count == 0), a thread consumidora dorme com pthread_cond_wait até o produtor inserir algo.
  • Quando há itens disponíveis, consome um item, imprime uma mensagem e acorda o produtor com pthread_cond_signal(&cond_full).

Função main():

  • Cria uma thread consumidora e depois chama a função produce(42) no thread principal.
  • O item 42 é produzido e consumido com a devida sincronização.

Resumo:

Este exemplo demonstra o uso das primitivas de sincronização POSIX para coordenar duas threads que compartilham um recurso. O uso correto de mutex garante exclusão mútua, enquanto as variáveis de condição permitem que as threads esperem de forma eficiente por mudanças no estado do buffer.

Monitores – Controle de Concorrência

A figura acima representa o funcionamento de um monitor, um mecanismo de sincronização de alto nível usado para controlar o acesso concorrente a recursos compartilhados por múltiplos processos ou threads.

Elementos da estrutura:

  • Fila de entrada: Mostra os processos aguardando para entrar no monitor. Apenas um processo pode acessar o monitor por vez. Os demais ficam bloqueados nesta fila até que o recurso seja liberado.
  • Monitor: É a estrutura que encapsula:
    • Declaração de variáveis globais: Usadas internamente por todos os procedimentos do monitor.
    • Procedimentos (Proc. 1, Proc. 2, ..., Proc. n): Representam as funções ou operações que acessam os dados protegidos. São executadas com exclusão mútua.
    • Inicialização de variáveis: Parte executada uma única vez, geralmente no momento da criação do monitor.

Funcionamento:

O monitor garante que apenas um processo por vez execute um de seus procedimentos. Quando um processo entra, os outros ficam bloqueados na fila de entrada. Isso garante a exclusão mútua de forma automática.

Além disso, monitores frequentemente oferecem suporte a condições (como wait() e signal()) que permitem que processos sejam suspensos internamente e acordados por outros, sem liberar o monitor externamente.

Resumo:

O monitor combina estrutura de dados, sincronização e exclusão mútua em uma única abstração, sendo muito útil para resolver problemas como produtor-consumidor, leitores-escritores e buffers circulares de forma segura e modular.

Monitores
Logo UFPR

Troca de Mensagens

Logo Licenciatura
Comunicação entre Processos com Pipes

Definição: Comunicação entre processos (IPC) por envio e recebimento de mensagens, sem compartilhar memória diretamente (Tanenbaum, 2.3.8). Em pipes anônimos, processos usam write() e read() para trocar dados unidirecionalmente via um buffer no kernel.

  • Tipos: Direta (processo para processo, ex.: sockets) ou indireta (via mailboxes, buffers ou pipes – Maziero, Cap. 8).
  • Mecanismo: Usa canais como pipes anônimos (para processos relacionados), named pipes, sockets ou filas de mensagens.

Vantagens: Evita condições de corrida, simplifica comunicação em pipelines (ex.: ls | grep), e suporta sistemas distribuídos com sockets.
Limitações: Latência e overhead na cópia de dados, unidirecionalidade em pipes anônimos, e buffer limitado (ex.: 64 KB).

Exemplo: Comunicação entre Processos com Pipes no Linux

Este exemplo ilustra a troca de mensagens entre um processo pai e um processo filho usando um pipe anônimo no Linux, um mecanismo de IPC que permite comunicação unidirecional entre processos relacionados.

Comunicação entre Processos via Pipe – Processo Pai e Processo Filho

A descrição a seguir representa a comunicação entre um processo pai e um processo filho utilizando um pipe anônimo, criado antes do fork() em sistemas Unix/Linux.

Elementos do Diagrama:

  • Pipe: Representado como um tubo azul, é um canal de dados unidirecional (FIFO) mantido no kernel, permitindo que dados escritos em uma extremidade sejam lidos na outra.
  • fd[0]: Extremidade de leitura do pipe, usada para acessar dados enviados.
  • fd[1]: Extremidade de escrita do pipe, usada para enviar dados.
  • Processo pai: Cria o pipe via pipe() e pode escrever em fd[1] ou ler de fd[0], fechando a extremidade não usada.
  • Processo filho: Após o fork(), herda os descritores fd[0] e fd[1], podendo ler ou escrever no pipe, fechando a extremidade não utilizada.

Diagrama Textual:

O pipe conecta os processos como um fluxo: [Pai] -- fd[1] (escrita) --> [Pipe] -- fd[0] (leitura) --> [Filho]. O pai escreve dados em fd[1], que fluem pelo pipe até serem lidos pelo filho em fd[0].

Funcionamento:

  • O pai cria o pipe com pipe(fd), obtendo fd[0] (leitura) e fd[1] (escrita).
  • Após fork(), o filho herda os descritores, permitindo comunicação.
  • O pai escreve dados no pipe com write(fd[1], ...), que seguem a ordem FIFO. Se o buffer estiver cheio, a escrita bloqueia.
  • O filho lê dados com read(fd[0], ...). Se não houver dados, a leitura bloqueia até que sejam escritos ou a extremidade de escrita seja fechada (EOF).
  • Para evitar bloqueios, cada processo fecha a extremidade não usada (ex.: pai fecha fd[0], filho fecha fd[1]).

Resumo:

O pipe() com fork() permite comunicação segura entre processos relacionados, como em pipelines (ls | grep). A herança de descritores e o gerenciamento de extremidades garantem um fluxo de dados unidirecional controlado, mas requerem dois pipes para comunicação bidirecional e cuidado com o tamanho do buffer.

Comunicação entre Processo Pai e Processo Filho via Pipe

Processo
Pai
Olá, filho!
Oi, pai!
Processo
Filho
PIPE

Funcionamento do Código:

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    int fd[2];                    // fd[0] = leitura, fd[1] = escrita
    char buffer[20];
    pid_t pid;

    if (pipe(fd) == -1) {         // Cria o pipe
        perror("Pipe falhou");
        return 1;
    }

    pid = fork();                 // Cria processo filho
    if (pid < 0) {
        perror("Fork falhou");
        return 1;
    }

    if (pid > 0) {                // Processo pai (produtor)
        close(fd[0]);             // Fecha extremidade de leitura
        char* msg = "Olá, filho!";
        write(fd[1], msg, strlen(msg) + 1);  // Envia mensagem
        printf("Pai enviou: %s\\n", msg);
        close(fd[1]);             // Fecha escrita após envio
    } else {                      // Processo filho (consumidor)
        close(fd[1]);             // Fecha extremidade de escrita
        read(fd[0], buffer, sizeof(buffer)); // Lê mensagem
        printf("Filho recebeu: %s\\n", buffer);
        close(fd[0]);             // Fecha leitura após leitura
    }
    return 0;
}

Etapas explicadas:

  • pipe(fd): Cria um canal de comunicação unidirecional. fd[0] é para leitura, fd[1] para escrita.
  • fork(): Cria um novo processo. O pai continua com pid > 0 e o filho com pid == 0.
  • Pai:
    • Fecha a leitura (fd[0]).
    • Escreve uma mensagem no pipe.
    • Fecha a escrita após o envio.
  • Filho:
    • Fecha a escrita (fd[1]).
    • Lê a mensagem recebida no pipe.
    • Fecha a leitura após a leitura.

Resultado esperado:

Pai enviou: Olá, filho!
Filho recebeu: Olá, filho!

Conceitos envolvidos:

  • Pipe: Mecanismo de IPC (Interprocess Communication) baseado em um fluxo de bytes unidirecional (FIFO), mantido no kernel. Permite comunicação entre processos relacionados (pai e filho), com um buffer de tamanho limitado (ex.: 64 KB).
  • fork(): Chamada de sistema que cria um processo filho, duplicando o processo pai. O filho herda os descritores de arquivo do pipe (fd[0] e fd[1]), permitindo comunicação entre os processos.
  • Sincronização implícita: O bloqueio em read() (aguarda dados) e write() (aguarda espaço no buffer) fornece sincronização. O uso correto de close() para extremidades não usadas evita bloqueios indefinidos, mas a ordem de execução depende do escalonador.

Observação: Pipes anônimos são unidirecionais (ex.: pai → filho). Para comunicação bidirecional, dois pipes são necessários, um para cada direção, exigindo gerenciamento cuidadoso de descritores para evitar bloqueios ou deadlocks.

Exemplo: Comunicação Bidirecional entre Pai e Filho com Dois Pipes

Neste exemplo, dois pipes são usados para permitir que o processo pai envie dados ao filho e o filho responda ao pai, possibilitando comunicação bidirecional em sistemas Unix/Linux.

Funcionamento:

  • pipe1: Criado com pipe(fd1), permite comunicação do pai → filho. O pai escreve em fd1[1], e o filho lê de fd1[0].
  • pipe2: Criado com pipe(fd2), permite comunicação do filho → pai. O filho escreve em fd2[1], e o pai lê de fd2[0].
  • Após fork(), ambos os processos herdam os descritores de ambos os pipes. Cada processo fecha as extremidades não usadas (ex.: pai fecha fd1[0] e fd2[1], filho fecha fd1[1] e fd2[0]).
  • Os dados fluem em ordem FIFO. Se o buffer de um pipe estiver cheio, write() bloqueia; se vazio, read() bloqueia até que dados sejam escritos ou a extremidade de escrita seja fechada (EOF).

Exemplo Prático:

O pai envia uma mensagem (ex.: "Calcule a soma") via pipe1. O filho lê, processa (ex.: soma números) e responde com o resultado via pipe2. O pai lê a resposta, completando a comunicação bidirecional.

Código:

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    int pipe1[2], pipe2[2];
    char buffer[100];
    pid_t pid;

    // Cria os dois pipes
    if (pipe(pipe1) == -1 || pipe(pipe2) == -1) {
        perror("Erro ao criar pipes");
        exit(1);
    }

    pid = fork();
    if (pid < 0) {
        perror("Fork falhou");
        exit(1);
    }

    if (pid > 0) {
        // Processo pai
        close(pipe1[0]); // Fecha leitura do pipe1
        close(pipe2[1]); // Fecha escrita do pipe2

        char msg[] = "Olá, filho!";
        write(pipe1[1], msg, strlen(msg) + 1);
        printf("Pai enviou: %s\\n", msg);

        read(pipe2[0], buffer, sizeof(buffer));
        printf("Pai recebeu: %s\\n", buffer);

        close(pipe1[1]);
        close(pipe2[0]);
    } else {
        // Processo filho
        close(pipe1[1]); // Fecha escrita do pipe1
        close(pipe2[0]); // Fecha leitura do pipe2

        read(pipe1[0], buffer, sizeof(buffer));
        printf("Filho recebeu: %s\\n", buffer);

        char resp[] = "Oi, pai!";
        write(pipe2[1], resp, strlen(resp) + 1);
        printf("Filho enviou: %s\\n", resp);

        close(pipe1[0]);
        close(pipe2[1]);
    }

    return 0;
}

Saída esperada:

Pai enviou: Olá, filho!
Filho recebeu: Olá, filho!
Filho enviou: Oi, pai!
Pai recebeu: Oi, pai!

Resumo dos conceitos:

  • Dois pipes anônimos permitem comunicação bidirecional entre processos relacionados (pai e filho).
  • É essencial fechar os extremos não utilizados (leitura ou escrita) para evitar bloqueios e vazamentos.
  • Esse exemplo simula uma troca de mensagens síncrona entre dois processos.

Exemplo em Shell Script: Comunicação entre Processos com Pipes

Em sistemas operacionais baseados em Unix/Linux, o operador | (pipe) permite que a saída de um processo (comando) seja usada como entrada de outro. Isso representa uma forma de comunicação entre processos via fluxo de dados.

O exemplo abaixo mostra o comando ls (listar arquivos) sendo encadeado com grep (filtrar arquivos com padrão específico):

#!/bin/bash

# Exemplo de pipe entre ls e grep

echo "Arquivos .txt no diretório atual:"
ls -l | grep ".txt"

O que acontece aqui:

  • ls -l: lista os arquivos do diretório atual com detalhes.
  • grep ".txt": filtra e mostra apenas as linhas (arquivos) que contêm .txt.
  • O operador | envia a saída padrão do ls como entrada padrão para o grep.

Saída esperada (exemplo):

-rw-r--r-- 1 user user   356 abr 16 13:22 anotações.txt
-rw-r--r-- 1 user user   142 abr 16 14:00 resumo.txt

Resumo dos conceitos:

  • Pipes: Representam troca de mensagens (dados) entre processos de forma unidirecional.
  • Comandos com pipe: São uma aplicação prática de comunicação entre processos via shell.
  • Encadeamento: Comandos podem ser combinados em sequências, formando "linhas de montagem" de processamento de dados.

Exercícios Práticos – Pipes no Shell Linux

Utilize comandos como ls, grep, wc, sort, cut e head combinados com | (pipe) para resolver os exercícios abaixo. Eles simulam cenários reais de manipulação de dados via comunicação entre processos.

  1. Listar arquivos .txt no diretório atual:
    Mostre apenas os arquivos que terminam com .txt no diretório atual.
    ls | grep ".txt"

  2. Contar quantos arquivos .sh existem no diretório atual:
    Utilize wc -l para contar linhas de saída do grep.
    ls | grep ".sh" | wc -l

  3. Ordenar alfabeticamente os arquivos e mostrar os 5 primeiros:
    ls | sort | head -n 5

  4. Exibir apenas os nomes de usuários logados atualmente:
    who | cut -d ' ' -f 1 | sort | uniq

  5. Mostrar os 3 maiores arquivos do diretório atual:
    ls -lhS | grep "^-" | head -n 3
    Dica: -lhS lista com tamanhos humanos ordenados por tamanho.

  6. Contar quantas linhas existem no arquivo /etc/passwd que contêm "/bin/bash":
    cat /etc/passwd | grep "/bin/bash" | wc -l

  7. Mostrar os 10 comandos mais usados no histórico:
    history | awk '{print $2}' | sort | uniq -c | sort -nr | head

Objetivos:

  • Praticar encadeamento de comandos com pipes (|).
  • Simular troca de mensagens entre comandos como processos independentes.
  • Reforçar o conceito de entrada/saída padrão como meio de comunicação entre processos.

Dica para aprofundar: Tente redirecionar as saídas para arquivos com > ou ler de arquivos com <, combinando com pipes para criar fluxos mais complexos.

Quadro-resumo – Comandos Essenciais para Pipes no Shell

Comando Descrição Exemplo
ls Lista arquivos e diretórios ls -l
grep Filtra linhas com base em um padrão grep "txt" arquivo.txt
wc Conta linhas, palavras e caracteres wc -l
sort Ordena linhas de texto sort nomes.txt
head Mostra as primeiras linhas head -n 5
cut Recorta colunas de texto cut -d ':' -f 1
uniq Remove linhas duplicadas (necessita de ordenação prévia) sort | uniq

Atividade Prática – Pipes e Processamento com Shell

  1. Liste os 10 arquivos mais recentemente modificados no diretório atual.
    ls -lt | head -n 10

  2. Conte quantos usuários usam /bin/bash como shell no /etc/passwd.
    grep "/bin/bash" /etc/passwd | wc -l

  3. Mostre os nomes de usuários atualmente logados, sem repetições.
    who | cut -d ' ' -f 1 | sort | uniq

  4. Liste os 5 comandos mais utilizados do histórico.
    history | awk '{print $2}' | sort | uniq -c | sort -nr | head -n 5

  5. Liste todos os arquivos que contenham a palavra "senha" no conteúdo.
    grep -rl "senha" .

Gabarito:

  • 1: ls -lt | head -n 10
  • 2: grep "/bin/bash" /etc/passwd | wc -l
  • 3: who | cut -d ' ' -f 1 | sort | uniq
  • 4: history | awk '{print $2}' | sort | uniq -c | sort -nr | head -n 5
  • 5: grep -rl "senha" .
Logo UFPR

OpenMP & Barriers

Logo Licenciatura

OpenMP: API para programação paralela em C/C++ e Fortran, baseada em diretivas que simplificam o uso de threads em arquiteturas de memória compartilhada (Tanenbaum, 2.3.9).

Barreiras: Pontos de sincronização onde todas as threads devem chegar antes de prosseguir. A diretiva #pragma omp barrier força essa espera explícita.

Vantagens: Coordena tarefas paralelas (ex.: cálculos em fases).
Limitações: Pode reduzir desempenho se as threads chegarem em tempos muito diferentes.

Exemplo em C: Threads calculam valores em duas fases, sincronizadas por uma barreira.

#include omp.h
#include stdio.h

int main() {
    int n = 4, a[4], b[4];
    
    // Fase 1: Calcula quadrados em paralelo
    #pragma omp parallel num_threads(4)
    {
        int tid = omp_get_thread_num();
        if (tid < n) { // Cada thread processa um índice
            a[tid] = tid * tid;
            printf("Fase 1 - Thread %d calculou a[%d] = %d\\n", tid, tid, a[tid]);
        }
        
        // Barreira explícita: todas as threads esperam aqui
        #pragma omp barrier
        
        // Fase 2: Calcula cubos, dependendo dos resultados da Fase 1
        if (tid < n) {
            b[tid] = a[tid] * tid;
            printf("Fase 2 - Thread %d calculou b[%d] = %d\\n", tid, tid, b[tid]);
        }
    }
    
    // Imprime resultados finais
    printf("Resultados finais:\\n");
    for (int i = 0; i < n; i++) {
        printf("a[%d] = %d, b[%d] = %d\\n", i, a[i], i, b[i]);
    }
    return 0;
}
    

Compilação: gcc -fopenmp exemplo.c -o exemplo
Resultado: Threads calculam a[i] (quadrados), esperam na barreira, depois calculam b[i] (cubos).

Fluxo de Threads com Barreira

Material sobre OpenMP:

https://docs.ufpr.br/~jefer/professor/disciplinas/slides/dee354-openmp.html

Logo UFPR

Barreiras e RCU

Logo Licenciatura

Contexto: Em sistemas operacionais multiprocessados, é comum que múltiplas threads compartilhem dados. Para evitar inconsistências, utilizam-se mecanismos de sincronização, como barreiras e técnicas como o RCU, que otimizam o desempenho especialmente em cenários de leitura intensiva.

Barreiras: Mecanismo de sincronização onde todas as threads ou processos devem alcançar um ponto comum antes de qualquer um deles continuar. Isso garante que todos chegaram a uma etapa específica da execução (Tanenbaum, 2.3.9).

RCU (Read-Copy-Update): Técnica eficiente para permitir leituras simultâneas sem travas (lock-free reads), ideal para sistemas com muitos leitores (Tanenbaum, 2.3.10). Muito usada no kernel do Linux, ela separa as operações de leitura e escrita:

  • Leitores acessam os dados diretamente, sem bloqueio.
  • Escritores fazem uma cópia dos dados, atualizam a cópia e, após sincronização, substituem o ponteiro global.

Analogia: Imagine leitores lendo uma versão do jornal, enquanto o editor prepara uma nova edição. Quando estiver pronta, o editor apenas troca o jornal na banca — sem interromper os leitores anteriores.

Exemplo: Em um banco de dados com múltiplos leitores, as atualizações ocorrem copiando os dados, modificando a cópia e depois atualizando o ponteiro global.

Exemplo prático (pseudo-C):

/* Lado do leitor */
rcu_read_lock();                             // Início da sessão de leitura protegida
data = rcu_dereference(global_data);         // Acessa ponteiro seguro
use(data);                                   // Utiliza os dados lidos
rcu_read_unlock();                           // Finaliza sessão de leitura

/* Lado do atualizador */
new_data = copy_of(global_data);             // Copia estrutura atual
modify(new_data);                            // Aplica alterações na cópia
synchronize_rcu();                           // Aguarda fim de leitores ativos
rcu_assign_pointer(global_data, new_data);   // Atualiza ponteiro global com segurança
    

Explicação do código:

  • rcu_read_lock() e rcu_read_unlock(): delimitam a seção crítica para leitura.
  • rcu_dereference(): lê o ponteiro global com garantias de visibilidade de memória.
  • copy_of(): gera uma cópia segura dos dados compartilhados.
  • modify(): aplica as modificações desejadas na nova versão.
  • synchronize_rcu(): bloqueia até que todos os leitores anteriores saiam de sua seção crítica.
  • rcu_assign_pointer(): publica a nova versão para os leitores futuros.

Vantagens do RCU:

  • Alta escalabilidade com múltiplos leitores simultâneos;
  • Sem necessidade de bloqueio em leitura;
  • Ideal para estruturas que raramente mudam, mas são frequentemente acessadas.

Diagrama explicativo:

RCU

Fonte: Adaptado de Tanenbaum, Cap. 2.3.9 e 2.3.10

Simulação RCU – Read-Copy-Update

Versão Antiga
Nova Versão
👓 Leitor 1
👓 Leitor 2
👓 Leitor 3
Logo UFPR

Prática de Sincronização

Logo Licenciatura

Objetivo: Explorar mecanismos de sincronização no terminal e em C, aplicando conceitos como pipes, sinais e semáforos (Silberschatz, Cap. 5).

Passo a Passo no Terminal:

  • Visualizar processos: ps -aux | grep firefox – Lista processos do Firefox com detalhes (PID, uso de CPU, etc.).
  • Usar pipes: echo "Teste prático" | tr '[:lower:]' '[:upper:]' | wc -c – Converte para maiúsculas e conta caracteres (saída: 14).
  • Controlar execução:
    • sleep 10 & – Executa em background (anote o PID, ex.: [1] 1234).
    • kill -SIGSTOP 1234 – Pausa o processo.
    • kill -SIGCONT 1234 – Retoma a execução.
  • Monitorar: top -p $(pidof sleep) – Observa o processo em tempo real.

Exemplo em C – Produtor-Consumidor com Semáforos:

#include pthread.h
#include semaphore.h
#include stdio.h
#include unistd.h

#define BUFFER_SIZE 3
#define NUM_ITEMS 5
int buffer[BUFFER_SIZE];
int in = 0, out = 0; // Índices de inserção e remoção
sem_t empty, full, mutex;

void* produtor(void* arg) {
    int id = *(int*)arg;
    for (int i = 0; i < NUM_ITEMS; i++) {
        sem_wait(&empty);         // Espera vaga
        sem_wait(&mutex);         // Protege o buffer
        buffer[in] = i;
        printf("Produtor %d adicionou %d na posição %d\\n", id, i, in);
        in = (in + 1) % BUFFER_SIZE;
        sem_post(&mutex);
        sem_post(&full);          // Sinaliza item
        sleep(1);
    }
    return NULL;
}

void* consumidor(void* arg) {
    int id = *(int*)arg;
    for (int i = 0; i < NUM_ITEMS; i++) {
        sem_wait(&full);          // Espera item
        sem_wait(&mutex);         // Protege o buffer
        int item = buffer[out];
        printf("Consumidor %d removeu %d da posição %d\\n", id, item, out);
        out = (out + 1) % BUFFER_SIZE;
        sem_post(&mutex);
        sem_post(&empty);         // Sinaliza vaga
        sleep(2);
    }
    return NULL;
}

int main() {
    sem_init(&empty, 0, BUFFER_SIZE); // Vagas iniciais
    sem_init(&full, 0, 0);            // Itens iniciais
    sem_init(&mutex, 0, 1);           // Mutex

    pthread_t prod1, prod2, cons1;
    int id1 = 1, id2 = 2, id3 = 1;
    pthread_create(&prod1, NULL, produtor, &id1);
    pthread_create(&prod2, NULL, produtor, &id2);
    pthread_create(&cons1, NULL, consumidor, &id3);

    pthread_join(prod1, NULL);
    pthread_join(prod2, NULL);
    pthread_join(cons1, NULL);

    sem_destroy(&empty);
    sem_destroy(&full);
    sem_destroy(&mutex);
    return 0;
}
    

Compilação: gcc -pthread exemplo.c -o exemplo
Saída esperada: Dois produtores enchem o buffer circular de tamanho 3, enquanto um consumidor o esvazia mais lentamente.

Fluxo de Sincronização
Logo UFPR

Mini-Quiz

Logo Licenciatura
Logo UFPR

Bibliografia

Logo Licenciatura

Básica

- Silberschatz et al., Fundamentos de SOs, 8ª ed., LTC, 2010.
- Tanenbaum, SOs Modernos, 3ª ed., Prentice Hall, 2009.
- Marques et al., Sistemas Operacionais, 1ª ed., LTC, 2011.

Complementar

- Maziero, SOCM
- Toscani et al., Sistemas Operacionais, 4ª ed., Bookman, 2010.
- Silberschatz et al., SOs com Java, 7ª ed., Campus, 2008.