Sistemas Operacionais

Processos e Threads

Professor: Gabriel Soares Baptista

Introdução aos Processos

  • O conceito mais central em sistemas operacionais é o processo.
  • Trata-se de uma abstração de um programa em execução.

Pseudoparalelismo: A CPU alterna entre processos em milissegundos, criando a ilusão de simultaneidade em uma única CPU física.

  • Em sistemas com múltiplas CPUs, ocorre o Verdadeiro Paralelismo, onde instruções são executadas literalmente ao mesmo tempo em unidades físicas distintas.

Programa vs. Processo

  • Programa: Uma entidade passiva, como um arquivo em disco contendo instruções (analogia: a receita do bolo).

  • Processo: Uma entidade ativa, com contador de programa, registradores e variáveis (analogia: a atividade de preparar o bolo).

  • Instâncias diferentes do mesmo programa são processos distintos.

Analogia do Cientista e o Bolo

O cientista (CPU) segue a receita (Programa) usando ingredientes (Dados) para realizar o processo (Atividade).

Criação de Processos no UNIX

  • No ambiente UNIX (Linux, MacOS), a criação baseia-se na chamada de sistema fork().
  • O fork() cria um clone exato do processo pai.
  • Após o clone, o processo filho costuma usar execve para carregar um novo programa.

O Funcionamento do fork()

Analise o retorno da função fork() para diferenciar pai e filho:

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

int main() {
    pid_t pid;
    pid = fork(); // Clona o processo

    if (pid < 0) {
        printf("Erro na criação!\n");
    } else if (pid == 0) {
        // Retorno 0 indica que estamos no FILHO
        printf("Eu sou o FILHO! Meu PID é %d\n", getpid());
    } else {
        // Retorno > 0 indica que estamos no PAI (pid é o ID do filho)
        printf("Eu sou o PAI! Criei o filho com PID %d\n", pid);
    }
    return 0;
}

Questões - Processos

O que este código irá imprimir no final?

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    int x = 100;
    pid_t cid;
    int status; // Variável para armazenar o status de saída do filho

    cid = fork();

    if (cid < 0) {
        perror("fork failed");
        return 1;
    }

    if (cid == 0) {
        // FILHO
        x = x + 50;
        printf("Filho: x = %d (Meu PID: %d)\n", x, getpid());
        _exit(0); // Boa prática: terminar o filho explicitamente
    } else {
        // PAI
        // cid aqui é o PID do filho que acabou de ser criado
        // 0 como terceiro argumento significa "bloqueie até o filho terminar"
        waitpid(cid, &status, 0); 
        
        printf("Pai: x = %d (Esperou pelo filho %d)\n", x, cid);
    }

    return 0;
}
Pergunta

O valor de x alterado no filho será visto pelo pai?

Questões - Processos

O que este código irá imprimir no final?

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    int x = 100;
    pid_t cid;
    int status; // Variável para armazenar o status de saída do filho

    cid = fork();

    if (cid < 0) {
        perror("fork failed");
        return 1;
    }

    if (cid == 0) {
        // FILHO
        x = x + 50;
        printf("Filho: x = %d (Meu PID: %d)\n", x, getpid());
        _exit(0); // Boa prática: terminar o filho explicitamente
    } else {
        // PAI
        // cid aqui é o PID do filho que acabou de ser criado
        // 0 como terceiro argumento significa "bloqueie até o filho terminar"
        waitpid(cid, &status, 0); 
        
        printf("Pai: x = %d (Esperou pelo filho %d)\n", x, cid);
    }

    return 0;
}
Pergunta

O valor de x alterado no filho será visto pelo pai?

Resposta: Não. Filho: 150, Pai: 100. No UNIX, pai e filho possuem espaços de endereçamento distintos.

Hierarquias de Processos

  • No UNIX: Existe uma árvore global rígida enraizada no processo init. O pai e seus descendentes formam um "grupo de processos".
  • No Windows: A hierarquia é tênue. Todos os processos são iguais; o pai recebe um handle para controlar o filho, mas esse identificador pode ser transferido.

Término de Processos

Um processo encerra por quatro motivos fundamentais:

  1. Saída Normal (Voluntária): Conclusão da tarefa (ex: exit no UNIX).
  2. Saída por Erro (Voluntária): O processo detecta um problema e encerra (ex: arquivo não encontrado).
  3. Erro Fatal (Involuntária): Bugs graves (divisão por zero, acesso à memória inexistente).
  4. Morto por outro (Involuntária): Uso de chamadas como kill (UNIX) ou TerminateProcess (Windows).

Processo - Estado Zumbi

Se o filho termina e o pai não lê seu estado, o filho torna-se um "Zumbi".

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t cid = fork();

    if (cid < 0) exit(1);

    if (cid == 0) {
        // FILHO: Morre rápido
        printf("[Filho] PID %d: Tchau, virei um zumbi...\n", getpid());
        exit(0); 
    } else {
        // PAI: Fica vivo por 20 segundos sem dar wait()
        printf("[Pai] PID %d: Criei o filho %d, mas não vou ler o estado dele agora.\n", getpid(), cid);
        printf("[Pai]: Verifique o terminal agora com: ps aux | grep 'Z'\n");
        
        sleep(20); // Janela de tempo para você rodar o comando no terminal
        
        printf("[Pai]: Acordei! Agora vou dar wait() e limpar o zumbi.\n");
        wait(NULL); 
        
        sleep(5); // Tempo para você ver que o zumbi sumiu
    }

    return 0;
}
Solução

O pai deve usar a chamada wait() para recolher os restos mortais do processo filho e liberá-lo da tabela de processos.

Estados de Processos

Um processo pode estar em um de três estados:

  1. Em Execução: Utilizando a CPU no momento.
  2. Pronto: Disposto a rodar, mas parado temporariamente para dar lugar a outro.
  3. Bloqueado: Incapaz de rodar até que um evento externo (como E/S) ocorra.

Transições de Estado

As transições são geridas pelo escalonador:

  • Bloqueio (1): O processo aguarda dados (ex: leitura de disco).
  • Preempção (2): O escalonador decide que o processo já usou CPU demais.
  • Seleção (3): O escalonador escolhe o próximo processo da fila "Pronto".
  • Desbloqueio (4): O evento externo ocorre (os dados chegaram).

A Tabela de Processos (PCB)

Para gerenciar processos, o SO utiliza o Bloco de Controle de Processos (PCB).

Gerenciamento de Processos Gerenciamento de Memória Gerenciamento de Arquivos
Contador de programa (PC) Ponteiro para código Diretório raiz
Registradores Ponteiro para dados Descritores de arquivos
Estado do processo Limites de memória IDs de usuário (UID)

Modelando a Multiprogramação

A utilização da CPU aumenta com o número de processos na memória ($n$).
Fórmula de utilização:

$$\text{Utilização} = 1 - p^n$$

onde $p$ é a fração de tempo que um processo passa esperando por E/S.

Loop com Processos

Quantas vezes a palavra "Olá" será impressa?

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

int main() {
    for (int i = 0; i < 3; i++) {
        fork();
    }

    printf("Olá! PID=%d PPID=%d\n", getpid(), getppid());
    sleep(20);
}
Quantos processos irão nascer?

Loop com Processos

Quantas vezes a palavra "Olá" será impressa?

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

int main() {
    for (int i = 0; i < 3; i++) {
        fork();
    }

    printf("Olá! PID=%d PPID=%d\n", getpid(), getppid());
    sleep(20);
}
Quantos processos irão nascer?

Cada fork() dobra o número de processos existentes.
Resposta: 8 vezes ($2^3$). O loop cria uma árvore de processos onde cada novo processo continua a execução do loop de onde o pai parou.

Introdução às Threads

  • Threads são "miniprocessos" que operam dentro do mesmo processo.
  • Diferença Crucial: Enquanto processos são isolados, threads compartilham o mesmo espaço de endereçamento e variáveis globais.
  • Criar uma thread pode ser de 10 a 100 vezes mais rápido que criar um processo.

Modelo Clássico de Thread

O que é compartilhado entre threads de um mesmo processo?

Itens Compartilhados Itens Privados (Individuais)
Espaço de endereçamento Contador de programa (PC)
Variáveis Globais Registradores
Arquivos abertos Pilha (Stack)
Alarmes e Sinais Estado da Thread

Criando Threads em C

Para usar threads no Linux, utilizamos a biblioteca pthread:

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

void* minha_tarefa(void* arg) {
    printf("Olá da Thread! ID: %ld\n", pthread_self());
    return NULL;
}

int main() {
    pthread_t tid;
    // Cria a thread
    pthread_create(&tid, NULL, minha_tarefa, NULL);
    // Aguarda a thread terminar (similar ao wait)
    pthread_join(tid, NULL);
    printf("Thread principal finalizada.\n");
    return 0;
}

Threads - Memória

Qual será o valor final do saldo?

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

int saldo = 100;

void* deposito(void* arg) {
    saldo = saldo + 50;
    return NULL;
}

int main() {
    pthread_t t1;
    pthread_create(&t1, NULL, deposito, NULL);
    pthread_join(t1, NULL);
    printf("Saldo Final: %d\n", saldo);
}
Qual o saldo final?

Threads - Memória

Qual será o valor final do saldo?

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

int saldo = 100;

void* deposito(void* arg) {
    saldo = saldo + 50;
    return NULL;
}

int main() {
    pthread_t t1;
    pthread_create(&t1, NULL, deposito, NULL);
    pthread_join(t1, NULL);
    printf("Saldo Final: %d\n", saldo);
}
Qual o saldo final?

Saldo Final: 150. Diferente de processos, as threads enxergam as mesmas variáveis.

Condição de Corrida

Se rodarmos 2 threads, cada uma somando 100 mil vezes, o total será 200.000?

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

long contador = 0;

void* incremento(void* arg) {
    // Aumentamos para 1 milhão para dar tempo das threads colidirem
    for (int i = 0; i < 1000000; i++) {
        contador++;
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;

    // Criamos duas threads que vão atacar a mesma variável global
    pthread_create(&t1, NULL, incremento, NULL);
    pthread_create(&t2, NULL, incremento, NULL);

    // Esperamos ambas terminarem
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    // O esperado seria 2.000.000, mas o resultado será menor e aleatório
    printf("Resultado esperado: 2000000\n");
    printf("Resultado real:     %ld\n", contador);

    return 0;
}
Qual será o valor final de contador?

Condição de Corrida

Se rodarmos 2 threads, cada uma somando 100 mil vezes, o total será 200.000?

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

long contador = 0;

void* incremento(void* arg) {
    // Aumentamos para 1 milhão para dar tempo das threads colidirem
    for (int i = 0; i < 1000000; i++) {
        contador++;
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;

    // Criamos duas threads que vão atacar a mesma variável global
    pthread_create(&t1, NULL, incremento, NULL);
    pthread_create(&t2, NULL, incremento, NULL);

    // Esperamos ambas terminarem
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    // O esperado seria 2.000.000, mas o resultado será menor e aleatório
    printf("Resultado esperado: 2000000\n");
    printf("Resultado real:     %ld\n", contador);

    return 0;
}
Qual será o valor final de contador?

O resultado será imprevisível (ex: 142.384).
Por quê? Como compartilham a mesma variável, uma thread pode ler o valor original antes que a outra termine de atualizar. Isso é uma Condição de Corrida.

Implementação: Usuário vs. Núcleo

  • Espaço do Usuário: O SO não sabe que as threads existem. A troca de threads é rápida (sem interrupção do núcleo), mas se uma thread bloqueia em E/S, o processo inteiro para.
  • Espaço do Núcleo: O SO gerencia as threads. Se uma thread bloqueia, o núcleo pode escolher outra thread do mesmo processo para rodar. A criação é mais lenta devido às chamadas de sistema.

Ativações pelo Escalonador

  • Tenta unir o melhor dos dois mundos: performance do usuário e robustez do núcleo.
  • Utiliza o mecanismo de Upcall: O núcleo notifica a biblioteca do usuário quando uma thread bloqueia ou é liberada.
  • Nota Técnica: Isso viola a regra de camadas, pois o nível inferior (núcleo) chama o nível superior (usuário).

Threads Pop-up

  • Úteis em sistemas distribuídos.
  • Em vez de uma thread ficar "dormindo" esperando mensagens, a própria chegada da mensagem faz o sistema "disparar" (pop-up) uma nova thread limpa para processá-la.
  • Vantagem: Não há histórico (registradores ou pilhas) para restaurar, tornando o início do processamento muito rápido.

Desafio 5: Variáveis Globais Privadas (TLS)

Como ter uma variável que é global para as funções da thread, mas privada para as outras threads?

Thread-Local Storage (TLS)

Útil para evitar conflitos em variáveis como errno (erros de sistema).

#include <pthread.h>

// __thread garante que cada thread tenha sua PRÓPRIA cópia
__thread int erro_local;

void* tarefa(void* arg) {
    erro_local = 5; // Não altera o erro_local das outras threads
    return NULL;
}

Reentrância e Bibliotecas

  • Muitas bibliotecas antigas não são reentrantes (não seguras para threads).
  • Exemplo: Se a thread A chama malloc e é interrompida no meio da atualização da lista de memória, e a thread B chama malloc logo em seguida, o sistema pode entrar em colapso.
  • Solução: Usar versões thread-safe das bibliotecas ou travas (locks).

Próximos Passos

Na próxima aula, abordaremos Comunicação entre Processos (IPC).

  • Como evitar as condições de corrida que vimos hoje?
  • Estudaremos: Semáforos, Exclusão Mútua (Mutexes) e Monitores.
  • Aprenderemos a sincronizar o acesso ao "Saldo" e ao "Contador" para garantir resultados consistentes.