Saltar al contenido
Ridgun
Español

07 El Libro de Ridgun

Funciones de Orden Superior y Paralelismo sin Sudor (Ni Mutex, ni Deadlocks)

En casi todos los lenguajes de programación hay un momento dramático en la vida de un proyecto: el día en que alguien dice en una reunión:

"Esto va lento. Hay que paralelizarlo con hilos."

Se hace el silencio en la sala. Todo el mundo sabe lo que viene después: semanas cazando race conditions fantasma que solo ocurren los martes con luna llena, bloqueos mutuos (deadlocks) y errores irreproducibles.

En Ridgun, cuando alguien dice "hay que paralelizarlo", basta con anteponer || a tus operaciones de lista (||map y ||filter) o poner tus cálculos en un bloque paralelo || con flechas -> y te vas a tomar un café.

¿Por qué? Porque cuando tus funciones son puras y tus datos son inmutables por defecto, dos núcleos de CPU trabajando a la vez jamás pueden pisarse la manguera.

Vamos a verlo paso a paso.


7.1. Funciones que reciben funciones: map, ||map, filter, ||filter y fold

En Ridgun una función es un valor más. Puedes pasarla por parámetro igual que pasas el número 7.

El lenguaje trae de serie los combinadores de colecciones para transformar listas sin escribir ni una sola recursión manual, tanto en modo secuencial (map, filter, fold) como en modo paralelo multicore con el prefijo || (||map, ||filter):

1. map (secuencial) y ||map (paralelo multicore)

Supón que tienes una función costoso Int => Int y una lista con números:

costoso Int => Int
costoso x   =  (x * x) + (x * 3)

main => ()
main =
  datos = [1, 2, 3, 4, 5, 6, 7, 8]
  -- Secuencial estricto de izquierda a derecha:
  r1 = map costoso datos
  -- Paralelo multicore con el prefijo `||`:
  r2 = ||map costoso datos
  echo r1
  echo r2
  • Con map, Ridgun aplica la función elemento a elemento de izquierda a derecha en el hilo actual (ideal para listas pequeñas o funciones con efectos de entrada/salida ordenados).
  • Con ||map, el runtime de Ridgun reparte los elementos de datos entre los hilos trabajadores de tu procesador y reconstruye la lista final exactamente en el mismo orden, garantizando que ||map f xs == map f xs.

2. filter (secuencial) y ||filter (paralelo): Quedarte solo con los que cumplan una condición

Le pasas una función que devuelva Bool (un predicado) y una lista:

fn :: es positivo Int => Bool
      es positivo n   =  n > 0

fn :: main => ()
      main =
        limpios = filter es positivo [-5, 10, 0, -2, 42]
        limpios_par = ||filter es positivo [-5, 10, 0, -2, 42]
        echo limpios      -- Imprime [10, 42]
        echo limpios_par  -- Imprime [10, 42]

3. fold: Reducir toda una lista a un solo resultado

Le pasas una función combinadora de dos argumentos (Acumulado Elemento => Acumulado), el valor inicial y la lista:

fn :: acumular producto Int Int => Int
      acumular producto acc x   =  acc * x

fn :: main => ()
      main =
        echo (fold acumular producto 1 [2, 3, 4, 5])  -- 1 * 2 * 3 * 4 * 5 = 120

¿Cómo escribo yo mi propia función que reciba otra función?

Muy fácil: en la firma de tipos pones la firma de la función invitada entre paréntesis (Entrada => Salida):

fn :: ejecutar dos veces (A => A) A => A
      ejecutar dos veces f        x =  f (f x)

4. Clausuras léxicas y funciones anónimas ((x => expr), (acc x => expr))

Cuando quieres pasar una función corta al vuelo o capturar variables locales de tu ámbito actual (como un desplazamiento, un prefijo o una sesión activa) sin declarar una función global auxiliar, usas una clausura léxica entre paréntesis con =>:

fn :: procesar con sesion => [Int]
      procesar con sesion =
        base      = 10
        bono      = 3
        sumados   = map (x => x + base) [1, 2, 3]
        filtrados = filter (x => x >= bono) [1, 2, 3, 4]
        total     = fold (acc x => acc + x + bono) 0 [1, 2, 3]
        pares     = map ((a b) => a + b + base) [(1, 2), (3, 4)]
        sumados :: filtrados :: [total] :: pares

Cinco reglas mantienen las lambdas limpias y sin ambigüedad:

  • Paréntesis obligatorios: toda lambda va siempre entre paréntesis (... => ...).
  • Lambda sin parámetros ((=> expr)): igual que fn :: main => () no lleva () antes de =>, una lambda sin parámetros se escribe (=> expr) (y su tipo es (=> Tipo)), nunca (() => expr).
  • Varios parámetros frente a tupla ((a b => ...) vs. ((a b) => ...)): (acc x => ...) recibe dos parámetros separados; ((a b) => ...) recibe una sola tupla y la desestructura.
  • Cuerpo de varias líneas: si pones un salto de línea tras =>, puedes escribir un bloque sangrado de varios pasos antes del ) de cierre:
  dobles = map (x =>
    paso = x * 2
    paso + base
  ) [1, 2, 3]
  • Mayúsculas frente a minúsculas: (Conn => Conn) es un tipo de función (inicial mayúscula) y (conn => conn) es un valor lambda (inicial minúscula).

Las variables capturadas (base, bono) se empaquetan automáticamente con gestión determinista de memoria (RMM1/RMM2), sin fugas y sin pausas de recolector de basura.

5. El estilo más Ridgunero (recomendado): Tuberías fluidas con ->, invocación por la izquierda y bloques sin _ = (FR-COMP-014)

Escribir map f xs o asignar cada paso intermedio a una variable (r1 = ..., r2 = ...) está muy bien, pero donde Ridgun se disfruta al 100% es encadenando transformaciones de izquierda a derecha con -> y ligando el resultado final directamente con -> variable:

op :: [Int] compact limit Int => [Int]
      []     compact limit _   =  []
      (x:xs) compact limit lim
        | x <= lim  = [x] :: (xs compact limit lim)
        | otherwise = xs compact limit lim

fn :: main => ()
      main =
        xs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

        -- Estilo recomendado (2b): arranque infijo natural + `->` entre etapas + `-> results`
        xs map (x => x * 2) -> filter (x => x > 6) -> compact limit 14 -> results
        echo results  -- Imprime [8, 10, 12, 14]

Ridgun unifica las cuatro formas de escribir la cadena en el mismo árbol sintáctico (AST), para que elijas la que mejor se lea en cada caso:

  1. Estilo 2b (recomendado): xs map f -> filter g -> compact limit 14 -> results (arranque infijo natural con el sujeto a la izquierda, -> separando cada etapa y -> results al final).
  2. Estilo 2 (-> desde el valor inicial): xs -> map f -> filter g -> compact limit 14 -> results.
  3. Estilo 1 (infijo continuo sin -> intermedios): xs map f filter g compact limit 14 -> results.
  4. Estilo 3 (arranque prefijo clásico + ->): map f xs -> filter g -> compact limit 14 -> results.

Y si la tubería es larga, puedes partirla en varias líneas sangradas que empiezan por ->:

        xs map (x => x * 3)
          -> filter (x => x >= 12)
          -> fold 0 (acc x => acc + x)
          -> total

La regla universal de -> es sencilla y consistente en todo el lenguaje:

  • El valor que viene por la izquierda entra como PRIMER argumento de la función u operador de la derecha (x -> f a b $\equiv$ x f a b $\equiv$ f x a b), salvo en map/filter/fold (donde entra como la colección sobre la que iterar) o si colocas un comodín explícito _ (payload -> write file ruta_salida _).
  • Envío directo a agentes: [5, 10, 15] -> sumar lista -> send to ag y [1, 2, 3] -> sumar lista -> ask to ag -> total.
  • Máscaras de secuencia en tránsito (-> (+ _ _ +) ->): parsear paquete "v0.9.9" -> (+ _ _ +) -> combinar codigo -> resumen.
  • Bloques secuenciales limpios sin _ = y descarte explícito -> (): las llamadas con efectos laterales (echo, write file, shell exec, log, etc.) se escriben directamente sin _ =. Si la última línea de una función => () llama a una función que devuelve un valor (como Int), basta con terminar esa línea con -> () para descartar el resultado explícitamente y devolver ().

7.2. La joya de la corona: Bloques || con -> como Grafos Paralelos (DAGs)

Aquí es donde Ridgun cambia tu forma de pensar.

En un lenguaje imperativo normal, cuando escribes tres líneas seguidas:

  1. Primero se ejecuta la línea 1.
  2. Luego la línea 2 (aunque no tenga nada que ver con la línea 1).
  3. Luego la línea 3.

Eso es obligar al ordenador a trabajar con orejeras. En Ridgun, cuando usas barras paralelas || con flechas -> (sin necesidad de escribir ninguna palabra clave como where), no estás dando órdenes secuenciales: estás dibujando un Grafo Acíclico Dirigido (DAG) que dice qué cálculo alimenta a qué variable:

fn :: fib Int => Int
      fib 0   =  0
      fib 1   =  1
      fib n   =  (fib (n - 1)) + (fib (n - 2))

fn :: fact Int => Int
      fact 0   =  1
      fact n   =  n * (fact (n - 1))

fn :: super calculo => Int
      super calculo =  rama_izquierda + rama_derecha
        || fib 10 -> rama_izquierda
        || fact 6 -> rama_derecha

Léelo en voz alta:

  • "Quiero calcular rama_izquierda + rama_derecha, mientras en paralelo (||) fib 10 va a rama_izquierda (->) y fact 6 va a rama_derecha (->)."

Lo que hace ridgunc entre bambalinas con tus líneas ||:

  1. Mira las flechas -> y arma el árbol de dependencias (DAG).
  2. Caza ciclos estáticamente: si por error escribes || a + 1 -> b y || b + 1 -> a, el compilador te dice en el acto "¡Quietos! Hay una dependencia circular" y no te deja compilar un cuelgue infinito.
  3. Ve que fib 10 y fact 6 no dependen el uno del otro, así que tienen luz verde para correr en paralelo antes de sumarse en rama_izquierda + rama_derecha.

Incluso puedes montar un pipeline de varias plantas dentro del mismo bloque || —ya sea al pie de la expresión o justo después del =— y el compilador deducirá solito qué plantas pueden ir a la vez y cuáles tienen que esperar:

fn :: balance trimestral Int  => Int
      balance trimestral base =
        || base * 100                  -> ventas_brutas
        || base * 40                   -> costes_fijos
        || ventas_brutas - costes_fijos -> beneficio_bruto ||.
        beneficio_bruto * 80 / 100

Fíjate en el detalle ||. al final de la última línea ||: igual que |. cierra un árbol de guardas anidado, ||. cierra el bloque paralelo || cuando debajo siguen sentencias secuenciales normales. Aquí ventas_brutas y costes_fijos arrancan en paralelo; cuando ambos están listos se calcula beneficio_bruto, y finalmente se devuelve beneficio_bruto * 80 / 100. Todo sin un solo async, Future, Promise ni Mutex.


7.3. El atajo || sin nombres: Tuberías paralelas que devuelven una tupla

¿Y si solo quieres lanzar dos o tres cálculos en paralelo y recogerlos juntos en una tupla sin siquiera inventar nombres de variables con ->?

Usas || directamente:

fn :: par paralelo Int => Int Int
      par paralelo n   =
        || fib n
        || fact n

Directo, visual y devuelve la tupla (fib n, fact n).


7.4. Cuando quieres el volante manual: el Modelo Nativo de Agentes (agent, send ... to, receive from) y Canales CSP

El paralelismo automático de || y ||map cubre el 90% de los casos. Pero cuando programas un servidor persistente, sesiones de usuario con estado aislado, colas de trabajo o un pipeline productor-consumidor, quieres agentes concurrentes con memoria aislada y paso de mensajes tipado.

En Ridgun no hace falta inventar una sintaxis extraña como actor ::: cualquier función normal (fn :: u op ::) se convierte en un agente concurrente en una línea con agent:

1. Agentes sin estado y con estado aislado (agent, send ... to, receive from, stop)

  • ag = agent (mi funcion): convierte una función M => R en un agente sin estado de tipo Agent M R.
  • ag = agent (mi funcion con estado) estado_inicial: convierte una función S M => S (o S M => S R) en un agente con estado aislado que recuerda su estado mensaje tras mensaje sin un solo Mutex.
  • send msg to ag (o con varios argumentos: send a b to ag): envía un mensaje tipado al buzón del agente.
  • res = receive from ag (o try receive from ag sin bloquear): recoge la siguiente respuesta del buzón de salida del agente.
  • stop ag: detiene el agente y libera su estado automáticamente mediante RMM1/RMM2.
fn :: sumar visita Int Int => Int
      sumar visita acum n  =  acum + n

fn :: procesar lote Int Int => Int
      procesar lote id  t   =  (id * 100) + t

fn :: main => ()
      main =
        contador = agent (sumar visita) 10
        send 5 to contador
        send 7 to contador
        v1 = receive from contador
        v2 = receive from contador
        stop contador

        trabajador = agent (procesar lote)
        send 3 25 to trabajador
        lote = receive from trabajador
        stop trabajador
        echo "Visitas:" v1 "->" v2 "| Lote:" lote

2. Elección inteligente en tiempo de compilación (Green Threads vs. Hilos Reales del Kernel)

Una pregunta habitual es: "¿Los bloques ||, ||map y los agentes agent crean siempre hilos del sistema operativo o corren como tareas ligeras?"

En Ridgun, el compilador toma esa decisión por ti analizando estáticamente cada función:

  • Funciones ligeras (TierTrivial / StratInlineSequential): Si la función que pasas a ||, ||map, ||filter o agent tiene poca profundidad (aritmética, formateo de cadenas, transición de estado rápida de una sesión HTTP), calentar hilos del sistema operativo costaría más que el propio cálculo. El compilador la ejecuta como un paso green-thread ultraligero sin sobrecarga de SYS_clone.
  • Cálculos pesados o recursivos profundos (TierDynamicRecursive / TierHeavyCPU): Si la función tiene recursión profunda (como fib o fact) o álgebra de tensores (einsum, hadamard, @), el compilador genera automáticamente el despacho multinúcleo sobre hilos reales del kernel (SYS_clone), procesando las ramas || o la cola de mensajes del agent en paralelo real sobre todos tus núcleos de CPU.

3. Pasar mensajes por canales acotados (new bounded channel)

Igual que en Go o Erlang, además de agentes también puedes crear tubos explícitos para comunicar etapas:

  • new bounded channel capacidad: crea un canal con espacio para capacidad mensajes.
  • send through valor canal (o chan send valor canal): mete un mensaje en el tubo.
  • receive from canal (o chan recv canal): saca el siguiente mensaje del tubo.
  • try receive from canal: mira si hay mensaje sin quedarse esperando; devuelve Some valor o None.
  • receive from timeout canal ticks: espera un límite de tiempo y devuelve Ok valor o Err Timeout.
fn :: main => ()
      main =
        tubo = new bounded channel 4
        send through "Tarea A completada" tubo
        send through "Tarea B completada" tubo
        primero = receive from tubo
        segundo = receive from tubo
        echo primero "|" segundo

4. Agentes sumidero (fire-and-forget agent sink), buzones globales (agent ::) y pruebas deterministas (with agent)

Cuando necesitas auditoría asíncrona, métricas, trazas o rate limiting transversal sin ensuciar todas las firmas con un parámetro extra ni acumular respuestas sin leer en una cola de salida, Ridgun ofrece agentes sumidero (agent sink) y buzones globales de nivel superior (agent ::):

  • rec = agent sink (paso) [estado_inicial], mailbox N drop|block, drain 2_s: procesa cada mensaje en orden FIFO (S M => S o M => ()), libera el estado anterior con RMM1/RMM2 y no acumula cola de salida (receive from, try receive from y ask sobre un SinkAgent son errores de compilación).
  • agent :: audit_log = sink (paso) [], mailbox 10_000 drop, drain 2_s: declara un agente sumidero global inicializado antes de main y vaciado ordenadamente al salir del programa.
  • agent dropped ag, agent flush ag, agent state ag y with agent audit_log = espia do ...: permiten observar mensajes descartados por contrapresión (drop), drenar el buzón, inspeccionar el estado y redirigir temporalmente un buzón global a un agente espía aislado en los tests.
  • Además, los descriptores de Channel, Agent y SinkAgent pueden compartirse libremente entre múltiples trabajadores (send ch 1 to w1, send ch 2 to w2), mientras que los datos enviados por el canal mantienen su propiedad afín (ErrUseAfterSend).

Y ahora, abróchate el cinturón para el Capítulo 08, el gran final: tensores con dimensiones verificadas en compilación, variables de ámbito (context/shared), regiones de memoria deterministas, acceso directo a hardware MMIO y programación de sistemas UNIX/POSIX.