Skip to content

Nível de sistema

Todo modelo de concorrência, por mais abstrato que pareça na linguagem, no fim das contas roda sobre a mesma base: um sistema operacional escalonando threads sobre um punhado de núcleos de CPU. As goroutines de Go, os atores de Elixir e o event loop de Node.js são todos ficções úteis construídas sobre essa base. Entender a base é o que impede que essas ficções pareçam mágica, e é o que explica por que elas existem, porque quase todas nascem para contornar alguma limitação concreta que só aparece aqui embaixo.

A primeira distinção é entre processo e thread, e ela é sobre isolamento de memória. Um processo é uma instância de um programa em execução com seu próprio espaço de endereçamento: sua memória é privada, e o sistema operacional garante que um processo não consiga ler nem escrever a memória de outro sem passar por um mecanismo explícito. Essa fronteira é uma proteção fundamental. É por ela que um navegador que trava não derruba o editor de texto ao lado.

Uma thread é um fluxo de execução dentro de um processo. Um processo começa com uma thread, a principal, mas pode criar mais, e o ponto crucial é que todas as threads de um processo compartilham o mesmo espaço de endereçamento. Elas veem a mesma memória, as mesmas variáveis globais, o mesmo heap. Cada uma tem apenas o seu próprio conjunto de registradores e sua própria pilha de chamadas, o suficiente para ter um ponto de execução independente, mas os dados que manipulam são comuns.

Essa diferença é a origem de quase tudo o que segue. Compartilhar memória é o que torna a comunicação entre threads barata, você só escreve numa variável que a outra lê, e é exatamente o que torna essa comunicação perigosa, porque duas threads escrevendo na mesma variável sem coordenação produzem corrupção. É a tensão que o documento sobre sincronização inteiro tenta resolver. Processos, por não compartilharem memória, não sofrem desse mal; pagam, em troca, o custo de ter que copiar dados de um lado para o outro quando precisam se comunicar. Esse mesmo trade-off, isolamento contra custo de cópia, reaparece lá em cima no actor model, que é, em essência, a ideia de processo levada para dentro da linguagem.

Há sempre mais threads querendo rodar do que núcleos para executá-las. Um sistema típico tem alguns milhares de threads vivas e talvez oito ou dezesseis núcleos. Alguém precisa decidir, a cada instante, quais threads ocupam os núcleos e por quanto tempo. Esse alguém é o scheduler do kernel.

A estratégia que domina os sistemas de propósito geral é o escalonamento preemptivo por fatias de tempo. O kernel dá a cada thread uma fatia de tempo, da ordem de alguns a algumas dezenas de milissegundos, e quando essa fatia acaba, ele interrompe a thread à força, salva seu estado e coloca outra para rodar. A palavra importante é “à força”: a thread não precisa cooperar, não precisa nem saber que foi interrompida. Um temporizador de hardware dispara, o controle passa ao kernel, e ele troca. Essa é a diferença entre escalonamento preemptivo e cooperativo, e ela tem uma consequência prática enorme: sob preempção, uma thread que entra num laço infinito não trava o sistema, porque o kernel a interrompe de qualquer forma e dá a vez às outras. Sob escalonamento cooperativo, onde a tarefa só cede a vez quando escolhe, o mesmo laço infinito congelaria tudo. Essa distinção volta a importar quando os modelos de espaço de usuário aparecem, porque vários deles são cooperativos por dentro.

É essa alternância rápida que cria a ilusão de paralelismo mesmo num único núcleo. As threads não rodam ao mesmo tempo; elas se revezam tão depressa que parecem simultâneas. Isso é concorrência sem paralelismo, no sentido exato do panorama: a estrutura tem muitas tarefas vivas, mas a execução, naquele núcleo, é uma de cada vez.

Cada vez que o kernel troca a thread que ocupa um núcleo, ele faz uma troca de contexto, e essa operação não é gratuita. Ele precisa salvar os registradores da thread que sai, carregar os da que entra, e frequentemente trocar estruturas de memória do kernel. O custo direto disso é da ordem de um a alguns microssegundos, o que já não é desprezível quando se multiplica por milhões de trocas.

O custo indireto costuma ser maior e é menos visível: a nova thread quase certamente quer dados que não estão nas caches da CPU, porque quem estava lá era a thread anterior. As caches “esfriam”, e os primeiros acessos da thread recém-chegada batem na memória principal, dezenas ou centenas de vezes mais lenta que a cache. Uma troca de contexto, portanto, não custa só o tempo de trocar os registradores; custa também a degradação de desempenho enquanto as caches reaquecem.

Some a isso o custo de memória. Cada thread do SO precisa de uma pilha, e o sistema operacional, sem saber de antemão quão fundo o código vai recursar, reserva um espaço generoso, tipicamente da ordem de um a oito megabytes por thread. Reserva, não necessariamente aloca fisicamente de imediato, mas o espaço de endereçamento é comprometido, e a contabilidade se acumula. Com oito megabytes por pilha, dez mil threads já comprometem dezenas de gigabytes de espaço de endereçamento só em pilhas.

Aqui está a raiz de quase todos os modelos mais leves. Threads do SO são caras em dois sentidos: caras de trocar, por causa do kernel e das caches, e caras de manter, por causa da pilha. Se o seu problema é atender cem mil conexões de rede simultâneas, na maioria delas ociosas esperando um pacote, mapear cada conexão a uma thread do SO é inviável: você gastaria centenas de gigabytes em pilhas e afogaria o scheduler em trocas de contexto. Foi essa constatação, cristalizada no clássico “problema C10k”, que empurrou a indústria para as green threads e para o event loop. Ambos são, na origem, respostas ao custo que se mede exatamente aqui.

Até agora falamos de concorrência num núcleo. O paralelismo verdadeiro exige hardware que execute instruções ao mesmo tempo, e isso são os núcleos. Um processador com oito núcleos pode executar oito fluxos de instrução genuinamente em paralelo, e é só assim, com mais núcleos, que trabalho limitado por CPU acelera. Nenhuma esperteza de escalonamento contorna isso: se o gargalo é o processador moendo números, a única saída é mais processadores moendo em paralelo.

Muitos processadores acrescentam uma camada a mais, o multithreading simultâneo, que a Intel chama de Hyper-Threading. Cada núcleo físico se apresenta ao sistema como dois núcleos lógicos, e o hardware intercala as instruções dos dois de modo a manter as unidades de execução ocupadas quando uma das threads está parada esperando memória. Não é paralelismo pleno, os dois núcleos lógicos disputam as mesmas unidades físicas, mas para cargas mistas costuma render um ganho real. O detalhe prático é que “oito núcleos” hoje frequentemente significa oito físicos e dezesseis lógicos, e nem sempre o dobro de threads rende o dobro de trabalho.

Há um teto para o que o paralelismo pode dar, e é honesto encará-lo: a Lei de Amdahl. Ela diz que o ganho de acelerar um programa jogando mais núcleos é limitado pela fração dele que é inerentemente sequencial. Se noventa por cento do tempo de um programa é paralelizável e dez por cento é sequencial, então mesmo com núcleos infinitos você nunca passa de um ganho de dez vezes, porque aqueles dez por cento sequenciais não encolhem. A moral é sóbria: paralelismo não é um botão de “mais rápido” que escala sem fim. Ele esbarra na parte do problema que se recusa a ser dividida, e essa parte, em muitos programas reais, é maior do que se gostaria.

Quando a linguagem amarra o paralelismo: o GIL

Section titled “Quando a linguagem amarra o paralelismo: o GIL”

Vale fechar com um caso que confunde muita gente, porque mostra que ter threads do SO não garante paralelismo. Algumas linguagens colocam uma trava global sobre o próprio interpretador. O exemplo canônico é o GIL, o Global Interpreter Lock de CPython, a implementação padrão de Python. O GIL é um mutex único que garante que apenas uma thread execute bytecode Python por vez, mesmo que o processo tenha várias threads do SO e a máquina tenha vários núcleos.

A consequência é contraintuitiva: um programa Python com quatro threads fazendo cálculo puro não roda quatro vezes mais rápido em quatro núcleos, roda essencialmente na velocidade de um, porque o GIL serializa a execução. As threads são reais, o SO as escalona de verdade, mas o GIL as impede de rodar bytecode em paralelo. Para trabalho limitado por CPU em Python, a saída histórica foi usar múltiplos processos em vez de threads, cada um com seu próprio interpretador e seu próprio GIL, pagando o custo de não compartilhar memória. Para trabalho limitado por I/O o GIL incomoda pouco, porque uma thread que espera a rede solta o GIL enquanto espera, e é por isso que threading em Python ainda serve bem a cargas de I/O.

O GIL existe por razões de simplicidade de implementação e segurança de memória do interpretador, e há um esforço em curso para torná-lo opcional. O que importa reter é a lição geral: a presença de threads do SO é condição necessária, mas não suficiente, para paralelismo. A linguagem, o runtime ou o próprio design do programa podem serializar o que o hardware permitiria rodar junto. É por isso que, ao avaliar qualquer modelo de concorrência, a pergunta “isso me dá paralelismo de verdade, ou só concorrência?” nunca é ociosa.

Conceitos: processo, thread, espaço de endereçamento, escalonamento preemptivo, time-slicing, troca de contexto, cache, pilha, paralelismo, concorrência Hardware: núcleo, CPU, SMT, Hyper-Threading, núcleo lógico vs físico Limites: Lei de Amdahl, problema C10k, GIL Linguagens: Python, CPython