Ronaldo
Ronaldo Desenvolvedor, pai, cidadão do mundo.

Processos, Threads e Co-rotinas

Como essa coisa toda funciona?

Processos, Threads e Co-rotinas

Com a popularização de linguagens de alto nível, cada vez menos programadores tem conhecimento sobre como funcionam os sistemas operacionais modernos. Ficar distante do sistema operacional faz com que vários conceitos tornem-se desconhecidos, ou superficialmente conhecidos, levando programadores à criação de código ineficiente ou com premissas incorretas.

Neste artigo, abordo três princípios importantes, sendo dois deles referindo-se diretamente a entidades do sistema operacional: Processos, Threads e Co-rotinas, nesta ordem. E há um motivo para esta ordem.

Processos

Vamos começar pelo topo da montanha: os processos. Um processo é uma entidade do kernel do sistema operacional que representa, em última instância, um programa em execução. Este programa pode ser qualquer coisa: um programa iniciado pelo usuário, um serviço do sistema operacional, um daemon, um servidor, não importa. O processo usa recursos: memória, arquivos em disco, conexões de rede, entidades de comunicação inter-processos (filas, memória compartilhada, semáforos). O processo, também, é univocamente identificado dentro do sistema operacional, através de uma fila circular. Esta identificação é o PID (de Process Identification).

O sistema operacional é quem cria e gerencia o seu processo. Sempre que seu processo necessita de um recurso, ele realiza uma chamada de sistema, ou seja, conversa com o sistema operacional para abrir um arquivo, seja para criar ou ler, para conectar pela rede, para solicitar mais memória, para acessar a sua placa de vídeo, etc. A chamada de sistema é algo importantíssimo no ciclo de vida do seu programa pois ele interrompe, momentaneamente, a sua execução para aguardar o retorno do sistema operacional.

Como pode ser observado, há uma relação próxima entre o sistema operacional e o processo. Porém, o processo não está sozinho dentro do computador: ele executa paralelamente a outros processos. E aqui há algo muito interessante: o processamento pode ser totalmente paralelo ou concorrente, dependendo de como estão os recursos da máquina. E existe uma grande diferença entre o paralelo e o concorrente.

Paralelismo e Concorrência

O que é paralelo não é, necessariamente concorrente. E o que é concorrente não é, necessariamente, paralelo. Estes são dois conceitos simples, mas normalmente mal-interpretados e que valem uma revisão.

Nos computadores modernos, o paralelismo é alcançado pela existência de vários processadores ou, numa terminologia mais adequada, vários núcleos. Quanto mais núcleos o seu processador tem, ou quanto mais processadores sua máquina tem, maior a capacidade de executar instruções em paralelo, ou seja, ao mesmo tempo. No entanto, a quantidade de processos em um sistema operacional moderno normalmente é muito superior à quantidade de núcleos disponíveis e é aí que entra a concorrência.

A concorrência é uma forma de gerenciar recursos compartilhados. Em um cenário no qual a quantidade de processos é muito maior do que a quantidade de núcleos disponíveis o sistema operacional passa a gerenciar os processos dando-lhes um certo tempo de processamento. Assim, um determinado processo usas o processador por um tempo e é suspenso depois de decorrido este tempo, usuamente denominado como quantum de tempo. Depois de suspenso, outro processo tem a oportunidade de usar o processador e isso é feito repetitidamente. Este fenômeno ocorre não somente com o processador, mas com todos os componentes de um computador que precisam ser usados por vários processos.

Trocando em miúdos, isto quer dizer que haverá, em determinado momento, apenas uma quantidade limitada de processos em execução, enquanto todos os outros aguardam por uma oportunidade de executar suas instruções. A título de exemplo, um processador com 12 núcleos é capaz de executar até 12 processos ao mesmo tempo, em teoria. Todos os outros processos permanecem aguardando para executar. E quem manda no espetáculo é o sistema operacional, pois é ele quem inicia ou suspende os processos.

A concorrência, assim implementada pelo sistema operacional, afeta diretamente processos e threads, pois estas são entidades do kernel. O processo não sabe quando será interrompido. A interrupção é feita com base na prioridade do processo, prioridade esta que influencia o quantum de tempo que é oferecido ao processo para sua execução.

Isto posto, paralelismo é quando as coisas acontecem ao mesmo tempo e concorrência é quando somente uma coisa é executada enquanto as outras coisas aguardam sua oportunidade para executar.

IPC: A comunicação inter-processos

Aqui está algo importante a discutir pois a intercomunicação inter-processos, ou IPC (do inglês Inter-process Communication), tem um papel muito importante em um sistema operacional, principalmente no que diz respeito à comunicação do próprio sistema com o processo.

Normalmente, os mecanismos disponíveis para a IPC são:

  • sinalização
  • memória compartilhada
  • sockets
  • pipes
  • filas de mensagens
  • semáforos

Não vou dissertar sobre todos. Um bom livro, que disserta em profundidade sobre o assunto é o excelente Unix Network Programming do fantástico Richard Stevens. Apesar de ser um livro focado em IPC para Unix, os princípios descritos são válidos para Linux e Windows. Linux, por ser um sistema operacional POSIX, faz uso das mesmas chamadas de sistema. No Windows, as chamadas de sistemas são diferentes e os mecanismos ligeiramente distintos, mas os conceitos são os mesmos.

O mecanismo de interesse para a nossa discussão é a sinalização. Além de causar a interrupção do seu processo, normalmente para executar outro processo na fila de execução, o sistema operacional também pode se comunicar com o seu processo através de sinais. Por exemplo, quando o sistema operacional está para desligar, ele sinaliza todos os processos em execução para que finalizem o mais rápido possível. Normalmente, o sinal é SIGTERM. Caso os processos não finalizem em tempo hábil, o sistema envia o sinal SIGKILL no intuito de que o processo seja finalizado imediatamente. Caso o processo cause um acesso ilegal à memória, o sistema operacional envia um sinal SIGSEGV.

Alguns sinais podem ser capturados e tratados, outros não. SIGKILL é um sinal que não pode ser capturado, nem tratado, e que provoca o término imediato do processo, não lhe dando chance de sincronizar processos filhos, salvar arquivos abertos, liberar recursos, etc.

O Processo Filho

Os processos obedecem uma hierarquia dentro do sistema operacional. O processo primordial, normalmente denominado como init ou systemd, é o pai de todos os processos do sistema operacional. Sempre que você executa algo no seu sistema operacional, esse “algo” torna-se um processo filho do shell que você está usando. No Windows, este shell é o Windows Explorer, que é o shell do usuário. Importante afirmar que sempre é possível substituir o shell padrão por outro, mesmo no Windows.

Como afirmei antes, todo processo tem uma identificação unívoca no sistema, o PID. E também tem uma identificação interessante, o PPID, de Parent Process IDentification, ou identificação de processo pai. Esta estrutura é uma árvore na qual todo processo tem o seu PID e o número do PID do processo superior, isto é, o PPID. E sempre que um processo executa um processo filho, ele tem em mãos o PID do processo filho. Por exemplo:

int child_pid = fork();

if (child_pid == 0) {
    /* Este é o processo filho */
} else {
    /* Este é o processo pai */
}

Neste exemplo, escrito em C, é usada a chamada de sistema fork que cria um processo filho. O processo filho nada mais é que uma cópia identica do processo pai e que herda tudo do processo pai: descritores de arquivos, descritores de memória compartilhada, sockets, etc. No entanto, ele não compartilha a memória do processo pai e é executado em um ambiente separado. Para comunicar-se com o processo pai é necessário usar algum dos mecanismos de IPC para este fim.

Sempre que um processo filho chega ao fim, ele envia ao processo pai o sinal SIGCLD, juntamente com um valor inteiro que identifica qual é o status de finalização de execução. Por convenção, o valor zero indica sucesso e valores diferentes de zero, normalmente negativos, indicam algum tipo de erro. O processo pai pode escolher capturar e tratar este sinal ou simplesmente ignorar.

É uma boa prática capturar e tratar este sinal para indicar ao kernel que o processo filho foi sincronizado com o processo pai. O efeito colateral de não tratar o sinal pode causar o surgimento de processos zumbis, ou seja, processos que não usam nenhum recurso mas prendem, para si, um PID. O PID é uma fila circular e se essa fila for exaurida, o sistema operacional simplesmente não consegue criar mais processos.

Normalmente quando o processo pai finaliza antes do filho, o processo filho é adotado pelo processo de inicialização do sistema que, por sua vez, sincroniza o status de finalização no intuito de evitar o surgimento de zumbis. Inclusive, esta é uma técnica útil para criar daemons, pois quando o processo pai finaliza antes do filho, o processo filho passa a independer do TTY ao qual foi originalmente associado e, com isso, o fechamento do terminal não afetará sua execução. Aqui está um truque simples e útil para criar daemons na linha de comando no Linux e Unix:

{ seuprograma & } &

Isso executará seuprograma em segundo plano dentro de um processo em segundo plano. O processo que chama o seu programa criará um processo filho e este processo filho, por sua vez, criará outro processo filho que executará seu programa. Quando o processo filho inicial retorna, ele não sincroniza com o processo filho que, por sua vez, é adotado pelo sistema de inicialização do sistema operacional.

As Threads

Como eu disse, as threads são entidades do kernel do sistema operacional. E, como toda entidade do kernel, necessitam de uma chamada de sistema para que sejam criadas e gerenciadas. Cada sistema operacional tem o seu conjunto de chamadas de sistema para este fim. No intuito de criar uma camada padronizada, surgiram as POSIX Threads, que nada mais são que uma abstração por cima das chamadas de sistema para a criação e gerenciamento de threads. No Windows, há um conjunto de chamadas próprias e bibliotecas próprias para este fim. As “Windows Threads” são muito similares às POSIX Threads, mas são iniciadas e gerenciadas por funções próprias do SDK. Há, no entanto, bibliotecas POSIX para Windows que seguem as interfaces das POSIX Threads.

As threads não existem sem um processo. Elas são, na verdade, parte do processo. Conceitualmente, um processo inicia-se com uma única thread, a thread principal ou, como descrito na literatura, a main thread. Tudo dentro de uma thread é serial, ou seja, é executado em sequência. Ao criar uma nova thread, esta nova thread passa a ter acesso a tudo do processo, compartilhando com outras threads todos os recursos que o processo usa. Isso é bom e ruim ao mesmo tempo. É bom porque não exige o uso de IPC para comunicação entre threads. É ruim porque faz com que você precise ter cuidado para que as estruturas de dados usadas pelo processo não sejam intempestivamente quebradas por acesso paralelo, o principal efeito colateral que cria bugs bem difíceis de encontrar e depurar.

As threads podem ser interrompidas pelo sistema operacional a qualquer momento, seja porque ela perdeu o seu time sharing, seja porque o processo inteiro perdeu o time sharing, ou seja por que tornou-se, programaticamente, inativa por aguardar em algum mutex.

O mutex funciona como um semáforo. Porém, ele é uma entidade que garante o travamento atômico, permitindo que apenas uma thread consiga travá-lo em determinado momento. É um mecanismo que permite sincronizar as threads quando o acesso a um recurso compartilhado é necessário.

O seu processo pode ter quantas threads quiser. Claro, há uma limitação imposta pelo seu sistema operacional que precisa ser respeitada. Normalmente a documentação do seu sistema operacional traz estes limites, que também podem ser impostos pelo administrador do sistema através de limitações do shell de usuário, no intuito de limitar recursos compartilhados utilizados.

As Co-rotinas

As Co-rotinas executam dentro de uma thread. Normalmente é uma forma de dividir o processamento em espaço de usuário, sem a intervenção do kernel. A co-rotina divide, portanto, o tempo de processamento de uma thread, alternando a execução entre cada co-rotina dentro da thread através de um processo chamado mudança de contexto. Esta mudança de contexto salva a pilha da co-rotina atual, restaura a pilha da co-rotina que estava aguardando na fila de execução, e resume a execução desta co-rotina que estava aguardando.

A principal vantagem da co-rotina, também chamada de lightweight thread ou thread em user space, é que não há a necessidade de sincronizar co-rotinas pois é garantido que apenas uma co-rotina execute por vez, havendo concorrência mas não paralelismo. No caso das threads, ocorre paralelimos e concorrência.

Como já pode ser visto, a co-rotina exige programação colaborativa para que todas possam ter a oportunidade de executar. A co-rotina, também, exige a existência de um agendador, ou scheduler, que gerencia como as co-rotinas irão executar. Via de regra, os schedulers são executados em uma thread em separado.

No caso das co-rotinas, você pode criar quantas couberem na memória do seu computador. Afinal, elas são entidades do seu processo e não do sistema operacional. A mudança de contexto nas co-rotinas, se comparadas com as mudanças de contexto de threads, são muito mais rápidas pois elas são gerenciadas pelo seu processo. Normalmente as mudanças de contexto das entidades de kernel são mais lentas pois exigem trabalho do kernel ao gerenciar os processos e, consequentemente, as threads dentro dos processos.

Para onde ir daqui

A explicação sobre o funcionamento dos processo é mais demorada porque trata-se de um ponto primordial na arquitetura dos sistemas operacionais modernos. Sem entender como funciona um processo não há como compreender as threads e, muito menos, as co-rotinas. Há outras formas de threading, como as Windows Fibers, que são quase co-rotinas. Para finalizar, deixo uma pequena lista de leitura com alguns livros interessantes para ajudar na compreensão destes princípios: