07 O Livro do Ridgun
Funções de Ordem Superior e Paralelismo sem Suor (Nem Mutex, nem Deadlocks)
Em quase todas as linguagens de programação há um momento dramático na vida de um projeto: o dia em que alguém diz numa reunião:
"Isto está lento. É preciso paralelizar com threads."
Faz-se silêncio na sala. Toda a gente sabe o que vem a seguir: semanas a caçar race conditions fantasma que só acontecem às terças-feiras de lua cheia, bloqueios mútuos (deadlocks) e erros irreprodutíveis.
Em Ridgun, quando alguém diz "é preciso paralelizar", basta antepor || às suas operações de lista (||map e ||filter) ou colocar os seus cálculos num bloco paralelo || com setas -> e ir tomar um café.
Porquê? Porque quando as suas funções são puras e os seus dados são imutáveis por omissão, dois núcleos de CPU a trabalhar ao mesmo tempo jamais podem pisar os calcanhares um do outro.
Vamos ver isto passo a passo.
7.1. Funções que recebem funções: map, ||map, filter, ||filter e fold
Em Ridgun uma função é mais um valor. Pode passá-la por parâmetro tal como passa o número 7.
A linguagem traz de série os combinadores de coleções para transformar listas sem escrever uma única recursão manual, tanto em modo sequencial (map, filter, fold) como em modo paralelo multicore com o prefixo || (||map, ||filter):
1. map (sequencial) e ||map (paralelo multicore)
Suponha que tem uma função pesado Int => Int e uma lista com números:
pesado Int => Int
pesado x = (x * x) + (x * 3)
main => ()
main =
dados = [1, 2, 3, 4, 5, 6, 7, 8]
-- Sequencial estrito da esquerda para a direita:
r1 = map pesado dados
-- Paralelo multicore com o prefixo `||`:
r2 = ||map pesado dados
echo r1
echo r2
- Com
map, o Ridgun aplica a função elemento a elemento da esquerda para a direita na thread atual (ideal para listas pequenas ou funções com efeitos de entrada/saída ordenados). - Com
||map, o runtime do Ridgun distribui os elementos dedadospelas threads trabalhadoras do seu processador e reconstrói a lista final exatamente pela mesma ordem, garantindo que||map f xs == map f xs.
2. filter (sequencial) e ||filter (paralelo): Ficar apenas com os que cumprem uma condição
Passa-lhe uma função que devolva Bool (um predicado) e uma lista:
e positivo Int => Bool
e positivo n = n > 0
main => ()
main =
limpos = filter e positivo [-5, 10, 0, -2, 42]
limpos_par = ||filter e positivo [-5, 10, 0, -2, 42]
echo limpos -- Imprime [10, 42]
echo limpos_par -- Imprime [10, 42]
3. fold: Reduzir toda uma lista a um único resultado
Passa-lhe uma função combinadora de dois argumentos (Acumulado Elemento => Acumulado), o valor inicial e a lista:
acumular produto Int Int => Int
acumular produto acc x = acc * x
main => ()
main =
echo (fold acumular produto 1 [2, 3, 4, 5]) -- 1 * 2 * 3 * 4 * 5 = 120
Como escrevo eu a minha própria função que receba outra função?
Muito fácil: na assinatura de tipos coloca a assinatura da função convidada entre parênteses (Entrada => Saida):
executar duas vezes (A => A) A => A
executar duas vezes f x = f (f x)
4. Tubagens fluidas da esquerda para a direita (->) — O estilo 100% Ridgun!
Quando encadeia várias transformações (map, filter, fold ou as suas próprias funções e operadores op ::), aninhar parênteses da direita para a esquerda (compact l (filter g (map f xs))) obriga-o a ler de dentro para fora.
No Ridgun, o estilo idiomático recomendado (2b) lê-se da esquerda para a direita com o operador de tubagem ->, onde o que vem da esquerda entra sempre como primeiro argumento da função ou op :: seguinte:
-- Estilo idiomático recomendado no Ridgun (2b):
xs map (x => x * 2) -> filter (x => x > 10) -> fold (acc x => acc + x) 0 -> resultado
-- E se uma função devolver uma tupla, filtra-a com uma máscara e desempacota-se sozinha:
analisar pacote "v0.9.9" -> (+ _ _ +) -> combinar codigo -> resumo
Além disso, qualquer expressão com efeitos secundários (escrever um ficheiro, invocar o sistema) pode ficar sozinha na sua linha sem echo nem variáveis mudas _ =, ou terminar em -> () se quiser descartar explicitamente o seu retorno.
5. Fechos léxicos e funções anónimas ((x => expr), (acc x => expr))
Quando quiser passar uma função curta em linha ou capturar variáveis locais do âmbito atual (como um deslocamento, um prefixo ou uma sessão ativa) sem declarar uma função global auxiliar, usa um fecho léxico entre parênteses com =>:
fn :: processar com sessao => [Int]
processar com sessao =
base = 10
bonus = 3
somados = map (x => x + base) [1, 2, 3]
filtrados = filter (x => x >= bonus) [1, 2, 3, 4]
total = fold (acc x => acc + x + bonus) 0 [1, 2, 3]
pares = map ((a b) => a + b + base) [(1, 2), (3, 4)]
somados :: filtrados :: [total] :: pares
Cinco regras mantêm as lambdas limpas e sem ambiguidade:
- Parênteses obrigatórios: toda a lambda vai sempre entre parênteses
(... => ...). - Lambda sem parâmetros (
(=> expr)): tal comofn :: main => ()não leva()antes de=>, uma lambda sem parâmetros escreve-se(=> expr)(e o seu tipo é(=> Tipo)), nunca(() => expr). - Vários parâmetros face a tupla (
(a b => ...)vs.((a b) => ...)):(acc x => ...)recebe dois parâmetros separados;((a b) => ...)recebe uma única tupla e desestrutura-a. - Corpo de várias linhas: se colocar uma quebra de linha após
=>, pode escrever um bloco indentado de vários passos antes do)de fecho:
dobros = map (x =>
passo = x * 2
passo + base
) [1, 2, 3]
- Maiúsculas face a minúsculas:
(Conn => Conn)é um tipo de função (inicial maiúscula) e(conn => conn)é um valor lambda (inicial minúscula).
As variáveis capturadas (base, bonus) são empacotadas automaticamente com gestão determinística de memória (RMM1/RMM2), sem fugas e sem pausas de coletor de lixo.
7.2. A joia da coroa: Blocos || com -> como Grafos Paralelos (DAGs)
É aqui que o Ridgun muda a sua forma de pensar.
Numa linguagem imperativa normal, quando escreve três linhas seguidas:
- Primeiro executa-se a linha 1.
- Depois a linha 2 (mesmo que não tenha nada a ver com a linha 1).
- Depois a linha 3.
Isso é obrigar o computador a trabalhar com antolhos. Em Ridgun, quando usa barras paralelas || com setas -> (sem necessidade de escrever nenhuma palavra-chave como where), não está a dar ordens sequenciais: está a desenhar um Grafo Acíclico Dirigido (DAG) que diz que cálculo alimenta que variável:
fib Int => Int
fib 0 = 0
fib 1 = 1
fib n = (fib (n - 1)) + (fib (n - 2))
fact Int => Int
fact 0 = 1
fact n = n * (fact (n - 1))
super calculo => Int
super calculo = ramo_esquerdo + ramo_direito
|| fib 10 -> ramo_esquerdo
|| fact 6 -> ramo_direito
Leia em voz alta:
- "Quero calcular
ramo_esquerdo + ramo_direito, enquanto em paralelo (||)fib 10vai pararamo_esquerdo(->) efact 6vai pararamo_direito(->)."
O que faz o ridgunc nos bastidores com as suas linhas ||:
- Olha para as setas
->e monta a árvore de dependências (DAG). - Caça ciclos estaticamente: se por engano escrever
|| a + 1 -> be|| b + 1 -> a, o compilador diz-lhe na hora "Alto lá! Há uma dependência circular" e não o deixa compilar um bloqueio infinito. - Vê que
fib 10efact 6não dependem um do outro, pelo que têm luz verde para correr em paralelo antes de se somarem emramo_esquerdo + ramo_direito.
Pode inclusive montar um pipeline de vários andares dentro do mesmo bloco || — seja no rodapé da expressão ou logo depois do = — e o compilador deduzirá sozinho que andares podem avançar ao mesmo tempo e quais têm de esperar:
balanco trimestral Int => Int
balanco trimestral base =
|| base * 100 -> vendas_brutas
|| base * 40 -> custos_fixos
|| vendas_brutas - custos_fixos -> lucro_bruto ||.
lucro_bruto * 80 / 100
Repare no detalhe ||. no final da última linha ||: tal como |. fecha uma árvore de guardas aninhada, ||. fecha o bloco paralelo || quando por baixo continuam instruções sequenciais normais. Aqui vendas_brutas e custos_fixos arrancam em paralelo; quando ambos estão prontos calcula-se lucro_bruto, e finalmente devolve-se lucro_bruto * 80 / 100. Tudo sem um único async, Future, Promise nem Mutex.
7.3. O atalho || sem nomes: Condutas paralelas que devolvem uma tupla
E se só quiser lançar dois ou três cálculos em paralelo e recolhê-los juntos numa tupla sem sequer inventar nomes de variáveis com ->?
Usa || diretamente:
par paralelo Int => (Int, Int)
par paralelo n =
|| fib n
|| fact n
Direto, visual e devolve a tupla (fib n, fact n).
7.4. Quando quer o volante manual: o Modelo Nativo de Agentes (agent, send ... to, receive from) e Canais CSP
O paralelismo automático de blocos || e ||map cobre 90% dos casos. Mas quando está a programar um servidor persistente, sessões de utilizador com estado isolado, uma fila de trabalhos ou um pipeline produtor-consumidor, quer agentes concorrentes com memória isolada e passagem de mensagens tipada.
No Ridgun não é preciso inventar uma sintaxe estranha como actor ::: qualquer função normal (fn :: ou op ::) transforma-se num agente concorrente numa única linha com agent:
1. Agentes sem estado e com estado isolado (agent, send ... to, receive from, stop)
ag = agent (minha funcao): transforma uma funçãoM => Rnum agente sem estado do tipoAgent M R.ag = agent (funcao com estado) estado_inicial: transforma uma funçãoS M => S(ouS M => S R) num agente com estado isolado que recorda o seu estado mensagem após mensagem sem um únicoMutex.send msg to ag(ou com vários argumentos:send a b to ag): envia uma mensagem tipada para a caixa de entrada do agente.res = receive from ag(outry receive from agsem bloquear): recolhe a resposta seguinte da caixa de saída do agente.stop ag: termina o agente e liberta o seu estado automaticamente através deRMM1/RMM2.
fn :: somar visita Int Int => Int
somar visita acc n = acc + n
fn :: processar lote Int Int => Int
processar lote id t = (id * 100) + t
fn :: main => ()
main =
contador = agent (somar visita) 10
send 5 to contador
send 7 to contador
v1 = receive from contador
v2 = receive from contador
stop contador
trabalhador = agent (processar lote)
send 3 25 to trabalhador
lote = receive from trabalhador
stop trabalhador
echo "Visitas:" v1 "->" v2 "| Lote:" lote
2. Escolha inteligente em tempo de compilação (Green Threads vs. Threads Reais do Kernel)
Uma pergunta habitual é: "Os blocos ||, ||map e os agentes agent criam sempre threads do sistema operativo ou correm como tarefas leves?"
Em Ridgun, o compilador toma essa decisão por si analisando estaticamente cada função:
- Funções leves (
TierTrivial/StratInlineSequential): Se a função que passa a||,||map,||filterouagenttiver pouca profundidade (aritmética, formatação de cadeias, transição rápida de estado de uma sessão HTTP), aquecer threads do sistema operativo custaria mais do que o próprio cálculo. O compilador executa-a como um passo green-thread ultraleve sem sobrecarga deSYS_clone. - Cálculos pesados ou recursivos profundos (
TierDynamicRecursive/TierHeavyCPU): Se a função tiver recursão profunda (comofiboufact) ou álgebra de tensores (einsum,hadamard,@), o compilador gera automaticamente o despacho multinúcleo sobre threads reais do kernel (SYS_clone), processando os ramos||ou a fila de mensagens doagentem paralelo real sobre todos os núcleos do seu CPU.
3. Passar mensagens por canais limitados (new bounded channel)
Tal como em Go ou Erlang, além de agentes também pode criar tubos explícitos para ligar etapas:
new bounded channel capacidade: cria um canal com espaço paracapacidademensagens.send through valor canal(ouchan send valor canal): coloca uma mensagem no tubo.receive from canal(ouchan recv canal): retira a mensagem seguinte do tubo.try receive from canal: verifica se há mensagem sem ficar à espera; devolveSome valorouNone.receive from timeout canal ticks: aguarda um limite de tempo e devolveOk valorouErr Timeout.
fn :: main => ()
main =
tubo = new bounded channel 4
_ = send through "Tarefa A concluída" tubo
_ = send through "Tarefa B concluída" tubo
primeiro = receive from tubo
segundo = receive from tubo
echo primeiro "|" segundo
4. Agentes sumidouro (fire-and-forget agent sink), caixas de correio globais (agent ::) e testes determinísticos (with agent)
Quando precisa de auditoria assíncrona, métricas ou rastreabilidade transversal sem poluir todas as assinaturas nem acumular respostas por ler numa fila de saída, o Ridgun oferece agentes sumidouro (agent sink) e caixas de correio globais de topo (agent ::) (agent dropped, agent flush, agent state e with agent audit_log = espiao do ...), além de descritores Channel e Agent partilháveis entre múltiplos trabalhadores.
E agora, aperte o cinto de segurança para o Capítulo 08, o grande final: tensores com dimensões verificadas na compilação, variáveis de âmbito (context/shared), regiões de memória determinísticas, acesso direto a hardware MMIO e programação de sistemas UNIX/POSIX.