DEV Community

Alex Volnei Galante
Alex Volnei Galante

Posted on

Kernel Linux para Desenvolvedores Backend - Processos & Threads Parte I

Sumário da Parte I

  1. Introdução ao Kernel Linux
  2. Estrutura do Kernel
  3. Gerenciamento de Processos
  4. Sua aplicação e o Kernel
  5. Referências Bibliográficas

Introdução ao Kernel Linux

A história começa em meados de 1991 quando um nerd que vivia em um país congelado chamado Linus Torvalds decidiu criar um sistema operacional baseado no Unix, mas que fosse gratuito e de código aberto. Ele começou a escrever o código do kernel do Linux em seu computador pessoal, e em pouco tempo, o projeto ganhou a atenção de outros desenvolvedores ao redor do mundo.

A história completa do Linux e sua união com o GNU pode ser encontrada no livro "Just for Fun: The Story of an Accidental Revolutionary" de Linus Torvalds, é uma leitura muito interessante para quem quer entender a história do Linux e como ele se tornou o que é hoje.


Estrutura do Kernel

O kernel possui uma estrutura super complexa, mas podemos dividi-lo em 3 blocos fundamentais:

  • User Space: onde os aplicativos e processos rodam, é a parte com a qual os desenvolvedores backend têm mais contato.
  • Kernel Space: onde o kernel do Linux roda, é a parte que gerencia os recursos de hardware e fornece uma interface para os aplicativos.
  • System Calls: é a interface entre o user space e o kernel space, é onde os aplicativos fazem chamadas para o kernel para acessar recursos de hardware ou realizar operações privilegiadas.

Estrutura Kernel Linux

Subsistemas do Kernel

O kernel do Linux é composto por vários subsistemas, cada um responsável por uma parte específica do sistema operacional. Alguns dos subsistemas mais importantes incluem:

  • Gerenciamento de Processos: responsável por criar, gerenciar e finalizar processos no sistema.
  • Gerenciamento de Memória: responsável por alocar e liberar memória para os processos.
  • Gerenciamento de Arquivos: responsável por gerenciar o sistema de arquivos e fornecer uma interface para os aplicativos acessarem arquivos.
  • Gerenciamento de Dispositivos: responsável por gerenciar os dispositivos de hardware e fornecer uma interface para os aplicativos acessarem esses dispositivos.
  • Gerenciamento de Rede: responsável por gerenciar as conexões de rede e fornecer uma interface para os aplicativos se comunicarem pela rede.

Nessa primeira parte, nosso objetivo é desvendar o subsistema de gerenciamento de processos, entender como ele funciona e como ele pode impactar o desenvolvimento backend.
Vamos primeiramente entender o que são processos e threads, e como o kernel do Linux gerencia esses recursos.


Processos

Um processo é a abstração mais fundamental que um sistema operacional oferece para a execução de programas. Em termos simples, um processo é um programa em execução — mas essa definição esconde uma complexidade considerável.

Quando você executa uma aplicação backend — seja um servidor Flask, uma API ASP.NET Core ou um microserviço em Go — o kernel Linux cria um processo que encapsula tudo o que é necessário para aquela execução:

  • Espaço de endereçamento: uma região de memória virtual exclusiva contendo o código (text), dados globais (data/BSS), heap e stack
  • Registradores da CPU: o program counter (PC/RIP), o stack pointer (SP/RSP), registradores de propósito geral e registradores de status
  • Recursos do sistema: file descriptors abertos, sinais pendentes, informações de credenciais, working directory, mapeamentos de memória

Cada processo opera sob a ilusão de que possui a máquina inteira para si (assim como um S.O virtualizado acredita que controla o hardware completo rsrsrsr). Essa ilusão é construída pelo kernel através de duas abstrações principais: virtualização de CPU (escalonamento) e virtualização de memória (memória virtual).

Multiprogramação e Pseudoparalelismo

Em um sistema com uma única CPU, apenas um processo pode executar instruções em um dado instante. No entanto, o kernel alterna entre processos tão rapidamente que, para um observador humano, parece que todos executam simultaneamente. Esse fenômeno é chamado de pseudoparalelismo.

A multiprogramação é a técnica que permite manter múltiplos processos em memória ao mesmo tempo, alternando a CPU entre eles. O objetivo é maximizar a utilização da CPU: quando um processo bloqueia aguardando I/O (uma query ao banco de dados, uma leitura de disco, uma resposta de rede), outro processo pode utilizar a CPU.

Tempo →
CPU:  |--P1--|--P2--|--P1--|--P3--|--P2--|--P1--|

P1:   ██████░░░░░░██████░░░░░░░░░░░░░░░░██████
P2:   ░░░░░░██████░░░░░░░░░░░░░░░░██████░░░░░░
P3:   ░░░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░

██ = executando    ░░ = aguardando/pronto
Enter fullscreen mode Exit fullscreen mode

Para aplicações backend, esse modelo tem implicações diretas:

  • Servidores web multi-processo (Gunicorn com workers pre-fork, por exemplo) dependem do kernel para distribuir tempo de CPU entre os workers
  • Microserviços em containers competem por CPU com outros containers no mesmo host
  • A latência de resposta da sua API é diretamente afetada pela capacidade do kernel de escalonar seu processo de forma eficiente.

Hierarquia de Processos

No Linux, processos formam uma árvore hierárquica. Todo processo (exceto o init/systemd, PID 1) possui um processo pai que o criou. Essa relação é estabelecida pela system call fork() (ou, mais modernamente, clone()).

systemd (PID 1)
├── sshd (PID 512)
│   └── bash (PID 1200)
│       └── python app.py (PID 1350)
│           ├── worker-1 (PID 1351)
│           ├── worker-2 (PID 1352)
│           └── worker-3 (PID 1353)
├── dockerd (PID 800)
│   └── containerd-shim (PID 900)
│       └── dotnet MyApi.dll (PID 950)
└── nginx (PID 600)
    ├── nginx worker (PID 601)
    └── nginx worker (PID 602)
Enter fullscreen mode Exit fullscreen mode

Essa hierarquia não é meramente organizacional — ela tem consequências práticas:

  • Sinais: quando um processo pai termina, sinais são enviados aos filhos (se você não sabe o que são sinais, fique tranquilo, vamos falar disso em breve...)
  • Processos zumbis: quando um filho termina mas o pai não coleta seu exit status via wait()/waitpid(), o processo permanece como zombie, consumindo uma entrada na tabela de processos
  • Processos órfãos: filhos cujo pai terminou são "adotados" pelo init/systemd
  • Grupos de processos e sessões: permitem gerenciar conjuntos de processos relacionados (fundamental para job control em shells e para containers)

[!IMPORTANT]
Implicação prática: Se sua aplicação Python com Gunicorn cria workers via fork(), cada worker é um processo filho. Se o master process morrer inesperadamente sem cleanup adequado, você pode acabar com workers orphans consumindo recursos.

Estados de Processo

Entender o ciclo de vida de um processo vai permitir que você, desenvolvedor backend, perceba por que a performance de sua aplicação pode ser afetada por fatores que estão fora do seu código — como a carga do sistema, a quantidade de processos concorrentes, o comportamento de I/O, etc.

Um processo no Linux transita entre estados bem definidos ao longo de sua vida. A compreensão desses estados é essencial para diagnosticar problemas de performance.

Os cinco estados fundamentais (modelo teórico)

                    ┌─────────────────────────┐
                    │                         │
                    ▼                         │
┌─────┐  admit  ┌───────┐  dispatch ┌─────────┐  exit  ┌────────────┐
│ New │────────►│ Ready │──────────►│ Running │──────► │ Terminated │
└─────┘         └───────┘           └─────────┘        └────────────┘
                    ▲                     │
                    │    I/O or event     │
                    │    completion       │
                    │                     │ I/O or event
                    │                     │ wait
                    │    ┌─────────┐      │
                    └─── │ Blocked │ ◄────┘
                         └─────────┘
Enter fullscreen mode Exit fullscreen mode
  1. New (Criado): o processo está sendo criado pelo kernel. A task_struct está sendo alocada e inicializada.
  2. Ready (Pronto): o processo está em memória, pronto para executar, aguardando que o escalonador lhe atribua a CPU.
  3. Running (Executando): o processo está efetivamente utilizando a CPU, executando instruções.
  4. Blocked (Bloqueado): o processo está aguardando algum evento externo — I/O de disco, resposta de rede, lock de mutex, etc.
  5. Terminated (Terminado): o processo finalizou sua execução, mas sua entrada na tabela de processos ainda existe até que o pai colete o exit status.

Estados no kernel Linux

O kernel Linux implementa esses estados conceituais com granularidade adicional, definidos no campo state da task_struct:

Estado do Kernel Valor Significado
TASK_RUNNING 0 Processo executando ou na fila de prontos (ready queue)
TASK_INTERRUPTIBLE 1 Bloqueado, mas pode ser acordado por sinais
TASK_UNINTERRUPTIBLE 2 Bloqueado em I/O crítico, não responde a sinais
__TASK_STOPPED 4 Parado por sinal (SIGSTOP, SIGTSTP)
__TASK_TRACED 8 Sendo rastreado por debugger (ptrace)
EXIT_ZOMBIE 16 Terminado, aguardando wait() do pai
EXIT_DEAD 32 Estado final antes da remoção
TASK_IDLE Idle (kernel 4.21+), similar a UNINTERRUPTIBLE mas não conta como load

A distinção entre TASK_INTERRUPTIBLE e TASK_UNINTERRUPTIBLE é particularmente importante:

  • Processos em TASK_UNINTERRUPTIBLE (estado D no ps/top) contam para o load average do sistema. Se sua aplicação tem muitos processos nesse estado, geralmente indica problemas de I/O — disco lento, NFS travado, ou storage com latência alta.
  • Processos em TASK_INTERRUPTIBLE (estado S) são o caso normal de processos aguardando I/O — um servidor web esperando conexões, por exemplo.
# Visualizando estados de processos
$ ps aux | head -5
USER       PID %CPU %MEM    VSZ   RSS TTY STAT START   TIME COMMAND
root         1  0.0  0.1 169536 13312 ?   Ss   May01   0:12 /sbin/init
root         2  0.0  0.0      0     0 ?   S    May01   0:00 [kthreadd]
www-data  1200  2.3  1.5 285432 61440 ?   Sl   09:00   1:45 gunicorn: worker
postgres  1500  0.1  0.8 215000 32768 ?   Ss   May01   0:55 postgres: writer

# STAT column: S=sleeping(interruptible), D=disk sleep(uninterruptible),
#              R=running, T=stopped, Z=zombie, l=multi-threaded, s=session leader
Enter fullscreen mode Exit fullscreen mode

[!TIP]
Dica de diagnóstico: Se o load average do seu servidor está alto, mas a utilização de CPU é baixa, procure processos no estado D (TASK_UNINTERRUPTIBLE). Isso indica gargalo de I/O, não de CPU.

Como um processo é criado?

A criação de um processo no Linux é realizada através da system call fork() ou, mais modernamente, clone(). O processo pai chama fork(), que cria um novo processo filho duplicando o contexto do pai — incluindo código, dados, heap e stack. O filho recebe um novo PID e é colocado na fila de prontos para execução. O processo filho pode então chamar execve() para substituir sua própria imagem por um novo programa, ou pai e filho podem simplesmente continuar executando o mesmo código.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(void) {
    pid_t pid = fork(); // syscall: duplica o processo atual

    if (pid < 0) {
        // fork() retorna -1 em caso de erro
        perror("fork falhou");
        return EXIT_FAILURE;
    }

    if (pid == 0) {
        // Estamos no processo filho (fork() retorna 0 para o filho)
        printf("[filho] PID=%d, pai PID=%d\n", getpid(), getppid());

        // execve() substitui a imagem do processo pelo programa especificado.
        // A partir daqui, o filho passa a executar /bin/echo.
        char *args[] = { "/bin/echo", "[filho] execve: processo substituído com sucesso", NULL };
        execve("/bin/echo", args, NULL);

        // Só chega aqui se execve() falhar
        perror("execve falhou");
        return EXIT_FAILURE;
    }

    // Estamos no processo pai (fork() retorna o PID do filho para o pai)
    printf("[pai] PID=%d, filho PID=%d\n", getpid(), pid);

    // wait() bloqueia o pai até o filho terminar, evitando processo zumbi
    int status;
    waitpid(pid, &status, 0);
    printf("[pai] filho encerrou com status %d\n", WEXITSTATUS(status));

    return EXIT_SUCCESS;
}
Enter fullscreen mode Exit fullscreen mode

A mesma criação pode ser feita com clone(), que é a syscall de baixo nível usada internamente pelo próprio fork() — com a diferença de que clone() permite controlar exatamente o que será compartilhado entre pai e filho, viabilizando a criação de threads (onde memória, file descriptors e outros recursos são compartilhados):

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sched.h>
#include <sys/types.h>
#include <sys/wait.h>

#define STACK_SIZE (1024 * 1024) // 1 MB de stack para o filho

// Função que será executada pelo processo/thread filho
static int filho_fn(void *arg) {
    printf("[filho] PID=%d, pai PID=%d, arg='%s'\n",
           getpid(), getppid(), (char *)arg);
    return 0;
}

int main(void) {
    // Aloca stack para o filho (clone() exige que o chamador forneça a stack)
    char *stack = malloc(STACK_SIZE);
    if (!stack) {
        perror("malloc falhou");
        return EXIT_FAILURE;
    }

    // clone() recebe um ponteiro para o TOPO da stack (cresce para baixo)
    char *stack_top = stack + STACK_SIZE;

    // Flags controlam o que será compartilhado entre pai e filho.
    // SIGCHLD: sinaliza o pai quando o filho terminar (necessário para waitpid).
    // Sem flags de compartilhamento → comportamento idêntico ao fork().
    pid_t pid = clone(filho_fn, stack_top, SIGCHLD, "dados do pai");

    if (pid < 0) {
        perror("clone falhou");
        free(stack);
        return EXIT_FAILURE;
    }

    printf("[pai] PID=%d, filho PID=%d\n", getpid(), pid);

    int status;
    waitpid(pid, &status, 0);
    printf("[pai] filho encerrou com status %d\n", WEXITSTATUS(status));

    free(stack);
    return EXIT_SUCCESS;
}
Enter fullscreen mode Exit fullscreen mode

Flags do clone() e o que cada uma controla

A principal diferença entre fork() e clone() está nas flags que clone() aceita. Elas definem precisamente quais recursos serão compartilhados (e não copiados) entre o processo pai e o filho:

Flag Efeito
CLONE_VM Compartilha o espaço de memória virtual — pai e filho enxergam as mesmas páginas. Sem essa flag, o kernel aplica Copy-on-Write (CoW).
CLONE_FS Compartilha o contexto de sistema de arquivos (working directory, root, umask).
CLONE_FILES Compartilha a tabela de file descriptors — um close() no pai fecha para o filho também.
CLONE_SIGHAND Compartilha os handlers de sinais. Obrigatório junto com CLONE_VM para threads POSIX.
CLONE_THREAD Coloca o filho no mesmo thread group do pai (mesmo tgid). Necessário para que getpid() retorne o mesmo valor em todas as threads.
CLONE_NEWPID Cria um novo namespace de PIDs — a base dos containers (o filho vira PID 1 dentro do namespace).
CLONE_NEWNET Cria um novo namespace de rede — interfaces, rotas e portas isoladas.
CLONE_NEWNS Cria um novo namespace de mount — sistema de arquivos isolado.
SIGCHLD Sinal enviado ao pai quando o filho terminar (necessário para waitpid() funcionar).

[!NOTE]
Threads vs Processos no Linux: ao contrário de outros sistemas operacionais, o Linux não tem um conceito de "thread" separado no kernel. Uma thread POSIX é simplesmente um clone() com CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD. A distinção entre processo e thread é feita pelas flags passadas ao clone().

Principais syscalls do ciclo de criação de processos

As syscalls abaixo formam o núcleo do gerenciamento de processos no Linux. Toda linguagem, framework ou runtime que cria processos ou threads passa por alguma combinação delas:

Syscall Número (x86-64) Descrição Quando é usada
fork() 57 Duplica o processo atual. Filho herda uma cópia do espaço de endereçamento do pai via Copy-on-Write. Retorna 0 para o filho e o PID do filho para o pai. Criação de processos filhos (Gunicorn workers, subprocessos de shell)
clone() 56 Versão parametrizável de fork(). Flags definem o que é compartilhado (memória, FDs, handlers de sinal). Base para criação de threads POSIX e namespaces de containers. Threads (pthread_create), containers (Docker, runc), runtimes de linguagens
execve() 59 Substitui a imagem do processo atual por um novo programa. O PID é mantido, mas código, dados, heap e stack são trocados. Inicialização de qualquer programa: python app.py, ./api, dotnet MyApi.dll
waitpid() 61 Bloqueia o processo pai até que um filho específico termine, coletando seu exit status. Evita processos zumbi. Qualquer pai que cria filhos com fork() ou clone()
exit_group() 231 Encerra o processo e todas as suas threads, liberando recursos. É chamada quando main() retorna ou quando exit() é invocado. Término normal de qualquer processo
getpid() 39 Retorna o PID do processo corrente. Para threads do mesmo grupo, retorna o PID do grupo (TGID). Diagnóstico, logging, sistemas de lock baseados em PID
getppid() 110 Retorna o PID do processo pai. Útil para detectar se o pai morreu (retorna 1 se adotado pelo init). Verificação de "pai vivo" em daemons e supervisores de processo
kill() 62 Envia um sinal a um processo ou grupo de processos. Apesar do nome, é usada para qualquer sinal — não apenas para encerramento. Envio de SIGTERM, SIGKILL, SIGHUP a workers e daemons
prctl() 157 Controla comportamentos específicos do processo: nome (PR_SET_NAME), comportamento ao morte do pai (PR_SET_PDEATHSIG), capacidades, etc. Nomeação de threads para diagnóstico, hardening de segurança
setrlimit() 160 Define limites de recursos do processo: número de FDs abertos, tamanho máximo de stack, uso de CPU, memória, etc. Configuração de ulimits em servidores, containers (cgroups v1 usa isso indiretamente)

[!TIP]
Como observar essas syscalls em sua aplicação: a ferramenta strace intercepta e exibe todas as syscalls feitas por um processo em tempo real. Para ver o ciclo de criação completo de um processo Python, por exemplo: strace -e trace=fork,clone,execve,waitpid python3 -c "import os; os.fork()". O número da syscall (coluna "Número") corresponde ao valor em rax no momento da instrução syscall em x86-64.

Como sua aplicação é iniciada pelo kernel

Quando você digita python app.py, ./minha-api ou dotnet MyApi.dll no terminal, uma sequência bem definida de eventos acontece antes de qualquer linha do seu código ser executada. Entender esse fluxo ajuda a compreender por que configurações de ambiente, limites de recursos e permissões afetam sua aplicação desde o primeiro instante.

O fluxo geral é sempre o mesmo, independente da linguagem: o shell (ou outro processo pai) chama fork() para se duplicar e, em seguida, o filho chama execve() para substituir sua imagem pelo executável da sua aplicação. O kernel então carrega o binário, configura o espaço de endereçamento e transfere o controle para o ponto de entrada do programa.

Python (python app.py)

Ao executar um script Python, o kernel carrega o binário do interpretador (/usr/bin/python3) via execve(). O interpretador é um executável ELF nativo — é ele que vira o processo, não o seu script. A partir daí:

  1. O dynamic linker (ld.so) carrega as bibliotecas compartilhadas do CPython (como libpython3.x.so)
  2. O CPython inicializa seu runtime: configura o GIL, o gerenciador de memória (pymalloc) e o sistema de módulos
  3. O interpretador abre e compila app.py para bytecode (.pyc) em memória
  4. A execução do bytecode começa — somente aqui seu código roda

Todo esse bootstrap acontece antes de a primeira linha do seu app.py ser lida. É por isso que um import pesado no topo do módulo eleva o tempo de inicialização do processo.

Go (./minha-api)

Diferente de Python, um binário Go é compilado estaticamente por padrão — não depende de um interpretador. O kernel carrega o ELF diretamente via execve() e:

  1. O dynamic linker tem pouco ou nenhum trabalho (binário estático)
  2. O runtime Go é inicializado: o scheduler M:N é configurado, as threads do SO (M) são criadas via clone() com CLONE_VM | CLONE_THREAD, e as estruturas de goroutines (G) são preparadas
  3. A goroutine principal (main goroutine) é criada e agendada
  4. A função main() do seu pacote main é chamada

O número de threads do SO criadas nesse bootstrap é controlado por GOMAXPROCS (padrão: número de CPUs lógicas disponíveis, respeitando cgroups em containers). Por isso binários Go iniciam tão rapidamente e já nascem prontos para paralelismo real.

.NET (dotnet MyApi.dll)

O comando dotnet é o host do CLR — um executável nativo que o kernel carrega via execve(). A DLL com seu código é passada como argumento. O processo de inicialização:

  1. O host carrega o CoreCLR (libcoreclr.so) via dynamic linker
  2. O CLR inicializa o JIT compiler, o Garbage Collector e o ThreadPool
  3. O ThreadPool cria um conjunto inicial de threads do SO via clone() (com CLONE_VM | CLONE_THREAD) prontas para executar work items
  4. O assembly MyApi.dll é carregado, o método Main é localizado, o JIT compila o IL para código nativo e a execução começa

O GC do .NET configura suas gerações de memória e barreiras de escrita durante essa inicialização — o que explica por que o .NET tem um footprint de memória inicial maior do que Go, mas amortiza esse custo ao longo do tempo de vida do processo com otimizações de JIT (tiered compilation).

Resumo comparativo

Python Go .NET
O que o kernel carrega python3 (interpretador) binário ELF nativo dotnet (host CLR)
Seu código chega ao CPU via interpretação de bytecode compilação AOT JIT (tiered compilation)
Threads do SO no startup 1 (+ GIL) GOMAXPROCS pool inicial do ThreadPool
Paralelismo de CPU real apenas com multiprocessing goroutines em N threads Task/Thread em N threads
Tempo de startup típico lento (inicialização do runtime + imports) muito rápido (binário estático) moderado (JIT warmup)

Process Control Block (PCB): task_struct no Linux

O Process Control Block é a estrutura de dados que o kernel mantém para cada processo, contendo todas as informações necessárias para gerenciá-lo. No Linux, essa estrutura é a task_struct, definida em include/linux/sched.h.

A task_struct é uma das maiores estruturas do kernel — com mais de 600 campos em kernels modernos — e inclui:

task_struct
├── Identificação
│   ├── pid          → PID do processo
│   ├── tgid         → Thread Group ID (PID visível em userspace)
│   ├── comm[16]     → Nome do processo (até 16 caracteres)
│   └── cred         → Credenciais (UID, GID, capabilities)
│
├── Estado e Escalonamento
│   ├── state        → Estado atual (RUNNING, INTERRUPTIBLE, etc.)
│   ├── prio         → Prioridade efetiva
│   ├── static_prio  → Prioridade estática (nice value mapeada)
│   ├── normal_prio  → Prioridade normal calculada
│   ├── policy       → Política de escalonamento (SCHED_NORMAL, etc.)
│   ├── se           → Scheduling entity (para CFS)
│   └── cpus_allowed → Máscara de CPUs permitidas (affinity)
│
├── Memória
│   ├── mm           → Descritor de memória (espaço de endereçamento)
│   └── active_mm    → mm ativo (mesmo para kernel threads)
│
├── Hierarquia
│   ├── parent       → Ponteiro para processo pai
│   ├── children     → Lista de processos filhos
│   └── sibling      → Lista de processos irmãos
│
├── Sistema de Arquivos
│   ├── fs           → Informações de filesystem (root dir, cwd)
│   └── files        → Tabela de file descriptors abertos
│
├── Sinais
│   ├── signal       → Estrutura de sinais compartilhada
│   ├── sighand      → Handlers de sinais
│   └── pending      → Sinais pendentes
│
├── Namespaces e cgroups
│   ├── nsproxy      → Referências aos namespaces
│   └── cgroups      → Associação com control groups
│
└── Contabilidade
    ├── utime        → Tempo em modo usuário
    ├── stime        → Tempo em modo kernel
    └── start_time   → Timestamp de criação
Enter fullscreen mode Exit fullscreen mode

Alguns aspectos dessa estrutura são particularmente relevantes para desenvolvedores backend:

pid vs tgid: No kernel, cada thread tem seu próprio pid. Porém, o que o userspace enxerga como PID é na verdade o tgid (Thread Group ID). Todas as threads de um processo compartilham o mesmo tgid. Quando você executa os.getpid() em Python ou Process.GetCurrentProcess().Id em .NET, está obtendo o tgid.

mm (memory descriptor): Processos que compartilham o mesmo mm compartilham o mesmo espaço de endereçamento — é isso que define threads vs processos. Quando clone() é chamado com CLONE_VM, o novo processo/thread compartilha o mm do pai.

files (file descriptor table): Cada processo tem sua própria tabela de file descriptors. Isso significa que o file descriptor 5 no processo A pode apontar para um arquivo completamente diferente do fd 5 no processo B. Threads, por outro lado, compartilham essa tabela quando criadas com CLONE_FILES.

nsproxy e cgroups: Essas são as bases da containerização. Quando sua aplicação roda em Docker/Kubernetes, cada container possui seus próprios namespaces (PID, network, mount, etc.) e está associado a cgroups específicos que limitam CPU, memória e I/O.

Ciclo de Vida de um Processo

A criação e destruição de processos no Linux segue um fluxo bem definido:

Criação: fork() e clone()

Processo Pai                    Kernel                         Processo Filho
     │                            │                                 │
     │── fork()/clone() ─────────►│                                 │
     │                            │── aloca task_struct             │
     │                            │── copia/compartilha recursos    │
     │                            │── configura espaço de endereço  │
     │                            │   (COW - Copy-on-Write)         │
     │                            │── insere na run queue           │
     │                            │                                 │
     │◄── retorna PID do filho ───│── retorna 0 ───────────────────►│
     │                            │                                 │
     │   (continua execução)      │            (continua execução)  │
     │                            │                                 │
Enter fullscreen mode Exit fullscreen mode

O mecanismo de Copy-on-Write (COW) é uma otimização crucial: ao invés de copiar todo o espaço de endereçamento do pai para o filho (operação cara), o kernel marca as páginas de memória como somente leitura e compartilha-as. Apenas quando um dos processos tenta escrever em uma página, o kernel cria uma cópia privada daquela página específica.

[!WARNING]
Implicação prática para Python: Servidores como Gunicorn no modo pre-fork criam workers via fork(). Graças ao COW, os workers inicialmente compartilham a memória do master process (incluindo o código Python carregado, módulos importados, etc.). Porém, o reference counting do CPython modifica os objetos em memória (incrementando/decrementando contadores), o que aciona o COW e gradualmente duplica as páginas. Isso pode resultar em consumo de memória significativamente maior do que o esperado.

Execução: exec()

Frequentemente, após um fork(), o processo filho substitui sua imagem por um novo programa via exec(). Isso:

  1. Descarta o espaço de endereçamento atual
  2. Carrega o novo binário
  3. Inicializa novos segmentos de text, data, BSS, heap e stack
  4. Preserva o PID, file descriptors (exceto os marcados com FD_CLOEXEC), e credenciais

Terminação: exit() e wait()

Quando um processo termina:

  1. Libera a maioria dos seus recursos (memória, file descriptors, etc.)
  2. Entra no estado EXIT_ZOMBIE — mantendo apenas a task_struct com o exit status
  3. Envia SIGCHLD ao processo pai
  4. O pai coleta o exit status via wait()/waitpid()
  5. O kernel remove a task_struct — o processo deixa de existir
# Detectando processos zombie
$ ps aux | awk '$8 ~ /Z/ {print}'

# Ou com contagem
$ ps aux | awk '$8 ~ /Z/' | wc -l
Enter fullscreen mode Exit fullscreen mode

[!IMPORTANT]
Implicação prática: Se sua aplicação cria processos filhos (via subprocess em Python, Process.Start em .NET, ou os/exec em Go) e não faz wait() adequadamente, você acumulará zombies. Em escala, isso pode esgotar a tabela de processos do sistema (kernel.pid_max).

Visualizando Processos na Prática

Para entender o estado dos processos em um sistema de produção, o kernel expõe informações detalhadas via /proc:

# Informações básicas do processo
$ cat /proc/<pid>/status
Name:   python3
State:  S (sleeping)
Tgid:   1350
Pid:    1350
PPid:   1200
Threads: 4
VmPeak: 285432 kB
VmRSS:  61440 kB
voluntary_ctxt_switches:    15230
nonvoluntary_ctxt_switches: 892

# Mapeamento de memória
$ cat /proc/<pid>/maps | head -5
00400000-00452000 r-xp 00000000 08:01 131074  /usr/bin/python3
00652000-00653000 r--p 00052000 08:01 131074  /usr/bin/python3
00653000-00654000 rw-p 00053000 08:01 131074  /usr/bin/python3
7f8a00000000-7f8a00021000 rw-p 00000000 00:00 0
7f8a04000000-7f8a04001000 rw-p 00000000 00:00 0

# Informações de escalonamento
$ cat /proc/<pid>/sched
python3 (1350, #threads: 4)
---
se.exec_start                      : 1234567890.123456
se.vruntime                        : 987654.321098
se.sum_exec_runtime                : 105678.000000
nr_switches                        : 16122
nr_voluntary_switches              : 15230
nr_involuntary_switches            : 892
Enter fullscreen mode Exit fullscreen mode

O campo voluntary_ctxt_switches vs nonvoluntary_ctxt_switches é revelador:

  • Voluntary: o processo cedeu a CPU voluntariamente (geralmente por I/O). Alto para servidores I/O-bound — normal.
  • Involuntary: o kernel forçou a preempção (o processo esgotou seu timeslice). Alto para processos CPU-bound — pode indicar contenção de CPU.

Teoria de Escalonamento

O escalonador (scheduler) é o componente do kernel que responde a uma pergunta aparentemente simples: qual processo deve executar agora? A resposta, no entanto, envolve trade-offs complexos que impactam diretamente a latência das suas APIs, o throughput dos seus workers e a responsividade dos seus serviços.

Por que escalonamento importa para backend?

Considere um servidor com 8 cores rodando:

  • 16 workers Gunicorn servindo uma API REST
  • 4 instâncias de Celery processando tarefas em background
  • 1 processo Redis
  • 1 processo PostgreSQL com múltiplas conexões
  • Dezenas de processos auxiliares do sistema

São potencialmente centenas de threads competindo por 8 cores. O escalonador precisa decidir, milhares de vezes por segundo, qual thread executa em qual core. Decisões ruins resultam em latência alta, tail latency imprevisível e throughput degradado.

Objetivos do Escalonamento

Todo algoritmo de escalonamento busca otimizar um conjunto de métricas que, frequentemente, são conflitantes entre si:

Métricas fundamentais

Métrica Definição Relevância para Backend
Fairness Distribuição justa de CPU entre processos Evita que um worker monopolize CPU enquanto outros ficam parados
Efficiency Manter a CPU ocupada (minimizar idle time) Maximizar utilização dos cores pagos na cloud
Turnaround time Tempo total desde submissão até conclusão Tempo total para processar um batch job ou ETL
Waiting time Tempo que o processo passa na ready queue Contribui diretamente para a latência da sua API
Response time Tempo até a primeira resposta Crítico para APIs interativas — o usuário percebe esse delay
Throughput Processos completados por unidade de tempo Requests/segundo que seu servidor consegue atender

O conflito fundamental

Essas métricas frequentemente se opõem:

  • Throughput vs Response time: Maximizar throughput favorece processos CPU-bound com timeslices longos (menos overhead de context switch). Minimizar response time favorece timeslices curtos e preempção frequente.
  • Fairness vs Efficiency: Garantir fairness perfeita exige context switches frequentes, que desperdiçam ciclos de CPU com overhead.
  • Batch vs Interactive: Jobs de processamento em lote (ETL, relatórios) se beneficiam de execução contínua. Serviços interativos (APIs) precisam de resposta rápida.
Trade-off: Timeslice Size

Timeslice curto (1ms)              Timeslice longo (100ms)
├─ + Melhor response time          ├─ + Maior throughput
├─ + Mais justo                    ├─ + Menos overhead de context switch
├─ - Muito overhead de switching   ├─ - Response time pior
└─ - Menor throughput              └─ - Menos justo (monopolização)

           Sistemas interativos ◄──────────► Batch systems
           (APIs, web servers)                (ETL, ML training)
Enter fullscreen mode Exit fullscreen mode

Implicação prática: Quando você configura o número de workers do Gunicorn ou o tamanho do thread pool do ASP.NET Core, está indiretamente influenciando como o escalonador distribui CPU entre suas threads. Mais workers do que cores disponíveis significa mais competição e mais context switches.

Continua nos próximos capítulos...
:D

Conteudo parcialmente gerado com auxilio de IA generatica (me ajudou organizar tudo isso kkkk)

Referencias Bibliográficas

Top comments (0)