Processos
Estados, espaço de endereçamento, fork, exec, wait, descritores e saída.
Uma shell que lança três compilações cria três processos, execuções com identidade e estado próprios. Cada um pode correr o mesmo compilador, com argumentos e dados diferentes. O PID distingue as execuções; o espaço de endereçamento separa os dados privados. Dois processos podem apresentar o mesmo endereço virtual sem esse número designar a mesma variável física.
Estado do processo
O bloco de controlo do processo, ou PCB, representa o estado que o sistema precisa de gerir: identificador PID, relação com o pai, registos guardados, estado de execução, mapa de memória, descritores, sinais, limites e dados de escalonamento. Numa implementação real, esta informação pode estar distribuída por várias estruturas.
O espaço de endereçamento contém código e dados do programa. Não o confundas com o PCB, que é informação de gestão no núcleo. O limite teórico de um endereço com bits é posições, ou seja isso não significa que cada processo tenha essa quantidade de RAM atribuída.
Terminar liberta a memória de execução e outros recursos. Pode restar um registo com PID e estado de saída para o pai consultar. Esse filho é um zombie e já não executa instruções. Um filho vivo cujo pai terminou é órfão, outra situação; em Linux pode ser adotado por um subreaper ou pelo processo de inicialização.
fork devolve duas vezes
fork() cria um filho. Retorna -1 em caso de falha, 0 no filho e o PID do filho no pai. Ambos continuam depois da chamada. A variável que recebe o retorno tem valores diferentes nas duas memórias.
Os programas desta página têm uma única thread antes de fork. Num processo com várias threads, o filho só conserva a thread que chamou fork, mas pode herdar locks obtidos pelas outras. Até exec, só deve chamar funções seguras nesse contexto, as async-signal-safe; copiar o exemplo para uma aplicação multithread exige esse cuidado.
int x = 4;
pid_t pid = fork();
if (pid == 0) x = 9;
Este é um excerto, assumindo fork bem-sucedido. O filho tem x == 9; o pai conserva x == 4. A cópia costuma ser implementada com copy-on-write: as páginas físicas podem ser partilhadas inicialmente e copiadas quando alguém escreve. A semântica dos dados privados continua a ser a de memórias separadas.
Contar processos pela árvore
Desenha cada fork como uma bifurcação com os PIDs reais. Num exemplo com pai 100: o primeiro fork cria o filho 101; depois o pai cria 102 e o filho 101 cria 103. A árvore tem quatro processos e mostra que PID identifica o processo, não o programa que ele corre.
Se todos os processos executam três chamadas sucessivas de fork, temos processos, incluindo o inicial, assumindo sucesso. Foram criados sete filhos no total.
Agora considera um ciclo em que cada filho termina imediatamente:
for (int i = 0; i < 3; i++) {
pid_t pid = fork();
if (pid == 0) _exit(0);
}
Há um pai que faz as três iterações, por isso ficam quatro processos criados ao longo da execução, não oito. O excerto omite tratamento de falhas e recolha; serve só para desenhar a árvore. A fórmula depende de cada descendente chegar às chamadas seguintes.
exec substitui, não cria
A família exec carrega outro programa no processo atual. Mantém o PID, mas substitui a imagem de memória, incluindo código, heap e pilha. Em caso de sucesso não retorna ao código antigo.
execlp recebe argumentos individuais e procura o comando no PATH. execvp recebe um vetor terminado por NULL, também com pesquisa no PATH. O primeiro argumento do novo programa costuma ser o seu nome. Passar apenas "ls -l" como nome não faz parsing de shell: tentaria encontrar um executável com esse nome inteiro.
Os descritores mantêm-se abertos salvo os marcados para fechar em exec, por exemplo com FD_CLOEXEC ou O_CLOEXEC. É isso que permite preparar redirecionamentos antes da substituição do programa.
Esperar e interpretar o estado
waitpid(pid, &estado, 0) espera pelo filho indicado e recolhe o estado. wait escolhe um filho que termine. O estado bruto não é diretamente o código de saída: testa WIFEXITED antes de WEXITSTATUS, e WIFSIGNALED antes de WTERMSIG.
O programa seguinte executa um comando com os argumentos que receber. Guarda-o em executar.c e compila num terminal UNIX com cc -std=c17 -Wall -Wextra -Wpedantic executar.c -o executar.
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "uso: %s comando [argumentos]\n", argv[0]);
return 1;
}
pid_t filho = fork();
if (filho < 0) {
perror("fork");
return 1;
}
if (filho == 0) {
execvp(argv[1], argv + 1);
perror("execvp");
_exit(127);
}
int estado;
pid_t r;
do {
r = waitpid(filho, &estado, 0);
} while (r < 0 && errno == EINTR);
if (r < 0) {
perror("waitpid");
return 1;
}
if (WIFEXITED(estado)) {
printf("estado=%d\n", WEXITSTATUS(estado));
return WEXITSTATUS(estado);
}
if (WIFSIGNALED(estado))
printf("sinal=%d\n", WTERMSIG(estado));
return 1;
}
./executar printf 'ola\n' escreve ola e depois estado=0. O waitpid impõe que o pai só imprime depois do fim do filho. Repara em execvp(argv[1], argv + 1): argv[1] é o nome do comando e argv + 1 é o vetor de argumentos do novo programa, a começar nesse nome e terminado por NULL. Para um nome inexistente, execvp falha, o filho termina com 127 e o pai mostra esse estado. O filho não deve continuar o código da shell depois da falha.
return de uma função auxiliar só retorna dessa função. exit termina o processo, executa funções registadas com atexit e trata buffers stdio. _exit termina sem essa limpeza da biblioteca, por isso no filho que falhou antes de exec usa _exit para evitar repetir limpezas e descarregar buffers herdados do pai.
Buffers e ordem observável
Um printf("antes") sem descarga antes de fork pode deixar bytes num buffer que será copiado; se pai e filho terminarem com exit, ambos podem descarregar a cópia, por isso usa fflush antes de criar o filho quando tens saída pendente. Uma mudança de linha não garante descarga em todas as situações: num terminal, stdout costuma ter buffer por linha, mas redirecionado para ficheiro costuma ter buffer por blocos.
Duas linhas impressas por processos concorrentes não têm ordem garantida só porque aparecem nessa ordem no código de ramos diferentes. Para impores ordem, precisas de uma relação como esperar pelo fim do filho ou receber uma mensagem dele. Um sleep só atrasa, ou seja não prova sincronização.
Criar vários trabalhadores
Para lançar várias tarefas em paralelo, o pai cria os filhos primeiro e espera depois. Guarda cada PID devolvido num vetor, com o nome da tarefa correspondente. Depois percorre o vetor com waitpid, um filho de cada vez, e regista o estado de cada um. Só avança para a etapa dependente, como ligar ficheiros objeto depois de compilá-los, quando todos terminaram com sucesso. Se esperar logo em cada iteração, serializa as tarefas. Se recolher sem associar PID a tarefa, não sabe qual falhou.
O programa seguinte lança três filhos que terminam com estados distintos e recolhe cada um pelo seu PID. Guarda-o em trabalhadores.c e compila num terminal UNIX com cc -std=c17 -Wall -Wextra -Wpedantic trabalhadores.c -o trabalhadores.
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
const char *nomes[] = {"a", "b", "c"};
pid_t pids[3];
for (int i = 0; i < 3; i++) {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0)
_exit(10 + i);
pids[i] = pid;
printf("lancei %s com PID %ld\n", nomes[i], (long)pid);
}
fflush(stdout);
int falhou = 0;
for (int i = 0; i < 3; i++) {
int estado;
pid_t r;
do {
r = waitpid(pids[i], &estado, 0);
} while (r < 0 && errno == EINTR);
if (r < 0) {
perror("waitpid");
return 1;
}
if (WIFEXITED(estado)) {
printf("%s (PID %ld): estado=%d\n", nomes[i], (long)pids[i],
WEXITSTATUS(estado));
if (WEXITSTATUS(estado) != 10 + i)
falhou = 1;
} else {
printf("%s: fim anormal\n", nomes[i]);
falhou = 1;
}
}
return falhou;
}
Saída observada (os PIDs variam em cada execução):
lancei a com PID 31832
lancei b com PID 31833
lancei c com PID 31834
a (PID 31832): estado=10
b (PID 31833): estado=11
c (PID 31834): estado=12
Os três filhos podem executar concorrentemente porque a criação antecede a recolha. Execução simultânea depende de haver vários núcleos disponíveis. Cada estado é lido com macros depois de confirmar o tipo de fim, e o vetor associa cada PID à sua tarefa.
Uma shell repete ler, analisar, criar, executar e recolher. Comandos internos que alteram o seu estado, como cd ou exit, ficam na própria shell. Um parser que divide apenas por espaços não suporta automaticamente aspas, expansões ou pipelines.
Exercícios
Contar descendentes
Assume sucesso. Um processo faz um fork; apenas o filho faz
depois outro
fork. Todos terminam. Quantos processos foram criados ao
todo, incluindo o inicial?
Pista
Solução
São 3, porque o primeiro cria um filho e o segundo, executado só nesse filho, cria mais um. A regra 2 elevado a n exigiria que todos executassem as duas chamadas.
Erros frequentes
Contar chamadas escritas em vez de processos que chegam a cada chamada.
Mesmo endereço, valores diferentes
Antes de fork, x=4. O filho escreve x=9 e termina. O pai
faz waitpid e imprime x. Os endereços virtuais apresentados
são iguais. Quais são os valores e o que prova essa igualdade de endereços?
Pista
Mais uma pista
wait impõe ordem de fim, não partilha de dados privados.
Solução
O filho tem 9 e o pai imprime 4. O mesmo endereço virtual pode traduzir para
dados privados diferentes, especialmente após copy-on-write.
waitpid não copia os valores alterados pelo filho.
Confere a tua resposta:
- Indiquei pai=4 e filho=9.
- Distingui endereço virtual de partilha de variável.
- Expliquei que wait não copia a alteração.
Erros frequentes
Usar a igualdade numérica do apontador para concluir que x está partilhado.
Recolher vários filhos
O pai lança três compilações em paralelo com fork e exec e só depois recolhe os resultados. Descreve onde guarda os PIDs, como espera por cada filho e o que verifica antes de passar à ligação dos objetos.
Pista
Esperar dentro do ciclo de criação serializa as tarefas.
Mais uma pista
Solução
Guarda cada PID com o nome da tarefa num vetor. Depois percorre o vetor com
waitpid, um filho de cada vez, repetindo em EINTR. Confirma WIFEXITED antes
de WEXITSTATUS e WIFSIGNALED antes de WTERMSIG. Só avança para a ligação
quando os três estados indicam sucesso; caso contrário, identifica qual a
tarefa falhada pelo PID. O programa trabalhadores.c da lição executa este
desenho e mostra cada PID com o seu estado.
Confere a tua resposta:
- Criei todos os filhos antes de esperar.
- Associei cada PID à sua tarefa.
- Verifiquei cada estado com as macros antes de avançar.
Erros frequentes
Esperar logo em cada iteração, ou recolher sem associar cada estado à sua tarefa.