[15]. Descreva como a chamada de sistema fork é implementada. Aborde o mecanismo pelo ponto de vista da eficiência e suporte ao compartilhamento entre uma família de processos. Como um processo pai pode sincronizar com o término de um processo filho?

Passo 1

O enunciado pede três coisas bem definidas:
  • Descrever como a chamada de sistema fork é implementada (ou seja, o que o kernel faz quando um processo chama fork).
  • Abordar o mecanismo do ponto de vista da eficiência e do suporte ao compartilhamento entre uma família de processos (por que é caro, quais otimizações existem, como se compartilha memória/arquivos/etc).
  • Explicar como um processo pai pode sincronizar com o término de um processo filho (mecanismos que o pai usa para saber quando o filho terminou e obter seu status).
Informações implícitas que vou assumir a partir do enunciado (não são perguntas, só para orientar a explicação):
  • Estamos falando do comportamento clássico em sistemas UNIX-like (Linux, BSD), onde fork cria um processo filho quase idêntico ao pai.
  • É esperado cobrir tanto a view em espaço de usuário (o que o programador vê) quanto as estruturas internas do kernel e otimizações práticas (copy-on-write, vfork, clone, wait/waitpid, sinais).

Passo 2

Visão de alto nível do que fork faz (o que o programador vê)
  • Quando um processo chama fork, o sistema cria um novo processo (o filho) que, do ponto de vista do código em usuário, começa executando no ponto imediatamente após a chamada fork.
  • No processo pai fork retorna o PID do filho; no processo filho fork retorna zero. Isso é a semântica que o programador usa para distinguir os dois fluxos.
  • Pai e filho têm cópias (lógicas) dos mesmos recursos: espaço de endereçamento, descritores de arquivo, variáveis de ambiente, máscaras de sinal, etc. Mas alguns identificadores (por exemplo PID) são distintos.

Passo 3

Estruturas internas do kernel envolvidas na implementação básica de fork
  • Process Control Block (PCB) / task_struct (no Linux): estrutura que guarda PID, estado do processo, ponteiro para a tabela de páginas, priorities, contadores de referência, signal handlers, etc.
  • Espaço de endereçamento / mm_struct / page tables: representação da memória virtual do processo.
  • File descriptor table (vetor de FDs por processo) e open file descriptions / file table (estrutura compartilhada que guarda offset, flags e refcount).
  • Recursos adicionais: group/session info, timers, rlimit, credentials (UID/GID), file locks, POSIX message queues, etc.
  • Mecanismo simples e “ingênuo” seria copiar todas essas estruturas em seguida; veremos por que isso é caro e como mitigar.

Passo 4

Implementação ingênua (como seria sem otimização) — para entender o custo
  • Copiar bit-a-bit todo o espaço de endereçamento do pai para o filho: todas as páginas de memória física seriam alocadas e copiadas.
  • Duplicar estruturas kernel (tabela de páginas, mm_struct, etc).
  • Criar nova tabela de descritores de arquivo com duplicatas dos descritores (que apontam para as mesmas open file descriptions, ou, se duplicadas, incrementar refcounts).
  • Custo: tempo O(tamanho do espaço de usuário) e muita memória. Para processos grandes (multimegabytes/GB) e em sistemas com muitas forks, isso é inaceitável.

Passo 5

O truque principal: Copy-On-Write (COW) — como funciona e por que é eficiente
  • Ideia: não copiar páginas de memória imediatamente. Em vez disso, pai e filho começam compartilhando as mesmas páginas físicas.
  • Implementação prática:
    • Ao criar o filho, o kernel faz com que as entradas da tabela de páginas do pai e do filho apontem para as mesmas páginas físicas.
    • Marca essas entradas como somente leitura (mesmo que fossem escritas antes).
    • Incrementa o contador de referência (refcount) da página física compartilhada.
    • Se pai ou filho tentar escrever em uma página compartilhada, ocorre uma page fault (falta de página) gerada porque a página está somente leitura.
    • O kernel, ao tratar a page fault, detecta que a página tem refcount > 1 (está compartilhada) e então: aloca uma nova página física, copia o conteúdo da página antiga para a nova (cópia real só então), atualiza a tabela de páginas do processo que escreveu para apontar para a nova página com permissão de escrita, decrementa refcount da antiga. Depois a execução continua.
  • Vantagens: se o filho imediatamente chama exec (padrão para muitos usos de fork+exec), praticamente nenhuma cópia real será feita. Economia enorme de tempo e memória.
  • Observação: precisa gerenciar TLBs (Translation Lookaside Buffers): ao alterar permissões nas tabelas de páginas o kernel pode precisar invalidar entradas de TLB para evitar que uma CPU acesse uma página com permissões antigas.

Passo 6

Outros detalhes de eficiência: estruturas com contagem de referência e lazy copy
  • Muitos objetos do kernel não são copiados: o kernel usa refcounts e compartilha estruturas até que alguma modificação obrigue uma cópia (copy-on-write também para estruturas como descriptors ou mm_struct em algumas implementações).
  • File descriptors: a tabela de FDs do processo é duplicada (tabela lógica), mas os entries em si apontam para a mesma open file description (estrutura que mantém offset, flags). Em UNIX, a cópia da tabela de FDs é barata (uma pequena tabela com ponteiros) e os open file descriptions têm refcount; logo não é preciso duplicar o estado do arquivo como offset.
  • VMAs (virtual memory areas): o kernel pode compartilhar descritores de áreas de memória (vma) e só criar estruturas separadas se necessário.
  • Copiar o kernel stack: normalmente o kernel cria um kernel stack para o novo processo, mas isso é pequeno comparado ao espaço de usuário.
  • Evitar cópias desnecessárias: o kernel otimiza o caminho comum (fork seguido de exec) de modo que nenhuma cópia volumosa seja feita.

Passo 7

vfork, posix_spawn e clone — alternativas/otimizações e controle de compartilhamento
  • vfork:
    • Variante antiga/rápida: o filho comparte o espaço de endereçamento do pai sem COW; o pai é suspenso até o filho chamar execve() ou _exit().
    • Muito rápido porque não cria cópias; muito perigoso se o filho modificar a memória do pai.
    • Uso típico: quando logo após fork o processo chama exec — vfork é uma otimização para esse caso.
  • posix_spawn:
    • Interface de mais alto nível que combina criação de processo e execução de novo programa. Permite implementar de forma eficiente (sem fazer fork completo em alguns sistemas).
  • clone (Linux) / flags de fork:
    • clone permite controlar o que é compartilhado entre processos: flags como CLONE_VM (compartilhar espaço de endereço), CLONE_FILES (compartilhar tabela de FDs), CLONE_SIGHAND (compartilhar handlers de sinal), etc.
    • Threads (p.ex. pthreads) são implementadas como processos que compartilham VM e FDs (usando clone com flags adequadas).
  • Conclusão prática: existe um espectro entre “processos totalmente independentes” (fork normal, cada qual com seu VM) e “threads” (compartilham VM). O kernel oferece mecanismos para escolher o ponto do espectro.

Passo 8

Como o kernel garante consistência e segurança ao usar COW e compartilhamentos
  • Proteção por permissão de páginas (marcar read-only) e trap para o kernel em write attempts.
  • Refcounts para páginas físicas e open file descriptions para manter vida útil correta.
  • Sincronização interna (locks) para evitar races ao atualizar page tables e refcounts.
  • Atualização atômica de estruturas: ao copiar página, o kernel atualiza a tabela de páginas do processo que escreve primeiro; o outro processo mantém a referência antiga até que seja copiada se necessário.
  • Tratamento de sinais e exec: quando o filho chama exec, o kernel descarrega o antigo mm (e libera páginas compartilhadas), monta o novo espaço do programa executado.

Passo 9

Compartilhamento funcional entre família de processos (o que é herdado e o que pode ser compartilhado)
  • Herdado automaticamente pelo filho:
    • Descritores de arquivo (com open file descriptions compartilhadas).
    • Variáveis de ambiente, working directory, umumask, rlimit, credenciais (UID/GID).
    • Handlers de sinal (com algumas nuances se usar flags de clone).
  • Pode ser explicitamente compartilhado:
    • Memória compartilhada via mmap com MAP_SHARED, System V shared memory, POSIX shared memory — ideal para comunicação entre membros da família sem duplicação.
    • Ao usar clone com CLONE_VM, processos compartilham o mesmo espaço de endereçamento (comportam-se como threads).
  • Recursos que não são automaticamente compartilhados:
    • ID de processo (PID), tabela de páginas (por padrão), contador de threads.
  • Organizações adicionais:
    • Process group e session: permitem agrupar família de processos para envio de sinais coletivos (ex.: SIGHUP no terminal).
    • Orfandade e reparenting: se pai termina antes do filho, o kernel reatribui o filho para init/systemd (processo reaper).

Passo 10

Sincronização do pai com término do filho — mecanismo kernel (zombie e wait)
  • Quando um processo termina (chama exit/_exit), o kernel:
    • Libera a maioria dos recursos do processo (memória, FDs, etc.), mas mantém uma pequena entrada no process table contendo o PID e o status de término (exit status, information about termination by signal) — esse estado é chamado de zombie.
    • Gera um evento para o pai: coloca o filho em estado zombie e (normalmente) envia sinal SIGCHLD ao pai.
  • Pai precisa “recolher” (reap) o filho chamando wait/waitpid/waitid:
    • wait(&status) bloqueia até que um filho termine e retorna o PID do filho terminado preenchendo status.
    • waitpid(pid, &status, options) permite esperar por um filho específico (pid > 0), por qualquer filho (-1), por grupo de processos (pid < -1), e usar flags como WNOHANG (não bloquear).
    • waitid fornece interface mais rica (com mais informação e controle).
  • Sem wait: zombie permanece no process table consumindo uma pequena entrada. Se o pai nunca chamar wait e não quiser cuidar de children, há soluções:
    • Pai pode tratar (ou ignorar) SIGCHLD: se definir SIGCHLD como SIG_IGN em alguns sistemas ou usar sigaction com SA_NOCLDWAIT, o kernel pode não deixar filhos virarem zombies (recolhe automaticamente).
    • Ou pai termina antes do filho: kernel reparenta o filho para init/systemd, que faz a recolha final quando o filho terminar.

Passo 11

Como usar wait/waitpid na prática e interpretar o status
  • Exemplo de fluxo simples (em pseudo-C):
    • pid = fork();
    • if (pid == 0) { /* filho */ _exit(42); }
    • else { int status; pid_t w = waitpid(pid, &status, 0); /* w == pid */ }
  • Interpretação do valor em status (macros POSIX):
    • WIFEXITED(status): verdadeira se o filho terminou normalmente com exit(code).
    • WEXITSTATUS(status): obtém o código de saída (válido se WIFEXITED).
    • WIFSIGNALED(status): verdadeira se o filho foi terminado por um sinal.
    • WTERMSIG(status): sinal que terminou o filho (se aplicável).
  • WNOHANG: permite checar sem bloquear — waitpid(pid, &status, WNOHANG) retorna 0 se o filho ainda está vivo.
  • Se há múltiplos filhos, o pai pode chamar wait repetidamente ou usar waitpid(-1, …)/waitid para colher qualquer filho disponível.

Passo 12

Sinais e notificações alternativas
  • SIGCHLD:
    • Kernel envia SIGCHLD ao pai quando um filho termina (ou é parado/contínuo dependendo das flags).
    • Pai pode instalar um handler para SIGCHLD para ser notificado; no handler pode chamar wait/waitpid para colher os filhos.
    • Atenção: handlers de sinal devem ser escritos com cuidado (reentrância).
  • signalfd, epoll, inotify: alternativas modernas para integrar eventos de término de filho a loops de eventos.
  • SA_NOCLDWAIT: flag de sigaction que diz ao kernel não gerar zombies — filhos terminam e são imediatamente descartados (útil para daemons que não querem lidar com wait).

Passo 13

Problemas práticos e recomendações (quando estudar para prova ou programar)
  • Fork em programas multithread é perigoso: só o thread que chamou fork continua; muitos recursos (locks) podem ficar em estado inconsistente. A recomendação POSIX: após fork em processo multithread, a chamada segura é imediatamente exec ou _exit; caso contrário, usar mecanismos seguros.
  • Para criar processos novos com menos overhead use posix_spawn quando disponível.
  • Evitar acumular zombies: sempre tratar SIGCHLD ou chamar wait periodicamente. Em servidores de alto volume, usar reaper dedicado ou permitir init/systemd cuidar.
  • Para IPC entre família de processos prefira memória compartilhada (mmap MAP_SHARED) ou sockets/pipe conforme o caso.

Passo 14

Resumo passo-a-passo do caminho de execução do kernel durante fork (linha do tempo)
  1. Usuário executa syscall fork().
  2. Kernel aloca um novo PCB/task_struct para o filho e copia campos do pai (credenciais, rlimits, signal handlers, etc).
  3. Tabelas “pesadas” (page tables, mm_struct) são marcadas para compartilhamento imediato: page tables apontam para as mesmas páginas físicas com marcação COW.
  4. Duplica (de forma barata) a tabela de descritores de arquivo (apontando para as mesmas open file descriptions; incremento de refcounts).
  5. Cria kernel stack para o filho, configura registradores (para retorno 0 no filho) e retorna do syscall no pai e no filho em contextos separados.
  6. Se pai ou filho escrever em página compartilhada, ocorre page fault e o kernel realiza a cópia da página (COW).
  7. Quando o filho terminar, passa para estado zombie até o pai chamar wait/waitpid, que então recolhe status e libera a entrada do process table.

Passo 15

Encerramento didático, dicas mnemônicas e incentivo
  • Mnemônica rápida: fork = “cria gêmeo lógico”; COW = “não copie até provar que precisa escrever”; wait = “venha aqui e me conte como você morreu”.
  • Se na prova perguntarem “por que fork é barato na prática?”, responda: por causa do copy-on-write e do compartilhamento de estruturas por referência, que evitam cópias imediatas do grande espaço de memória.
  • Se sentir nervoso na prova: escreva primeiro a sequência (fork cria, retorna PID/0), depois descreva COW e finalmente diga como usar wait/waitpid e SIGCHLD para sincronizar — em geral isso cobre a maior parte dos pontos.
  • Você consegue — respira fundo, escreva os passos principais e desenhe (se puder) a ideia do COW: pai e filho apontando para as mesmas páginas, com marcação read-only, e a page fault que dispara a cópia.

Resposta

Implementação: fork cria um novo PCB/task_struct copiando campos do pai e preparando registros para que o filho retorne 0; estruturas grandes (espaço de endereço, page tables, etc.) são inicialmente compartilhadas usando mecanismos de contagem de referência e marcações read-only.
Eficiência e compartilhamento: o kernel usa copy-on-write (COW) para evitar cópias imediatas do espaço de memória, compartilha open file descriptions com refcounts e faz cópias “lazy” apenas quando necessário; alternativas como vfork, posix_spawn e clone permitem otimizações e controle fino do compartilhamento (CLONE_VM, CLONE_FILES, …). Para comunicação entre a família, usar mmap MAP_SHARED, System V/POSIX shared memory ou flags de clone conforme necessidade.
Sincronização do pai com término do filho: quando o filho termina fica em estado zombie até o pai chamar wait/waitpid/waitid para recolher o status (WIFEXITED, WEXITSTATUS, WIFSIGNALED, WTERMSIG); o kernel envia SIGCHLD ao pai ao término do filho; opções adicionais incluem WNOHANG (não bloquear), SA_NOCLDWAIT (auto-reap) ou reparenting para init/systemd se o pai morrer.

Exercícios relacionados