Green threads e o modelo M:N
O nível de sistema deixou um problema em aberto: threads do sistema operacional são caras, caras de trocar por causa do kernel e das caches, caras de manter por causa da pilha de megabytes. Se você quer atender centenas de milhares de conexões concorrentes, mapear cada uma a uma thread do SO é inviável. As green threads são a primeira das duas grandes respostas a esse impasse. A outra, o event loop, aparece no documento seguinte, e vale ter as duas em mente porque elas resolvem o mesmo problema por caminhos opostos.
A ideia central das green threads é simples de enunciar e cheia de consequências: se a thread do SO é cara porque é gerenciada pelo kernel, crie uma thread mais leve, gerenciada por um runtime dentro do próprio processo, invisível ao kernel. O kernel continua vendo um punhado de threads do SO; dentro delas, o runtime da linguagem escalona milhares ou milhões de tarefas leves, sem jamais incomodar o kernel para trocar entre elas.
1:1, N:1 e M:N
Section titled “1:1, N:1 e M:N”A forma mais clara de situar as green threads é pelo mapeamento entre threads da linguagem e threads do SO. São três desenhos possíveis, e cada linguagem escolheu um.
No mapeamento 1:1, cada thread da linguagem é uma thread do SO. É o modelo das threads de Java tradicionais, de C++, de C. Simples e honesto: o kernel faz todo o escalonamento, você ganha preempção de graça e paralelismo real sem esforço, mas paga o custo integral de cada thread e esbarra no teto de quantas pode ter. Dezenas de milhares de threads 1:1 já pesam; milhões estão fora de questão.
No mapeamento N:1, muitas threads da linguagem rodam sobre uma única thread do SO. É o modelo das primeiras bibliotecas de green threads e, em essência, o de um event loop. As tarefas são baratíssimas, mas há um defeito fatal para trabalho de CPU: como tudo roda numa thread do SO só, não há paralelismo nenhum, e pior, se uma tarefa faz uma chamada bloqueante ao kernel, ela bloqueia a thread do SO inteira e congela todas as outras tarefas junto.
O desenho que venceu para concorrência de propósito geral é o M:N: M threads da linguagem multiplexadas sobre N threads do SO, com N tipicamente igual ao número de núcleos. É o melhor dos dois mundos, e é o que Go e as virtual threads de Java fazem. As tarefas são leves como no N:1, então você tem milhões; mas como há várias threads do SO por baixo, o runtime pode rodar tarefas em paralelo de verdade, uma por núcleo, e quando uma tarefa bloqueia, o runtime move as outras para uma thread do SO livre. O preço é a complexidade: alguém precisa escrever esse escalonador M:N, e ele é uma peça de engenharia delicada. Essa complexidade é o custo real das green threads, e é por isso que elas costumam vir embutidas no runtime da linguagem, não como biblioteca que você acopla.
Stackful e stackless
Section titled “Stackful e stackless”Uma tarefa suspensa precisa guardar seu estado em algum lugar para poder retomar depois: onde estava executando, quais variáveis locais tinha. Há duas formas de guardar isso, e a escolha entre elas é a divisão técnica mais importante dentro do tema.
Uma green thread stackful tem uma pilha de verdade, própria, alocada pelo runtime, só que pequena. Ela pode ser suspensa em qualquer ponto, no meio de qualquer função, porque suspender é só guardar o ponteiro de pilha e trocar. Do ponto de vista de quem escreve, a green thread stackful se comporta exatamente como uma thread comum: você escreve código sequencial normal, chama funções, e o runtime cuida de suspender e retomar sem que o código precise saber. As goroutines de Go são stackful, e é por isso que em Go você não vê async/await em lugar nenhum: a concorrência é transparente na sintaxe.
Uma green thread stackless não tem pilha própria. Seu estado é guardado numa estrutura de dados, tipicamente uma máquina de estados que o compilador gera, e ela só pode ser suspensa em pontos marcados explicitamente no código. Esses pontos são exatamente os await. É o modelo das corrotinas de Rust, de C++, do async/await em geral. A vantagem é a eficiência: sem pilha própria, cada tarefa ocupa só os bytes do estado que de fato precisa guardar, o que é ainda mais barato que a pilha pequena de uma stackful, e não exige alocar pilhas. A desvantagem é a visibilidade na sintaxe, cada ponto de suspensão precisa ser marcado, e é essa marcação que dá origem ao problema das “funções coloridas” que o event loop discute em detalhe.
O eixo stackful contra stackless corta o campo em dois. Go e as virtual threads de Java são stackful e escondem a concorrência da sintaxe; Rust, C++, JavaScript e Python são stackless e a expõem via async/await. Nenhum dos dois é gratuitamente superior: o stackful compra ergonomia ao preço de um runtime mais pesado e pilhas para gerenciar; o stackless compra eficiência e controle ao preço de espalhar async/await pelo código.
Escalonamento cooperativo e trabalho roubado
Section titled “Escalonamento cooperativo e trabalho roubado”As green threads são escalonadas pelo runtime, e o runtime, ao contrário do kernel, tradicionalmente não pode interromper uma tarefa à força a qualquer instante, porque não tem o temporizador de hardware do kernel à disposição. O escalonamento delas é, na base, cooperativo: uma tarefa cede a vez em pontos de yield. Em modelos stackless, esses pontos são os await. Em Go, o runtime insere pontos de yield em lugares estratégicos, como chamadas de função e operações de channel, de modo que na prática o programador quase nunca precisa pensar nisso.
Esse “quase” esconde o calcanhar de Aquiles do escalonamento cooperativo, o mesmo que já apareceu no nível de sistema: uma tarefa que nunca cede a vez pode monopolizar sua thread do SO. Um laço apertado que só faz cálculo, sem chamar função nem tocar em I/O, não passa por nenhum ponto de yield, e num runtime puramente cooperativo ele travaria as outras tarefas daquela thread. Os runtimes maduros mitigam isso: o escalonador de Go ganhou, a partir da versão 1.14, preempção assíncrona baseada em sinais, capaz de interromper até um laço sem pontos de yield. É a prova de que a fronteira entre cooperativo e preemptivo, nítida na teoria, borra-se na prática quando o runtime fica sofisticado.
A peça que faz o modelo M:N escalar bem entre núcleos é o trabalho roubado (work stealing). Cada thread do SO do runtime tem sua própria fila de tarefas prontas para rodar. Quando uma thread esvazia a própria fila, em vez de ficar ociosa, ela “rouba” tarefas da fila de outra thread que esteja atolada. O efeito é um balanceamento de carga automático e descentralizado, sem um coordenador central que vire gargalo, e é o motivo de as goroutines distribuírem-se bem por todos os núcleos disponíveis sem que você faça nada.
Go e o modelo CSP
Section titled “Go e o modelo CSP”Go é o exemplo mais influente das green threads modernas, e sua aposta vai além de tornar as tarefas baratas. As goroutines são green threads stackful num escalonador M:N com trabalho roubado, e você cria uma escrevendo go f(). A pilha inicial de uma goroutine é de poucos kilobytes e cresce sob demanda, o que permite ter milhões delas onde caberiam apenas milhares de threads do SO. Só isso já resolveria o problema do custo. Mas Go acrescentou uma segunda decisão, sobre como as goroutines se comunicam, e essa decisão é o que dá a Go sua identidade.
Em vez de memória compartilhada com locks, Go promove a comunicação por channels, seguindo o modelo CSP (Communicating Sequential Processes, de Tony Hoare). Um channel é um conduíte tipado pelo qual uma goroutine envia valores e outra recebe, e o envio e a recepção são pontos de sincronização: num channel sem buffer, o remetente espera até que alguém receba. O lema de Go resume a filosofia: “não se comunique compartilhando memória; compartilhe memória comunicando-se”. Em vez de duas goroutines mexerem numa variável comum protegida por mutex, uma passa o dado à outra pelo channel, e a posse do dado migra junto. A disciplina que o mutex pedia, e que o documento sobre sincronização mostrou ser tão fácil de furar, dá lugar a um fluxo onde o dado tem, a cada instante, um dono claro.
Vale marcar que CSP não é a mesma coisa que o actor model, embora os dois tratem de troca de mensagens. A diferença está em quem tem identidade. No CSP, o channel é a entidade nomeada e as goroutines são anônimas: você envia para um channel, não para uma goroutine específica, e qualquer goroutine pode receber daquele channel. No actor model, é o ator que tem identidade e endereço, e você envia para um ator. É uma inversão sutil de onde mora o nome, e ela tem consequências reais de design, mas ambos partem da mesma recusa a compartilhar memória.
Go não é purista, é pragmático: a biblioteca padrão oferece mutex e atomics para quando fazem mais sentido, e há usos legítimos deles. A aposta de Go é de default, não de proibição: channels são o caminho recomendado, e a linguagem é desenhada para que ele seja o mais natural.
Java, Zig e Rust
Section titled “Java, Zig e Rust”As virtual threads de Java, entregues pelo Project Loom no JDK 21, são a chegada tardia mas notável do modelo M:N a uma plataforma consagrada. São green threads stackful escalonadas pela JVM sobre um pool de threads do SO, e o golpe de mestre foi de compatibilidade: uma virtual thread implementa a mesma interface Thread de sempre, então o código bloqueante existente, aquele mar de bibliotecas escritas para o modelo 1:1, roda sobre elas sem reescrita. Uma chamada bloqueante que antes prendia uma thread do SO cara agora só suspende uma virtual thread barata, e a JVM libera a thread do SO para outra. Java conseguiu, com isso, o que Go tem desde o início, sem obrigar o ecossistema a migrar para async/await.
Zig faz uma escolha deliberadamente diferente: não embute um runtime de green threads nem um escalonador na linguagem. Fiel à sua filosofia de “sem alocações escondidas, sem controle de fluxo escondido”, Zig quer que a concorrência seja explícita e que o custo esteja à vista. O suporte a async da linguagem passou por reformulações profundas e, historicamente, a expectativa é que o escalonador venha de uma biblioteca que você escolhe, não de um runtime imposto. É o extremo oposto de Go no eixo de quanto a linguagem esconde: onde Go torna a concorrência invisível e conveniente, Zig a torna visível e sua responsabilidade.
Rust fica num meio-termo instrutivo. A linguagem oferece a sintaxe async/await e o tipo Future, mas de propósito não inclui um runtime que os execute. Quem fornece o escalonador M:N é uma biblioteca, na prática quase sempre o tokio, que roda um pool de threads do SO com trabalho roubado muito parecido com o de Go. A diferença é que Rust é stackless, então a concorrência aparece na sintaxe via async/await, e o modelo é de corrotinas, não de green threads stackful. A discussão detalhada disso, incluindo por que essa escolha traz o problema das funções coloridas, é o assunto do event loop e I/O assíncrono.
Onde a abstração vaza
Section titled “Onde a abstração vaza”Green threads são uma abstração poderosa, e como toda abstração poderosa, ela vaza em pontos previsíveis que vale conhecer para não ser pego de surpresa.
O vazamento clássico é a chamada bloqueante que escapa ao runtime. O runtime de green threads sabe suspender uma tarefa quando ela espera por I/O que o runtime intermedia. Mas se a tarefa chama diretamente uma função bloqueante do sistema operacional que o runtime não conhece, uma chamada de sistema síncrona, um trecho de código C via FFI, ela bloqueia a thread do SO inteira, e todas as green threads escaladas naquela thread do SO param junto. Runtimes maduros contornam isso mantendo threads do SO extras para absorver chamadas bloqueantes, mas o cuidado de não bloquear o executor é uma preocupação real em Rust e em qualquer ambiente async, e a fonte de travamentos misteriosos para quem não sabe da armadilha.
O outro vazamento é o laço sem yield já mencionado: código CPU-bound que nunca cede a vez pode monopolizar seu núcleo num runtime cooperativo. E há o vazamento de mentalidade, mais insidioso: por green threads serem tão baratas, é tentador criar milhões sem pensar, e embora cada uma custe pouco, milhões de qualquer coisa somam, em memória e em pressão sobre o escalonador. Barato não é grátis. A regra que atravessa o tema é que a abstração serve você bem enquanto você fica dentro do modelo que ela pressupõe, I/O intermediado pelo runtime, tarefas que cedem a vez, e passa a surpreender exatamente quando você sai dele.
Palavras-chave
Section titled “Palavras-chave”Conceitos: green thread, escalonamento M:N, 1:1, N:1, stackful, stackless, escalonamento cooperativo, ponto de yield, trabalho roubado, corrotina, CSP, channel
Go: goroutine, go f(), pilha crescente, preempção assíncrona, “compartilhe memória comunicando-se”
Linguagens: Go, Java, Project Loom, virtual threads, Zig, Rust, tokio
Armadilhas: chamada bloqueante, laço sem yield, monopolização de núcleo