BA Barış Akın~/blog

·22 min de lectura·61 lecturas

Canales y strands en Rill: un análisis a fondo de la concurrencia

Canales de rendezvous y con búfer, difusión con close, pipelines, una criba de primos concurrente, sondeos y plazos con select, apagado ordenado por señal, máquinas de estados al estilo SDL, un millón de strands y la comprobación de --parallel. Todos los ejemplos se ejecutaron.

Canales y strands en Rill: un análisis a fondo de la concurrencia

En el artículo anterior hicimos un recorrido general por Rill. Este se centra en un solo tema, que creo que es la faceta más divertida del lenguaje: los strands y los canales. Iremos desde lo que garantiza exactamente un canal de rendezvous hasta los canales con búfer, desde la difusión con close hasta los pipelines, desde una criba de primos concurrente hasta los timeouts con select, desde el apagado ordenado al recibir una señal hasta las máquinas de estados al estilo SDL y la comprobación de compartición en varios núcleos.

Compilé y ejecuté cada programa de este artículo antes de escribirlo; los bloques de salida son la salida real de esas ejecuciones. (Las cadenas dentro de los programas están en turco, porque son exactamente los programas que ejecuté.) Las mediciones se tomaron en un Apple M4 con un único worker, salvo que se indique lo contrario.

¿Qué es un strand?

Un strand es el hilo ligero de Rill. Es la misma idea que Go llama goroutine, Erlang proceso y Java virtual thread; el nombre viene de las hebras (strands) de una cuerda: un programa está hecho de muchas hebras trenzadas entre sí. (Una nota para los programadores de red en C++: strand en Boost.Asio es algo completamente distinto, un contexto de ejecución serializado. No tienen relación.)

Para lanzar un strand basta una palabra clave:

spawn worker(jobs, results)

spawn ejecuta la expresión entera en el nuevo strand. Ese pequeño detalle explica una trampa clásica al escribir servidores. Si se escribe spawn handle(accept(server)), el accept también se ejecuta en el nuevo strand. El bucle principal nunca espera; en cada vuelta lanza un nuevo strand aparcado en accept y sigue girando. Lo correcto es enlazar primero y después entregar:

fn serve(server) =
  conn = tcp_accept(server)
  spawn handle(conn)
  serve(server)

Aquí es el propio bucle el que espera, y entrega la conexión al strand. Como serve se llama a sí misma en posición de cola, la pila tampoco crece.

Los strands son baratos porque Rill usa un modelo de pila copy-on-block. Un strand en ejecución corre sobre la pila de su worker; cuando se aparca, la parte de la pila que usaba se copia y se guarda. En las mediciones del repositorio de Rill, un strand aparcado ocupa unos 300 bytes.

El canal de rendezvous: un apretón de manos

channel() crea un canal sin búfer. En un canal así, el emisor espera hasta que llega un receptor, y el receptor espera hasta que llega un emisor. El valor solo cambia de manos cuando ambos se encuentran. Para verlo, enviemos tres mensajes entre dos strands e imprimamos cada paso:

fn ping(ch, n) =
  if n == 0 then ()
  else
    println("ping: " + int_to_str(n) + " gönderiliyor")
    send(ch, n)
    println("ping: " + int_to_str(n) + " teslim edildi")
    ping(ch, n - 1)

fn pong(ch, k) =
  if k == 0 then ()
  else
    v = recv(ch)
    println("pong: " + int_to_str(v) + " alındı")
    pong(ch, k - 1)

fn main() =
  ch = channel()
  spawn ping(ch, 3)
  pong(ch, 3)
ping: 3 gönderiliyor
ping: 3 teslim edildi
ping: 2 gönderiliyor
pong: 3 alındı
pong: 2 alındı
ping: 2 teslim edildi
ping: 1 gönderiliyor
ping: 1 teslim edildi
pong: 1 alındı

El orden de las líneas puede parecer desordenado al principio, pero refleja exactamente la regla. Ninguna línea "teslim edildi" (entregado) puede ocurrir antes de que ese valor se haya recibido en el otro lado. Como la impresión continúa de forma independiente en ambos strands tras el apretón de manos, cuál llega antes a la pantalla depende del planificador. Lo que está garantizado no es el orden sino la sincronización: que un send retorne es la prueba de que un recv se ha completado.

Un canal de rendezvous y un canal con búfer

Canales con búfer y buzones

channel(n) guarda hasta n valores en un búfer. El emisor solo espera cuando el búfer está lleno, y el receptor solo cuando está vacío. Para ver al productor adelantarse, durmamos al consumidor durante 50 ms:

fn producer(ch, i, n) =
  if i > n then ()
  else
    send(ch, i)
    println("üretici: " + int_to_str(i) + " kuyruğa kondu")
    producer(ch, i + 1, n)

fn main() =
  ch = channel(3)
  spawn producer(ch, 1, 5)
  sleep_ms(50)
  println("tüketici uyandı")
  for _ in 0..5
    println("tüketici: " + int_to_str(recv(ch)))
üretici: 1 kuyruğa kondu
üretici: 2 kuyruğa kondu
üretici: 3 kuyruğa kondu
tüketici uyandı
tüketici: 1
üretici: 4 kuyruğa kondu
tüketici: 2
tüketici: 3
tüketici: 4
tüketici: 5

El productor encoló los tres primeros valores sin esperar en absoluto al consumidor, y se aparcó en el cuarto porque el búfer estaba lleno. Despertó en cuanto el consumidor tomó un valor. El búfer absorbe la diferencia de velocidad entre productor y consumidor y, al mismo tiempo, proporciona una contrapresión (backpressure) natural: el productor no puede adelantarse arbitrariamente al consumidor.

Hay una tercera opción: mailbox(). Un buzón crece sin límite, así que el emisor nunca espera. Es ideal para dos partes que se envían mensajes mutuamente en el mismo instante, porque dos canales con búfer pueden quedarse cada uno esperando a que el otro se vacíe y producir un deadlock. Su coste también está claro: nada frena al emisor. En un momento veremos por qué los buzones son la opción por defecto para las máquinas de estados.

Canal¿Cuándo espera el emisor?Cuándo usarlo
channel()siempre, hasta que llega un receptorsincronización, apretones de manos, una señal de "terminado"
channel(n)mientras el búfer está llenoproductor/consumidor, colas de trabajos, contrapresión
mailbox()nuncamáquinas de estados, mensajería bidireccional

Terminar: close y recv_opt

Un strand aparcado en recv se queda ahí hasta que llega un valor. Eliminar lo que fuera a enviárselo deja al strand donde está, reteniendo su pila. En un servidor que abre un strand por conexión, eso es una fuga tan grande como el número de conexiones que haya tenido nunca. close existe exactamente para esto:

fn produce(ch, n) =
  if n <= 0 then close(ch)
  else
    send(ch, n)
    produce(ch, n - 1)

fn drain(ch, acc) = match recv_opt(ch)
  None -> acc
  Some(v) -> drain(ch, acc + v)

fn main() =
  ch = channel(4)
  spawn produce(ch, 100)
  println(drain(ch, 0))
  println(closed(ch))
5050
true

En este ejemplo se ven varias decisiones de diseño importantes:

  • close es una difusión. Enviar un valor llega a un receptor; cerrar llega a la vez a todos los que están aparcados en el canal. Un programa que se apaga no necesita saber cuántos strands están esperando.
  • Los valores del búfer no se pierden. El productor puede cerrar en cuanto no tiene nada más que decir, y el consumidor sigue viendo todo lo que estaba en tránsito. Que el total salga exactamente 5050 es la prueba.
  • Cerrar dos veces no es un error. Un apagado que se ejecuta desde dos direcciones no necesita organizar quién llega primero.
  • recv_opt es para los canales que terminan. Devuelve Some(v) mientras hay un valor, y None una vez que el canal está cerrado y vacío.

Entonces, ¿por qué no devuelve cada recv un Option? Porque la mayoría de los canales nunca terminan. Convertir todos en Option pondría un match delante de cada recepción del lenguaje para pagar por los pocos que sí terminan. En su lugar, un recv simple sobre un canal cerrado detiene el programa y dice qué hacer:

fn main() =
  ch = channel(1)
  close(ch)
  println(recv(ch))
rill: recv on a closed channel - use recv_opt

Es la misma respuesta que recibe un índice fuera de un búfer, y por la misma razón: no hay ningún valor que devolver que no sea una mentira. Enviar a un canal cerrado también detiene el programa. La regla que lo evita es la misma a la que llegó Go: quien envía, cierra.

Pipelines

Si se juntan los canales y close, construir un pipeline de etapas conectadas resulta muy natural. Cada etapa cierra su propia salida cuando se cierra su entrada, así que la información de "terminado" fluye sola por la cadena. Generemos los números del 1 al 1.000.000, quedémonos con los múltiplos de 3, elevémoslos al cuadrado y sumémoslos:

fn numbers(out, i, n) =
  if i > n then close(out)
  else
    send(out, i)
    numbers(out, i + 1, n)

fn keep(inp, out, p) = match recv_opt(inp)
  None -> close(out)
  Some(v) ->
    if p(v) then send(out, v) else ()
    keep(inp, out, p)

fn square(inp, out) = match recv_opt(inp)
  None -> close(out)
  Some(v) ->
    send(out, v * v)
    square(inp, out)

fn total(inp, acc) = match recv_opt(inp)
  None -> acc
  Some(v) -> total(inp, acc + v)

fn main() =
  a = channel(64)
  b = channel(64)
  c = channel(64)
  spawn numbers(a, 1, 1000000)
  spawn keep(a, b, \x -> x % 3 == 0)
  spawn square(b, c)
  println(total(c, 0))
111111277777611111

Comprobé el resultado de forma independiente: la suma de los cuadrados de los múltiplos de 3 es exactamente este número según la fórmula 9 · n(n+1)(2n+1)/6 con n = 333.333. También importa que la etapa keep reciba una lambda: las etapas son funciones normales que pueden reutilizarse y combinarse como se quiera. Los búferes de 64 entre etapas evitan que los strands se esperen unos a otros por cada valor.

Una criba de primos concurrente

Hay un ejemplo precioso con el que todo el que aprende Go se topa en algún momento: la criba de Eratóstenes concurrente. Un strand genera números empezando por 2. El flujo principal toma el primer número al final de la cadena como primo y añade a la cadena un nuevo strand que dice "filtra los múltiplos de este primo". En Rill queda así:

fn generate(ch, i) =
  send(ch, i)
  generate(ch, i + 1)

fn filter_out(inp, out, p) =
  v = recv(inp)
  if v % p != 0 then send(out, v) else ()
  filter_out(inp, out, p)

fn sieve(ch, k, acc) =
  if k == 0 then reverse(acc)
  else
    p = recv(ch)
    next = channel()
    spawn filter_out(ch, next, p)
    sieve(next, k - 1, Cons(p, acc))

fn main() =
  ch = channel()
  spawn generate(ch, 2)
  println(sieve(ch, 20, Nil))
Cons(2, Cons(3, Cons(5, Cons(7, Cons(11, Cons(13, Cons(17, Cons(19, Cons(23, Cons(29, Cons(31, Cons(37, Cons(41, Cons(43, Cons(47, Cons(53, Cons(59, Cons(61, Cons(67, Cons(71, Nil))))))))))))))))))))

La criba de primos concurrente

Para los 20 primeros primos hay 21 strands y 21 canales de rendezvous funcionando. No hay memoria compartida ni bloqueos en ninguna parte; cada strand solo conoce su propio canal de entrada y de salida. generate y filter_out son bucles infinitos, pero ambos son llamadas de cola, así que la pila no crece. Cuando main retorna, el programa termina y los strands que quedan se abandonan, igual que en Go.

select: esperar en varios canales a la vez

Los programas reales rara vez esperan en un solo canal. select espera en varios canales a la vez y ejecuta la rama del que esté listo primero. Las ramas se prueban en orden, así que en caso de empate gana la rama anterior. Una rama send está lista cuando el envío puede realizarse. Puede haber como máximo 64 ramas.

Sondear sin bloquear: default

Una rama default convierte select en un sondeo no bloqueante: si ningún canal está listo, esa rama se ejecuta inmediatamente.

fn poll(ch) =
  v = select
    x = recv(ch) -> "hazır: " + int_to_str(x)
    default -> "henüz bir şey yok"
  v

fn main() =
  ch = channel(1)
  println(poll(ch))
  send(ch, 42)
  println(poll(ch))
henüz bir şey yok
hazır: 42

Vigilar un reloj y un trabajo a la vez

Rill no tiene un tipo Ticker, porque un ticker son tres líneas y un canal. Escribamos un vigilante que imprima un mensaje de progreso a un ritmo constante hasta que termine un trabajo largo, vigilando un ticker y el trabajo en el mismo select:

fn ticker(ch, ms) =
  sleep_ms(ms)
  send(ch, 1)
  ticker(ch, ms)

fn job(done) =
  sleep_ms(230)
  send(done, "iş bitti")

fn watch(tick, done, n) =
  r = select
    msg = recv(done) -> msg + " (" + int_to_str(n) + " tik)"
    t = recv(tick) ->
      println("  ... hâlâ çalışıyor, tik " + int_to_str(n + t))
      watch(tick, done, n + t)
  r

fn main() =
  tick = channel(1)
  done = channel()
  spawn ticker(tick, 50)
  spawn job(done)
  println(watch(tick, done, 0))
  ... hâlâ çalışıyor, tik 1
  ... hâlâ çalışıyor, tik 2
  ... hâlâ çalışıyor, tik 3
  ... hâlâ çalışıyor, tik 4
iş bitti (4 tik)

Darle al ticker un channel(1) es una decisión de diseño: un tic puede esperar mientras el vigilante está ocupado, pero los tics no se acumulan. Un strand dormido no usa nada de procesador; mil strands pueden estar dormidos a la vez y el programa sigue inactivo. Si se escribe un bucle de ritmo fijo, hay que dormir la distancia hasta el siguiente plazo y no una constante; de lo contrario, la holgura de los temporizadores del sistema operativo se va acumulando.

Plazos: timeout(ms)

Una rama timeout(ms) se pone lista cuando nada más ha estado listo durante ese tiempo. Ya la vimos en el artículo anterior; el detalle de ingeniería importante aquí es que esta rama no gasta ningún strand ni ningún canal. Se aparca dentro del poller junto a los canales. Un servidor que pone un plazo a cada petición en curso no paga un strand por petición para ello. Algunas reglas:

  • Gana el plazo más corto, así que una misma espera puede llevar a la vez un aviso y un límite.
  • Un plazo que ya está en el pasado está listo de inmediato. Así que timeout(0) es un sondeo, otra forma de escribir default.
  • default y timeout no pueden escribirse en el mismo select, porque default significa "no esperar" y el plazo nunca podría alcanzarse.

Apagado ordenado: señales y close

Ya tenemos las dos piezas que un servidor necesita para apagarse limpiamente con Ctrl-C: wait_signal() se aparca hasta que el programa recibe una señal y dice cuál, y close se lo difunde a todos. Un strand espera y avisa a los demás; el que espera no necesita saber cuántos otros hay.

fn worker(id, stop, n) =
  r = select
    recv(stop) -> "işçi " + int_to_str(id) + " durdu, " + int_to_str(n) + " iş yapmıştı"
    timeout(20) -> worker(id, stop, n + 1)
  r

fn run(id, stop, report) = send(report, worker(id, stop, 0))

fn main() =
  stop = channel()
  report = channel(4)
  for id in 0..3
    spawn run(id, stop, report)
  println("hazır, Ctrl-C bekleniyor")
  s = wait_signal()
  println("sinyal: " + int_to_str(s))
  close(stop)
  for _ in 0..3
    println(recv(report))

Arranqué el programa y envié kill -INT aproximadamente medio segundo después:

hazır, Ctrl-C bekleniyor
sinyal: 2
işçi 0 durdu, 25 iş yapmıştı
işçi 1 durdu, 25 iş yapmıştı
işçi 2 durdu, 25 iş yapmıştı

El punto sutil es que la rama recv(stop) -> no enlaza nada. Un canal cerrado pone lista una rama de recepción, pero no tiene ningún valor que producir. Una rama escrita como x = recv(stop) -> tendría que producir un valor, y el programa se detendría avisándolo. Así, close(stop) llega a todos los workers, haga lo que haga la otra rama.

En el lado de las señales también se han pensado algunos detalles. Una señal puede llegar en cualquier hilo que elija el sistema operativo, entre dos instrucciones cualesquiera. Por eso ahí no se ejecuta nada que haya escrito el programa: el manejador escribe un solo byte en un pipe y todo lo demás ocurre en el strand. SIGUSR1 y SIGUSR2 faltan deliberadamente, porque sus números son 30/31 en macOS y 10/12 en Linux. Un número que significa una cosa donde se escribió y otra donde se ejecuta es peor que no tenerlo. Para releer la configuración está el tradicional SIGHUP. Un programa que solo está esperando una señal no se considera en deadlock, porque está esperando algo que responde.

Un deadlock no es silencioso

Un programa en el que todos esperan a todos lo dice en lugar de quedarse colgado. Si dos strands esperan cada uno recibir un valor antes de enviar:

fn main() =
  a = channel()
  b = channel()
  spawn send(a, recv(b))
  send(b, recv(a))
rill: deadlock - all strands are blocked

El runtime no cuenta como bloqueados los strands dormidos ni los que esperan en un socket o una señal, porque están esperando algo que responde. Solo informa de la situación y detiene el programa cuando todos se esperan mutuamente en canales.

Máquinas de estados: machine

Un strand que espera una señal, hace lo que la señal pide según el estado en que se encuentra y vuelve a esperar: eso es lo que las centrales telefónicas llamaban un proceso y escribían en SDL. En Rill es una función recursiva de cola sobre un canal y un estado. machine es la forma de escribir esa función como la máquina que es. Escribamos un torniquete que se abre al pagar y se vuelve a bloquear cuando alguien pasa, o cuando nadie pasa en 3 segundos:

type Signal
  Coin(kurus: Int)
  Push
  Report
  Status(text: Str)

type Gate
  Locked(credit: Int)
  Open

machine gate(me: Chan(Mail(Signal)), log: Chan(Str))
  state Locked(credit)
    Coin(k) if credit + k >= 500 ->
      send(log, "jeton tamam, kapı açıldı")
      Open
    Coin(k) ->
      send(log, "bakiye: " + int_to_str(credit + k))
      Locked(credit + k)
    Push ->
      send(log, "kilitli, önce ödeme")
      stay
  state Open
    Push ->
      send(log, "geçildi, kilitlendi")
      Locked(0)
    Coin(_) ->
      send(log, "zaten açık, para iade")
      stay
    after 3000 ->
      send(log, "kimse geçmedi, yeniden kilitlendi")
      Locked(0)
  state *
    Report ->
      reply(Status("rapor istendi"))
      stay

fn show_log(log, n) =
  if n == 0 then ()
  else
    println(recv(log))
    show_log(log, n - 1)

fn main() =
  env = mailbox()
  g = mailbox()
  log = channel(16)
  spawn gate(g, log, Locked(0))
  post(g, env, Push)
  post(g, env, Coin(250))
  post(g, env, Coin(250))
  post(g, env, Coin(100))
  post(g, env, Push)
  post(g, env, Coin(500))
  show_log(log, 6)
  println(recv(log))
  post(g, env, Report)
  println(recv(env).signal)
kilitli, önce ödeme
bakiye: 250
jeton tamam, kapı açıldı
zaten açık, para iade
geçildi, kilitlendi
jeton tamam, kapı açıldı
kimse geçmedi, yeniden kilitlendi
Status(rapor istendi)

La máquina se lee casi como una especificación:

  • Los estados son los constructores de un tipo normal (Gate). Los tipos de sus campos se escriben una sola vez.
  • Bajo cada state, las señales a las que responde se colocan como las ramas de un match. El valor de una rama es el siguiente estado. stay significa permanecer en el estado actual, y stop significa terminar el strand.
  • after 3000 -> es la rama que se toma cuando no llega ninguna señal durante ese tiempo.
  • state * contiene las ramas a las que responden todos los estados; aquí cualquier estado puede informar.
  • Todo lo que llega es un Mail: la señal y el buzón que la envió. reply(sig) responde al emisor; la señal no necesita llevar un canal de respuesta. Cuando main habla desde fuera, post(to, from, sig) indica el buzón al que vuelve la respuesta (env).
  • Una señal que ninguna rama nombra se consume y se mantiene el estado; eso es lo que hace SDL con una señal inesperada. Todos los estados del tipo tienen que aparecer en la máquina, o el compilador nombra el que falta.

El compilador convierte esta máquina exactamente en la función que se escribiría a mano: un match sobre el estado, un recv en cada estado (o un select con un timeout donde hay un after) y llamadas de cola que se llaman a sí mismas con el siguiente estado. No cuesta nada más allá de la espera.

Mejor aún, la máquina es un diagrama antes que código. rill diagram turnstile.rill --svg dibuja el diagrama de proceso SDL de abajo directamente a partir de la declaración. El diagrama se lee del código y no se mantiene al lado, así que nunca puede quedarse desfasado:

Salida de rill diagram: la máquina del torniquete

Tiempo virtual: probar el modelo

La última línea del torniquete realmente espera 3 segundos por el after de 3 segundos. Con RILL_VIRTUAL_TIME=1 el programa funciona con un reloj propio: el tiempo no pasa mientras algo se ejecuta, y cuando todos los strands están esperando y lo más próximo que se espera es un temporizador, el reloj salta directamente a él.

$ time ./turnstile
real 3.18
$ RILL_VIRTUAL_TIME=1 time ./turnstile
real 0.00

La salida es idéntica, y el tiempo baja de 3,18 segundos a cero. Además, con tiempo virtual la traza de una ejecución es la misma cada vez, lo que hace que el comportamiento de un modelo se pueda probar. RILL_TRACE=1 escribe cada evento de la máquina como una línea, y rill msc convierte esas líneas en el diagrama de secuencia de mensajes que dibujaban las herramientas de SDL.

Las máquinas pueden hacer mucho más: temporizadores que envían una señal a la máquina más tarde (set(Retry, 200)), guardar una señal para un estado posterior (save), prioridad (priority), estados anidados y diagramas de bloques system cuyas rutas entre máquinas se comprueban en tiempo de compilación. Eso será tema de otro artículo.

Escala: un millón de strands

Quería medir yo mismo lo baratos que son los strands. El programa de abajo lanza N strands, cada uno aparcado en un canal de "parada". Después, un solo close los despierta a todos, y cada uno informa a un buzón:

fn parked(stop, done) =
  r = select
    recv(stop) -> 1
  send(done, r)

fn count(done, k, acc) = if k == 0 then acc else count(done, k - 1, acc + recv(done))

fn main() =
  n = str_to_int(unwrap_or(nth(args(), 1), "1000"))
  stop = channel()
  done = mailbox()
  t0 = now_nanos()
  for _ in 0..n
    spawn parked(stop, done)
  yield()
  sleep_ms(1)
  t1 = now_nanos()
  close(stop)
  woke = count(done, n, 0)
  t2 = now_nanos()
  println(int_to_str(n) + " strand: başlat+park " + int_to_str((t1 - t0) / 1000000) + " ms, close ile uyandır " + int_to_str((t2 - t1) / 1000000) + " ms, uyanan " + int_to_str(woke))
1000 strand: başlat+park 1 ms, close ile uyandır 0 ms, uyanan 1000
10000 strand: başlat+park 5 ms, close ile uyandır 1 ms, uyanan 10000
100000 strand: başlat+park 22 ms, close ile uyandır 11 ms, uyanan 100000
1000000 strand: başlat+park 162 ms, close ile uyandır 124 ms, uyanan 1000000

Escalado de N strands

Un millón de strands arrancan y se aparcan en 162 ms, y una sola difusión con close los despierta a todos en 124 ms. El tiempo y la memoria escalan linealmente. Siendo honesto con la memoria: en este programa sale a unos 1,5 KB por strand. Es más que el "strand aparcado ≈ 300 bytes" del repositorio, porque aquí cada strand espera dentro de un select y, al despertar, escribe en un buzón sin límite; ese buzón de un millón de mensajes también forma parte de la memoria. Son mediciones de una sola ejecución; la idea es mostrar el orden de magnitud más que un número exacto.

Más de un núcleo

Por defecto, todos los strands se ejecutan en un único hilo del sistema operativo. Eso es lo que permite que el conteo de referencias use operaciones no atómicas y, por tanto, rápidas. El paralelismo real se pide explícitamente, dos veces: al compilar y al ejecutar.

rill build main.rill -o main --parallel   # reference counts become safe across threads
RILL_THREADS=8 ./main                     # run on eight workers

Los workers se roban entre sí los strands que todavía no han empezado. Un strand que ya ha empezado se queda en su worker, porque sus marcos de pila viven en las direcciones de ese worker. El paralelismo ayuda cuando los strands calculan de forma independiente. Las cadenas de rendezvous en las que un strand pasa un valor al siguiente (como la criba o el anillo de arriba) no tienen nada que solapar; se ejecutan más rápido en un único worker.

La compartición se comprueba en tiempo de compilación

El único tipo de valor sobre el que dos strands pueden discrepar es el almacenamiento: un Buf, un Map, un StrBuf o cualquier cosa que contenga uno. Que uno escriba mientras el otro lee o escribe es una condición de carrera. Una compilación con --parallel comprueba lo que comparten los strands antes de que el programa se ejecute:

fn fill(b: Buf(Int), v) = for i in 0..#b
  b[i] = v

fn main() =
  b = buf_int(8)
  spawn fill(b, 1)
  spawn fill(b, 2)
  println(int_to_str(b[0]))
$ rill check share.rill --parallel
share.rill: type error at line 6: `b` is written by this strand and still used here after
the `spawn`: two strands writing one piece of storage is a race. Hand it over (do not use
it after the spawn), or send each strand its own, or, where the writes are kept apart on
purpose, say so: `apart(b)`

Las reglas son sencillas:

  1. Un valor entregado a un strand (capturado por spawn o enviado por un canal) que el lado que lo entrega no usa después pasa a pertenecer a ese strand. Todo está permitido.
  2. Un valor que se usa después está compartido. Compartir para leer está bien. Compartir y escribir se rechaza, y el error nombra el valor y la línea.
  3. Un valor función es opaco: lo que contiene y lo que escribe no está en su tipo. Compartirlo entre strands se rechaza, a menos que el programa diga lo que sabe.

Lo que el programa sabe se dice con apart(x): "este valor se comparte a propósito y las escrituras se mantienen separadas". Cada strand podría estar escribiendo en su propio rango de un búfer. El compilador no puede verificar la afirmación, pero se asegura de que haya una afirmación que encontrar. En una compilación de un solo worker, los strands solo se intercalan donde esperan, así que allí el mismo programa no se comprueba.

Un ejemplo real: el servidor de este blog

Esta página se sirve exactamente con estas herramientas. El servidor HTTP de Rill abre un strand por cada conexión; el strand de una conexión inactiva está aparcado y no retiene nada. Las conexiones a la base de datos son un pool que vive dentro de un canal con búfer: una petición toma una conexión, la usa y la devuelve. Una petición que no encuentra conexión espera, lo que le da al servidor contrapresión gratis:

fn with_conn(pool, ask) =
  fd = recv(pool)
  res = ask(fd)
  send(pool, fd)
  res

Cuando se lee un artículo, el registro de la visita se escribe en la base de datos en un strand aparte; la página no espera a que la base de datos escriba en disco:

fn spawn_view(app: App, slug, referrer) =
  spawn with_conn(app.db, \fd -> record_view(fd, slug, referrer))
  ()

Aquí, que spawn ejecute la expresión entera en el nuevo strand es exactamente lo que queremos: tomar una conexión del pool, escribir y devolver la conexión ocurren todos fuera del strand que responde a la petición.

Resumen

  • channel() es un apretón de manos: que un send retorne es la prueba de que un recv se completó. channel(n) absorbe diferencias de velocidad y da contrapresión. mailbox() nunca espera.
  • close es una difusión, no pierde nada de lo que hay en el búfer y puede hacerse dos veces. recv_opt es para los canales que terminan. Hay que recordar la regla: quien envía, cierra.
  • select espera en varios canales. default es un sondeo no bloqueante, timeout(ms) un plazo que no gasta ningún strand.
  • wait_signal + close son las dos mitades de un apagado ordenado. Un deadlock real no es silencioso.
  • machine escribe un strand como una máquina de estados al estilo SDL; rill diagram la dibuja y el tiempo virtual la hace comprobable.
  • --parallel da paralelismo real y rechaza en tiempo de compilación las escrituras en almacenamiento compartido; las excepciones deliberadas se declaran con apart.

Los strands son tan baratos que la pregunta de diseño deja de ser "¿cuántos hilos puedo abrir?" y pasa a ser "¿cuál es la forma natural de este trabajo?". Una conexión es un strand, una etapa es un strand, un primo es un strand.

En el próximo artículo escribiré sobre OxiDB, la base de datos que hay detrás de este blog. Me alegrará recibir preguntas y conocer experimentos en X o LinkedIn.

BA
Barış Akın

Arquitecto de soluciones de software con más de 25 años de experiencia. FinTech y sistemas distribuidos; creador de OxiDB, Rill y Cobra.