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.

Ponto chave: O erro ocorre porque as instruções de leitura, incremento e escrita podem ser intercaladas (interleaving) entre threads. Mesmo em um único núcleo, o problema ocorre devido à troca de contexto.

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.
  • Independência de velocidade: A solução não deve depender da velocidade relativa dos processos.
  • 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.

    Importante: Este exemplo não é seguro em arquiteturas modernas, pois não garante atomicidade nem visibilidade entre múltiplos núcleos. Serve apenas para fins didáticos.

    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.
    • Ainda utiliza espera ocupada, porém garante atomicidade da operação.
    • Continua consumindo CPU enquanto espera.

    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.

    Na prática: Sistemas operacionais como Linux normalmente não evitam deadlock automaticamente — cabe ao programador evitar essa situação.

    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.

    Importante: O algoritmo é raramente usado em sistemas reais, pois exige conhecimento prévio das necessidades máximas de recursos.

    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;
    }
        

    Observação: Este exemplo é uma simplificação didática. Em sistemas reais, o mecanismo de dormir/acordar é implementado com primitivas do kernel (ex: semáforos, condition variables), não com sleep().

    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.

    Cuidado: Uso incorreto de semáforos pode gerar deadlock ou starvation.

    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.

    Interpretação importante:

    • Semáforos: controlam a quantidade de recursos disponíveis (vagas e itens)
    • Mutex: protege o acesso à região crítica (variável 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.

    Importante: Monitores encapsulam automaticamente a exclusão mútua, evitando erros comuns no uso manual de semáforos.

    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).

    Comparação:

    • Memória compartilhada: mais rápida, mas exige sincronização
    • Troca de mensagens: mais segura, pois evita acesso direto a dados compartilhados

    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.

    Barreira: garante que todas as threads alcancem um ponto antes de qualquer uma continuar.

    Exemplo: processamento em etapas, onde todos precisam terminar uma fase antes da próxima.

    #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

    Código mais pesado:

    #include <stdio.h>
    #include <omp.h>
    #include <unistd.h>
    
    int main() {
        printf("PID: %d\n", getpid());
    
        #pragma omp parallel num_threads(4)
        {
            int id = omp_get_thread_num();
    
            // Loop pesado para manter CPU ocupada
            for (long i = 0; i < 10000000; i++);
    
            printf("Thread %d terminou\n", id);
            sleep(1); // tempo para observar no top
        }
    
        return 0;
    }
    

    Como observar as threads no Linux

    # Compile
    gcc -fopenmp programa.c -o programa
    
    # Execute
    ./programa
    
    # Em outro terminal:
    ps -T -p PID
    top -H -p PID
    ls /proc/PID/task/
    

    O que observar:

    Importante: OpenMP cria threads reais do sistema (modelo 1:1), visíveis diretamente pelo kernel Linux.

    export OMP_NUM_THREADS=8
    ./programa
    

    Material EXTRA 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.

    RCU (Read-Copy-Update): permite múltiplos leitores simultâneos sem bloqueio, sendo muito utilizado no kernel Linux para alto desempenho.

    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

    Dica de Experimento

    • Execute múltiplas threads acessando a mesma variável
    • Observe inconsistências sem mutex
    • Adicione mutex e compare o resultado
    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.