Além de `fork()` + `exec()`
(lwn.net)- spawn templates são uma proposta de criação de processos para o kernel Linux que busca permitir ao kernel armazenar em cache informações de executáveis em aplicações que executam repetidamente o mesmo binário, acelerando assim a inicialização de processos posteriores
- fork() precisa copiar todo o estado do processo, incluindo memória, para o processo filho, e quando um
exec()vem logo em seguida, essa memória muitas vezes é descartada, gerando ineficiência no padrão tradicional - spawn_template_create() retorna um descritor de arquivo de template ao especificar o executável por
execfdou pelo caminho absolutofilename, e o kernel abre esse arquivo e armazena em cache as informações necessárias para execução rápida - spawn_template_spawn() funciona de forma próxima ao caminho comum de
fork()/exec(), preserva as verificações aplicadas ao executar um novo arquivo, e os benchmarks da cover letter registraram melhora de cerca de 2% {p:2} - a criação de processos vazios com base em pidfd e a configuração via
pidfd_config()são vistas como uma abordagem melhor, com o objetivo de dar suporte a uma implementação deposix_spawn()no espaço do usuário
Limites do modelo de criação de processos do Unix
- Desde os primórdios do Unix,
fork()é a chamada de sistema central, orientada a processos, que cria um processo filho como cópia do pai, eexec()executa um novo programa no lugar do processo atual - No kernel Linux, a mesma funcionalidade central é mais conhecida como clone() e execve()
- Esse modelo de criação de processos tem tanto elegância quanto desvantagens, e a proposta de spawn templates de Li Chen não deve ser aceita no kernel Linux em sua forma atual, mas pode levar a novas primitivas de criação de processos no futuro
fork()é uma chamada de sistema relativamente cara porque precisa copiar todo o estado do processo, incluindo memória, para criar o processo filho- Houve várias otimizações ao longo dos anos, mas
fork()continua sendo, em essência, uma operação custosa - Em muitos casos, a chamada
fork()é seguida imediatamente porexec(), eexec()descarta toda a memória copiada para o filho - Houve tentativas de otimização como vfork(), mas o padrão
fork()seguido deexec()ainda é mais caro do que poderia ser
Spawn templates
- O conjunto de patches de Li Chen foca em aplicações que executam repetidamente o mesmo binário para otimizar o padrão
fork()eexec() - Um exemplo é um programa que precisa executar o Git repetidamente para obter informações sobre o conteúdo de um repositório
- Nesses casos, o programa pode criar um template para diluir o custo de configuração entre várias execuções e acelerar as invocações com esse template
- A criação do template usa a chamada de sistema
spawn_template_create()- assinatura no formato
int spawn_template_create(struct spawn_template_create_args *args, size_t args_size);
- assinatura no formato
- Essa chamada retorna um descritor de arquivo que representa o template do executável
- O executável deve ser especificado por meio do descritor de arquivo
execfdou do caminho absolutofilename, e não é possível usar ambos ao mesmo tempo - O kernel abre o arquivo especificado e armazena em cache várias informações necessárias para executá-lo mais rapidamente depois
- Cada execução pode ter argumentos, ambiente, alterações em descritores de arquivo e mudanças no tratamento de sinais diferentes
- As informações específicas de execução são colocadas na estrutura
spawn_template_spawn_argsargvaponta para a lista de argumentos passada ao programaenvpaponta para o ambiente do programaactionsaponta para um array despawn_template_actionque transmite alterações em descritores de arquivo e no tratamento de sinais
spawn_template_actioné composto pelos campostype,flags,fd,newfdearg- Se for necessário fechar o descritor de arquivo 4 no filho,
typedeve ser definido comoSPAWN_TEMPLATE_ACTION_CLOSEefdcomo 4 - Outras ações suportam duplicação de descritores de arquivo, abertura de arquivo, mudança do diretório de trabalho e alteração do tratamento de sinais
- Se for necessário fechar o descritor de arquivo 4 no filho,
- Depois de preencher as informações de execução, o novo processo é iniciado com
spawn_template_spawn()- assinatura no formato
int spawn_template_spawn(int template_fd, struct spawn_template_spawn_args *args, int args_size);
- assinatura no formato
- O funcionamento interno é próximo do caminho comum de
fork()/exec() - Todas as verificações normais aplicadas à execução de um novo arquivo são mantidas
- As informações armazenadas em cache no template aceleram todo o fluxo de criação
- Os resultados de benchmark na cover letter mostram melhora de cerca de 2%, o que pode ser relevante para aplicações que se encaixam no padrão esperado {p:2}
Rumo ao posix_spawn()
- Mateusz Guzik avaliou que “o idiomático completo de fork + exec é terrível e deveria ser eliminado”
- Um ponto estranho do conjunto de patches é que ele mantém a parte do
fork(), justamente onde se considera estar a maior parte do custo - A otimização deveria remover a cópia do processo atual e criar um “processo limpo” (
pristine process) - Christian Brauner considera que a ideia de uma API builder para
exec“não é tão estranha assim” - Ainda assim, ele prefere uma abordagem em que a nova API seja construída sobre a abstração existente de pidfd
- Embora não haja detalhes concretos, a abordagem correta seria adicionar a pidfd_open() uma opção para criar um processo vazio
- Depois disso, uma nova chamada de sistema
pidfd_config()poderia ser chamada várias vezes para aplicar ao novo processo a configuração desejada, como ambiente e imagem a executar pidfd_config()exerceria um papel semelhante ao de fsconfig()- Um objetivo importante da nova interface é dar suporte a uma implementação de posix_spawn() no espaço do usuário
posix_spawn()é uma alternativa adequada ao padrãofork()/exec()- A implementação atual esconde internamente
fork()eexec(), e uma implementação nativa teria uma estrutura diferente - Li Chen concordou que a API descrita de forma mais ampla por Brauner parece melhor e planeja direcionar o trabalho futuro nessa direção
- Spawn templates não entrarão no kernel Linux, mas, se o trabalho futuro der frutos, o Linux poderá acabar tendo uma implementação adequada de
posix_spawn()
1 comentários
Comentários do Hacker News
Há um artigo relacionado, A fork() in the road: https://www.microsoft.com/en-us/research/wp-content/uploads/...
O resumo argumenta que, ao contrário da noção comum de que a combinação Unix
fork()+exec()é um projeto inspirado, ela foi um hack esperto para máquinas e programas dos anos 1970, mas hoje é uma abstração ruim para programadores modernos e também impõe restrições à implementação de sistemas operacionaisA posição é que, em vez de permanecer como uma primitiva de primeira classe do sistema operacional, isso deveria ser ensinado como um artefato histórico e não ser a primeira forma de criação de processos que os estudantes aprendem
fork()+exec()ter surgido assim era permitir executar programas grandes demais para caber na memória junto com o programa paiA implementação original, ao chamar
fork(), fazia swap para disco do programa que estava forkeando e, antes de devolver o controle, duplicava e ajustava a entrada da tabela de processos para que existissem um processo em memória e um processo swapado para disco; o lado em memória recebia o controle e podia chamarexec()Esse método permitia executar programas grandes mesmo em máquinas PDP-11 pequenas, e era necessário numa época em que memória era muito cara
Curiosamente, no QNX o carregamento de programas não fica dentro do sistema operacional, e sim em uma biblioteca. Ela lê o cabeçalho do executável, aloca memória, carrega o programa, prepara sua execução e faz link com o
.soque o inicia; o carregador de programas roda em espaço de usuário sem privilégios. Talvez isso esteja mais próximo da forma corretafork(), seja muito lentaConcordo que deveria haver uma primitiva diferente de
fork(), mas não tenho certeza de que desempenho seja o melhor argumentofork(): The Scalable Commutativity Rule: Designing Scalable Software for Multicore Processors https://people.csail.mit.edu/nickolai/papers/clements-sc.pdffork()é excelente para o padrão zygoteÉ difícil imaginar uma otimização tão eficiente e elegante quanto essa
Recentemente enfrentei um bug obscuro causado pela necessidade de fechar mais descritores de arquivo em um processo recém-forkado
Pela minha experiência, “quero uma cópia do processo atual” é muito menos comum do que “quero um processo completamente novo”, e parece estranho não haver uma forma de expressar diretamente a segunda opção, sendo preciso apenas aproximá-la copiando e depois corrigindo tudo no pós-processamento
O_CLOEXEC?posix_spawn?Dizer que “
fork()é uma chamada de sistema relativamente cara, e exige copiar todo o estado do processo, inclusive a memória, para o processo filho. Ao longo dos anos houve muitas otimizações, mas no fundo continua sendo uma operação cara. Pior ainda, muitas vezes uma chamadafork()é seguida imediatamente porexec(), o que acaba descartando toda a memória cuidadosamente copiada para o filho” sem mencionar copy-on-write (escrita sob cópia) é estranhoÉ justamente a otimização que evita copiar toda a memória de fato, e ela ficou de fora
Mesmo que a memória apontada pelas páginas reais seja compartilhada, ainda é preciso alocar novas páginas para guardar cópias dessas estruturas. E percorrer todas essas estruturas para copiá-las continua sendo caro
fork()não copia a memória em si, mas ainda precisa copiar as tabelas de páginasSe for um processo com dezenas de GB de RAM,
fork()pode levar bastante tempo, e isso acontece sempre que o Redis faz dump do arquivo.rdbou reescreve o AOF de log binárioJá em 2012 havia um post mostrando o alto custo dessa operação: https://redis.io/blog/testing-fork-time-on-awsxen-infrastruc...
Em uma
m2.xlargeusando cerca de 25GB de RAM,fork()levou 5,67 segundos. Considerando que clientes Redis normalmente sofrem latências de apenas alguns milissegundos na maior parte das operações, isso representa uma pausa longa. E esse é só o tempo de cópia das tabelas de páginasÉ surpreendente que não haja menção a huge pages, que aqui parecem ser um ponto central. Quatorze anos depois o hardware deve estar mais rápido, mas instâncias Redis também provavelmente usam mais RAM, então seria interessante refazer esse benchmark
fork()ainda precisa pagar o custo de configurar isso. Se houver muitas threads ocupadas no processo pai, como por exemplo em Java, pode acabar ocorrendo muito copy-on-write desnecessário antes deexec()ser executadoJá é um problema bem conhecido que forkar programas com grande tamanho de memória virtual é lento
A elegância do modelo
fork()+exec()está no fato de que, após ofork(), dá para usar a API comum do jeito de sempre para fazer todo tipo de configuraçãoAté agora, as alternativas de chamada combinada que vimos pareciam fundamentalmente fracas, porque é preciso adicionar todas as opções de configuração como parâmetros da chamada e ainda fazer isso de um jeito que possa ser expandido depois sem virar uma bagunça
fork()/exec()possa ser útil em alguns casos, parece que ficaria bem bom se as APIs aceitassem um argumentopidfd. O 0 poderia significar o processo atualO problema seria mais com binários
setuid/setgid; nesse caso, talvez fosse melhor um tratamento especial noexecPor exemplo, seria possível criar um processo parado com
pidfd_t ps = spawn();e configurá-lo comsetuid(ps, 33);,capset(ps, ...);,socket(ps, ...);,mmap(ps, ...);,process_vm_writev(ps, ...);,exec(ps, ...);,signal(ps, SIGCONT);Isso também é uma crítica ao fato de que a API comum de chamadas de sistema não considera bem o suficiente a pergunta “e se eu quiser fazer isso em outro processo ao qual tenho acesso?”. Assim, também se tornaria possível lidar em certa medida com a segurança de threads no
fork()Ainda assim, concordo que uma abordagem como a do
CreateProcess, que recebe inúmeros parâmetros, não é excelente como API de espaço do usuárioPor exemplo, existem APIs que fazem com que algum objeto vire o descritor de arquivo número 4, e então dá para executar um programa e fazer com que ele encontre esse objeto no descritor 4. Isso é estranho
O Windows, apesar de todos os seus defeitos, não usa
fork()+exec()e, em vez disso, oferece principalmente opções sobre como criar processos. Não era elegante, mas estava indo na direção certafork()+exec()Em outro mundo onde
fork()+exec()não existisse, muitas dessas “APIs comuns” teriam um argumento explícito depidpara permitir mudar a configuração de outro processo. O Fuchsia funciona mais ou menos assimEsse mundo tem muitas vantagens. A mais óbvia é que não seria preciso inventar magicamente um esquema de IPC separado só para relatar erros de configuração, e também seria bem útil ter um processo gerenciador que ajusta as propriedades do filho. Os depuradores em especial gostariam disso
fork()é fazer com que as APIs comuns que mudam o estado do processo recebam um handle de processo explícitoAí a mesma API poderia ser usada para configurar um processo vazio e também se combinar com outras abordagens, como IPC ou depuração
Se o processo começasse com uma ligação
ptracee sem threads, seria possível forçar a execução de chamadas de sistema na etapa de configuração. Como o Linux nem sequer tem o conceito de “processo sem threads”, provavelmente seria necessária uma thread de mentiraO mal-entendido de que
fork()é barato é estranhamente comum, mas ele é O(N) em relação ao tamanho do processo, e sempre foiSim, é cópia na escrita. Mas existe uma relação linear entre o tamanho do processo e a quantidade de entradas da tabela de páginas necessária para representá-lo
Não surpreende que o patch do Chen tenha sido rejeitado. É um caso de uso específico demais, então o valor de dar suporte é baixo
Da perspectiva de um desenvolvedor de shell, concordo com a conclusão de que “os desenvolvedores provavelmente receberiam bem uma implementação nativa que não esconda
fork()eexec()internamente como a implementação atual faz”Desde a primeira vez que aprendi
fork(), ele me pareceu conceitualmente horrível. Se você quer fazer uma única coisa, isto é, iniciar um processo, não deveria ter de passar por um encantamento enigmático de fazer o fork do processo atual, que é outra tarefa sem relaçãoFico curioso sobre qual seria a melhor forma de lidar com situações como o exemplo do texto, em que um processo dispara muitos subprocessos
git. Recomeçar ogitdo zero repetidamente durante um trabalho longo do processo pai não parece fazer sentido; então qual seria a abstração de baixo custo que produz o mesmo resultado?fork()é conceitualmente simples. Sem trazer outras camadas, você inicia um processo a partir da única coisa cuja existência você conhece com certeza: você mesmoDo contrário, seriam necessárias várias etapas para criar um processo, preenchê-lo com algo a executar e colocá-lo para rodar. Ou então seria preciso esmagar tudo para sempre junto com outras camadas, como sistema de arquivos, carregador de objetos e linker, como no Win32
fork()+exec()não fazia sentido nenhum para mim. Agora eu sei que é só uma esquisitice histórica, mas ainda existem pessoas fingindo quefork()+exec()é realmente uma boa ideialibgit2. Dá para imaginar alguma forma de se comunicar com umgitdpor pipe ou socket, mas não sei por que isso seria uma boa ideia. Fora isso, é preciso iniciar um processoO motivo de ser difícil substituir
exec/forké que normalmente o novo processo precisa ser configurado. Por exemplo, pode ser necessário ajustar manipuladores de sinal, fechar ou abrir descritores de arquivo, trocar namespaces, configurarseccompe ajustar privilégiosSó que as chamadas de sistema para isso hoje se aplicam apenas ao processo atual, então seria preciso algum substituto. A proposta do texto era criar uma nova API para isso
Na minha visão, uma nova chamada de sistema como
spawnpoderia criar um processo vazio, carregar nele um loader leve e depois passar dados arbitrários de configuração. O loader configuraria o processo e fariaexec()do programa principalIsso permitiria manter a API existente sem fazer fork da memória, embora ainda fosse necessário duplicar descritores de arquivo e outras coisas
Desculpe se não era piada, mas
posix_spawn()já existe e, no glibc,forké apenas um apelido paraclone()Mesmo não sendo exatamente igual à proposta original,
fork()/exec()realmente já está bem perto de legadoSe
forkeexecpudessem exibir comportamento persistente e algébrico, em vez de ir apenas até a natureza de cópia na escrita, eles seriam não só mais úteis como também mais interessantes de usar. Por exemplo, poderiam servir para avaliação preguiçosaHouve muitas discussões sobre essa API antiga no Hacker News, por exemplo https://news.ycombinator.com/item?id=31739794