DEE355 – Sistemas Operacionais
Prof. Jéfer – jefer@ufpr.br
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.
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:
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:
contador = 0
contador
(0), calcula 0 + 1
, escreve 1
contador
(0), calcula 0 + 1
, escreve 1
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:
t1
e t2
. Ambas executam a função incrementar()
, que deve incrementar o valor da variável global contador
.incrementar()
executa três passos:
contador
para uma variável local temp
.temp
.temp
de volta em contador
.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:
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):
Exemplo Prático: Controle de uma fila de impressão em um servidor.
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:
mutex
para proteger a variável contador
contra acessos simultâneos de múltiplas threads.pthread_mutex_lock(&lock);
, que garante que apenas uma thread por vez poderá entrar na região crítica.contador
é incrementada com segurança e seu valor é exibido.pthread_mutex_unlock(&lock);
, permitindo que outra thread possa entrar na região crítica.Comportamento esperado:
contador
com segurança, e o valor final será sempre 2
.Conceitos ilustrados:
Resultado: Executar várias vezes sempre imprime dois valores e garante que contador = 2
, validando o uso correto do mutex.
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:
lock
atua como uma *flag* de controle: quando vale 0
, a região crítica está livre; quando vale 1
, ela está ocupada.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.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.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:
Desvantagens:
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:
test_and_set
lê o valor atual da variável lock
e, ao mesmo tempo, define seu valor para 1
.1
, significa que a região crítica já estava sendo utilizada por outro processo/thread.test_and_set
retorne 0
, indicando que a região crítica está livre.lock = 0
.Vantagens:
Desvantagens:
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.pthread_mutex_unlock
libera o acesso após o uso, permitindo que outra thread entre.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.
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.
Esse ciclo gera um impasse circular, onde:
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.
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.
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.
Need = Max - Allocation
.Request ≤ Need
. Caso contrário, erro.Request ≤ Available
. Caso contrário, o processo espera.[ 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 → ...
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]
Passo 1: Verificar processos que podem executar com Available [3 3 2]:
Após execução de P2: libera Allocation de P2: [2 0 0].
Available = [3+2, 3+0, 2+0] = [5 3 2].
Passo 2: Verificar novamente:
Após execução de P4: libera Allocation de P4: [2 1 1].
Available = [5+2, 3+1, 2+1] = [7 4 3].
Passo 3: Verificar novamente:
Após execução de P1: libera Allocation de P1: [0 1 0].
Available = [7+0, 4+1, 3+0] = [7 5 3].
Passo 4: Verificar finalmente:
Após execução de P3: libera Allocation de P3: [3 0 2].
Available = [7+3, 5+0, 3+2] = [10 5 5].
Sequência segura encontrada: P2 → P4 → P1 → P3.
- Need de P2 é [1 2 2].
- O pedido [1 0 2] é ≤ Need [1 2 2] (OK)
- Available [3 3 2] é ≥ Pedido [1 0 2] (OK)
Resposta: O sistema pode conceder o pedido do processo P2.
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.
Semáforos: Variável de controle para sincronização entre processos/threads (Tanenbaum, 2.3.5).
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).
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.
DOWN(S)
no semáforo S
.S = 1
, ele é decrementado para S = 0
e o processo entra na região crítica.S = 0
, o processo é bloqueado e colocado na fila de espera.S
permanece 0
enquanto a região crítica está ocupada.UP(S)
, que define S = 1
e sinaliza que a região crítica está livre.DOWN(S)
quando S = 0
.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
.
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.
DOWN(S)
.S == 1
, o processo entra na região crítica e S
passa a ser 0.S == 0
, o processo é colocado na fila de espera e entra em estado de dormência.UP(S)
, liberando o recurso (S = 1).DOWN(S)
.S == 0
, o processo dorme.Essa figura e explicação se encaixam nas seções de:
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
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:
sem_vagas
: Controla o número de posições livres no buffer. O produtor só pode inserir quando houver vaga.sem_itens
: Controla o número de itens disponíveis no buffer. O consumidor só pode retirar se houver pelo menos um item.count
por vez, evitando condição de corrida.sem_wait(&sem_vagas)
.sem_post(&sem_itens)
.sem_wait(&sem_itens)
.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:
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.
A figura acima representa visualmente o funcionamento da solução clássica do problema Produtor-Consumidor utilizando semáforos e mutex.
sem_wait(&sem_vagas)
);sem_post(&sem_itens)
.sem_wait(&sem_itens)
);sem_post(&sem_vagas)
.sem_vagas
) no buffer;sem_itens
);count
), garantindo que apenas uma thread modifique o buffer por vez.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.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.
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:
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:
empty
antes de adicionar um item.full
antes de retirar um item.mutex
para garantir acesso exclusivo ao buffer.Visualização: Abaixo, o fluxo de preenchimento e consumo do buffer ao longo da execução:
Fonte: Adaptado de Silberschatz, Cap. 5
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.empty
).mutex
, insere o item e libera mutex
.full.release()
).full.acquire()
).mutex
, consome o item e libera mutex
.empty.release()
).Esse modelo garante que produtor e consumidor operem em harmonia, sem interferências e respeitando os limites do buffer.
BUFFER_SIZE
define o tamanho máximo do buffer compartilhado.buffer
é uma lista global que armazena os itens produzidos.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:
update_buffer_display()
atualiza visualmente o conteúdo do buffer a cada ação.produzir()
:
empty.acquire()
);mutex.acquire()
;mutex
e full
após a produção.consumir()
:
full.acquire()
);mutex.acquire()
;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.
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).
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()
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.
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:
MonitorBuffer
representa um buffer limitado, compartilhado entre produtores e consumidores.count
indica quantos itens há atualmente no buffer.synchronized
garante exclusão mútua: apenas uma thread executa produce()
ou consume()
por vez.produce(int item)
count == buffer.length
), a thread produtora entra em espera com wait()
.notifyAll()
para acordar as threads consumidoras que estejam esperando por itens.consume()
count == 0
), a thread consumidora entra em espera com wait()
.notifyAll()
para acordar os produtores que estejam esperando espaço no buffer.Vantagens da abordagem:
synchronized
e os métodos wait()
/notifyAll()
.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.
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;
}
BUFFER_SIZE
. A variável count
controla quantos itens há no buffer no momento.pthread_mutex_t
): Garante que apenas uma thread (produtor ou consumidor) possa acessar o buffer por vez, evitando condições de corrida.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).produce(int item)
:count == BUFFER_SIZE
), o produtor dorme (bloqueia) com pthread_cond_wait
até ser acordado por um consumidor.pthread_cond_signal(&cond_empty)
.consume(void* arg)
:count == 0
), a thread consumidora dorme com pthread_cond_wait
até o produtor inserir algo.pthread_cond_signal(&cond_full)
.main()
:produce(42)
no thread principal.42
é produzido e consumido com a devida sincronização.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.
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.
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.
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.
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.
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).
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.
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.
pipe()
e pode escrever em fd[1]
ou ler de fd[0]
, fechando a extremidade não usada.fork()
, herda os descritores fd[0]
e fd[1]
, podendo ler ou escrever no pipe, fechando a extremidade não utilizada.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]
.
pipe(fd)
, obtendo fd[0]
(leitura) e fd[1]
(escrita).fork()
, o filho herda os descritores, permitindo comunicação.write(fd[1], ...)
, que seguem a ordem FIFO. Se o buffer estiver cheio, a escrita bloqueia.read(fd[0], ...)
. Se não houver dados, a leitura bloqueia até que sejam escritos ou a extremidade de escrita seja fechada (EOF).fd[0]
, filho fecha fd[1]
).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.
#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;
}
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
.fd[0]
).fd[1]
).Pai enviou: Olá, filho! Filho recebeu: Olá, filho!
fd[0]
e fd[1]
), permitindo comunicação entre os processos.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.
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.
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]
.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]
).write()
bloqueia; se vazio, read()
bloqueia até que dados sejam escritos ou a extremidade de escrita seja fechada (EOF).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.
#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;
}
Pai enviou: Olá, filho! Filho recebeu: Olá, filho! Filho enviou: Oi, pai! Pai recebeu: Oi, pai!
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
.|
envia a saída padrão do ls
como entrada padrão para o grep
.-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
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.
.txt
no diretório atual.ls | grep ".txt"
wc -l
para contar linhas de saída do grep
.ls | grep ".sh" | wc -l
ls | sort | head -n 5
who | cut -d ' ' -f 1 | sort | uniq
ls -lhS | grep "^-" | head -n 3
-lhS
lista com tamanhos humanos ordenados por tamanho.
/etc/passwd
que contêm "/bin/bash":cat /etc/passwd | grep "/bin/bash" | wc -l
history | awk '{print $2}' | sort | uniq -c | sort -nr | head
|
).Dica para aprofundar: Tente redirecionar as saídas para arquivos com >
ou ler de arquivos com <
, combinando com pipes para criar fluxos mais complexos.
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 |
ls -lt | head -n 10
/bin/bash
como shell no /etc/passwd
.grep "/bin/bash" /etc/passwd | wc -l
who | cut -d ' ' -f 1 | sort | uniq
history | awk '{print $2}' | sort | uniq -c | sort -nr | head -n 5
grep -rl "senha" .
ls -lt | head -n 10
grep "/bin/bash" /etc/passwd | wc -l
who | cut -d ' ' -f 1 | sort | uniq
history | awk '{print $2}' | sort | uniq -c | sort -nr | head -n 5
grep -rl "senha" .
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).
Material sobre OpenMP:
https://docs.ufpr.br/~jefer/professor/disciplinas/slides/dee354-openmp.html
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:
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:
Diagrama explicativo:
Fonte: Adaptado de Tanenbaum, Cap. 2.3.9 e 2.3.10
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:
ps -aux | grep firefox
– Lista processos do Firefox com detalhes (PID, uso de CPU, etc.).echo "Teste prático" | tr '[:lower:]' '[:upper:]' | wc -c
– Converte para maiúsculas e conta caracteres (saída: 14).sleep 10 &
– Executa em background (anote o PID, ex.: [1] 1234).kill -SIGSTOP 1234
– Pausa o processo.kill -SIGCONT 1234
– Retoma a execução.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.
- 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.
- Maziero, SOCM
- Toscani et al., Sistemas Operacionais, 4ª ed., Bookman, 2010.
- Silberschatz et al., SOs com Java, 7ª ed., Campus, 2008.