Processos de aprendizado no Linux


Neste artigo, gostaria de falar sobre o caminho da vida dos processos na família Linux OS. Na teoria e nos exemplos, examinarei como os processos nascem e morrem, uma pequena conversa sobre a mecânica das chamadas e sinais do sistema.

Este artigo é mais voltado para iniciantes em programação de sistemas e para aqueles que querem apenas aprender um pouco mais sobre como os processos funcionam no Linux.

Tudo escrito abaixo se aplica ao Debian Linux com o kernel 4.15.0.

Conteúdo


  1. 1. Introdução
  2. Atributos do processo
  3. Ciclo de vida do processo
    1. Processar nascimento
    2. Estado pronto
    3. O status está "em execução"
    4. Reencarnação em outro programa
    5. Estado pendente
    6. Status de parada
    7. Conclusão do processo
    8. O estado dos "zumbis"
    9. Esquecimento
  4. Agradecimentos

1. Introdução


O software do sistema interage com o núcleo do sistema através de funções especiais - chamadas do sistema. Em casos raros, existe uma API alternativa, por exemplo, procfs ou sysfs, criada na forma de sistemas de arquivos virtuais.

Atributos do processo


O processo no kernel é apresentado simplesmente como uma estrutura com muitos campos (a definição da estrutura pode ser lida aqui ).
Mas como o artigo é dedicado à programação do sistema, e não ao desenvolvimento do kernel, somos um pouco abstratos e simplesmente focamos nos campos importantes do processo para nós:

  • ID do processo (pid)
  • Descritores de arquivo aberto (fd)
  • Manipuladores de sinal
  • Diretório de trabalho atual (cwd)
  • Variáveis ​​de ambiente (ambiente)
  • Código de retorno

Ciclo de vida do processo




Processar nascimento


Somente um processo no sistema nasce de uma maneira especial - init - é gerado diretamente pelo kernel. Todos os outros processos aparecem duplicando o processo atual usando a chamada do sistema fork(2) . Após a execução do fork(2) , obtemos dois processos quase idênticos, com exceção dos seguintes pontos:

  1. fork(2) retorna o PID do filho ao pai, 0 é retornado ao filho;
  2. O filho altera o PPID (ID do processo pai) para o PID do pai.

Após a execução da fork(2) , todos os recursos do processo filho são uma cópia dos recursos do pai. Copiar um processo com todas as páginas alocadas de memória é caro, portanto o kernel Linux usa a tecnologia Copy-On-Write.
Todas as páginas na memória do pai são marcadas como somente leitura e ficam disponíveis para o pai e o filho. Assim que um dos processos altera os dados em uma página específica, essa página não muda, mas a cópia já está copiada e alterada. Nesse caso, o original é "desatado" desse processo. Assim que o original somente leitura permanecer "vinculado" a um único processo, a página será reatribuída ao status de leitura e gravação.

Um exemplo de um programa inútil simples com fork (2)

 #include <stdio.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #include <sys/types.h> int main() { int pid = fork(); switch(pid) { case -1: perror("fork"); return -1; case 0: // Child printf("my pid = %i, returned pid = %i\n", getpid(), pid); break; default: // Parent printf("my pid = %i, returned pid = %i\n", getpid(), pid); break; } return 0; } 


 $ gcc test.c && ./a.out my pid = 15594, returned pid = 15595 my pid = 15595, returned pid = 0 


Estado pronto


Imediatamente após a execução, o fork(2) entra no estado pronto.
De fato, o processo enfileira e aguarda o agendador no kernel para permitir que o processo seja executado no processador.

O status está "em execução"


Assim que o agendador colocou o processo em execução, o estado "running" começou. O processo pode executar todo o período proposto (quantum), ou pode dar lugar a outros processos usando a exportação do sistema sched_yield .

Reencarnação em outro programa


Alguns programas implementam lógica na qual o processo pai cria um filho para resolver um problema. A criança, neste caso, resolve um problema específico, e os pais delegam apenas tarefas aos filhos. Por exemplo, um servidor Web com uma conexão de entrada cria um filho e passa o processamento da conexão para ele.
No entanto, se você precisar executar outro programa, deverá recorrer à chamada de sistema execve(2) :

 int execve(const char *filename, char *const argv[], char *const envp[]); 

ou chamadas de biblioteca execl(3), execlp(3), execle(3), execv(3), execvp(3), execvpe(3) :

 int execl(const char *path, const char *arg, ... /* (char *) NULL */); int execlp(const char *file, const char *arg, ... /* (char *) NULL */); int execle(const char *path, const char *arg, ... /*, (char *) NULL, char * const envp[] */); int execv(const char *path, char *const argv[]); int execvp(const char *file, char *const argv[]); int execvpe(const char *file, char *const argv[], char *const envp[]); 

Todas as chamadas acima executam o programa, cujo caminho é indicado no primeiro argumento. Se for bem-sucedido, o controle é transferido para o programa carregado e não é retornado ao programa original. Nesse caso, o programa carregado possui todos os campos da estrutura do processo, exceto os descritores de arquivo marcados como O_CLOEXEC , que serão fechados.

Como não se confundir em todos esses desafios e escolher o certo? É o suficiente para entender a lógica de nomenclatura:

  • Todas as chamadas começam com exec
  • A quinta letra define o tipo de passagem de argumento:
    • l significa lista , todos os parâmetros são passados ​​como arg1, arg2, ..., NULL
    • v significa vetor , todos os parâmetros são passados ​​em uma matriz terminada em nulo;
  • A letra p , que significa caminho , pode ser seguida. Se o argumento do file começar com um caractere diferente de "/", o file especificado será procurado nos diretórios listados na variável de ambiente PATH
  • A última pode ser a letra e , indicando o ambiente . Nessas chamadas, o último argumento é uma matriz terminada em nulo de cadeias terminadas em nulo do formulário key=value - variáveis ​​de ambiente que serão passadas para o novo programa.

Exemplo de chamada para / bin / cat --help via execve

 #define _GNU_SOURCE #include <unistd.h> int main() { char* args[] = { "/bin/cat", "--help", NULL }; execve("/bin/cat", args, environ); // Unreachable return 1; } 


 $ gcc test.c && ./a.out Usage: /bin/cat [OPTION]... [FILE]... Concatenate FILE(s) to standard output. * * 


A família de chamadas exec* permite executar scripts com direitos de execução e começar com uma sequência de shebangs (#!).

Um exemplo de execução de um script com um PATH falsificado usando execle

 #define _GNU_SOURCE #include <unistd.h> int main() { char* e[] = {"PATH=/habr:/rulez", NULL}; execle("/tmp/test.sh", "test.sh", NULL, e); // Unreachable return 1; } 


 $ cat test.sh #!/bin/bash echo $0 echo $PATH $ gcc test.c && ./a.out /tmp/test.sh /habr:/rulez 


Existe uma convenção que implica que argv [0] corresponde aos argumentos nulos para funções na família exec *. No entanto, isso pode ser violado.

Exemplo quando gato se torna cachorro usando execlp

 #define _GNU_SOURCE #include <unistd.h> int main() { execlp("cat", "dog", "--help", NULL); // Unreachable return 1; } 


 $ gcc test.c && ./a.out Usage: dog [OPTION]... [FILE]... * * 


Um leitor curioso pode perceber que há um número na assinatura da função int main(int argc, char* argv[]) - o número de argumentos, mas nada desse tipo é passado para a família de funções exec* . Porque Porque quando o programa inicia, o controle não é imediatamente transferido para o main. Antes disso, algumas ações definidas pelo glibc são executadas, incluindo argc de contagem.

Estado pendente


Algumas chamadas do sistema podem demorar, como E / S. Nesses casos, o processo entra em um estado "pendente". Assim que a chamada do sistema for concluída, o kernel colocará o processo no estado "pronto".
No Linux, há também um estado de "espera" no qual o processo não responde a sinais de interrupção. Nesse estado, o processo se torna "indestrutível", e todos os sinais recebidos permanecem alinhados até o processo deixar esse estado.
O próprio kernel escolhe para qual estado transferir o processo. Na maioria das vezes, os processos que solicitam E / S estão no estado "em espera (sem interrupções)". Isso é especialmente perceptível ao usar um disco remoto (NFS) com uma Internet não muito rápida.

Status de parada


Você pode pausar um processo a qualquer momento enviando um sinal SIGSTOP. O processo entrará em um estado "parado" e permanecerá lá até receber um sinal para continuar trabalhando (SIGCONT) ou morrer (SIGKILL). Os sinais restantes serão colocados na fila.

Conclusão do processo


Nenhum programa pode se desligar. Eles só podem solicitar isso ao sistema com a chamada do sistema _exit ou serem encerrados pelo sistema devido a um erro. Mesmo quando você retorna um número de main() , _exit ainda é chamado implicitamente.
Embora o argumento para a chamada do sistema seja int, apenas o byte baixo do número é considerado o código de retorno.

O estado dos "zumbis"


Imediatamente após a conclusão do processo (não importa se está correto ou não), o kernel grava informações sobre como o processo terminou e o coloca no estado "zumbi". Em outras palavras, um zumbi é um processo concluído, mas a memória dele ainda é armazenada no kernel.
Além disso, este é o segundo estado em que o processo pode ignorar com segurança o sinal SIGKILL, porque não pode morrer morto novamente.

Esquecimento


O código de retorno e o motivo da conclusão do processo ainda estão armazenados no kernel e devem ser retirados de lá. Para fazer isso, você pode usar as chamadas de sistema apropriadas:

 pid_t wait(int *wstatus); /*  waitpid(-1, wstatus, 0) */ pid_t waitpid(pid_t pid, int *wstatus, int options); 

Todas as informações sobre a finalização do processo se encaixam no tipo de dados int. As macros descritas na página do waitpid(2) são usadas para obter o código de retorno e o motivo da finalização do programa.

Exemplo de conclusão e recebimento corretos de um código de retorno

 #include <stdio.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #include <sys/types.h> int main() { int pid = fork(); switch(pid) { case -1: perror("fork"); return -1; case 0: // Child return 13; default: { // Parent int status; waitpid(pid, &status, 0); printf("exit normally? %s\n", (WIFEXITED(status) ? "true" : "false")); printf("child exitcode = %i\n", WEXITSTATUS(status)); break; } } return 0; } 


 $ gcc test.c && ./a.out exit normally? true child exitcode = 13 


Exemplo de conclusão incorreta

Passar argv [0] como NULL resulta em uma falha.

 #include <stdio.h> #include <unistd.h> #include <errno.h> #include <sys/wait.h> #include <sys/types.h> int main() { int pid = fork(); switch(pid) { case -1: perror("fork"); return -1; case 0: // Child execl("/bin/cat", NULL); return 13; default: { // Parent int status; waitpid(pid, &status, 0); if(WIFEXITED(status)) { printf("Exit normally with code %i\n", WEXITSTATUS(status)); } if(WIFSIGNALED(status)) { printf("killed with signal %i\n", WTERMSIG(status)); } break; } } return 0; } 


 $ gcc test.c && ./a.out killed with signal 6 


Há momentos em que um pai termina mais cedo que o filho. Nesses casos, o init se tornará o pai da criança e ele usará a chamada de wait(2) quando chegar a hora.

Depois que os pais obtêm informações sobre a morte da criança, o núcleo apaga todas as informações sobre a criança, para que outro processo em breve ocorra.

Agradecimentos


Agradecimentos a Sasha "Al" pela edição e assistência no design;

Obrigado a Sasha "Reisse" pelas respostas claras a perguntas difíceis.

Eles suportaram a inspiração que me atacou e a enxurrada de minhas perguntas que me atacaram.

Source: https://habr.com/ru/post/pt423049/


All Articles