IPC não é o que parece na aula de SO
Você já tentou fazer dois processos conversarem e acabou perdendo três dias debugando um deadlock que não deveria existir. A comunicação entre processos pode ser definida como: o conjunto de mecanismos que permitem que entidades isoladas troquem dados e sincronizem ações dentro de um sistema operacional. Mas na prática, a definição do livro é só o começo. O que importa é saber qual mecanismo usar, quando usar, e como não destruir seu sistema no caminho.
O que é realmente uma comunicação entre processos pode ser definida como:
É um protocolo implícito ou explícito entre processos que compartilham espaço endereçado diferente. Cada mecanismo de IPC tem suas próprias regras de ownership, latência, throughput e semantics de entrega. Esqueça a ideia de que todos funcionam da mesma forma. Eles não funcionam. Os mecanismos básicos que você vai encontrar em qualquer sistema Unix-like são: pipes e pipes nomeados (FIFOs), mensagens de sistema, buffers compartilhados, memoria compartilhada, semáforos, mutexes, sockets Unix domain, e message queues POSIX. Em Windows, a coisa muda para named pipes, mailslots, shared memory com mutexes, RPC, e message queues. O conceito é o mesmo. A implementação é onde tudo dá errado.
Eu já vi engenheiros usarem memória compartilhada para trocar 64 bytes de metadata entre dois processos porque "era mais rápido". Funciona até o dia em que o processo B crasha sem desmapear a região, e o processo A começa a ler lixo aleatório da memória que já foi realocada para outra coisa. Isso não é hypothetical. Acontece. Sempre acontece.
Mecanismos na prática
Pipes anônimos são a coisa mais simples que existe. Criam um canal unidirecional entre processos relacionados (pai e filho, tipicamente). Dados entram por uma ponta e saem pela outra. Buffer do kernel gerencia tudo. Latência típica: microssegundos para payloads pequenos. Throughput máximo em sistemas modernos gira em torno de 1-2 GB/s em benchmarks de memcpy via pipe, mas isso cai drasticamente com payloads pequenos e muitos context switches. Pipes nomeados resolvem o problema de processos não relacionados. Você cria um arquivo especial no sistema de arquivos e qualquer processo com permissão pode abrir. O problema é que FIFOs não têm semanticas de message boundary. Se você escreve três mensagens de 100 bytes cada, o leitor pode receber um único read de 300 bytes, ou três reads de 100, ou qualquer combinação no meio do caminho. Você precisa implementar seu own framing protocol se quiser preservar boundaries. Muitas pessoas não fazem isso e depois passam dias questionando por que os dados chegam embaralhados.
Sockets do domínio Unix são basicamente pipes nomeados com interface de rede. Suportam comunicação bidirecional, multiplexação, e podem cruzar boundaries de namespace. São a escolha padrão para a maioria dos serviços modernos que precisam de IPC eficiente. A diferença prática entre um socket Unix e um TCP localhost é ridicularamente pequena — talvez 10-20% de overhead a mais no TCP devido à stack completa. Para a grande maioria dos casos, isso é irrelevante. Use Unix domain sockets e termine a discussão. Memória compartilhada é o mecanismo de mais alta performance que existe. Dois processos mapeiam a mesma região física de memória. sem nenhuma cópia pelo kernel. Latência na ordem de nanosegundos por operação de leitura. Mas o custo é altíssimo em complexidade. Você precisa de synchronisation primitives (mutexes, semáforos, memory barriers) para evitar race conditions. E cada vez que um processo modifica dados compartilhados, você precisa se preocupar com cache coherency em sistemas multi-core. Em minha experiência, memória compartilhada vale a pena apenas quando você está transferindo megabytes de dados entre processos que rodam no mesmo host e a latência é crítica. Para payloads menores que alguns kilobytes, o overhead de synchronisation frequentemente supera qualquer ganho de performance.
System V message queues e POSIX message queues oferecem delivery semântico com priority e timeout. São mais pesadas que sockets mas mais fáceis de usar corretamente. O problema prático é que resource limits do kernel (MSGMNB, MSGMNI, MSGMAP) são frequentemente configurados com valores padrão baixos em distribuições Linux, e muitos desenvolvedores não sabem que precisam ajustar /etc/sysctl.conf antes de deploy. Eu perdi uma manhã inteira tentando debugar um ENOSYS em uma message queue que simplesmente não cabia nos limites do sistema.
👉 Clique no botão abaixo para saber mais sobre o assunto!
Dilemas que ninguém conta
A primeira coisa que todo mundo aprende é sobre blocking vs non-blocking I/O. A segunda coisa que todo mundo esquece é que blocking calls em IPC bloqueiam o thread inteiro, não apenas o processo. Se seu processo tem múltiplos threads e um deles bloqueia esperando por dados de IPC, os outros threads continuam rodando normalmente. Mas se você está usando select/poll/epoll para multiplexar várias connections de IPC, um buffer cheio pode fazer seu epoll_wait retornar com um evento que, quando processado, ainda assim bloqueia porque o destinatário não está lendo rápido o suficiente. Isso cria um pattern de backpressure que é extremamente sutil de debuggar. O segundo dilema é sobre buffer sizing. Pipes têm tamanho fixo definido pelo kernel (geralmente 64KB em Linux moderno). Se um escritor escreve mais do que o buffer cabe sem um leitor ativo, a escrita bloqueia. Isso é comportamento esperado. O problema é que muitos desenvolvedores assumem que pipes são infinitos e escrevem benchmarks que parecem funcionar até adicionar carga real. Um pipe cheio comwriters múltiplos criando um deadlock clássico é um dos cenários mais comuns em sistemas de produção mal projetados.
O terceiro dilema, e talvez o mais importante, é sobre failure semantics. Quando um processo que está lendo de um pipe morre, o escritor recebe SIGPIPE e a escrita falha silenciosamente se você não tratar o sinal. Quando um processo que escreve para uma mensagem queue morre, o leitor continua bloqueado indefinidamente. Quando um processo que detém um mutex de memória compartilhada crasha sem liberar, todos os outros processos que dependem daquele mutex ficam travados para sempre. Essas não são edge cases. São comportamentos padrão que você precisa explicitamente lidar com retry logic, timeouts, e health checks.
Um caso real que eu enfrentei
Trabalhando em um sistema de streaming de dados em tempo real, tínhamos um producer que gerava eventos e um consumer que processava. Usamos shared memory com ring buffer e um par de semáforos POSIX para synchronisation. Funcionou perfeitamente em testes. Em produção, sob carga constante, o consumer ocasionalmente perdia eventos. O problema era que o semáforo de data-ready era incrementado pelo producer antes que todos os bytes do evento fossem escritos no buffer. O consumer lia o semáforo, acessava o buffer, e lia dados parcialmente escritos. A race condition existia porque não tínhamos memory barrier explícita entre a escrita dos dados e o incremento do semáforo. A correção foi simples mas levou duas semanas para identificar: substituímos o semáforo POSIX por um sequence counter com memory barriers adequadas, e o consumer passa a ler o sequence number antes de acessar qualquer dado no buffer. O pattern é bem documentado no kernel Linux (read_copy_seq), mas implementá-lo corretamente em user space exige entender o memory model do hardware alvo. Em x86, as memory barriers são relativamente fracas. Em ARM, elas são necessárias quase em toda operação de shared memory.
Quando não usar IPC
Se você pode resolver o problema com threads no mesmo processo, faça isso. Comunicação entre threads é trivialmente mais barata porque não envolve kernel, não há cópias de buffer, e as primitivas de synchronisation são mais simples. IPC existe porque às vezes você precisa de isolation — processos diferentes rodando com diferentes privileges, diferentes languages runtimes, ou simplesmente para containment de failure. Se isolation não é um requisito, threads resolvem 90% dos casos com menos complexidade. Se a comunicação é esporádica e os dados são pequenos, use sockets Unix mesmo que ambos os processos estejam no mesmo host. A abstração de network I/O é suficientemente eficiente e evita todas as armadilhas de shared memory e synchronisation. O overhead de uma syscall de sendmsg/recvmsg é da ordem de 1-5 microssegundos em hardware moderno. Shared memory com synchronisation adequada pode chegar a sub-microssegundos, mas esse ganho só importa em loops de alta frequência onde cada ciclo conta.
Se você está construindo um sistema distribuído e pensa que IPC local vai escalar para múltiplos hosts, pare agora. IPC é local por definição. Quando a necessidade de rede aparecer, reescrever a camada de comunicação é muito mais caro do que ter pensado em serialization, discovery, e fault tolerance desde o início. Thrift, gRPC, ou até JSON-over-TCP são opções válidas e resolvem problemas que IPC nunca pretendeu resolver.
Checklist prático
Antes de escolher um mecanismo de IPC, responda a estas perguntas: os processos estão no mesmo host? A latência é crítica (<1ms)? O volume de dados é alto (>10MB/s)? Os processos precisam de failure isolation? Há requisitos de segurança (SELinux, namespaces)? Se a resposta para as três primeiras é não, use threads. Se sim a uma e não às outras duas, use Unix domain sockets. Se sim a todas, considere shared memory com synchronization adequada e memory barriers explícitas. E faça load testing com falhas simuladas antes de colocar em produção. O que separa um sistema robusto de um que falha de formas inexplicáveis raramente é a escolha do mecanismo de IPC. É a tratamento de erros. Timeouts em todas as operações de leitura. Retry com backoff em todas as escritas. Health checks periódicos entre processos parceiros. E logs detalhados de cada transição de estado na comunicação. Sem isso, você está apenas adiando o problema até o horário de pico.