07 El Llibre de Ridgun
Funcions d'Ordre Superior i Paral·lelisme sense Suor (Ni Mutex, ni Deadlocks)
En gairebé tots els llenguatges de programació hi ha un moment dramàtic en la vida d'un projecte: el dia en què algú diu en una reunió:
"Això va lent. Cal paral·lelitzar-ho amb fils."
Es fa el silenci a la sala. Tothom sap què ve després: setmanes caçant race conditions fantasma que només passen els dimarts amb lluna plena, bloquejos mutus (deadlocks) i errors irreproduïbles.
En Ridgun, quan algú diu "cal paral·lelitzar-ho", n'hi ha prou d'anteposar || a les teves operacions de llista (||map i ||filter) o posar els teus càlculs en un bloc paral·lel || amb fletxes -> i te'n vas a fer un cafè.
Per què? Perquè quan les teves funcions són pures i les teves dades són immutables per defecte, dos nuclis de CPU treballant alhora mai no es poden trepitjar la mànega.
Vegem-ho pas a pas.
7.1. Funcions que reben funcions: map, ||map, filter, ||filter i fold
En Ridgun una funció és un valor més. Pots passar-la per paràmetre igual que passes el número 7.
El llenguatge porta de sèrie els combinadors de col·leccions per transformar llistes sense escriure ni una sola recursió manual, tant en mode seqüencial (map, filter, fold) com en mode paral·lel multicore amb el prefix || (||map, ||filter):
1. map (seqüencial) i ||map (paral·lel multicore)
Suposa que tens una funció costos Int => Int i una llista amb números:
costos Int => Int
costos x = (x * x) + (x * 3)
main => ()
main =
dades = [1, 2, 3, 4, 5, 6, 7, 8]
-- Seqüencial estricte d'esquerra a dreta:
r1 = map costos dades
-- Paral·lel multicore amb el prefix `||`:
r2 = ||map costos dades
echo r1
echo r2
- Amb
map, Ridgun aplica la funció element a element d'esquerra a dreta en el fil actual (ideal per a llistes petites o funcions amb efectes d'entrada/sortida ordenats). - Amb
||map, el runtime de Ridgun reparteix els elements dedadesentre els fils treballadors del teu processador i reconstrueix la llista final exactament en el mateix ordre, garantint que||map f xs == map f xs.
2. filter (seqüencial) i ||filter (paral·lel): Quedar-te només amb els que compleixin una condició
Li passes una funció que retorni Bool (un predicat) i una llista:
es positiu Int => Bool
es positiu n = n > 0
main => ()
main =
nets = filter es positiu [-5, 10, 0, -2, 42]
nets_par = ||filter es positiu [-5, 10, 0, -2, 42]
echo nets -- Imprimeix [10, 42]
echo nets_par -- Imprimeix [10, 42]
3. fold: Reduir tota una llista a un sol resultat
Li passes una funció combinadora de dos arguments (Acumulat Element => Acumulat), el valor inicial i la llista:
acumular producte Int Int => Int
acumular producte acc x = acc * x
main => ()
main =
echo (fold acumular producte 1 [2, 3, 4, 5]) -- 1 * 2 * 3 * 4 * 5 = 120
Com escric jo la meva pròpia funció que rebi una altra funció?
Molt fàcil: a la signatura de tipus poses la signatura de la funció convidada entre parèntesis (Entrada => Sortida):
executar dues vegades (A => A) A => A
executar dues vegades f x = f (f x)
4. Canonades fluides d'esquerra a dreta (->) — L'estil 100% Ridguner!
Quan encadenes diverses transformacions (map, filter, fold o les teves pròpies funcions i operadors op ::), niar parèntesis de dreta a esquerra (compact l (filter g (map f xs))) t'obliga a llegir de dins cap a fora.
En Ridgun, l'estil idiomàtic recomanat (2b) es llegeix d'esquerra a dreta amb l'operador de canonada ->, on el que ve de l'esquerra entra sempre com a primer argument de la funció o op :: següent:
-- Estil idiomàtic recomanat en Ridgun (2b):
xs map (x => x * 2) -> filter (x => x > 10) -> fold (acc x => acc + x) 0 -> resultat
-- I si una funció retorna una tupla, la filtres amb una màscara i es desempaqueta sola:
parsejar paquet "v0.9.9" -> (+ _ _ +) -> combinar codi -> resum
A més, qualsevol expressió amb efectes secundaris (escriure un fitxer, invocar el sistema) es pot posar sola a la seva línia sense echo ni variables mudes _ =, o acabar en -> () si vols descartar explícitament el seu retorn.
5. Clausures lèxiques i funcions anònimes ((x => expr), (acc x => expr))
Quan vols passar una funció curta al vol o capturar variables locals del teu àmbit actual (com un desplaçament, un prefix o una sessió activa) sense declarar una funció global auxiliar, fas servir una clausura lèxica entre parèntesis amb =>:
fn :: processar amb sessio => [Int]
processar amb sessio =
base = 10
bo = 3
sumats = map (x => x + base) [1, 2, 3]
filtrats = filter (x => x >= bo) [1, 2, 3, 4]
total = fold (acc x => acc + x + bo) 0 [1, 2, 3]
parells = map ((a b) => a + b + base) [(1, 2), (3, 4)]
sumats :: filtrats :: [total] :: parells
Cinc regles mantenen les lambdes netes i sense ambigüitat:
- Parèntesis obligatoris: tota lambda va sempre entre parèntesis
(... => ...). - Lambda sense paràmetres (
(=> expr)): igual quefn :: main => ()no porta()abans de=>, una lambda sense paràmetres s'escriu(=> expr)(i el seu tipus és(=> Tipus)), mai(() => expr). - Diversos paràmetres enfront de tupla (
(a b => ...)vs.((a b) => ...)):(acc x => ...)rep dos paràmetres separats;((a b) => ...)rep una sola tupla i la desestructura. - Cos de diverses línies: si poses un salt de línia després de
=>, pots escriure un bloc sagnat de diversos passos abans del)de tancament:
dobles = map (x =>
pas = x * 2
pas + base
) [1, 2, 3]
- Majúscules enfront de minúscules:
(Conn => Conn)és un tipus de funció (inicial majúscula) i(conn => conn)és un valor lambda (inicial minúscula).
Les variables capturades (base, bo) s'empaqueten automàticament amb gestió determinista de memòria (RMM1/RMM2), sense fuites i sense pauses de recol·lector de brossa.
7.2. La joia de la corona: Blocs || amb -> com a Grafs Paral·lels (DAGs)
Aquí és on Ridgun canvia la teva manera de pensar.
En un llenguatge imperatiu normal, quan escrius tres línies seguides:
- Primer s'executa la línia 1.
- Després la línia 2 (encara que no tingui res a veure amb la línia 1).
- Després la línia 3.
Això és obligar l'ordinador a treballar amb clucales. En Ridgun, quan fas servir barres paral·leles || amb fletxes -> (sense necessitat d'escriure cap paraula clau com where), no estàs donant ordres seqüencials: estàs dibuixant un Graf Acíclic Dirigit (DAG) que diu quin càlcul alimenta quina variable:
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 calcul => Int
super calcul = branca_esquerra + branca_dreta
|| fib 10 -> branca_esquerra
|| fact 6 -> branca_dreta
Llegeix-ho en veu alta:
- "Vull calcular
branca_esquerra + branca_dreta, mentre en paral·lel (||)fib 10va abranca_esquerra(->) ifact 6va abranca_dreta(->)."
Què fa ridgunc entre bastidors amb les teves línies ||:
- Mira les fletxes
->i munta l'arbre de dependències (DAG). - Caça cicles estàticament: si per error escrius
|| a + 1 -> bi|| b + 1 -> a, el compilador et diu a l'instant "Quiets! Hi ha una dependència circular" i no et deixa compilar un bloqueig infinit. - Veu que
fib 10ifact 6no depenen l'un de l'altre, així que tenen llum verda per córrer en paral·lel abans de sumar-se abranca_esquerra + branca_dreta.
Fins i tot pots muntar una canonada de diverses plantes dins del mateix bloc || —ja sigui al peu de l'expressió o just després de l'=— i el compilador deduirà tot sol quines plantes poden anar alhora i quines s'han d'esperar:
balanc trimestral Int => Int
balanc trimestral base =
|| base * 100 -> vendes_brutes
|| base * 40 -> costos_fixos
|| vendes_brutes - costos_fixos -> benefici_brut ||.
benefici_brut * 80 / 100
Fixa't en el detall ||. al final de l'última línia ||: igual que |. tanca un arbre de guardes imbricat, ||. tanca el bloc paral·lel || quan a sota continuen sentències seqüencials normals. Aquí vendes_brutes i costos_fixos arrenquen en paral·lel; quan tots dos estan a punt es calcula benefici_brut, i finalment es retorna benefici_brut * 80 / 100. Tot sense un sol async, Future, Promise ni Mutex.
7.3. La drecera || sense noms: Canonades paral·leles que retornen una tupla
I si només vols llançar dos o tres càlculs en paral·lel i recollir-los junts en una tupla sense ni tan sols inventar noms de variables amb ->?
Fas servir || directament:
parell parallel Int => (Int, Int)
parell parallel n =
|| fib n
|| fact n
Directe, visual i retorna la tupla (fib n, fact n).
7.4. Quan vols el volant manual: el Model Natiu d'Agents (agent, send ... to, receive from) i Canals CSP
El paral·lelisme automàtic de || i ||map cobreix el 90% dels casos. Però quan programes un servidor persistent, sessions d'usuari amb estat aïllat, cues de treballs o una canonada productor-consumidor, vols agents concurrents amb memòria aïllada i pas de missatges tipat.
En Ridgun no cal inventar cap sintaxi estranya com actor ::: qualsevol funció normal (fn :: o op ::) es converteix en un agent concurrent en una sola línia amb agent:
1. Agents sense estat i amb estat aïllat (agent, send ... to, receive from, stop)
ag = agent (la meva funcio): converteix una funcióM => Ren un agent sense estat de tipusAgent M R.ag = agent (funcio amb estat) estat_inicial: converteix una funcióS M => S(oS M => S R) en un agent amb estat aïllat que recorda el seu estat missatge rere missatge sense capMutex.send msg to ag(o amb diversos arguments:send a b to ag): envia un missatge tipat a la bústia de l'agent.res = receive from ag(otry receive from agsense bloquejar): recull la següent resposta de la bústia de sortida de l'agent.stop ag: atura l'agent i allibera el seu estat automàticament mitjançantRMM1/RMM2.
fn :: sumar visita Int Int => Int
sumar visita acum n = acum + n
fn :: processar lot Int Int => Int
processar lot id t = (id * 100) + t
fn :: main => ()
main =
comptador = agent (sumar visita) 10
send 5 to comptador
send 7 to comptador
v1 = receive from comptador
v2 = receive from comptador
stop comptador
treballador = agent (processar lot)
send 3 25 to treballador
lot = receive from treballador
stop treballador
echo "Visites:" v1 "->" v2 "| Lot:" lot
2. Elecció intel·ligent en temps de compilació (Green Threads vs. Fils Reals del Nucli)
Una pregunta habitual és: "Els blocs ||, ||map i els agents agent creen sempre fils del sistema operatiu o corren com a tasques lleugeres?"
En Ridgun, el compilador pren aquesta decisió per tu analitzant estàticament cada funció:
- Funcions lleugeres (
TierTrivial/StratInlineSequential): Si la funció que passes a||,||map,||filteroagentté poca profunditat (aritmètica, formatat de cadenes, transició d'estat ràpida d'una sessió HTTP), escalfar fils del sistema operatiu costaria més que el propi càlcul. El compilador l'executa com un pas green-thread ultralleuger sense sobrecàrrega deSYS_clone. - Càlculs pesats o recursius profunds (
TierDynamicRecursive/TierHeavyCPU): Si la funció té recursió profunda (comfibofact) o àlgebra de tensors (einsum,hadamard,@), el compilador genera automàticament el despatx multinucli sobre fils reals del nucli (SYS_clone), processant les branques||o la cua de missatges de l'agenten paral·lel real sobre tots els teus nuclis de CPU.
3. Passar missatges per canals afitats (new bounded channel)
Igual que en Go o Erlang, a més d'agents també pots crear tubs explícits per comunicar etapes:
new bounded channel capacitat: crea un canal amb espai per acapacitatmissatges.send through valor canal(ochan send valor canal): fica un missatge al tub.receive from canal(ochan recv canal): treu el següent missatge del tub.try receive from canal: mira si hi ha missatge sense quedar-se esperant; retornaSome valoroNone.receive from timeout canal ticks: espera un límit de temps i retornaOk valoroErr Timeout.
fn :: main => ()
main =
tub = new bounded channel 4
_ = send through "Tasca A completada" tub
_ = send through "Tasca B completada" tub
primer = receive from tub
segon = receive from tub
echo primer "|" segon
4. Agents embornal (fire-and-forget agent sink), bústies globals (agent ::) i proves deterministes (with agent)
Quan necessites auditoria asíncrona, mètriques o traçabilitat transversal sense embrutar totes les signatures ni acumular respostes sense llegir en una cua de sortida, Ridgun ofereix agents embornal (agent sink) i bústies globals de nivell superior (agent ::) (agent dropped, agent flush, agent state, i with agent audit_log = espia do ...), a més de descriptors Channel i Agent compartibles entre múltiples treballadors.
I ara, corda't el cinturó per al Capítol 08, el gran final: tensors amb dimensions verificades en compilació, variables d'àmbit (context/shared), regions de memòria deterministes, accés directe a maquinari MMIO i programació de sistemes UNIX/POSIX.