Criando Processos em Linux

Criação e Manipulação de Processos em Linux

Saber criar e manipular processos em Linux é a base para o desenvolvimento de aplicações robustas e de maior complexidade. Este artigo explica com riqueza de detalhes o que são processos em Linux e mostra como eles são criados e manipulados através da programação em linguagem C. Serão apresentados também dois exemplos com casos especiais: um onde os processos filhos se tornam zumbis e o outro em que o processo pai aguarda todos os processos filhos terminarem sua execução.

Introdução Teórica

Processo é uma entidade dinâmica que executa tarefas dentro de um sistema operacional. Ele pode ser visto como um programa de computador em execução e, portanto, é criado a partir de um código fonte compilado para determinada arquitetura. O processo é baseado em dois conceitos independentes: agrupamento de recursos e execução. Os recursos agrupados compreendem o espaço de endereçamento que contém o código e os dados do programa, bem como outros recursos. Esses outros recursos podem ser arquivos abertos, processos filhos, manipuladores de sinais, etc.  Todo este conteúdo está armazenado em uma imagem executável no disco.

O conceito de execução está associado à thread de execução, também chamada de thread de controle. A thread de execução pode ser entendida como uma linha de sucessivas operações executadas pelo processo. No modelo clássico, cada processo possui uma única thread de execução, porém, no estado atual da computação, processos podem ter dezenas de threads executando em paralelo. Então, apesar de uma thread ser executada dentro do contexto de um processo, ambos – thread e processo – são conceitos distintos. Processos são um agrupamento de recursos; threads são entidades escalonadas para execução na CPU. Para saber mais sobre threads, leia o artigo Criação e Manipulação de Threads POSIX.

Bem como o conjunto de instruções de máquina e dados específicos, um processo também possui associado a ele um número identificador (PID – process identifier), um contador de programa (PC – program counter), uma pilha (stack) e uma região de memória para armazenamento de dados temporários gerados durante o tempo de execução, também conhecida como heap. A pilha, em especial, é uma estrutura de dados do tipo LIFO (do inglês last in first out) que armazena as variáveis e endereços de retorno de chamadas de sub-rotinas. Tais processos são independentes entre si e possuem suas próprias tarefas. Logo, um processo não é capaz de interferir na execução de outro processo a menos que faça uso de mecanismos seguros fornecidos pelo kernel. A figura 1 mostra um modelo com três processos independentes e cada um deles com várias threads de execução.

Modelo de Processos em Linux
Figura 1 – Modelo de processos e threads rodando concorrentemente em um sistema operacional.

Sistemas operacionais baseados no kernel Linux são do tipo multiprogramado. Isto quer dizer que o sistema possui a capacidade de executar vários processos – normalmente são dezenas deles – de forma pseudo-paralela. Neste caso, a CPU chaveia de programa para programa executando-os diversas vezes em um espaço muito curto de tempo. Isto dá ao usuário a sensação de que os programas estão sendo executados ao mesmo tempo. Contudo, com o avanço da tecnologia de processadores de vários núcleos, atualmente é comum encontrar sistemas multiprocessados, os quais possuem várias CPUs integradas e conseguem executar de forma realmente paralela os processos do sistema.

Para que um processo execute suas tarefas, ele precisa fazer uso de vários recursos do sistema, sendo o principal recurso a CPU. Por este motivo, o uso disputado da CPU é controlado por um mecanismo chamado escalonador (scheduler). O escalonador de processos é responsável por controlar a fila de espera para uso da CPU, dando uma parcela de tempo (time slice) para cada processo executar suas tarefas e realizando o revezamento entre eles. Para controlar de forma justa e balanceada o uso da CPU entre os processos, o escalonador utiliza várias primícias buscando sempre a maximização do processamento, evitando deixar a CPU ociosa.

O primeiro processo a ser executado pelo kernel durante a inicialização da máquina é o processo init (abreviação de initialization), o qual recebe o PID número 1. A execução deste processo se dá como a de um daemon (i.e. rodando em background) e permanece até que o sistema seja desligado. Além disto, o processo init é o ancestral de todos os outros processos. Ou seja, todos os processos em Linux possuem algum grau de parentesco, direto ou indireto, com o processo init. Para visualizar isto de uma forma melhor, pode-se executar o comando pstree no terminal. Este comando imprime uma espécie de “árvore genealógica” de todos os processos correntes na máquina, de forma que o processo init é o primeiro de todos e encontra-se no topo mais à esquerda.

Criação de Processos em Linux

Um novo processo pode ser criado através das chamadas de sistema fork() ou clone(). A função fork() cria uma cópia exata do processo que faz a chamada de sistema. O processo criado é denominado processo filho e o processo que o gerou é denominado processo pai. Embora eles sejam cópias um do outro, ambos os processos são executados em espaços de memória diferentes. Isto quer dizer que se o valor de uma variável é modificado em um dos processos, a variável de mesmo nome no outro processo não é alterada. Já a função clone() possibilita que o processo filho compartilhe partes do seu contexto de execução com o processo pai, tais como o espaço de memória, a tabela de descritores de arquivos e a tabela de manipuladores de sinais.

Porém, em termos práticos, a maior diferença entre fork() e clone(), é que a primeira continua a execução do processo filho a partir do ponto onde houve a chamada; a segunda executa o corpo de uma função que é repassada como parâmetro no momento da chamada. Por estes aspectos, a chamada fork() é bastante utilizada em conjunto com as chamadas da família exec() para substituir completamente a imagem do novo processo, ao passo que a chamada clone() é mais utilizada quando deseja-se criar uma thread, que nada mais é que um fluxo de execução pertencente a um processo, que compartilha o seu espaço de memória virtual e pode ser escalonado para execução.

Para ver o funcionamento e a dinâmica de criação de processos em Linux, você pode baixar os arquivos dos códigos fonte clicando AQUI ou digitando os seguintes comandos abaixo a partir de uma pasta em seu computador.

O programa fork_and_wait.c possui uma variável global e uma variável local contendo os valores 50 e 10, respectivamente. O processo filho criado através da chamada fork() modifica os valores destas variáveis e imprime-os na tela junto com o seu PID e PPID. O processo pai, sem modificar o valor das variáveis, também os imprime na tela junto com o seu PID e o PID do processo filho.

Como tudo isto ocorre dentro de um único código, é preciso fazer uma diferenciação entre o que é processo pai e o que é processo filho. Acontece que a chamada fork() retorna zero na execução do processo filho e, no processo pai, ela retorna o PID do processo criado, que é um número maior do que zero. Quando há erro na criação do novo processo, um valor negativo é retornado no processo pai e a variável externa errno é sinalizada. Então, com uma simples estrutura if/else é possível definir o que cada processo irá executar.

Compilando o código e executando-o no terminal, você deverá perceber uma saída similar à esta:

Como já esperado, a alteração do valor das variáveis pelo processo filho não interferiu no processo pai. Pode-se notar também que o PID do processo filho é um número maior que o PID do processo pai, o que leva à conclusão de que a atribuição de PIDs se dá de forma incremental. Entretanto, a grande novidade neste código é a chamada de sistema wait(). Esta chamada de sistema bloqueia o processo pai aguardando por alguma mudança no estado de qualquer processo filho. Uma mudança no estado do processo pode ser: o processo filho terminou; o processo filho foi terminado por um sinal; ou o processo filho teve sua execução retomada por um sinal. O status de mudança do processo filho é armazenado numa variável do tipo inteiro repassada como ponteiro no argumento da função e, posteriormente, pode ser testado com diversas macros, entre elas WIFEXITED(), WEXITSTATUS(), WIFSIGNALED(), etc.

Processos Zumbis

No caso de processos que terminaram, realizar um wait() permite que o sistema libere os recursos associados ao processo filho, caso contrário o processo terminado permanecerá no estado de zumbi. Sim, isso mesmo: zumbi. Um processo morto, mas que ainda tem um traço de vida. Isto acontece porque o kernel mantém um conjunto mínimo de informações sobre o processo zumbi (PID, status de saída, informação de uso de recursos) com o intuito de permitir que processo pai realize uma chamada wait() depois para obter informações sobre seu filho. Enquanto o processo não é removido do sistema com a chamada wait(), ele irá ocupar uma posição na tabela de processos do kernel e, se esta tabela for completamente preenchida, não será mais possível criar novos processos. Ainda, se um processo pai terminar deixando filhos (mesmo que não sejam zumbis), estes serão adotados pelo processo init, o qual realizará automaticamente uma chamada wait() para massacrar os zumbis existentes.

Vejamos, pois, isto acontecer na prática. O programa zombie_process.c cria um processo filho com a chamada fork(). O processo filho termina imediatamente e o processo pai, após imprimir informações na tela, fica inativo durante 60 segundos. O fato do processo filho terminar e não ter sido “esperado” pelo seu pai, coloca-o na condição de zumbi até que o processo pai termine e ele seja adotado pelo processo init.

Antes de compilar e rodar o programa zombie_process.c, precisamos abrir três janelas do terminal. Na primeira janela deve-se executar este comando:

 E, na segunda janela, deve-se executar este comando:

O primeiro comando irá listar os processos do Linux mostrando no canto superior direito a quantidade de processos zumbis existentes. O segundo comando é uma junção do comando watch, que irá atualizar a tela do terminal a cada um segundo, com o comando ps que também lista os processos do Linux, porém de uma maneira mais otimizável.

Na terceira janela do terminal, compile e execute o código, observando que o nome do executável deve coincidir com o nome repassado para o comando ps com a flag -C. Ao executar o programa, ele irá imprimir no terminal o PID do processo pai e do processo filho. Observe na janela do comando top que agora existe um processo zumbi no sistema. A janela do comando watch mostrará dois processos, sendo um o processo pai e o outro sendo o processo filho com a inscrição <defunct> à frente de seu nome, indicando que ele é um zumbi. Tão logo a execução do programa acabe no terceiro terminal, o registro dos processos deve sumir nas outras duas janelas e o motivo já foi dito anteriormente.

Aguardando o Término de Todos os Processos Filhos

E se o processo pai tiver mais de um filho? Como fazer para que ele aguarde todos os processos filhos terminarem? A resposta é simples: a função wait() sempre retornará o valor -1 em caso de erro e a variável externa errno será sinalizada com a macro ECHILD quando um processo pai não possuir mais filhos a serem esperados. Juntando estas duas informações, a seguinte porção de código pode ser implementada para esperar todos processos filhos terminarem:

Há um exemplo completo no arquivo de códigos fonte deste artigo onde são criados cinco processos filhos que vão terminando a cada cinco segundos. O processo pai somente finaliza a sua execução após aguardar todos os filhos terminarem.

Conclusão

Embora todo o código do processo filho possa estar contido no mesmo arquivo do processo pai, esta não é a melhor abordagem, pois pode gerar complicações nos casos em que se está trabalhando com um grande volume de código. No próximo artigo, você verá como substituir toda a imagem de um processo por um outro programa utilizando a família de chamadas de sistema exec(). Esta abordagem permite guardar o código do processo filho em outro arquivo executável e fazer a substituição da imagem do processo logo após a sua criação com a chamada fork().

Outro problema reside no tempo gasto para copiar toda a imagem de um processo no momento da chamada fork(). Em se tratando de processos muito grandes, esta pode ser uma operação dispendiosa, podendo gerar atrasos e latência devido ao acesso ao disco. Assim, deve-se ponderar quando é melhor copiar toda a imagem de um processo e quando a criação de threads já se faz suficiente.

A criação e manipulação de threads serão exploradas num artigo futuro. Espero que até o presente momento os artigos estejam sendo claros e a aprendizagem proveitosa.

Um abraço e até a próxima!

Sobre

Jair Junior é Bacharel em Engenharia Eletrônica pela Universidade de Brasília [2014] com ênfase em microeletrônica. Suas especialidades na área são microcontroladores, sistemas embarcados e projeto de hardware. Também possui conhecimentos aprofundados em aplicações web e processamento digital de imagens. Atualmente, é aluno de pós-graduação lato sensu da PUC Minas no curso de Desenvolvimento de Aplicações Web. Ademais, tem como hobbies viajar, praticar esportes na natureza, apreciar cervejas artesanais e escutar um bom e velho rock 'n roll. Para mais detalhes, acesso a página sobre o autor.

Ver todos os posts de

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *