2 pontos por GN⁺ 3 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • 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 execfd ou pelo caminho absoluto filename, 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 de posix_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, e exec() 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 por exec(), e exec() descarta toda a memória copiada para o filho
  • Houve tentativas de otimização como vfork(), mas o padrão fork() seguido de exec() 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() e exec()
  • 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);
  • 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 execfd ou do caminho absoluto filename, 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_args
    • argv aponta para a lista de argumentos passada ao programa
    • envp aponta para o ambiente do programa
    • actions aponta para um array de spawn_template_action que transmite alterações em descritores de arquivo e no tratamento de sinais
    Publicidade
  • spawn_template_action é composto pelos campos type, flags, fd, newfd e arg
    • Se for necessário fechar o descritor de arquivo 4 no filho, type deve ser definido como SPAWN_TEMPLATE_ACTION_CLOSE e fd como 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
  • 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);
  • 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ão fork()/exec()
  • A implementação atual esconde internamente fork() e exec(), 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

 
GN⁺ 3 시간 전
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 operacionais
    A 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

    • O motivo de fork()+exec() ter surgido assim era permitir executar programas grandes demais para caber na memória junto com o programa pai
      A 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 chamar exec()
      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 .so que o inicia; o carregador de programas roda em espaço de usuário sem privilégios. Talvez isso esteja mais próximo da forma correta
    • É interessante que a criação de processos no Windows, o maior sistema operacional amplamente usado que não usa fork(), seja muito lenta
      Concordo que deveria haver uma primitiva diferente de fork(), mas não tenho certeza de que desempenho seja o melhor argumento
    • Este artigo também é bom, e a referência [29] também foi especialmente boa por tratar das partes sutis de interfaces escaláveis, incluindo fork(): The Scalable Commutativity Rule: Designing Scalable Software for Multicore Processors https://people.csail.mit.edu/nickolai/papers/clements-sc.pdf
    • A discussão da época está aqui: https://news.ycombinator.com/item?id=19621799 - A fork() in the road (2019-04-10, 178 comentários)
    • fork() é 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

    • Normalmente você quer se comunicar com esse processo, então precisa configurar coisas como descritores de arquivo e passar informações do processo pai, por exemplo
    • Isso não seria resolvido com O_CLOEXEC?
    • Se a questão é “uma forma de expressar diretamente a segunda opção”, não é exatamente para isso que serve posix_spawn?
    • O que exatamente significa “um processo completamente novo”?
  • 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 chamada fork() é seguida imediatamente por exec(), 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

    • O texto trata disso de forma implícita, mas aqui a cópia do estado do processo se refere às estruturas de gerenciamento de memória. Principalmente tabelas de páginas e VMAs
      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
    • O Redis é um tipo de processo em que esse custo é bastante importante. fork() não copia a memória em si, mas ainda precisa copiar as tabelas de páginas
      Se 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 .rdb ou reescreve o AOF de log binário
      Já 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.xlarge usando 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
    • Para o público-alvo pretendido de artigos assim, copy-on-write provavelmente é conhecimento básico e por isso foi omitido
    • Mesmo com copy-on-write, 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 de exec() ser executado
    • O texto falou em “estado”. Mesmo com copy-on-write, o conteúdo não é copiado, mas o custo proporcional ao número de entradas na tabela de páginas continua existindo
      Já é 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 o fork(), dá para usar a API comum do jeito de sempre para fazer todo tipo de configuração
    Até 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

    • Não concordo totalmente, mas vejo utilidade. Mesmo que fork()/exec() possa ser útil em alguns casos, parece que ficaria bem bom se as APIs aceitassem um argumento pidfd. O 0 poderia significar o processo atual
      O problema seria mais com binários setuid/setgid; nesse caso, talvez fosse melhor um tratamento especial no exec
      Por exemplo, seria possível criar um processo parado com pidfd_t ps = spawn(); e configurá-lo com setuid(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ário
    • Penso exatamente o contrário. O grande erro do modelo ao estilo UNIX é que estado demais é preservado na criação do processo
      Por 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 certa
    • Chamar isso de elegante é dependência de trajetória da história de fork()+exec()
      Em outro mundo onde fork()+exec() não existisse, muitas dessas “APIs comuns” teriam um argumento explícito de pid para permitir mudar a configuração de outro processo. O Fuchsia funciona mais ou menos assim
      Esse 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
    • A forma correta de eliminar fork() é fazer com que as APIs comuns que mudam o estado do processo recebam um handle de processo explícito
      Aí a mesma API poderia ser usada para configurar um processo vazio e também se combinar com outras abordagens, como IPC ou depuração
    • A ordem deveria ser spawn, configure, exec
      Se o processo começasse com uma ligação ptrace e 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 mentira
  • O mal-entendido de que fork() é barato é estranhamente comum, mas ele é O(N) em relação ao tamanho do processo, e sempre foi
    Sim, é 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() e exec() internamente como a implementação atual faz”

    • Parece haver interesse não em uma implementação específica, mas no próprio conceito
  • 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ção
    Fico 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 o git do 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ê mesmo
      Do 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
    • Como alguém vindo do Windows, o modelo fork()+exec() não fazia sentido nenhum para mim. Agora eu sei que é só uma esquisitice histórica, mas ainda existem pessoas fingindo que fork()+exec() é realmente uma boa ideia
    • Existe libgit2. Dá para imaginar alguma forma de se comunicar com um gitd por pipe ou socket, mas não sei por que isso seria uma boa ideia. Fora isso, é preciso iniciar um processo
  • O 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, configurar seccomp e ajustar privilégios
    Só 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 spawn poderia criar um processo vazio, carregar nele um loader leve e depois passar dados arbitrários de configuração. O loader configuraria o processo e faria exec() do programa principal
    Isso permitiria manter a API existente sem fazer fork da memória, embora ainda fosse necessário duplicar descritores de arquivo e outras coisas

    • Felizmente, alguém pegou uma máquina do tempo, viu este texto e adicionou isso ao POSIX.1-2001 :)
      Desculpe se não era piada, mas posix_spawn() já existe e, no glibc, fork é apenas um apelido para clone()
      Mesmo não sendo exatamente igual à proposta original, fork()/exec() realmente já está bem perto de legado
  • Se fork e exec pudessem 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çosa

  • Houve muitas discussões sobre essa API antiga no Hacker News, por exemplo https://news.ycombinator.com/item?id=31739794