Procesos

El corazón de la concurrencia en Elixir: procesos ligeros y comunicación por mensajes.

¿Qué son los procesos en Elixir?

Los procesos en Elixir no son procesos del sistema operativo. Son extremadamente ligeros (solo ~2KB de memoria inicial) y son manejados por la máquina virtual BEAM:

El modelo de actores

Elixir sigue el modelo de actores: cada proceso es un actor independiente que recibe mensajes, los procesa, y puede enviar mensajes a otros actores. No hay memoria compartida.

Crear procesos con spawn

# spawn crea un nuevo proceso y retorna su PID
pid = spawn(fn ->
  IO.puts("¡Hola desde otro proceso!")
end)

# El PID identifica al proceso
iex> pid
#PID<0.110.0>

# Verificar si está vivo
iex> Process.alive?(pid)
false  # Ya terminó su trabajo

# El proceso actual
iex> self()
#PID<0.105.0>

Enviar y recibir mensajes

Los procesos se comunican mediante send y receive:

# Proceso que espera un mensaje
pid = spawn(fn ->
  receive do
    {:saludo, nombre} ->
      IO.puts("¡Hola, #{nombre}!")
    mensaje ->
      IO.puts("Recibido: #{inspect(mensaje)}")
  end
end)

# Enviar mensaje
send(pid, {:saludo, "Ana"})
# Imprime: ¡Hola, Ana!

Responder al remitente

# Incluir self() para recibir respuesta
pid = spawn(fn ->
  receive do
    {:calcular, a, b, remitente} ->
      send(remitente, {:resultado, a + b})
  end
end)

# Enviar y esperar respuesta
send(pid, {:calcular, 5, 3, self()})

receive do
  {:resultado, valor} -> IO.puts("Resultado: #{valor}")
end
# Imprime: Resultado: 8

Procesos que mantienen estado

Los procesos pueden mantener estado usando recursión:

defmodule Contador do
  def iniciar(valor_inicial \\ 0) do
    spawn(fn -> loop(valor_inicial) end)
  end

  defp loop(estado) do
    receive do
      :incrementar ->
        loop(estado + 1)

      :decrementar ->
        loop(estado - 1)

      {:obtener, remitente} ->
        send(remitente, {:valor, estado})
        loop(estado)  # Continuar con el mismo estado

      :detener ->
        IO.puts("Contador detenido con valor: #{estado}")
        # No llamar loop() = proceso termina
    end
  end
end

# Uso
iex> pid = Contador.iniciar(10)
iex> send(pid, :incrementar)
iex> send(pid, :incrementar)
iex> send(pid, {:obtener, self()})
iex> receive do {:valor, v} -> v end
12

Timeout en receive

receive do
  mensaje -> IO.puts("Recibido: #{mensaje}")
after
  5000 -> IO.puts("Timeout después de 5 segundos")
end

Vincular procesos con spawn_link

spawn_link crea un proceso vinculado. Si uno muere, el otro también:

# Con spawn normal, el proceso padre no se entera del crash
spawn(fn -> raise "¡Error!" end)
# El proceso padre sigue vivo

# Con spawn_link, el crash se propaga
spawn_link(fn -> raise "¡Error!" end)
# El proceso padre también muere
¿Por qué vincular procesos?

Los vínculos son la base de la tolerancia a fallos en Elixir. Permiten que los supervisores detecten cuando un proceso hijo falla y tomen acción (reiniciarlo, por ejemplo).

Monitorear procesos

Los monitores son como vínculos unidireccionales. Recibes una notificación si el proceso monitoreado muere:

pid = spawn(fn ->
  Process.sleep(1000)
  IO.puts("Proceso terminado")
end)

# Monitorear el proceso
ref = Process.monitor(pid)

# Esperar notificación
receive do
  {:DOWN, ^ref, :process, ^pid, razon} ->
    IO.puts("Proceso #{inspect(pid)} terminó: #{inspect(razon)}")
end

Registro de procesos

Puedes dar nombre a un proceso para encontrarlo fácilmente:

# Registrar un proceso con un nombre
pid = spawn(fn ->
  receive do
    msg -> IO.puts("Servidor recibió: #{msg}")
  end
end)

Process.register(pid, :mi_servidor)

# Enviar mensaje por nombre
send(:mi_servidor, "Hola")

# Obtener PID por nombre
iex> Process.whereis(:mi_servidor)
#PID<0.115.0>

Task: Procesos para trabajo asíncrono

El módulo Task simplifica el trabajo con procesos para tareas únicas:

# Ejecutar tarea asíncrona
tarea = Task.async(fn ->
  Process.sleep(1000)  # Simular trabajo
  42
end)

# Hacer otras cosas mientras...
IO.puts("Trabajando en paralelo...")

# Obtener resultado (espera si es necesario)
resultado = Task.await(tarea)
IO.puts("Resultado: #{resultado}")

Múltiples tareas en paralelo

# Ejecutar varias tareas concurrentemente
tareas = Enum.map(1..5, fn n ->
  Task.async(fn ->
    Process.sleep(100 * n)  # Simular trabajo variable
    n * n
  end)
end)

# Esperar todas las tareas
resultados = Task.await_many(tareas)
# [1, 4, 9, 16, 25]

Agent: Estado simple

Agent es una abstracción simple para mantener estado:

# Iniciar un Agent
{:ok, agente} = Agent.start_link(fn -> 0 end)

# Obtener estado
Agent.get(agente, & &1)
# 0

# Actualizar estado
Agent.update(agente, &(&1 + 1))

# Obtener y actualizar en un paso
Agent.get_and_update(agente, fn estado ->
  {estado, estado + 10}  # {valor_retornado, nuevo_estado}
end)
# 1 (retorna el estado anterior)

Agent.get(agente, & &1)
# 11
Task vs Agent vs GenServer

Task: Para trabajo único asíncrono.
Agent: Para estado simple sin lógica compleja.
GenServer: Para servidores con estado y lógica compleja (siguiente capítulo).

Ejercicio 7.1 Ping-Pong Básico

Crea dos procesos que jueguen ping-pong:

  • Un proceso envía :ping y espera :pong
  • El otro responde :pong cuando recibe :ping
  • Que intercambien 5 mensajes y luego terminen
Ejercicio 7.2 Calculadora paralela Intermedio

Usa Task para calcular en paralelo:

  • La suma de 1 a 1,000,000
  • El factorial de 20
  • Los primeros 30 números de Fibonacci

Mide cuánto tarda en paralelo vs secuencial.

Ejercicio 7.3 Lista de tareas con Agent Intermedio

Implementa una lista de tareas (TODO list) usando Agent:

  • agregar(agente, tarea) - agrega una tarea
  • completar(agente, indice) - marca como completada
  • listar(agente) - muestra todas las tareas
  • pendientes(agente) - cuenta tareas pendientes