
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. Introdução
- Atributos do processo
- Ciclo de vida do processo
- Processar nascimento
- Estado pronto
- O status está "em execução"
- Reencarnação em outro programa
- Estado pendente
- Status de parada
- Conclusão do processo
- O estado dos "zumbis"
- Esquecimento
- 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:
fork(2)
retorna o PID do filho ao pai, 0 é retornado ao filho;- 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, ... ); int execlp(const char *file, const char *arg, ... ); int execle(const char *path, const char *arg, ... ); 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
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); 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 incorretaPassar 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.