Translate

Mostrando las entradas con la etiqueta Erlang. Mostrar todas las entradas
Mostrando las entradas con la etiqueta Erlang. Mostrar todas las entradas

lunes, 17 de febrero de 2025

Concurrencia en Erlang parte 13


Soy una persona un poco desorganizada. Con suerte, todavía necesitas recordatorios de lo que tienes que hacer, porque vamos a escribir una de estas aplicaciones de recordatorio de eventos que te incitan a hacer cosas y te recuerdan las citas.

El primer paso es saber qué diablos estamos haciendo. "Una aplicación de recordatorios", dices. "Por supuesto", digo yo. Pero hay más. ¿Cómo planeamos interactuar con el software? ¿Qué queremos que haga por nosotros? ¿Cómo representamos el programa con procesos? ¿Cómo sabemos qué mensajes enviar?

Como dice la cita, "Caminar sobre el agua y desarrollar software a partir de una especificación son fáciles si ambos están congelados". Así que obtengamos una especificación y apeguémonos a ella. Nuestro pequeño software nos permitirá hacer lo siguiente:

  • Agregar un evento. Los eventos contienen una fecha límite (el momento en el que se debe advertir), un nombre de evento y una descripción.
  • Mostrar una advertencia cuando haya llegado el momento.
  • Cancelar un evento por nombre.

Sin almacenamiento en disco persistente. No es necesario para mostrar los conceptos arquitectónicos que veremos. Sería un fastidio para una aplicación real, pero en su lugar solo mostraré dónde se podría insertar si quisiera hacerlo y también señalaré algunas funciones útiles. Dado que no tenemos almacenamiento persistente, tenemos que poder actualizar el código mientras se está ejecutando.

La interacción con el software se realizará a través de la línea de comandos, pero debería ser posible ampliarla más adelante para que se puedan utilizar otros medios (por ejemplo, una GUI, acceso a una página web, software de mensajería instantánea, correo electrónico, etc.)

Esta es la estructura del programa que elegí para hacerlo:

Hay 5 componentes: Un cliente (1) que puede comunicarse con un servidor de eventos (2) y 3 pequeños círculos etiquetados como 'x', 'y' y 'z'. Los tres están vinculados al servidor de eventos.

Donde el cliente, el servidor de eventos y x, y y z son todos procesos. Esto es lo que cada uno de ellos puede hacer:

Servidor de eventos

  • Acepta suscripciones de clientes
  • Reenvía notificaciones de procesos de eventos a cada uno de los suscriptores
  • Acepta mensajes para agregar eventos (e iniciar los procesos x, y, z necesarios)
  • Puede aceptar mensajes para cancelar un evento y, posteriormente, matar los procesos de eventos
  • Puede ser finalizado por un cliente
  • Puede hacer que su código se vuelva a cargar a través del shell.

Cliente

Se suscribe al servidor de eventos y recibe notificaciones como mensajes. Por lo tanto, debería ser fácil diseñar un grupo de clientes que se suscriban al servidor de eventos. Cada uno de ellos podría ser potencialmente una puerta de entrada a los diferentes puntos de interacción mencionados anteriormente (GUI, página web, software de mensajería instantánea, correo electrónico, etc.)

  • Pide al servidor que agregue un evento con todos sus detalles
  • Pide al servidor que cancele un evento
  • Monitorea el servidor (para saber si se cae)
  • Apaga el servidor de eventos si es necesario

x, y y z:

  • Representan una notificación que espera ser activada (básicamente son solo temporizadores vinculados al servidor de eventos)
  • Envían un mensaje al servidor de eventos cuando se acaba el tiempo
  • Reciben un mensaje de cancelación y mueren

Tenga en cuenta que todos los clientes (mensajería instantánea, correo, etc. que no están implementados en este libro) reciben notificaciones sobre todos los eventos, y una cancelación no es algo sobre lo que advertir a los clientes. Aquí el software está escrito para usted y para mí, y se supone que solo un usuario lo ejecutará.

Esto representa cada proceso que tendremos. Al dibujar todas las flechas allí y decir que son mensajes, hemos escrito un protocolo de alto nivel, o al menos su esqueleto.

Se debe tener en cuenta que usar un proceso por evento para recordar probablemente sea excesivo y difícil de escalar en una aplicación del mundo real. Sin embargo, para una aplicación de la que será el único usuario, esto es suficiente. Un enfoque diferente podría ser usar funciones como timer:send_after/2-3 para evitar generar demasiados procesos.

martes, 11 de febrero de 2025

Concurrencia en Erlang parte 12


Una vez entendidos los enlaces y los monitores, queda otro problema por resolver. Utilicemos las siguientes funciones del módulo linkmon.erl:


start_critic() ->

    spawn(?MODULE, critic, []).


judge(Pid, Band, Album) ->

    Pid ! {self(), {Band, Album}},

    receive

        {Pid, Criticism} -> Criticism

    after 2000 ->

        timeout

    end.


critic() ->

    receive

        {From, {"Rage Against the Turing Machine", "Unit Testify"}} ->

            From ! {self(), "They are great!"};

        {From, {"System of a Downtime", "Memoize"}} ->

            From ! {self(), "They're not Johnny Crash but they're good."};

        {From, {"Johnny Crash", "The Token Ring of Fire"}} ->

            From ! {self(), "Simply incredible."};

        {From, {_Band, _Album}} ->

            From ! {self(), "They are terrible!"}

    end,

    critic().


Ahora vamos a fingir que vamos a las tiendas a comprar música. Hay algunos álbumes que suenan interesantes, pero nunca estamos del todo seguros. Decides llamar a tu amigo, el crítico.


1> c(linkmon).                         

{ok,linkmon}

2> Critic = linkmon:start_critic().

<0.47.0>

3> linkmon:judge(Critic, "Genesis", "The Lambda Lies Down on Broadway").

"They are terrible!"


Debido a una tormenta solar (estoy tratando de encontrar algo realista aquí), la conexión se interrumpe:


4> exit(Critic, solar_storm).

true

5> linkmon:judge(Critic, "Genesis", "A trick of the Tail Recursion").

timeout


Es molesto. Ya no podemos recibir críticas por los álbumes. Para mantener viva la crítica, escribiremos un proceso básico de "supervisión" cuya única función es reiniciarlo cuando deje de funcionar:


start_critic2() ->

    spawn(?MODULE, restarter, []).


restarter() ->

    process_flag(trap_exit, true),

    Pid = spawn_link(?MODULE, critic, []),

    receive

        {'EXIT', Pid, normal} -> % not a crash

            ok;

        {'EXIT', Pid, shutdown} -> % manual termination, not a crash

            ok;

        {'EXIT', Pid, _} ->

            restarter()

    end.


Aquí, el reiniciador será su propio proceso. A su vez, iniciará el proceso del crítico y, si alguna vez muere por una causa anormal, restarter/0 se repetirá y creará un nuevo crítico. Tenga en cuenta que agregué una cláusula para {'EXIT', Pid, ​​shutoff} como una forma de matar manualmente al crítico si alguna vez lo necesitamos.

El problema con nuestro enfoque es que no hay forma de encontrar el Pid del crítico y, por lo tanto, no podemos llamarlo para conocer su opinión. Una de las soluciones que Erlang tiene para resolver esto es dar nombres a los procesos.

El acto de dar un nombre a un proceso le permite reemplazar el pid impredecible por un átomo. Este átomo puede usarse exactamente como un Pid al enviar mensajes. Para darle un nombre a un proceso, se usa la función erlang:register/2. Si el proceso muere, perderá automáticamente su nombre o también puede usar unregister/1 para hacerlo manualmente. Puede obtener una lista de todos los procesos registrados con register/0 o una lista más detallada con el comando de shell regs(). Aquí podemos reescribir la función restarter/0 de la siguiente manera:

restarter() ->

    process_flag(trap_exit, true),

    Pid = spawn_link(?MODULE, critic, []),

    register(critic, Pid),

    receive

        {'EXIT', Pid, normal} -> % not a crash

            ok;

        {'EXIT', Pid, shutdown} -> % manual termination, not a crash

            ok;

        {'EXIT', Pid, _} ->

            restarter()

    end. 

Como puede ver, register/2 siempre le dará a nuestro crítico el nombre 'critic', sin importar cuál sea el Pid. Lo que debemos hacer es eliminar la necesidad de pasar un Pid desde las funciones de abstracción. Probemos esto:


judge2(Band, Album) ->

    critic ! {self(), {Band, Album}},

    Pid = whereis(critic),

    receive

        {Pid, Criticism} -> Criticism

    after 2000 ->

        timeout

    end.


Aquí, la línea Pid = whereis(critic) se utiliza para encontrar el identificador de proceso del crítico con el fin de realizar una comparación de patrones con él en la expresión de recepción. Queremos hacer una comparación con este pid, porque nos asegura que encontraremos el mensaje correcto (¡podría haber 500 de ellos en el buzón mientras hablamos!). Sin embargo, esto puede ser la fuente de un problema. El código anterior supone que el pid del crítico seguirá siendo el mismo entre las dos primeras líneas de la función. Sin embargo, es completamente plausible que suceda lo siguiente:


  1. critic ! Message

                        2. critic receives

                        3. critic replies

                        4. critic dies

  5. whereis fails

                        6. critic is restarted

  7. code crashes


O bien, también es una posibilidad:


  1. critic ! Message

                           2. critic receives

                           3. critic replies

                           4. critic dies

                           5. critic is restarted

  6. whereis picks up

     wrong pid

  7. message never matches


La posibilidad de que las cosas salgan mal en un proceso diferente puede hacer que salga mal otro si no hacemos las cosas bien. En este caso, el valor del átomo crítico se puede ver desde varios procesos. Esto se conoce como estado compartido. El problema aquí es que el valor del átomo crítico puede ser accedido y modificado por diferentes procesos prácticamente al mismo tiempo, lo que da como resultado información inconsistente y errores de software. El término común para este tipo de cosas es condición de carrera. Las condiciones de carrera son particularmente peligrosas porque dependen del momento en que ocurren los eventos. En casi todos los lenguajes concurrentes y paralelos que existen, este momento depende de factores impredecibles, como qué tan ocupado está el procesador, a dónde van los procesos y qué datos está procesando su programa.

Es posible que haya escuchado que Erlang generalmente no tiene condiciones de carrera ni interbloqueos y hace que el código paralelo sea seguro. Esto es cierto en muchas circunstancias, pero nunca suponga que su código es realmente tan seguro. Los procesos nombrados son solo un ejemplo de las múltiples formas en que el código paralelo puede salir mal.

Otros ejemplos incluyen el acceso a archivos en la computadora (para modificarlos), la actualización de los mismos registros de la base de datos desde muchos procesos diferentes, etc.

Afortunadamente para nosotros, es relativamente fácil corregir el código anterior si no asumimos que el proceso nombrado sigue siendo el mismo. En su lugar, utilizaremos referencias (creadas con make_ref()) como valores únicos para identificar mensajes. Necesitaremos reescribir la función critic/0 en critic2/0 y judge/3 en judge2/2:


judge2(Band, Album) ->

    Ref = make_ref(),

    critic ! {self(), Ref, {Band, Album}},

    receive

        {Ref, Criticism} -> Criticism

    after 2000 ->

        timeout

    end.


critic2() ->

    receive

        {From, Ref, {"Rage Against the Turing Machine", "Unit Testify"}} ->

            From ! {Ref, "They are great!"};

        {From, Ref, {"System of a Downtime", "Memoize"}} ->

            From ! {Ref, "They're not Johnny Crash but they're good."};

        {From, Ref, {"Johnny Crash", "The Token Ring of Fire"}} ->

            From ! {Ref, "Simply incredible."};

        {From, Ref, {_Band, _Album}} ->

            From ! {Ref, "They are terrible!"}

    end,

    critic2().


Y luego cambia restarter/0 para que se ajuste haciendo que genere critic2/0 en lugar de critic/0. Ahora las otras funciones deberían seguir funcionando bien. El usuario no notará ninguna diferencia. Bueno, la notará porque cambiamos el nombre de las funciones y cambiamos la cantidad de parámetros, pero no sabrá qué detalles de implementación se cambiaron y por qué fue importante. Todo lo que verá es que su código se simplificó y ya no necesita enviar un PID en las llamadas a funciones:


6> c(linkmon).

{ok,linkmon}

7> linkmon:start_critic2().

<0.55.0>

8> linkmon:judge2("The Doors", "Light my Firewall").

"They are terrible!"

9> exit(whereis(critic), kill).

true

10> linkmon:judge2("Rage Against the Turing Machine", "Unit Testify").     

"They are great!"


Y ahora, aunque eliminamos al crítico, uno nuevo volvió instantáneamente para resolver nuestros problemas. Esa es la utilidad de los procesos nombrados. Si hubiera intentado llamar a linkmon:judge/2 sin un proceso registrado, el operador ! dentro de la función habría arrojado un error de argumento incorrecto, lo que garantizaría que los procesos que dependen de los nombrados no puedan ejecutarse sin ellos.

Los átomos se pueden usar en una cantidad limitada (aunque alta). Nunca debería crear átomos dinámicos. Esto significa que los procesos nombrados deben reservarse para servicios importantes exclusivos de una instancia de la máquina virtual y procesos que deberían estar allí durante todo el tiempo que se ejecuta su aplicación.

Si necesita procesos nombrados pero son transitorios o no hay ninguno que pueda ser exclusivo de la máquina virtual, puede significar que deben representarse como un grupo. Vincularlos y reiniciarlos juntos si fallan puede ser la opción más sensata, en lugar de intentar usar nombres dinámicos.




lunes, 3 de febrero de 2025

Concurrencia en Erlang parte 11


Los monitores son un tipo especial de enlace con dos diferencias:

  • son unidireccionales;
  • se pueden apilar.

Los monitores son lo que necesitas cuando un proceso quiere saber qué está pasando con un segundo proceso, pero ninguno de ellos es realmente vital para el otro.

Otra razón, como se mencionó anteriormente, es apilar las referencias. Ahora bien, esto puede parecer inútil a primera vista, pero es genial para escribir bibliotecas que necesitan saber qué está pasando con otros procesos.

Verás, los enlaces son más una construcción organizacional. Cuando diseñas la arquitectura de tu aplicación, determinas qué proceso hará qué trabajos y qué dependerá de qué. Algunos procesos supervisarán a otros, otros no podrían vivir sin un proceso gemelo, etc. Esta estructura suele ser algo fijo, conocido de antemano. Los enlaces son útiles para eso y no necesariamente deberían usarse fuera de ella.

Pero, ¿qué sucede si tienes 2 o 3 bibliotecas diferentes a las que llamas y todas necesitan saber si un proceso está activo o no? Si usaras enlaces para esto, rápidamente te encontrarías con un problema cada vez que necesitaras desvincular un proceso. Ahora bien, los enlaces no son apilables, por lo que en el momento en que desvinculas uno, los desvinculas a todos y arruinas todas las suposiciones realizadas por las otras bibliotecas. Eso es bastante malo. Por lo tanto, necesitas enlaces apilables, y los monitores son tu solución. Se pueden eliminar individualmente. Además, ser unidireccional es útil en las bibliotecas porque otros procesos no deberían tener que estar al tanto de dichas bibliotecas.

Entonces, ¿cómo se ve un monitor? Bastante fácil, configuremos uno. La función es erlang:monitor/2, donde el primer argumento es el proceso atom y el segundo es el pid:


1> erlang:monitor(process, spawn(fun() -> timer:sleep(500) end)).

#Ref<0.0.0.77>

2> flush().

Shell got {'DOWN',#Ref<0.0.0.77>,process,<0.63.0>,normal}

ok


Cada vez que un proceso que monitorizas deja de funcionar, recibirás un mensaje como este. El mensaje es {'DOWN', MonitorReference, process, Pid, ​​Reason}. La referencia está ahí para permitirte demostrar el proceso. Recuerda, los monitores son apilables, por lo que es posible dejar fuera de servicio más de uno. Las referencias te permiten rastrear cada uno de ellos de una manera única. También ten en cuenta que, al igual que con los enlaces, hay una función atómica para generar un proceso mientras lo monitorizas, spawn_monitor/1-3:


3> {Pid, Ref} = spawn_monitor(fun() -> receive _ -> exit(boom) end end).

{<0.73.0>,#Ref<0.0.0.100>}

4> erlang:demonitor(Ref).

true

5> Pid ! die.

die

6> flush().

ok


En este caso, demostramos el otro proceso antes de que se bloqueara y, por lo tanto, no teníamos rastros de que se hubiera detenido. La función demonitor/2 también existe y brinda un poco más de información. El segundo parámetro puede ser una lista de opciones. Solo existen dos, info y flush:


7> f().

ok

8> {Pid, Ref} = spawn_monitor(fun() -> receive _ -> exit(boom) end end). 

{<0.35.0>,#Ref<0.0.0.35>}

9> Pid ! die.

die

10> erlang:demonitor(Ref, [flush, info]).

false

11> flush().

ok


La opción info le indica si existía o no un monitor cuando intentó eliminarlo. Por eso la expresión 10 devolvió falso. El uso de flush como opción eliminará el mensaje DOWN del buzón si existía, lo que hará que flush() no encuentre nada en el buzón del proceso actual.




sábado, 1 de febrero de 2025

Concurrencia en Erlang parte 10


Ahora volvamos a los links y procesos que mueren. La propagación de errores entre procesos se realiza a través de un proceso similar al paso de mensajes, pero con un tipo especial de mensaje llamado señales o signals. Las señales de salida son mensajes "secretos" que actúan automáticamente sobre los procesos, matándolos en la acción.

Ya he mencionado muchas veces que para ser confiable, una aplicación necesita poder matar y reiniciar un proceso rápidamente. En este momento, los enlaces están bien para hacer la parte de matar. Lo que falta es el reinicio.

Para reiniciar un proceso, necesitamos una forma de saber primero que murió. Esto se puede hacer agregando una capa sobre los enlaces con un concepto llamado procesos del sistema. Los procesos del sistema son básicamente procesos normales, excepto que pueden convertir señales de salida en mensajes normales. Esto se hace llamando a process_flag(trap_exit, true) en un proceso en ejecución. Nada dice tanto como un ejemplo, así que lo usaremos. Simplemente volveré a hacer el ejemplo de la cadena con un proceso del sistema al principio:


1> process_flag(trap_exit, true).

true

2> spawn_link(fun() -> linkmon:chain(3) end).

<0.49.0>

3> receive X -> X end.

{'EXIT',<0.49.0>,"chain dies here"}


Ahora las cosas se ponen interesantes. Volviendo a nuestros dibujos, lo que sucede ahora es más bien así:


[shell] == [3] == [2] == [1] == [0]

[shell] == [3] == [2] == [1] == *dead*

[shell] == [3] == [2] == *dead*

[shell] == [3] == *dead*

[shell] <-- {'EXIT,Pid,"chain dies here"} -- *dead*

[shell] <-- still alive!


Y este es el mecanismo que permite reiniciar rápidamente los procesos. Al escribir programas que utilizan procesos del sistema, es fácil crear un proceso cuyo único papel sea comprobar si algo muere y luego reiniciarlo cuando falle.

Por ahora, quiero volver a las funciones de excepción que vimos anteriormente y mostrar cómo se comportan en torno a los procesos que atrapan salidas. Primero, establezcamos las bases para experimentar sin un proceso del sistema. Mostraré sucesivamente los resultados de lanzamientos, errores y salidas no atrapados en procesos vecinos:

Exception source: spawn_link(fun() -> ok end)
Untrapped Result: - nothing -
Trapped Result{'EXIT', <0.61.0>, normal}
The process exited normally, without a problem. Note that this looks a bit like the result of catch exit(normal), except a PID is added to the tuple to know what processed failed.
Exception source: spawn_link(fun() -> exit(reason) end)
Untrapped Result** exception exit: reason
Trapped Result{'EXIT', <0.55.0>, reason}
The process has terminated for a custom reason. In this case, if there is no trapped exit, the process crashes. Otherwise, you get the above message.
Exception source: spawn_link(fun() -> exit(normal) end)
Untrapped Result: - nothing -
Trapped Result{'EXIT', <0.58.0>, normal}
This successfully emulates a process terminating normally. In some cases, you might want to kill a process as part of the normal flow of a program, without anything exceptional going on. This is the way to do it.
Exception source: spawn_link(fun() -> 1/0 end)
Untrapped ResultError in process <0.44.0> with exit value: {badarith, [{erlang, '/', [1,0]}]}
Trapped Result{'EXIT', <0.52.0>, {badarith, [{erlang, '/', [1,0]}]}}
The error ({badarith, Reason}) is never caught by a try ... catch block and bubbles up into an 'EXIT'. At this point, it behaves exactly the same as exit(reason) did, but with a stack trace giving more details about what happened.
Exception source: spawn_link(fun() -> erlang:error(reason) end)
Untrapped ResultError in process <0.47.0> with exit value: {reason, [{erlang, apply, 2}]}
Trapped Result{'EXIT', <0.74.0>, {reason, [{erlang, apply, 2}]}}
Pretty much the same as with 1/0. That's normal, erlang:error/1 is meant to allow you to do just that.
Exception source: spawn_link(fun() -> throw(rocks) end)
Untrapped ResultError in process <0.51.0> with exit value: {{nocatch, rocks}, [{erlang, apply, 2}]}
Trapped Result{'EXIT', <0.79.0>, {{nocatch, rocks}, [{erlang, apply, 2}]}}
Because the throw is never caught by a try ... catch, it bubbles up into an error, which in turn bubbles up into an EXIT. Without trapping exit, the process fails. Otherwise it deals with it fine.


Y eso es todo en lo que respecta a las excepciones habituales. Las cosas son normales: todo va bien. Suceden cosas excepcionales: los procesos mueren, se envían diferentes señales.

Luego está exit/2. Este es el equivalente a un arma en el proceso Erlang. Permite que un proceso mate a otro a distancia, de forma segura. Estas son algunas de las posibles llamadas:


Exception source: exit(self(), normal)
Untrapped Result** exception exit: normal
Trapped Result{'EXIT', <0.31.0>, normal}
When not trapping exits, exit(self(), normal) acts the same as exit(normal). Otherwise, you receive a message with the same format you would have had by listening to links from foreign processes dying.
Exception source: exit(spawn_link(fun() -> timer:sleep(50000) end), normal)
Untrapped Result: - nothing -
Trapped Result: - nothing -
This basically is a call to exit(Pid, normal). This command doesn't do anything useful, because a process can not be remotely killed with the reason normal as an argument.
Exception source: exit(spawn_link(fun() -> timer:sleep(50000) end), reason)
Untrapped Result** exception exit: reason
Trapped Result{'EXIT', <0.52.0>, reason}
This is the foreign process terminating for reason itself. Looks the same as if the foreign process called exit(reason) on itself.
Exception source: exit(spawn_link(fun() -> timer:sleep(50000) end), kill)
Untrapped Result** exception exit: killed
Trapped Result{'EXIT', <0.58.0>, killed}
Surprisingly, the message gets changed from the dying process to the spawner. The spawner now receives killed instead of kill. That's because kill is a special exit signal. More details on this later.
Exception source: exit(self(), kill)
Untrapped Result** exception exit: killed
Trapped Result** exception exit: killed
Oops, look at that. It seems like this one is actually impossible to trap. Let's check something.
Exception source: spawn_link(fun() -> exit(kill) end)
Untrapped Result** exception exit: killed
Trapped Result{'EXIT', <0.67.0>, kill}
Now that's getting confusing. When another process kills itself with exit(kill) and we don't trap exits, our own process dies with the reason killed. However, when we trap exits, things don't happen that way.


Aunque se pueden atrapar la mayoría de las razones de salida, hay situaciones en las que se puede querer asesinar brutalmente un proceso: tal vez uno de ellos esté atrapando salidas pero también está atascado en un bucle infinito, sin leer ningún mensaje. La razón de eliminación actúa como una señal especial que no se puede atrapar. Esto garantiza que cualquier proceso que se termine con ella realmente estará muerto. Por lo general, matar es un último recurso, cuando todo lo demás ha fallado.

Como la razón de eliminación nunca se puede atrapar, se debe cambiar a matar cuando otros procesos reciben el mensaje. Si no se cambiara de esa manera, todos los demás procesos vinculados a ella morirían a su vez por la misma razón de eliminación y, a su vez, matarían a sus vecinos, y así sucesivamente. Se produciría una cascada de muertes.

Esto también explica por qué exit(kill) parece muerto cuando se recibe de otro proceso vinculado (la señal se modifica para que no se produzca una cascada), pero sigue pareciendo muerto cuando se atrapa localmente.

Si todo esto le parece confuso, no se preocupe. Muchos programadores sienten lo mismo. Las señales de salida son un poco curiosas. Afortunadamente, no hay muchos más casos especiales que los descritos anteriormente. Una vez que los comprenda, podrá comprender la mayor parte de la gestión de errores concurrentes de Erlang sin problemas.


miércoles, 29 de enero de 2025

Concurrencia en Erlang parte 9


Un enlace es un tipo específico de relación que se puede crear entre dos procesos. Cuando se establece esa relación y uno de los procesos muere debido a un error o salida inesperados, el otro proceso vinculado también muere.

Este es un concepto útil desde la perspectiva de no detener los errores lo antes posible: si el proceso que tiene un error se bloquea pero los que dependen de él no, entonces todos estos procesos dependientes tienen que lidiar con la desaparición de una dependencia. Dejar que mueran y luego reiniciar todo el grupo suele ser una alternativa aceptable. Los enlaces nos permiten hacer exactamente esto.

Para establecer un enlace entre dos procesos, Erlang tiene la función primitiva link/1, que toma un Pid como argumento. Cuando se llama, la función creará un enlace entre el proceso actual y el identificado por Pid. Para deshacerse de un enlace, usamos unlink/1. Cuando uno de los procesos vinculados se bloquea, se envía un tipo especial de mensaje, con información relativa a lo que sucedió. No se envía dicho mensaje si el proceso muere por causas naturales (léase: termina de ejecutar sus funciones). Primero veamos esta nueva función como parte de linkmon.erl:


myproc() ->

    timer:sleep(5000),

    exit(reason).


Si ejecutamos las siguiente función (y espera 5 segundos entre cada comando de generación), debería ver que el shell se bloquea por "razón" solo cuando se haya establecido un vínculo entre los dos procesos.


1> c(linkmon).

{ok,linkmon}

2> spawn(fun linkmon:myproc/0).

<0.52.0>

3> link(spawn(fun linkmon:myproc/0)).

true

** exception error: reason


No se puede capturar con un try... catch el mensaje de error como de costumbre. Se deben utilizar otros mecanismos para hacer esto. 

Es importante señalar que los enlaces se utilizan para establecer grupos más grandes de procesos que deberían morir todos juntos:


chain(0) ->

    receive

        _ -> ok

    after 2000 ->

        exit("chain dies here")

    end;

chain(N) ->

    Pid = spawn(fun() -> chain(N-1) end),

    link(Pid),

    receive

        _ -> ok

    end.


Esta función tomará un entero N, iniciará N procesos vinculados entre sí. Para poder pasar el argumento N-1 al siguiente proceso de "cadena" (que llama a spawn/1), envuelvo la llamada dentro de una función anónima para que ya no necesite argumentos. Llamar a spawn(?MODULE, chain, [N-1]) habría hecho un trabajo similar.

Aquí, tendremos muchos procesos vinculados entre sí, que morirán cuando cada uno de sus sucesores salga:


4> c(linkmon).               

{ok,linkmon}

5> link(spawn(linkmon, chain, [3])).

true

** exception error: "chain dies here"


Y como puedes ver, el shell recibe la señal de muerte de algún otro proceso. Aquí hay una representación dibujada de los procesos generados y los enlaces que se caen:


[shell] == [3] == [2] == [1] == [0]

[shell] == [3] == [2] == [1] == *dead*

[shell] == [3] == [2] == *dead*

[shell] == [3] == *dead*

[shell] == *dead*

*dead, error message shown*

[shell] <-- restarted


Después de que el proceso que ejecuta linkmon:chain(0) muere, el error se propaga a lo largo de la cadena de enlaces hasta que el proceso de shell muere por ello. El fallo podría haber ocurrido en cualquiera de los procesos enlazados; como los enlaces son bidireccionales, solo es necesario que uno de ellos muera para que los demás sigan su ejemplo.

Si deseamos matar otro proceso desde el shell, podemos utilizar la función exit/2, que se llama de esta manera: exit(Pid, Reason). 

Los enlaces no se pueden apilar. Si llama a link/1 15 veces para los mismos dos procesos, solo seguirá existiendo un enlace entre ellos y una sola llamada a unlink/1 será suficiente para borrarlo.

Es importante tener en cuenta que link(spawn(Function)) o link(spawn(M,F,A)) ocurren en más de un paso. En algunos casos, es posible que un proceso muera antes de que se haya establecido el enlace y luego provoque un comportamiento inesperado. Por este motivo, se ha añadido al lenguaje la función spawn_link/1-3. Esta función toma los mismos argumentos que spawn/1-3, crea un proceso y lo vincula como si link/1 hubiera estado allí, excepto que todo se realiza como una operación atómica (las operaciones se combinan como una sola, que puede fallar o tener éxito, pero nada más). Esto generalmente se considera más seguro y también ahorra un conjunto de paréntesis.

viernes, 17 de enero de 2025

Concurrencia en Erlang parte 8


El concepto de 'flushing' permite implementar una recepción selectiva que puede dar prioridad a los mensajes que recibe mediante la anidación de llamadas:


important() ->

    receive

        {Priority, Message} when Priority > 10 ->

            [Message | important()]

    after 0 ->

        normal()

    end.


normal() ->

    receive

        {_, Message} ->

            [Message | normal()]

    after 0 ->

        []

    end.


Esta función creará una lista de todos los mensajes, comenzando primero con aquellos con una prioridad superior a 10:


1> c(multiproc).

{ok,multiproc}

2> self() ! {15, high}, self() ! {7, low}, self() ! {1, low}, self() ! {17, high}.       

{17,high}

3> multiproc:important().

[high,high,low,low]


Como utilicé el bit posterior a 0, se obtendrán todos los mensajes hasta que no quede ninguno, pero el proceso intentará capturar todos aquellos con una prioridad superior a 10 antes incluso de considerar los demás mensajes, que se acumulan en la llamada normal/0.

Si esta práctica le parece interesante, tenga en cuenta que a veces no es segura debido a la forma en que funcionan las recepciones selectivas en Erlang.

Cuando se envían mensajes a un proceso, se almacenan en el buzón hasta que el proceso los lee y coinciden con un patrón allí. Los mensajes se almacenan en el orden en que se recibieron. Esto significa que cada vez que coincide con un mensaje, comienza por el más antiguo.

Luego, ese mensaje más antiguo se prueba con cada patrón de la recepción hasta que uno de ellos coincide. Cuando lo hace, el mensaje se elimina del buzón y el código del proceso se ejecuta normalmente hasta la próxima recepción. Cuando se evalúa esta próxima recepción, la máquina virtual buscará el mensaje más antiguo que se encuentre actualmente en el buzón (el siguiente al que eliminamos), y así sucesivamente.

Cuando no hay forma de encontrar una coincidencia con un mensaje determinado, se lo coloca en una cola de guardado y se intenta con el siguiente mensaje. Si el segundo mensaje coincide, el primero se vuelve a colocar en la parte superior del buzón para volver a intentarlo más tarde.

Esto le permite preocuparse únicamente por los mensajes que son útiles. Ignorar algunos mensajes para manejarlos más tarde de la manera descrita anteriormente es la esencia de las recepciones selectivas. Si bien son útiles, el problema con ellas es que si su proceso tiene muchos mensajes que nunca le interesan, leer los mensajes útiles en realidad llevará cada vez más tiempo (y los procesos también crecerán en tamaño).

Imagine que queremos el mensaje n.° 367, pero los primeros 366 son basura ignorada por nuestro código. Para obtener el mensaje n.° 367, el proceso debe intentar hacer coincidir los 366 primeros. Una vez que haya terminado y todos se hayan colocado en la cola, se saca el mensaje n.° 367 y los primeros 366 se vuelven a colocar en la parte superior del buzón. El siguiente mensaje útil podría estar mucho más escondido y tardar aún más en encontrarlo.

Este tipo de recepción es una causa frecuente de problemas de rendimiento en Erlang. Si su aplicación se ejecuta con lentitud y sabe que hay muchos mensajes circulando, esta podría ser la causa.

Si estas recepciones selectivas están causando una ralentización masiva de su código, lo primero que debe hacer es preguntarse por qué recibe mensajes que no desea. ¿Se envían los mensajes a los procesos correctos? ¿Son correctos los patrones? ¿Los mensajes tienen un formato incorrecto? ¿Está utilizando un proceso cuando debería haber muchos? Responder a una o varias de estas preguntas podría resolver su problema.

Debido a los riesgos de que los mensajes inútiles contaminen el buzón de un proceso, los programadores de Erlang a veces toman una medida defensiva contra tales eventos. Una forma estándar de hacerlo podría ser la siguiente:


receive

    Pattern1 -> Expression1;

    Pattern2 -> Expression2;

    Pattern3 -> Expression3;

    ...

    PatternN -> ExpressionN;

    Unexpected ->

        io:format("unexpected message ~p~n", [Unexpected])

end.


Lo que esto hace es asegurarse de que cualquier mensaje coincida con al menos una cláusula. La variable Unexpected coincidirá con cualquier cosa, sacará el mensaje inesperado del buzón y mostrará una advertencia. Dependiendo de su aplicación, es posible que desee almacenar el mensaje en algún tipo de servicio de registro donde podrá encontrar información sobre él más adelante: si los mensajes van al proceso equivocado, sería una pena perderlos para siempre y tener dificultades para encontrar por qué ese otro proceso no recibe lo que debería.

En el caso de que necesite trabajar con una prioridad en sus mensajes y no pueda usar una cláusula de captura general, una forma más inteligente de hacerlo sería implementar un min-heap o usar el módulo gb_trees y volcar todos los mensajes recibidos en él (asegúrese de poner el número de prioridad primero en la clave para que se use para ordenar los mensajes). Luego, puede simplemente buscar el elemento más pequeño o más grande en la estructura de datos según sus necesidades.

En la mayoría de los casos, esta técnica debería permitirle recibir mensajes con una prioridad de manera más eficiente que las recepciones selectivas. Sin embargo, podría ralentizarlo si la mayoría de los mensajes que recibe tienen la máxima prioridad posible. Como siempre, el truco es perfilar y medir antes de optimizar.

Desde R14A, se agregó una nueva optimización al compilador de Erlang. Simplifica las recepciones selectivas en casos muy específicos de comunicaciones de ida y vuelta entre procesos. Un ejemplo de una función de este tipo es optimized/1 en multiproc.erl.

-module(multiproc).

-compile([export_all]).


sleep(T) ->

    receive

    after T -> ok

    end.


flush() ->

    receive

        _ -> flush()

    after 0 ->

        ok

    end.


important() ->

    receive

        {Priority, Message} when Priority > 10 ->

            [Message | important()]

    after 0 ->

        normal()

    end.


normal() ->

    receive

        {_, Message} ->

            [Message | normal()]

    after 0 ->

        []

    end.


%% optimized in R14A

optimized(Pid) ->

    Ref = make_ref(),

    Pid ! {self(), Ref, hello},

    receive

        {Pid, Ref, Msg} ->

            io:format("~p~n", [Msg])

    end.

Para que funcione, se debe crear una referencia (make_ref()) en una función y luego enviarla en un mensaje. En la misma función, se realiza una recepción selectiva. Si ningún mensaje puede coincidir a menos que contenga la misma referencia, el compilador se asegura automáticamente de que la máquina virtual omitirá los mensajes recibidos antes de la creación de esa referencia.

Tenga en cuenta que no debe intentar forzar su código para que se ajuste a dichas optimizaciones. Los desarrolladores de Erlang solo buscan patrones que se usan con frecuencia y luego los hacen más rápidos. Si escribe código idiomático, las optimizaciones deberían venir a usted. No al revés.

Con estos conceptos entendidos, el siguiente paso será realizar el manejo de errores con múltiples procesos.


domingo, 12 de enero de 2025

Concurrencia en Erlang parte 7


Probemos algo con la ayuda del comando pid(A,B,C), que nos permite convertir los 3 números enteros A, B y C en un pid. Aquí le daremos deliberadamente a kitchen:take/2 uno falso:


20> kitchen:take(pid(0,250,0), dog).


Ups. El shell está congelado. Esto sucedió debido a la forma en que se implementó take/2. Para entender lo que sucede, primero revisemos lo que sucede en el caso normal:


  1. Un mensaje para tomar comida se envía desde el shell al proceso del refrigerador;
  2. Su proceso cambia al modo de recepción y espera un nuevo mensaje;
  3. El refrigerador retira el artículo y lo envía a su proceso;
  4. Su proceso lo recibe y continúa con su vida.


Y esto es lo que sucede cuando el shell se congela:


  1. Un mensaje para tomar comida se envía desde el shell a un proceso desconocido;
  2. Su proceso cambia al modo de recepción y espera un nuevo mensaje;
  3. El proceso desconocido no existe o no espera tal mensaje y no hace nada con él;
  4. Su proceso de shell está bloqueado en modo de recepción.

Eso es molesto, especialmente porque no hay manejo de errores posible aquí. No sucedió nada ilegal, el programa solo está esperando. En general, cualquier cosa que tenga que ver con operaciones asincrónicas (que es como se hace el paso de mensajes en Erlang) necesita una forma de abandonar el proceso después de un cierto período de tiempo si no obtiene señales de recibir datos. Un navegador web lo hace cuando una página o imagen tarda demasiado en cargarse, usted lo hace cuando alguien tarda demasiado en responder el teléfono o llega tarde a una reunión. Erlang ciertamente tiene un mecanismo apropiado para eso, y es parte de la construcción de recepción:


receive

    Match -> Expression1

after Delay ->

    Expression2

end.


La parte entre recibir y después es exactamente la misma que ya conocemos. La parte después se activará si se ha pasado tanto tiempo como Delay (un entero que representa milisegundos) sin recibir un mensaje que coincida con el patrón Match. Cuando esto sucede, se ejecuta Expression2.

Escribiremos dos nuevas funciones de interfaz, store2/2 y take2/2, que actuarán exactamente como store/2 y take/2 con la excepción de que dejarán de esperar después de 3 segundos:


store2(Pid, Food) ->

    Pid ! {self(), {store, Food}},

    receive

        {Pid, Msg} -> Msg

    after 3000 ->

        timeout

    end.


take2(Pid, Food) ->

    Pid ! {self(), {take, Food}},

    receive

        {Pid, Msg} -> Msg

    after 3000 ->

        timeout

    end.


Ahora puedes descongelar el shell con ^G y probar las nuevas funciones de la interfaz:


User switch command

 --> k 

 --> s

 --> c

Eshell V5.7.5  (abort with ^G)

1> c(kitchen).

{ok,kitchen}

2> kitchen:take2(pid(0,250,0), dog).

timeout


Y ahora funciona.

After no solo toma milisegundos como valor, en realidad es posible usar el átomo infinito. Si bien esto no es útil en muchos casos (podría simplemente eliminar la cláusula after por completo), a veces se usa cuando el programador puede enviar el tiempo de espera a una función donde se espera recibir un resultado. De esa manera, si el programador realmente quiere esperar eternamente, puede hacerlo.

Existen usos para estos temporizadores además de darse por vencidos después de demasiado tiempo. Un ejemplo muy simple es cómo funciona la función timer:sleep/1 que hemos usado antes. Aquí se muestra cómo se implementa (pongámosla en un nuevo módulo multiproc.erl):


sleep(T) ->

    receive

    after T -> ok

    end.


En este caso específico, nunca se encontrará ningún mensaje en la parte de recepción de la construcción porque no hay ningún patrón. En cambio, se llamará a la parte posterior de la construcción una vez que haya transcurrido el retraso T.

Otro caso especial es cuando el tiempo de espera es 0:


flush() ->

    receive

        _ -> flush()

    after 0 ->

        ok

    end.


Cuando esto sucede, la máquina virtual Erlang intentará encontrar un mensaje que se ajuste a uno de los patrones disponibles. En el caso anterior, todo coincide. Mientras haya mensajes, la función flush/0 se llamará a sí misma de forma recursiva hasta que el buzón esté vacío. Una vez hecho esto, se ejecuta la parte after 0 -> ok del código y la función retorna.

miércoles, 8 de enero de 2025

Concurrencia en Erlang parte 6


Algo molesto del ejemplo del post anterior es que el programador que va a utilizar el frigorífico tiene que conocer el protocolo que se ha inventado para ese proceso. Es una carga inútil. Una buena forma de solucionarlo es abstraer los mensajes con la ayuda de funciones que se ocupen de recibirlos y enviarlos:


store(Pid, Food) ->

    Pid ! {self(), {store, Food}},

    receive

        {Pid, Msg} -> Msg

    end.


take(Pid, Food) ->

    Pid ! {self(), {take, Food}},

    receive

        {Pid, Msg} -> Msg

    end.


Ahora la interacción con el proceso es mucho más limpia:


9> c(kitchen).

{ok,kitchen}

10> f().

ok

11> Pid = spawn(kitchen, fridge2, [[baking_soda]]).

<0.73.0>

12> kitchen:store(Pid, water).

ok

13> kitchen:take(Pid, water).

{ok,water}

14> kitchen:take(Pid, juice).

not_found


Ya no tenemos que preocuparnos por cómo funcionan los mensajes si se necesita enviar self() o un átomo preciso como take o store: todo lo que se necesita es un pid y saber qué funciones llamar. Esto oculta todo el trabajo sucio y facilita la creación del proceso de la nevera.

Una cosa que queda por hacer sería ocultar toda esa parte sobre la necesidad de generar un proceso. Nos ocupamos de ocultar los mensajes, pero aún esperamos que el usuario se encargue de la creación del proceso. Veamos la siguiente función start/1:


start(FoodList) ->

    spawn(?MODULE, fridge2, [FoodList]).


Aquí, ?MODULE es una macro que devuelve el nombre del módulo actual. No parece que haya ventajas en escribir una función de este tipo, pero realmente las hay. La parte esencial sería la coherencia con las llamadas a take/2 y store/2: todo lo relacionado con el proceso de refrigerador ahora lo maneja el módulo de cocina. Si tuviera que agregar un registro cuando se inicia el proceso de refrigerador o iniciar un segundo proceso (por ejemplo, un congelador), sería muy fácil hacerlo dentro de nuestra función start/1. Sin embargo, si se deja que el usuario realice la generación a través de spawn/3, entonces cada lugar que inicie un refrigerador ahora debe agregar las nuevas llamadas. Eso es propenso a errores y los errores son una mierda.

Veamos cómo se usa esta función:

15> f().

ok

16> c(kitchen).

{ok,kitchen}

17> Pid = kitchen:start([rhubarb, dog, hotdog]).

<0.84.0>

18> kitchen:take(Pid, dog).

{ok,dog}

19> kitchen:take(Pid, dog).

not_found


¡Hurra! ¡El perro ha salido del frigorífico y nuestra abstracción es total!


domingo, 5 de enero de 2025

Concurrencia en Erlang parte 5


No hay una gran ventaja para los procesos y actores si son solo funciones con mensajes. Para solucionar esto, tenemos que poder mantener el estado en un proceso.

Primero, creemos una función en un nuevo módulo kitchen.erl que permitirá que un proceso actúe como un refrigerador. El proceso permitirá dos operaciones: almacenar alimentos en el refrigerador y sacar alimentos del refrigerador. Solo debería ser posible sacar alimentos que se hayan almacenado de antemano. La siguiente función puede actuar como base para nuestro proceso:


-module(kitchen).

-compile(export_all).


fridge1() ->

    receive

        {From, {store, _Food}} ->

            From ! {self(), ok},

            fridge1();

        {From, {take, _Food}} ->

            %% uh....

            From ! {self(), not_found},

            fridge1();

        terminate ->

            ok

    end.


Algo anda mal. Cuando pedimos almacenar la comida, el proceso debería responder ok, pero no hay nada que almacene la comida; se llama a fridge1() y luego la función comienza desde cero, sin estado. También puedes ver que cuando llamamos al proceso para sacar comida del refrigerador, no hay estado del cual sacarla y, por lo tanto, lo único que se debe responder es not_found. Para almacenar y sacar alimentos, necesitaremos agregar estado a la función.

Con la ayuda de la recursión, el estado de un proceso puede entonces mantenerse completamente en los parámetros de la función. En el caso de nuestro proceso de refrigerador, una posibilidad sería almacenar toda la comida como una lista y luego buscar en esa lista cuando alguien necesite comer algo:


fridge2(FoodList) ->

    receive

        {From, {store, Food}} ->

            From ! {self(), ok},

            fridge2([Food|FoodList]);

        {From, {take, Food}} ->

            case lists:member(Food, FoodList) of

                true ->

                    From ! {self(), {ok, Food}},

                    fridge2(lists:delete(Food, FoodList));

                false ->

                    From ! {self(), not_found},

                    fridge2(FoodList)

            end;

        terminate ->

            ok

    end.


Lo primero que hay que notar es que fridge2/1 toma un argumento, FoodList. Puedes ver que cuando enviamos un mensaje que coincide con {From, {store, Food}}, la función agregará Food a FoodList antes de continuar. Una vez que se realiza esa llamada recursiva, será posible recuperar el mismo elemento. De hecho, lo implementé allí. La función usa lists:member/2 para verificar si Food es parte de FoodList o no. Dependiendo del resultado, el elemento se envía de vuelta al proceso de llamada (y se elimina de FoodList) o se envía not_found de vuelta en caso contrario:


1> c(kitchen).

{ok,kitchen}

2> Pid = spawn(kitchen, fridge2, [[baking_soda]]).

<0.51.0>

3> Pid ! {self(), {store, milk}}.

{<0.33.0>,{store,milk}}

4> flush().

Shell got {<0.51.0>,ok}

ok

Parece que almacenar los alimentos en el frigorífico funciona. Probaremos con más cosas y luego intentaremos sacarlas del frigorífico.


5> Pid ! {self(), {store, bacon}}.

{<0.33.0>,{store,bacon}}

6> Pid ! {self(), {take, bacon}}.

{<0.33.0>,{take,bacon}}

7> Pid ! {self(), {take, turkey}}.

{<0.33.0>,{take,turkey}}

8> flush().

Shell got {<0.51.0>,ok}

Shell got {<0.51.0>,{ok,bacon}}

Shell got {<0.51.0>,not_found}

ok


Como era de esperar, podemos sacar el tocino del frigorífico porque lo hemos puesto allí primero (junto con la leche y el bicarbonato de sodio), pero el proceso del frigorífico no encuentra ningún pavo cuando lo solicitamos. Por eso recibimos el último mensaje {<0.51.0>,not_found}.


viernes, 3 de enero de 2025

Concurrencia en Erlang parte 4


Vamos a ver las tres primitivas necesarias para la concurrencia en Erlang: generar nuevos procesos, enviar mensajes y recibir mensajes. En la práctica, se requieren más mecanismos para crear aplicaciones realmente confiables, pero por ahora esto será suficiente.

Me he saltado mucho el tema y todavía tengo que explicar qué es realmente un proceso. De hecho, no es más que una función. Eso es todo. Ejecuta una función y, una vez que termina, desaparece. Técnicamente, un proceso también tiene algún estado oculto (como un buzón para mensajes), pero las funciones son suficientes por ahora.

Para iniciar un nuevo proceso, Erlang proporciona la función spawn/1, que toma una sola función y la ejecuta:


1> F = fun() -> 2 + 2 end.

#Fun<erl_eval.20.67289768>

2> spawn(F).

<0.44.0>


El resultado de spawn/1 (<0.44.0>) se denomina Identificador de proceso, que la comunidad suele escribir simplemente PID, Pid o pid. El identificador de proceso es un valor arbitrario que representa cualquier proceso que exista (o pueda haber existido) en algún momento de la vida de la máquina virtual. Se utiliza como una dirección para comunicarse con el proceso.

Notarás que no podemos ver el resultado de la función F. Solo obtenemos su pid. Esto se debe a que los procesos no devuelven nada. ¿Cómo podemos ver entonces el resultado de F? Bueno, hay dos formas. La más fácil es simplemente mostrar lo que obtenemos:


3> spawn(fun() -> io:format("~p~n",[2 + 2]) end).

4

<0.46.0>


Esto no es práctico para un programa real, pero es útil para ver cómo Erlang distribuye los procesos. Afortunadamente, usar io:format/2 es suficiente para permitirnos experimentar. Iniciaremos 10 procesos muy rápido y pausaremos cada uno de ellos por un tiempo con la ayuda de la función timer:sleep/1, que toma un valor entero N y espera N milisegundos antes de reanudar el código. Después del retraso, se muestra el valor presente en el proceso.


4> G = fun(X) -> timer:sleep(10), io:format("~p~n", [X]) end.

#Fun<erl_eval.6.13229925>

5> [spawn(fun() -> G(X) end) || X <- lists:seq(1,10)].

[<0.273.0>,<0.274.0>,<0.275.0>,<0.276.0>,<0.277.0>,

 <0.278.0>,<0.279.0>,<0.280.0>,<0.281.0>,<0.282.0>]

2   

1   

4   

3   

5   

8   

7   

6   

10  

9   


El orden no tiene sentido. Bienvenido al paralelismo. Debido a que los procesos se ejecutan al mismo tiempo, el orden de los eventos ya no está garantizado. Esto se debe a que la máquina virtual Erlang utiliza muchos trucos para decidir cuándo ejecutar un proceso u otro, asegurándose de que cada uno tenga una buena parte del tiempo. Muchos servicios de Erlang se implementan como procesos, incluido el shell en el que está escribiendo. Sus procesos deben equilibrarse con los que necesita el propio sistema y esta podría ser la causa del orden extraño.

Los resultados son similares independientemente de si el multiprocesamiento simétrico está habilitado o no. Para comprobarlo, puede probarlo iniciando la máquina virtual Erlang con $ erl -smp disable

Para ver si su máquina virtual Erlang se ejecuta con o sin soporte SMP en primer lugar, inicie una nueva máquina virtual sin ninguna opción y busque la salida de la primera línea. Si puede ver el texto [smp:2:2] [rq:2], significa que está ejecutando con SMP habilitado y que tiene 2 colas de ejecución (rq o programadores) ejecutándose en dos núcleos. Si solo ve [rq:1], significa que está ejecutando con SMP deshabilitado.

Si desea saberlo, [smp:2:2] significa que hay dos núcleos disponibles, con dos programadores. [rq:2] significa que hay dos colas de ejecución activas. En versiones anteriores de Erlang, podía tener varios programadores, pero con solo una cola de ejecución compartida. Desde R13B, hay una cola de ejecución por programador de manera predeterminada; esto permite un mejor paralelismo.

Para demostrar que el shell en sí está implementado como un proceso regular, usaré el BIF self/0, que devuelve el pid del proceso actual:


6> self().

<0.41.0>

7> exit(self()).

** exception exit: <0.41.0>

8> self().

<0.285.0>


Y el pid cambia porque el proceso se ha reiniciado. Los detalles de cómo funciona esto se verán más adelante. Por ahora, hay cosas más básicas que cubrir. La más importante en este momento es averiguar cómo enviar mensajes, porque nadie quiere quedarse atascado con la salida de los valores resultantes de los procesos todo el tiempo y luego ingresarlos manualmente en otros procesos (al menos yo sé que no).

El siguiente primitivo necesario para realizar el paso de mensajes es el operador !, también conocido como el símbolo bang. En el lado izquierdo toma un pid y en el lado derecho toma cualquier término de Erlang. Luego, el término se envía al proceso representado por el pid, que puede acceder a él:


9> self() ! hello.

hello


El mensaje se ha colocado en el buzón del proceso, pero aún no se ha leído. El segundo saludo que se muestra aquí es el valor de retorno de la operación de envío. Esto significa que es posible enviar el mismo mensaje a muchos procesos haciendo lo siguiente:


10> self() ! self() ! double.

double


Lo cual es equivalente a self() ! (self() ! double). Una cosa a tener en cuenta sobre el buzón de un proceso es que los mensajes se guardan en el orden en que se reciben. Cada vez que se lee un mensaje, se saca del buzón. 

Para ver el contenido del buzón actual, puede usar el comando flush() mientras está en el shell:


11> flush().

Shell got hello

Shell got double

Shell got double

ok

Esta función es simplemente un atajo que muestra los mensajes recibidos. Esto significa que todavía no podemos vincular el resultado de un proceso a una variable, pero al menos sabemos cómo enviarlo de un proceso a otro y verificar si se ha recibido.

Enviar mensajes que nadie leerá es tan útil como escribir poesía emo; no mucho. Por eso necesitamos la declaración de recepción. En lugar de jugar demasiado tiempo en el shell, escribiremos un programa corto sobre los delfines para aprender sobre ellos:


-module(dolphins).

-compile(export_all).


dolphin1() ->

    receive

        do_a_flip ->

            io:format("How about no?~n");

        fish ->

            io:format("So long and thanks for all the fish!~n");

        _ ->

            io:format("Heh, we're smarter than you humans.~n")

    end.


Como puede ver, la sintaxis de receive es similar a la de case ... of. De hecho, los patrones funcionan exactamente de la misma manera, excepto que vinculan variables que provienen de mensajes en lugar de la expresión entre caso y de. Los patrones de recepción también pueden tener guards:

receive

    Pattern1 when Guard1 -> Expr1;

    Pattern2 when Guard2 -> Expr2;

    Pattern3 -> Expr3

end

Ahora podemos compilar el módulo anterior, ejecutarlo y comenzar a comunicarnos con los delfines:

11> c(dolphins).

{ok,dolphins}

12> Dolphin = spawn(dolphins, dolphin1, []).

<0.40.0>

13> Dolphin ! "oh, hello dolphin!".

Heh, we're smarter than you humans.

"oh, hello dolphin!"

14> Dolphin ! fish.                

fish

15> 

Aquí presentamos una nueva forma de generar con spawn/3. En lugar de tomar una sola función, spawn/3 toma el módulo, la función y sus argumentos como sus propios argumentos. Una vez que la función se está ejecutando, ocurren los siguientes eventos:

  1. La función llega a la declaración de recepción. Dado que el buzón del proceso está vacío, nuestro delfín espera hasta que recibe un mensaje;
  2. Se recibe el mensaje "oh, hello dolphin!". La función intenta hacer coincidir el patrón con do_a_flip. Esto falla, por lo que se intenta el patrón fish y también falla. Finalmente, el mensaje cumple con la cláusula catch-all (_) y coincide.
  3. El proceso genera el mensaje "Heh, we're smarter than you humans.".

Entonces, debe notarse que si el primer mensaje que enviamos funcionó, el segundo no provocó ninguna reacción del proceso <0.40.0>. Esto se debe al hecho de que una vez que nuestra función generó "Heh, we're smarter than you humans.", finalizó y también lo hizo el proceso. Necesitaremos reiniciar el delfín:

8> f(Dolphin).    

ok

9> Dolphin = spawn(dolphins, dolphin1, []).

<0.53.0>

10> Dolphin ! fish.

So long and thanks for all the fish!

fish

Y esta vez el mensaje fish funciona. ¿No sería útil poder recibir una respuesta del delfín en lugar de tener que usar io:format/2? Por supuesto que sí (¿por qué lo pregunto?). La única manera de saber si un proceso ha recibido un mensaje es enviar una respuesta. Nuestro proceso delfín necesitará saber a quién responder. Esto funciona como lo hace con el servicio postal. Si queremos que alguien sepa que debe responder a nuestra carta, necesitamos agregar nuestra dirección. En términos de Erlang, esto se hace empaquetando el pid de un proceso en una tupla. El resultado final es un mensaje que se parece un poco a {Pid, Message}. Creemos una nueva función delfín que acepte dichos mensajes:

dolphin2() ->

    receive

        {From, do_a_flip} ->

            From ! "How about no?";

        {From, fish} ->

            From ! "So long and thanks for all the fish!";

        _ ->

            io:format("Heh, we're smarter than you humans.~n")

    end.


Como puede ver, en lugar de aceptar do_a_flip y buscar mensajes, ahora necesitamos una variable From. Ahí es donde irá el identificador del proceso.


11> c(dolphins).

{ok,dolphins}

12> Dolphin2 = spawn(dolphins, dolphin2, []).

<0.65.0>

13> Dolphin2 ! {self(), do_a_flip}.          

{<0.32.0>,do_a_flip}

14> flush().

Shell got "How about no?"

ok


Parece funcionar bastante bien. Podemos recibir respuestas a los mensajes que enviamos (necesitamos agregar una dirección a cada mensaje), pero aún necesitamos iniciar un nuevo proceso para cada llamada. La recursión es la forma de resolver este problema. Solo necesitamos que la función se llame a sí misma para que nunca finalice y siempre espere más mensajes. Aquí hay una función dolphin3/0 que pone esto en práctica:

dolphin3() ->

    receive

        {From, do_a_flip} ->

            From ! "How about no?",

            dolphin3();

        {From, fish} ->

            From ! "So long and thanks for all the fish!";

        _ ->

            io:format("Heh, we're smarter than you humans.~n"),

            dolphin3()

    end.


Aquí, la cláusula catch-all y la cláusula do_a_flip se repiten con la ayuda de dolphin3/0. Tenga en cuenta que la función no hará estallar la pila porque es recursiva de cola. Mientras solo se envíen estos mensajes, el proceso dolphin se repetirá indefinidamente. Sin embargo, si enviamos el mensaje fish, el proceso se detendrá:


15> Dolphin3 = spawn(dolphins, dolphin3, []).

<0.75.0>

16> Dolphin3 ! Dolphin3 ! {self(), do_a_flip}.

{<0.32.0>,do_a_flip}

17> flush().

Shell got "How about no?"

Shell got "How about no?"

ok

18> Dolphin3 ! {self(), unknown_message}.     

Heh, we're smarter than you humans.

{<0.32.0>,unknown_message}

19> Dolphin3 ! Dolphin3 ! {self(), fish}.

{<0.32.0>,fish}

20> flush().

Shell got "So long and thanks for all the fish!"

ok

Y eso debería ser todo para dolphins.erl. Como puede ver, respeta nuestro comportamiento esperado de responder una vez por cada mensaje y seguir adelante después, excepto por el llamado con el mensaje fish. El delfín se hartó de nuestras locas payasadas humanas y nos dejó para siempre.