Comunicação entre processos
Pipes, FIFOs, dup2, sockets, sinais e memória partilhada, com ordem e fim de dados.
O pai produz bytes e o filho consome-os. Um pipe permite transferi-los entre memórias privadas, mas não basta escrever: o pai também tem de fechar a escrita para o filho reconhecer o fim. O canal define como os dados passam; o protocolo define quando cada processo lê, escreve e termina.
Escolher um mecanismo
Começa pelo caso mais simples: um pipe de pai para filho, com uma só direção e fim dos dados marcado por EOF. Os outros mecanismos resolvem variações deste problema: nome persistente com FIFO, dois sentidos ou rede com sockets, notificação sem dados com sinais e acesso direto com memória partilhada.
| Mecanismo | O que transporta | Identificação | Condição a tratar |
|---|---|---|---|
| Pipe | Fluxo de bytes, um sentido | Descritores | Fechar todas as escritas para obter EOF |
| FIFO | Fluxo de bytes, um sentido | Nome no sistema de ficheiros | Abertura pode esperar pela outra ponta |
| Socket | Fluxo ou datagramas | Descritor e endereço | Definir fronteiras das mensagens |
| Sinal | Notificação de evento | PID e número simbólico | Tratador restrito e sinais pendentes |
| Memória partilhada | Bytes acessíveis pelos processos | Mapeamento | Sincronizar acessos e tempo de vida |
Partilhar memória não torna uma operação composta atómica, ou seja continua a precisar de sincronização. Receber um sinal também não entrega automaticamente uma estrutura C com todos os dados do remetente.
Pipe: leitura e escrita
pipe(fd) cria fd[0] para ler e fd[1] para escrever. Depois de fork, ambos os processos herdam ambas as pontas. Quem só lê deve fechar a escrita; quem só escreve deve fechar a leitura.
Se o buffer estiver vazio e existir uma escrita aberta, read bloqueia. Se estiver vazio e todas as pontas de escrita tiverem sido fechadas, read devolve 0, ou seja EOF. Se não houver leitores, uma escrita pode gerar SIGPIPE e falhar com EPIPE se o sinal não terminar o processo.
O seguinte programa envia uma mensagem do pai ao filho. Guarda-o em pipe.c, compila num terminal UNIX com cc -std=c17 -Wall -Wextra -Wpedantic pipe.c -o pipe e corre ./pipe.
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
static int escrever_tudo(int fd, const char *buf, size_t n) {
size_t feito = 0;
while (feito < n) {
ssize_t k = write(fd, buf + feito, n - feito);
if (k < 0 && errno == EINTR) continue;
if (k <= 0) return -1;
feito += (size_t)k;
}
return 0;
}
int main(void) {
int fd[2];
if (pipe(fd) < 0) { perror("pipe"); return 1; }
pid_t filho = fork();
if (filho < 0) {
perror("fork");
close(fd[0]); close(fd[1]);
return 1;
}
if (filho == 0) {
close(fd[1]);
char buf[32];
for (;;) {
ssize_t n = read(fd[0], buf, sizeof buf);
if (n < 0 && errno == EINTR) continue;
if (n < 0) _exit(1);
if (n == 0) break;
if (escrever_tudo(STDOUT_FILENO, buf, (size_t)n) < 0)
_exit(1);
}
close(fd[0]);
_exit(0);
}
close(fd[0]);
const char mensagem[] = "dados do pai\n";
int erro = escrever_tudo(fd[1], mensagem, sizeof mensagem - 1) < 0;
close(fd[1]);
int estado;
pid_t r;
do { r = waitpid(filho, &estado, 0); } while (r < 0 && errno == EINTR);
if (r < 0) { perror("waitpid"); return 1; }
return erro || !WIFEXITED(estado) || WEXITSTATUS(estado) != 0;
}
A saída é dados do pai. Não enviamos o terminador zero, porque o filho escreve a quantidade realmente lida.
Conta os descritores depois de fork: o pai e o filho herdam as duas pontas, por isso há quatro referências abertas. O pai fecha a leitura e conserva a escrita; o filho fecha a escrita e conserva a leitura. O pai fecha a escrita antes de esperar, porque o filho precisa desse fecho para sair do ciclo. Qualquer cópia esquecida da ponta de escrita adia o EOF.
Pipes são fluxos, não caixas de mensagens. Uma escrita pode ser entregue em várias leituras. Escritas até PIPE_BUF têm uma garantia de não entrelaçamento com outras escritas no pipe, mas isso não obriga read a devolver uma mensagem inteira. Dois sentidos independentes precisam de dois pipes ou de um socket bidirecional.
Criar o canal antes de fork
O canal tem de existir antes de ser herdado. Se primeiro fizeres fork e depois cada processo chamar pipe(fd), obténs dois pipes distintos. Até podem receber os mesmos números de descritor, como 3 e 4, porque esses números só identificam posições nas tabelas locais de cada processo.
No filho que fecha a escrita do seu próprio pipe, a leitura encontra o canal vazio e sem escritores, por isso devolve EOF. No pai que fecha a leitura do outro pipe, uma tentativa de escrita não tem leitor: pode terminar com SIGPIPE, ou falhar com EPIPE se o sinal for ignorado. O pai não está a escrever no canal do filho.
A ordem correta é criar um pipe, verificar sucesso, criar o filho e fechar as pontas desnecessárias em cada processo. A mesma distinção entre número local e objeto do núcleo explica por que dup e duas chamadas independentes a open têm efeitos diferentes.
Redirecionar com dup2
dup2(origem, destino) faz destino referir a descrição aberta de origem, fechando o destino anterior quando necessário. A shell usa esta ideia para preparar stdin e stdout antes de exec.
Numa pipeline produtor | consumidor, um processo supervisor pode criar o pipe e dois filhos. No produtor:
close(fd[0]);
if (dup2(fd[1], STDOUT_FILENO) < 0) _exit(1);
close(fd[1]);
execvp(comando1[0], comando1);
_exit(127);
No consumidor, troca leitura e escrita e duplica fd[0] para STDIN_FILENO. Estes são excertos com descritores distintos dos canais padrão e vetores de argumentos terminados por NULL. O supervisor fecha ambas as pontas e recolhe os dois filhos. Se conservar a escrita, o consumidor pode nunca receber EOF. Se esperar por um produtor de dados grandes antes de lançar o consumidor, o pipe pode encher e bloquear o produtor.
FIFOs e sockets
mkfifo cria uma entrada com nome. Os dados ficam no canal do núcleo, não são armazenados como conteúdo persistente de um ficheiro regular. O nome continua a existir até ser removido, mesmo depois de todos fecharem o canal.
mkfifo canal
cat canal
No segundo terminal, printf 'ola\n' > canal permite que a leitura avance. Quando a escrita fecha, cat recebe EOF e termina. Remove canal quando acabares. Com abertura bloqueante, abrir só para leitura costuma esperar por um escritor e abrir só para escrita espera por um leitor.
socketpair(AF_UNIX, SOCK_STREAM, 0, fd) cria dois descritores bidirecionais locais. Cada processo pode ler e escrever na ponta que conserva. SOCK_STREAM continua a exigir um protocolo de mensagens: por exemplo linhas, um comprimento prefixado ou uma quantidade fixa de bytes. Não imprimas um buffer com %s sem garantir \0.
Num protocolo pedido/resposta sobre fluxo, define primeiro as fronteiras: linhas terminadas por newline, comprimento prefixado ou tamanho fixo. Depois define o fim do pedido; uma combinação comum é o servidor ler até EOF antes de responder.
O cliente que conserva a ponta aberta para receber a resposta precisa de terminar apenas a escrita com shutdown(fd, SHUT_WR). O servidor esvazia os dados pendentes e depois vê EOF, mas ainda pode responder no sentido inverso; close fecharia também a leitura do cliente. Se ambos enviarem mensagens grandes sem ler, os buffers podem encher e ambos bloquear, por isso define a ordem de troca ou trata os dois sentidos em simultâneo.
Sockets AF_UNIX também podem ser identificados por um endereço local; AF_INET permite comunicação por rede. Pai e filho são uma forma simples de distribuir descritores, não uma restrição geral a todos os sockets ou a toda a memória partilhada.
Sinais sem trabalho inseguro
Um sinal notifica um evento, como pedido de terminação ou alteração de configuração. kill envia o sinal, sujeito a permissões. Usa nomes como SIGINT, SIGTERM, SIGHUP e SIGUSR1, não números dependentes do sistema. SIGKILL e SIGSTOP não podem ser capturados nem ignorados.
Um tratador pode interromper código que já está dentro de printf ou malloc, por isso voltar a chamar essas funções no tratador não é seguro; guarda uma indicação do tipo volatile sig_atomic_t e faz o trabalho no fluxo normal. Este tipo permite a indicação simples, mas não torna qualquer estrutura partilhada segura. Os exemplos das aulas usam signal com printf no tratador por brevidade didática; em código de produção segue o padrão desta secção, com sigaction, máscara e apenas funções async-signal-safe no tratador.
O programa seguinte espera por SIGUSR1 sem a janela entre testar a condição e adormecer. Guarda-o como sinal.c e compila no terminal UNIX com cc -std=c17 -Wall -Wextra -Wpedantic sinal.c -o sinal.
#define _POSIX_C_SOURCE 200809L
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
static volatile sig_atomic_t recebido = 0;
static void tratar(int sinal) {
(void)sinal;
recebido = 1;
}
int main(void) {
sigset_t bloqueados, anterior;
sigemptyset(&bloqueados);
sigaddset(&bloqueados, SIGUSR1);
if (sigprocmask(SIG_BLOCK, &bloqueados, &anterior) < 0) return 1;
struct sigaction acao = {0};
acao.sa_handler = tratar;
sigemptyset(&acao.sa_mask);
if (sigaction(SIGUSR1, &acao, NULL) < 0) return 1;
printf("PID=%ld\n", (long)getpid());
fflush(stdout);
sigset_t espera = anterior;
sigdelset(&espera, SIGUSR1);
while (!recebido) sigsuspend(&espera);
if (sigprocmask(SIG_SETMASK, &anterior, NULL) < 0) return 1;
puts("SIGUSR1 recebido");
return 0;
}
Segue a ordem segura: bloqueia SIGUSR1 com sigprocmask, instala o tratador com sigaction, mostra o PID e chama sigsuspend com uma máscara que desbloqueia o sinal. Corre ./sinal, copia o PID apresentado e envia kill -USR1 PID noutro terminal, substituindo PID. O programa mostra a mensagem e termina. sigsuspend muda temporariamente a máscara e espera de forma atómica. No padrão ingénuo while (!recebido) pause(), o sinal poderia chegar depois do teste e antes de pause, deixando o processo à espera de um segundo sinal.
Sinais tradicionais repetidos podem fundir-se enquanto estão pendentes. Não os uses como um contador fiável de mensagens. Para atualizar configuração, o tratador assinala o pedido e o ciclo principal lê o ficheiro.
Memória partilhada
mmap devolve um mapeamento ou MAP_FAILED, que não é necessariamente NULL. MAP_SHARED permite partilhar alterações; MAP_PRIVATE dá alterações privadas com copy-on-write. Um mapeamento anónimo partilhado criado antes de fork pode ser usado por pai e filho. Processos sem parentesco podem mapear o mesmo objeto partilhado.
Se o filho escreve uma mensagem no mapeamento e termina, e o pai só a lê depois de waitpid, a ordem de produção e leitura está imposta. Se ambos atualizam continuamente a mesma fila, precisam de sincronização apropriada para processos, não apenas de MAP_SHARED nem de volatile. munmap remove o mapeamento; fechar o descritor usado para o criar não o remove automaticamente.
Este exemplo completo usa um mapeamento anónimo de Linux. Guarda-o em partilha.c e compila com cc -std=c17 -Wall -Wextra -Wpedantic partilha.c -o partilha. Não usa o executor C do navegador.
#define _DEFAULT_SOURCE
#include <errno.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
size_t tamanho = sizeof(int);
int *valor = mmap(NULL, tamanho, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (valor == MAP_FAILED) { perror("mmap"); return 1; }
*valor = 4;
pid_t filho = fork();
if (filho < 0) {
perror("fork");
munmap(valor, tamanho);
return 1;
}
if (filho == 0) {
*valor = 9;
_exit(0);
}
int estado;
pid_t r;
do { r = waitpid(filho, &estado, 0); } while (r < 0 && errno == EINTR);
if (r < 0 || !WIFEXITED(estado) || WEXITSTATUS(estado) != 0) {
munmap(valor, tamanho);
return 1;
}
printf("partilhado=%d\n", *valor);
if (munmap(valor, tamanho) < 0) { perror("munmap"); return 1; }
return 0;
}
A saída é partilhado=9. Ao contrário da variável privada do exemplo de fork, este objeto pertence ao mapeamento partilhado. O pai espera pela terminação antes de ler; não existem duas atualizações concorrentes a coordenar neste exemplo.
Exercícios
A ponta que impede o fim
O filho fecha a sua escrita do pipe e lê em ciclo. O pai
envia dados e faz
waitpid sem fechar a escrita. Porque podem ficar ambos à
espera? Explica também o que muda numa pipeline com dois filhos se o
supervisor conservar uma cópia da escrita.
Pista
EOF depende de referências abertas, não da intenção de voltar a escrever.
Mais uma pista
O filho precisa de sair antes de waitpid poder terminar.
Solução
Depois de consumir os dados, o filho vê o buffer vazio mas há uma escrita aberta no pai, por isso bloqueia. O pai espera pelo fim do filho. Fecha a escrita antes de esperar, e fecha outras cópias que possam existir. Numa pipeline, o supervisor fecha ambas as pontas após criar os filhos; conservar uma escrita tem o mesmo efeito, mesmo que o produtor já terminou.
Confere a tua resposta:
- Identifiquei a escrita ainda aberta no pai.
- Fechei todas as escritas quando acabam os dados.
- Expliquei a espera com buffer vazio.
Erros frequentes
Achar que uma read curta prova EOF ou que só a ponta do filho importa.
Um sinal perdido antes de pause
Num programa while (!recebido) pause(), o tratador muda
recebido para 1. Mostra uma ordem que deixa o programa bloqueado depois de
receber o sinal.
Pista
Mais uma pista
O sinal pode chegar depois de confirmar recebido=0 e antes da chamada pause.
Solução
O ciclo testa recebido=0; o sinal chega e o tratador põe 1; o tratador retorna; o programa chama pause e espera outro sinal. Bloquear o sinal durante o teste e usar sigsuspend para desbloquear e dormir atomicamente fecha essa janela.
Confere a tua resposta:
- Coloquei a chegada entre o teste e pause.
- Expliquei que o tratador já terminou antes de dormir.
- Propus máscara e sigsuspend ou mecanismo equivalente.
Erros frequentes
Pensar que volatile liga atomicamente o teste à espera ou chamar printf num tratador sem verificar segurança.
Acabar o pedido e receber a resposta
Num socketpair de fluxo, cada processo fechou a ponta que não usa. O cliente envia um pedido e começa a ler a resposta sem fechar a escrita. O servidor só responde depois de ler EOF. Porque param os dois? Como terminar o pedido sem perder a possibilidade de receber a resposta?
Pista
Mais uma pista
Solução
O servidor esvazia o pedido e espera mais bytes porque a escrita do cliente continua aberta. O cliente espera uma resposta que o servidor ainda não produz. Depois de enviar todo o pedido, o cliente faz shutdown(fd, SHUT_WR) e verifica o retorno. O servidor recebe os bytes restantes e depois EOF, envia a resposta e fecha. O cliente conserva a leitura até receber a resposta e só depois fecha. As transferências continuam a exigir ciclos para resultados parciais.
Confere a tua resposta:
- Identifiquei que o servidor espera EOF e o cliente espera a resposta.
- Usei shutdown com SHUT_WR depois de enviar todo o pedido.
- Conservei a leitura e só fechei o descritor depois de receber a resposta.
Erros frequentes
close antes de receber a resposta, ou interpretar uma leitura curta como fim do pedido.Descritores iguais, canais distintos
Um programa faz primeiro fork. Depois, pai e filho chamam separadamente pipe(fd) e ambos recebem os números 3 e 4. O filho fecha fd[1] e lê de fd[0]; o pai fecha fd[0] e tenta escrever em fd[1]. Os canais estão inicialmente vazios e não há outras referências. Assume sucesso na criação.
Estão a comunicar? Que acontece à leitura do filho e à escrita do pai? Corrige a ordem de criação. Na versão corrigida, o pai envia 18 bytes e o filho faz apenas uma leitura com capacidade 7: pode concluir que recebeu todo o pedido?
Pista
Solução
Não comunicam: cada chamada cria outro canal. O filho fechou o único escritor do seu canal vazio, portanto recebe 0, EOF. O pai fechou o único leitor do seu canal; a escrita gera SIGPIPE e, se o sinal não o terminar, falha com EPIPE. Os mesmos números nas duas tabelas locais não representam necessariamente o mesmo objeto.
Cria um pipe antes de fork, verifica sucesso e só depois separa as pontas entre pai e filho. Na versão corrigida, uma leitura de capacidade 7 recebe no máximo 7 dos 18 bytes e pode receber menos. O filho precisa de ler em ciclo até EOF; o pai completa as escritas e fecha a escrita antes de esperar pela terminação. Uma leitura curta não marca o fim de uma mensagem.
Confere a tua resposta:
- Identifiquei dois pipes distintos criados depois do fork.
- Previ EOF no filho e ausência de leitor no canal do pai.
- Criei o canal antes do fork e conservei ciclos de transferência.