IPC (Pipes, Memória Compartilhada, Sinais)
Inter-process communication (IPC) é como dois processos já criados e já escalonados de fato trocam dados ou eventos entre si, um problema separado tanto de criá-los quanto de escaloná-los: Multitasking cobre conseguir mais de um processo rodando ao mesmo tempo, mas não diz nada sobre como dois deles, uma vez rodando, conversam entre si.
Um pipe é um buffer gerenciado pelo kernel exposto ao user space como dois descritores de arquivo, uma extremidade de leitura e uma de escrita, mais comumente criado como um par herdado por dois processos relacionados através de um fork (um shell conectando a saída de um comando à entrada de outro, ls | grep foo, é o caso clássico). Dados escritos na extremidade de escrita são copiados para o próprio buffer do kernel, não diretamente para a memória do processo que lê, e uma leitura na extremidade de leitura copia de volta a partir desse mesmo buffer, em ordem FIFO, sem busca nem acesso aleatório algum, apenas um fluxo estrito de primeiro a entrar, primeiro a sair.
int fds[2];pipe(fds); // fds[0] = extremidade de leitura, fds[1] = de escrita
if (fork() == 0) { close(fds[0]); write(fds[1], "hello", 5);} else { close(fds[1]); char buf[5]; read(fds[0], buf, 5);}A capacidade do buffer é fixa (comumente 64 KB no Linux) em vez de crescer sem limite: um escritor que o enche bloqueia até um leitor esvaziar parte dele, e um leitor num pipe vazio bloqueia até um escritor fornecer mais, o que é o que torna um pipe um mecanismo de sincronização tanto quanto de transferência de dados, coordenando o ritmo de dois processos de outra forma independentes sem que nenhum dos dois precise sondar o outro. Uma leitura ou escrita num pipe com a outra extremidade já fechada se comporta de forma distinta em vez de bloquear para sempre: ler um pipe vazio cuja extremidade de escrita está fechada retorna fim de arquivo imediatamente, e escrever num pipe cuja extremidade de leitura está fechada levanta SIGPIPE em vez de bloquear esperando por um leitor que nunca vai chegar.
Memória compartilhada
Seção intitulada “Memória compartilhada”Memória compartilhada adota uma abordagem inteiramente diferente: em vez de copiar dados através do kernel a cada transferência, ela mapeia exatamente os mesmos frames físicos nos espaços de endereçamento virtuais separados de dois (ou mais) processos ao mesmo tempo, então uma escrita que um processo faz fica visível para o outro imediatamente, sem cópia alguma e sem chamada de kernel envolvida na transferência em si. Essa é a mesma ideia de múltiplos mapeamentos para um frame que Paging já cobre para outros propósitos (copy-on-write, por exemplo), aplicada aqui deliberadamente em vez de como um efeito colateral disparado por falha: as tabelas de página dos dois processos apontam endereços virtuais separados para frames físicos idênticos desde o momento em que a região compartilhada é configurada, não só depois que algum acesso posterior força isso.
void *shm = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);// escritas através de *shm ficam visíveis para qualquer outro processo// mapeando o mesmo shm_fd, sem chamada read()/write() algumaComo não há mediação do kernel em cada acesso individual, memória compartilhada reintroduz exatamente as mesmas condições de corrida que Synchronization já cobre para threads dentro de um único processo, agora entre processos inteiramente separados: dois processos escrevendo na mesma estrutura compartilhada ao mesmo tempo precisam dos mesmos locks, atômicos, ou outra coordenação que um programa multithreaded precisaria, já que o kernel não impõe nada sobre ordem ou exclusividade num mapeamento compartilhado por conta própria. Essa é a troca central em relação a pipes: memória compartilhada é dramaticamente mais rápida para transferência de alto volume ou baixa latência, já que dados grandes nunca precisam ser copiados através do kernel, mas empurra toda preocupação de sincronização de volta para os processos que a usam, onde o comportamento de bloqueio de leitura/escrita de um pipe trata uma parte substancial dessa coordenação automaticamente.
Um sinal é uma notificação assíncrona entregue a um processo, interrompendo o que quer que ele esteja fazendo no momento (de forma parecida com uma interrupção de hardware interrompendo o que quer que a CPU esteja executando no momento) em vez de exigir que o processo leia algo ativamente para saber que um sinal chegou. SIGKILL termina um processo incondicionalmente sem forma alguma de o alvo intervir; SIGTERM pede terminação mas pode ser capturado e tratado primeiro, deixando um processo limpar seu próprio estado antes de sair; SIGCHLD notifica um pai de que um processo filho saiu, comumente usado para disparar uma chamada wait em vez de sondar por conclusão do filho.
void handler(int sig) { // roda assincronamente, interrompendo o que o processo estava fazendo cleanup(); _exit(0);}
signal(SIGTERM, handler);Um handler registrado roda na própria stack do processo que o recebe, interrompendo seu fluxo de controle normal em essencialmente qualquer ponto, o que é o que torna handlers de sinal restritos a operações async-signal-safe: código que estava no meio, digamos, da própria contabilidade interna do malloc quando um sinal chegou e o interrompeu não pode chamar malloc novamente com segurança de dentro do handler, já que a chamada interrompida pode ter deixado o estado interno do alocador apenas parcialmente consistente. Entregar um sinal a um processo atualmente bloqueado numa chamada de sistema (um read esperando naquele mesmo pipe da seção acima, por exemplo) comumente interrompe a chamada mais cedo, retornando um erro que o chamador precisa checar e tratar em vez de silenciosamente tentar de novo, um detalhe que pega de surpresa código escrito sem sinais em mente desde o início.
Notas de implementação
Seção intitulada “Notas de implementação”Um kernel implementando pipes precisa que o próprio buffer seja um objeto genuinamente separado da memória de qualquer um dos processos, referenciado pelas entradas de tabela de arquivo dos dois descritores em vez de ser dono de quem quer que o tenha criado, já que qualquer extremidade pode sobreviver ao processo que a abriu (herdada através de mais chamadas fork, por exemplo) e o buffer precisa persistir enquanto pelo menos uma referência a qualquer extremidade permanecer aberta. Implementações de memória compartilhada precisam rastrear contagens de referência nos frames físicos por baixo de forma parecida, desmontando o mapeamento (e liberando os frames) só depois que todo processo que o compartilha desmapeou ou saiu, não simplesmente quando o primeiro o faz. Entrega de sinal a um processo atualmente fora de escala, não rodando em CPU alguma no momento em que um sinal chega, precisa ser registrada como pendente e de fato entregue na próxima vez que esse processo for escalonado para rodar, em vez de exigir que o processo esteja executando no exato instante em que o sinal é gerado.
Referências
Seção intitulada “Referências”- ^ W. R. Stevens e S. A. Rago, Advanced Programming in the UNIX Environment, Capítulos 15 e 10: a referência padrão sobre pipes, memória compartilhada, e tratamento de sinais em sistemas Unix-like.
Ver também
Seção intitulada “Ver também”- Multitasking: conseguir mais de um processo rodando ao mesmo tempo, o pré-requisito sobre o qual os mecanismos de comunicação deste artigo se apoiam.
- Synchronization: as mesmas condições de corrida cobertas ali para threads, reintroduzidas aqui entre processos separados compartilhando memória.
- Paging & Virtual Memory: o mecanismo de múltiplos mapeamentos para um frame sobre o qual memória compartilhada é diretamente construída.
- Término de Processo, Zumbis, e wait(): o que um pai de fato faz em resposta ao sinal SIGCHLD descrito acima.