Processos: fork, exec e wait

[430] Processos: fork, exec e wait

A linha do fork é executada uma vez e retorna duas — e é do valor diferente em cada lado que o pai e o filho descobrem quem são. Daí saem a memória que não fica compartilhada, o wait que evita o zumbi, e o exec que nunca retorna: é assim que o terminal roda cada comando que você digita nele.
Linguagem C

14 min de leitura

Chegamos à fase que revela por que o C é a linguagem dos sistemas operacionais. Até agora, cada programa que escrevemos foi um universo fechado, rodando sozinho. Hoje descobrimos como um programa pode criar outros programas — dividir-se em cópias de si mesmo e executar programas totalmente diferentes. Essa é a base de tudo que acontece num sistema Unix: quando você digita um comando no terminal, quando o navegador abre uma aba, quando um servidor atende muitos clientes, há processos sendo criados. Vamos descer ao nível onde o C conversa diretamente com o núcleo do sistema operacional. Uma nota: as chamadas de hoje são do mundo Unix/Linux (e macOS); no Windows, os mecanismos diferem, embora os conceitos sejam análogos.

O que é um processo

Um processo é um programa em execução — não o arquivo no disco, mas a instância viva, com sua própria memória (a pilha e o heap que estudamos), seus próprios registradores, seu próprio estado. Quando você roda ./programa, o sistema operacional cria um processo para executá-lo. Cada processo tem um identificador único, o PID (Process ID), um número que o sistema usa para gerenciá-lo. Um processo pode, por sua vez, criar outros processos — os chamados processos filhos —, formando uma hierarquia. Todo processo (exceto o primeiro do sistema) tem um processo pai que o criou.

Você pode ver o PID do seu próprio programa com a função getpid, de <unistd.h>:

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

int main(void) {
    printf("Meu PID é %d\n", getpid());
    printf("O PID do meu pai é %d\n", getppid());
    return 0;
}

Cada vez que você roda o programa, o sistema atribui um PID diferente. Esse número é a "identidade" do processo perante o sistema operacional, e será central nas operações de hoje.

fork: um processo se divide em dois

A chamada fork é uma das mais peculiares e poderosas de todo o Unix. Ela cria um novo processo duplicando o processo atual. Após o fork, existem dois processos quase idênticos — o pai (o original) e o filho (a cópia) — executando o mesmo código a partir do mesmo ponto. É como se o programa se dividisse em dois.

O detalhe genial está em como fork permite distinguir quem é quem. Ela retorna um valor diferente em cada processo: no processo pai, retorna o PID do filho recém-criado; no processo filho, retorna 0. Assim, examinando o retorno, cada processo sabe qual é o seu papel:

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

int main(void) {
    printf("Antes do fork (PID %d)\n", getpid());

    pid_t resultado = fork(); // aqui o processo se divide em dois!

    if (resultado < 0) {
        // erro: fork falhou
        perror("fork");
        return 1;
    } else if (resultado == 0) {
        // este bloco roda no processo FILHO (fork retornou 0)
        printf("Sou o filho! Meu PID é %d\n", getpid());
    } else {
        // este bloco roda no processo PAI (fork retornou o PID do filho)
        printf("Sou o pai! O PID do meu filho é %d\n", resultado);
    }

    printf("Ambos executam esta linha (PID %d)\n", getpid());
    return 0;
}

Acompanhe o que acontece. A linha fork() é executada uma vez, mas retorna duas vezes — uma em cada processo. A partir dali, ambos continuam executando o mesmo código, mas o if os separa: o filho (onde fork retornou 0) entra num bloco, o pai (onde retornou o PID do filho) entra em outro. A última linha, fora do if, é executada por ambos — você verá a mensagem duas vezes, com PIDs diferentes. Essa capacidade de um único fork gerar dois fluxos de execução distintos é a base da criação de processos no Unix.

A memória após o fork: cópias independentes

Um ponto crucial: após o fork, o pai e o filho têm cópias separadas da memória. Eles começam idênticos (o filho é uma duplicata do pai no instante do fork), mas a partir dali são independentes — alterar uma variável no filho não afeta o pai, e vice-versa. Cada um tem sua própria pilha, seu próprio heap, suas próprias variáveis:

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

int main(void) {
    int x = 100;
    pid_t pid = fork();

    if (pid == 0) {
        x = 200; // altera x SÓ no filho
        printf("Filho: x = %d\n", x); // 200
    } else {
        // pequena espera didática; veremos wait a seguir
        sleep(1);
        printf("Pai: x = %d\n", x); // 100 — inalterado!
    }
    return 0;
}

O x do pai continua 100 mesmo depois de o filho mudá-lo para 200, porque cada processo tem sua própria cópia da memória. Isso é fundamentalmente diferente das threads que veremos na próxima aula (onde a memória é compartilhada). Processos são isolados — essa é sua característica de segurança e também seu custo. Um processo não pode corromper acidentalmente a memória de outro.

wait: o pai espera o filho terminar

Há um problema de coordenação: pai e filho executam de forma independente e concorrente, e não há garantia de qual termina primeiro. Frequentemente, o pai precisa esperar o filho concluir seu trabalho antes de prosseguir. A chamada wait (de <sys/wait.h>) faz isso: ela pausa o pai até que um filho termine, e recupera o status de saída do filho:

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

int main(void) {
    pid_t pid = fork();

    if (pid == 0) {
        // filho: faz algum trabalho e termina com um código de saída
        printf("Filho trabalhando...\n");
        sleep(1);
        printf("Filho terminando.\n");
        return 42; // código de saída do filho
    } else {
        int status;
        wait(&status); // pai ESPERA o filho terminar

        if (WIFEXITED(status)) { // o filho terminou normalmente?
            printf("Pai: filho terminou com código %d\n", WEXITSTATUS(status)); // 42
        }
        printf("Pai continua após o filho terminar.\n");
    }
    return 0;
}

O wait(&status) pausa o pai até um filho terminar, e preenche status com informação sobre como o filho saiu. As macros WIFEXITED (o filho terminou normalmente?) e WEXITSTATUS (qual foi seu código de saída?) extraem essa informação — reencontramos aqui o código de retorno de main da aula Por que C existe e o que ele quer de você, agora recuperado pelo processo pai. Sem wait, o pai poderia terminar antes do filho, deixando-o "órfão", ou não saberia se o filho concluiu com sucesso. O wait é a ferramenta de coordenação entre pai e filho.

Um aviso: processos zumbis

Vale um alerta que todo programador de sistemas conhece. Quando um filho termina mas o pai não chama wait para recolher seu status, o filho vira um processo zumbi — um processo morto que ainda ocupa uma entrada na tabela de processos do sistema, esperando ser "recolhido". Zumbis ocasionais são inofensivos, mas acumulá-los (num programa que cria muitos filhos sem esperá-los) esgota recursos do sistema. A regra: para cada filho que você cria com fork, deve haver um wait correspondente que recolha seu término. É a mesma disciplina de "par" que vimos com malloc/free e fopen/fclose — cada criação pede sua contrapartida de limpeza, agora no nível de processos.

exec: transformando-se em outro programa

O fork cria uma cópia do mesmo programa. Mas e se quisermos que o filho execute um programa diferente — digamos, rodar o comando ls? Para isso existe a família de funções exec. Uma chamada exec substitui o programa atual por outro: o processo continua o mesmo (mesmo PID), mas seu código, seus dados, toda a sua memória são trocados pelos do novo programa. É uma metamorfose — o processo "vira" outro programa.

O padrão clássico do Unix combina fork e exec: o pai faz fork para criar um filho, e o filho faz exec para se transformar no programa desejado, enquanto o pai espera com wait:

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

int main(void) {
    pid_t pid = fork();

    if (pid == 0) {
        // filho: transforma-se no comando 'ls -l'
        execlp("ls", "ls", "-l", NULL); // substitui este programa por 'ls'
        // se exec funcionar, as linhas abaixo NUNCA executam
        perror("execlp"); // só chega aqui se exec FALHAR
        return 1;
    } else {
        wait(NULL); // pai espera o filho (o 'ls') terminar
        printf("Comando concluído.\n");
    }
    return 0;
}

Este pequeno programa é, essencialmente, o que um shell (o terminal) faz toda vez que você digita um comando: ele faz fork para criar um filho, o filho faz exec para virar o programa que você pediu (ls, gcc, o que for), e o shell (pai) espera com wait. Note um detalhe importante: se exec tem sucesso, ele nunca retorna — o programa antigo deixou de existir, substituído pelo novo. Por isso, qualquer linha após execlp só executa se o exec falhar. Esse trio — fork, exec, wait — é o coração de como programas lançam outros programas no Unix, e agora você entende o mecanismo por trás de cada comando que já digitou num terminal.

O que vem a seguir

Hoje descemos ao nível do sistema operacional e aprendemos a criar processos: fork para duplicar o processo atual (com memória isolada entre pai e filho), wait para o pai coordenar e recolher o término do filho (evitando zumbis), e exec para um processo se transformar em outro programa — o trio que sustenta a execução de comandos no Unix. Processos são poderosos, mas isolados e relativamente custosos de criar. Na próxima aula, conheceremos uma alternativa mais leve para fazer várias coisas ao mesmo tempo dentro de um único processo: as threads, com a biblioteca pthreads. Diferente dos processos, threads compartilham a memória — o que traz enorme poder e, como veremos, novos e sutis perigos.

Fontes e leituras recomendadas

  • The Linux Programming Interface, Michael Kerrisk — a referência definitiva sobre processos no Linux
  • Advanced Programming in the UNIX Environment (APUE), Stevens & Rago — o clássico sobre programação de sistemas Unix
  • man pages — man 2 fork, man 2 execve, man 2 wait — a documentação oficial das chamadas
  • Computer Systems: A Programmer's Perspective, Bryant & O'Hallaron — Cap. 8, sobre processos
  • Operating Systems: Three Easy Pieces — capítulos sobre a API de processos — https://pages.cs.wisc.edu/~remzi/OSTEP/

Exercícios

Exercício 1

Escreva um programa que imprima seu PID (com getpid), depois faça fork, e faça o filho imprimir "Sou o filho, PID X" e o pai imprimir "Sou o pai, meu filho é X". Rode algumas vezes e observe os PIDs mudando.

Ver resposta

✓ Resposta:

#include <stdio.h>
#include <unistd.h>
int main(void) {
    printf("Início, PID %d\n", getpid());
    pid_t pid = fork();
    if (pid == 0) {
        printf("Sou o filho, PID %d\n", getpid());
    } else if (pid > 0) {
        printf("Sou o pai, meu filho é %d\n", pid);
    } else {
        perror("fork");
    }
    return 0;
}

A cada execução, os PIDs mudam, pois o sistema atribui novos identificadores. Note que a ordem das mensagens do pai e do filho pode variar entre execuções — eles rodam concorrentemente.

Exercício 2

Escreva um programa onde uma variável inteira é inicializada com 10 antes do fork. O filho a altera para 99 e imprime; o pai (após um sleep(1)) imprime seu valor. Confirme que o pai ainda vê 10 e explique por quê.

Ver resposta

✓ Resposta:

#include <stdio.h>
#include <unistd.h>
int main(void) {
    int x = 10;
    pid_t pid = fork();
    if (pid == 0) {
        x = 99;
        printf("Filho: x = %d\n", x); // 99
    } else {
        sleep(1);
        printf("Pai: x = %d\n", x);   // 10
    }
    return 0;
}

O pai ainda vê 10 porque, após o fork, pai e filho têm cópias separadas e independentes da memória. O filho alterou a sua cópia de x para 99, mas isso não tem efeito algum sobre a cópia do pai, que permanece 10. Processos não compartilham memória — cada um vive em seu próprio espaço isolado. (Isso contrasta com threads, onde a memória seria compartilhada e a mudança seria visível.)

Exercício 3

Escreva um programa em que o filho termina com um código de saída específico (por exemplo, return 7), e o pai usa wait com as macros WIFEXITED e WEXITSTATUS para recuperar e imprimir esse código.

Ver resposta

✓ Resposta:

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
    pid_t pid = fork();
    if (pid == 0) {
        return 7; // código de saída do filho
    } else {
        int status;
        wait(&status);
        if (WIFEXITED(status)) {
            printf("Filho saiu com código %d\n", WEXITSTATUS(status)); // 7
        }
    }
    return 0;
}

O wait recolhe o término do filho, WIFEXITED confirma que ele saiu normalmente, e WEXITSTATUS extrai o código 7 — o mesmo valor que o filho devolveu com return.

Exercício 4

Use fork + exec + wait para fazer seu programa executar o comando echo Ola do exec (com execlp). Confirme que o pai imprime uma mensagem só depois que o echo termina. Explique por que a linha após execlp no filho normalmente não é executada.

Ver resposta

✓ Resposta:

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
    pid_t pid = fork();
    if (pid == 0) {
        execlp("echo", "echo", "Ola", "do", "exec", NULL);
        perror("execlp"); // só executa se exec falhar
        return 1;
    } else {
        wait(NULL);
        printf("O echo terminou.\n");
    }
    return 0;
}

O pai imprime "O echo terminou." somente após o wait — que só retorna quando o filho (o echo) termina. A linha perror após execlp no filho normalmente não é executada porque, quando exec tem sucesso, ele substitui completamente o programa do processo pelo novo (echo): o código antigo, incluindo aquela linha perror, deixa de existir na memória do processo. O exec bem-sucedido nunca retorna, pois não há mais para onde retornar — o programa original foi trocado. A linha após execlp só roda se o exec falhar (por exemplo, comando não encontrado), caso em que o programa original ainda está no lugar e continua a partir dali.

Exercício 5

Explique a diferença fundamental entre o que fork faz e o que exec faz. Por que o padrão clássico do Unix combina os dois (fork seguido de exec no filho), em vez de usar apenas um deles?

Ver resposta

✓ Resposta: fork e exec fazem coisas fundamentalmente diferentes. O fork duplica o processo atual, criando um novo processo (filho) que é uma cópia do pai, executando o mesmo programa a partir do mesmo ponto — o resultado são dois processos rodando o mesmo código. O exec substitui o programa que um processo está executando por um programa diferente — o processo continua existindo (mesmo PID), mas seu código e memória são inteiramente trocados pelos do novo programa; não cria um novo processo, transforma o existente. fork cria um processo novo com o mesmo programa; exec troca o programa de um processo existente. O padrão clássico do Unix combina os dois porque, isoladamente, nenhum faz o que geralmente queremos: rodar um programa diferente num processo separado, sem encerrar o programa atual. Se você usasse só fork, teria um segundo processo, mas rodando o mesmo programa (não o ls que você queria). Se usasse só exec, o programa desejado rodaria, mas substituindo o seu programa atual, que deixaria de existir — você perderia o controle, o pai não continuaria. Combinando os dois — fork para criar um processo filho separado, e exec dentro do filho para transformá-lo no programa desejado — você consegue o melhor dos dois mundos: o programa atual (pai) continua vivo e pode esperar/coordenar, enquanto o filho se torna o novo programa. É exatamente isso que um shell faz ao rodar um comando: ele se preserva (para receber o próximo comando) enquanto lança o programa pedido num processo separado.

Comentários

Mais em Linguagem C

Listas Duplamente Encadeadas e Circulares
Listas Duplamente Encadeadas e Circulares

Numa lista dupla, toda ligação tem dois lados: fez A->proximo = B, tem de…

Strings: Vetores de Caracteres e a Biblioteca string.h
Strings: Vetores de Caracteres e a Biblioteca string.h

A palavra Ana tem três letras e ocupa quatro bytes: o quarto é o terminador…

Structs: Criando Seus Próprios Tipos
Structs: Criando Seus Próprios Tipos

Três variáveis soltas dão conta de uma data; de cem, não. A struct cola o que…