wandres.dev
RENDIMIENTO · el runtime de Elm

El runtime por dentro: el bucle de eventos, la cola de mensajes y el instante del dibujado

Un programa de Elm no contiene ninguna instrucción sobre cuándo ejecutarse: el valor que devuelve Browser.element es un registro de cuatro campos que describe una aplicación y deja para otro la tarea de interpretarla. Ese otro es el runtime, unos pocos miles de líneas de JavaScript que el compilador incrusta en la salida y que casi nadie lee, aunque su comportamiento explica buena parte de las propiedades que atribuimos al lenguaje. Esta lección lo abre y examina las tres piezas que lo componen: la única celda mutable del sistema, donde vive el modelo actual y que nadie salvo el bucle puede tocar; el mecanismo por el que un mensaje se procesa de principio a fin antes de que otro empiece, lo cual elimina de raíz la reentrada y el estado a medio actualizar; y el despacho de efectos hacia los gestores que los ejecutan y devuelven sus resultados como mensajes nuevos. Se estudia además el punto que más sorprende cuando se descubre: el dibujado no ocurre al terminar cada actualización sino una sola vez por fotograma, de modo que varios mensajes seguidos producen un único repintado, con las consecuencias observables que eso tiene en pestañas de fondo y en campos de texto controlados.

⏱ 18 min

Hasta ahora el runtime ha sido una palabra cómoda: eso que llama a update, eso que ejecuta los comandos, eso que se encarga del documento. Se puede escribir una aplicación entera sin abrir esa caja, y esa es precisamente su virtud. Pero llega un momento, normalmente cuando algo va más despacio de lo esperado o cuando un comportamiento no encaja con el modelo mental, en que la caja hay que abrirla. Dentro no hay magia: hay una variable que guarda el modelo, una función que se llama con un mensaje cada vez, un mecanismo que impide que dos llamadas se solapen, un conjunto de gestores que traducen descripciones de efecto en llamadas reales al navegador, y un temporizador que decide cuándo vale la pena volver a mirar la pantalla. Es un diseño sorprendentemente pequeño, y su tamaño no es accidental: la mayor parte de la complejidad que otros entornos necesitan resolver en tiempo de ejecución —qué componentes tienen estado propio, qué actualizaciones pueden interrumpirse, qué hacer si el usuario muta un valor que se le pasó a la vista— aquí simplemente no existe, porque el lenguaje la eliminó antes de compilar. Conocer el bucle no cambia lo que escribes en un día normal. Cambia tu capacidad de explicar por qué el programa hace lo que hace cuando deja de ser un día normal.

🎯 Al terminar esta lección sabrás
  • Describir el bucle de eventos del runtime y localizar la única posición mutable de todo el sistema.
  • Explicar por qué dos actualizaciones nunca se solapan y qué clases de fallo desaparecen con esa garantía.
  • Seguir el recorrido completo de un efecto desde que se describe hasta que su resultado vuelve como mensaje.
  • Predecir en qué instante se dibuja la pantalla y razonar las consecuencias observables de que sea una vez por fotograma.

La única celda mutable de todo el sistema

Al arrancar, el runtime evalúa init, guarda el modelo resultante en una variable interna y monta la vista inicial en el documento. Esa variable es la totalidad del estado mutable del programa: no hay campos que se reasignen, ni objetos compartidos, ni referencias que dos partes del código puedan tocar a la vez. Todo lo demás es inmutable por construcción, así que la superficie sobre la que puede aparecer una inconsistencia se reduce a un único punto, y ese punto solo lo escribe el bucle. Cuando decimos que el estado está centralizado no describimos una convención de arquitectura sino un hecho literal de la implementación.

El bucle recibe mensajes de cuatro procedencias y no distingue entre ellas: un manejador de la vista que decodificó un evento del documento, un gestor de efectos que terminó una petición, una suscripción activa que emitió un valor, o un puerto de entrada por el que el anfitrión envió algo. Sea cual sea el origen, el tratamiento es idéntico y es el que ya conoces desde el primer nivel, salvo que ahora conviene verlo escrito como lo que realmente es: una secuencia estricta de cinco pasos que se ejecuta de principio a fin sin interrupciones.

-- El bucle, escrito como si fuera Elm
paso : Msg -> Estado -> Estado
paso msg estado =
    let
        ( modeloNuevo, comando ) =
            update msg estado.modelo
    in
    estado
        |> guardarModelo modeloNuevo
        |> despachar comando
        |> reconciliarSuscripciones (subscriptions modeloNuevo)
        |> marcarVistaPendiente

El detalle decisivo es que ese paso es indivisible. Si mientras se ejecuta llega otro mensaje —porque un puerto llamó a su propia devolución de forma sincrónica, o porque un efecto se resolvió al instante— no se abre una segunda ejecución encima de la primera: el mensaje espera. La consecuencia es que jamás existe un modelo a medio construir, jamás dos actualizaciones compiten por escribir la misma celda, y jamás una función lee un estado que otra está a punto de invalidar. La familia entera de errores por reentrada, que en un entorno con devoluciones de llamada es una fuente inagotable de defectos intermitentes, aquí no tiene dónde manifestarse.

🗃️

Una celda

El modelo actual vive en una sola variable interna. Ninguna función de usuario la ve ni la escribe: solo el bucle, y solo al final de cada paso.

📬

Una cola

Los mensajes se atienden de uno en uno y hasta el final. Lo que llegue durante un paso espera al siguiente, sin excepciones ni prioridades.

📡

Cuatro orígenes

Eventos de la vista, resultados de comandos, emisiones de suscripciones y entradas por puerto. El bucle los trata a todos igual.

🖼️

Un dibujado

Marcar la vista como pendiente no es dibujarla. El repintado se agenda para el próximo fotograma y agrupa todo lo ocurrido hasta entonces.

Los efectos salen, los mensajes vuelven

El comando que devuelve update no ejecuta nada: es una descripción etiquetada con el gestor que sabrá interpretarla. El runtime la entrega al gestor correspondiente —el de peticiones, el del tiempo, el de la aleatoriedad, el de tus puertos— y ahí termina su responsabilidad. Cada gestor mantiene su propio estado interno, hace el trabajo impuro que haga falta y, cuando obtiene un resultado, lo empaqueta como un mensaje del vocabulario de la aplicación y lo devuelve al bucle. Ese retorno es un mensaje corriente, indistinguible de un clic, y por eso la lógica que lo trata es la misma función update de siempre.

Las suscripciones siguen la ruta simétrica y añaden un matiz que no siempre se aprecia. Después de cada actualización, el runtime vuelve a evaluar subscriptions con el modelo nuevo y compara el conjunto resultante con el anterior; lo que aparece se activa, lo que desaparece se desactiva, y lo que sigue igual no se toca. Esa reconciliación es la que convierte una declaración —qué debe estar escuchando este programa dado su estado— en las altas y bajas de escuchas reales del navegador, sin que el programador tenga que emparejar a mano ninguna suscripción con su cancelación.

-- Describir el efecto: update no lo ejecuta, lo devuelve
update msg model =
    case msg of
        PidioDatos ->
            ( { model | cargando = True }
            , Http.get { url = "/api/items", expect = Http.expectJson Recibio decoder }
            )

        Recibio resultado ->
            -- El resultado vuelve como un mensaje mas de la cola
            ( { model | cargando = False, items = fromResult resultado }, Cmd.none )
ℹ️
Por qué el orden de llegada no está garantizado y por qué da igual

Dos comandos despachados en la misma actualización pueden terminar en cualquier orden, y el runtime no promete nada al respecto. Esto asusta la primera vez y deja de asustar en cuanto se ve que la garantía que sí existe es la que importa: cada resultado vuelve como un mensaje que se procesará en solitario, contra el modelo que haya en ese instante. Si el orden importa para el dominio, la respuesta no es pedirle secuencialidad al runtime sino modelarlo: encadenar con Task.andThen, o incorporar al modelo la información que permita descartar una respuesta que ya no viene al caso. El problema se resuelve donde se puede razonar sobre él, que es en los tipos, y no en una promesa de planificación que ningún entorno concurrente puede cumplir.

El dibujado no ocurre donde crees

Aquí está la pieza que más veces contradice el modelo mental de quien llega. Terminar un paso del bucle no repinta la pantalla: solo anota que la vista quedó obsoleta y pide al navegador un hueco en el próximo fotograma. Si en ese intervalo se procesan diez mensajes, se ejecutan diez actualizaciones y se guardan diez modelos, pero solo el último se dibuja, y de la vista solo se evalúa una versión. El trabajo de construir descripciones y compararlas queda así acotado por la frecuencia de refresco de la pantalla y no por la frecuencia con que llegan los eventos, que es la diferencia entre una aplicación que sobrevive a un flujo intenso de mensajes y una que se ahoga en él.

flowchart TD
E[Evento decodificado] --> Q[Cola de mensajes]
R[Resultado de un comando] --> Q
S[Emision de una suscripcion] --> Q
Q --> U[update sobre el modelo actual]
U --> G[Guardar modelo en la celda]
G --> D[Despachar comandos a los gestores]
G --> N[Reconciliar suscripciones]
G --> M[Marcar vista pendiente]
M --> F[Proximo fotograma dibuja una sola vez]
D --> R
style Q fill:#89b4fa,color:#11111b
style G fill:#a6e3a1,color:#11111b
style F fill:#cba6f7,color:#11111b

Esta agrupación tiene consecuencias observables que conviene tener presentes antes de diagnosticar un problema inexistente. En una pestaña de fondo el navegador deja de conceder fotogramas, así que los mensajes se siguen procesando y el modelo se sigue actualizando, pero nada se dibuja hasta que la pestaña vuelve al frente, momento en el que aparece de golpe el estado final y no la secuencia intermedia. Existe además un caso deliberado en el que el runtime renuncia a esperar y dibuja de forma sincrónica: ciertos manejadores de eventos de entrada lo fuerzan, porque un campo de texto controlado que muestre durante una fracción de segundo el valor anterior produce saltos de cursor perceptibles. Es la excepción que confirma la regla, y existe por una razón de percepción, no de corrección.

💡
Medir el bucle antes de acusar a la vista

Cuando una interfaz va lenta, el reflejo es sospechar del dibujado, y muchas veces la culpa está antes. Coloca marcas temporales alrededor de update en el perfilador del navegador y separa tres magnitudes: cuántos mensajes se procesan por fotograma, cuánto cuesta cada actualización y cuánto cuesta la evaluación de la vista más su comparación. Si el número de mensajes es alto, el remedio no está en memorizar vistas sino en emitir menos mensajes, agrupando o limitando la frecuencia en la suscripción que los produce. Optimizar la vista cuando el problema es la cola es trabajo desperdiciado que además deja el código peor.

Qué puedes suponer del bucle y qué no

Conviene separar con precisión las garantías reales de las creencias que se le atribuyen sin fundamento, porque casi todos los razonamientos erróneos sobre concurrencia en Elm nacen de mezclar unas con otras. Puedes suponer cuatro cosas y las cuatro son firmes: que una actualización se ejecuta entera antes de que empiece otra; que el modelo queda comprometido en la celda antes de que se despache ningún comando, de modo que ningún efecto puede observar un estado a medio construir; que las suscripciones se reconcilian después de cada actualización y no en un instante indeterminado; y que la vista se evalúa siempre sobre un modelo comprometido y nunca sobre uno intermedio.

No puedes suponer nada sobre el tiempo. No hay garantía de que dos comandos terminen en el orden en que se despacharon, ni de que un efecto termine alguna vez, ni de que exista un fotograma entre dos actualizaciones consecutivas, ni de que una suscripción emita antes que otra. Todo aquello que dependa de esas cuestiones tiene que estar representado en el modelo, y el patrón canónico consiste en etiquetar cada petición con un identificador creciente y descartar al volver las respuestas que ya no correspondan al estado vigente. Es la traducción a datos de una garantía que ningún planificador puede ofrecer.

type Msg
    = Escribio String
    | Respondio Int (Result Http.Error (List Item))


update msg model =
    case msg of
        Escribio texto ->
            let
                token =
                    model.token + 1
            in
            ( { model | consulta = texto, token = token }
            , buscar token texto
            )

        Respondio token resultado ->
            if token /= model.token then
                -- Llego tarde: describe un estado que ya no existe
                ( model, Cmd.none )

            else
                ( aplicar resultado model, Cmd.none )
📝
El estado a medio actualizar no existe y por eso no hay que defenderse de él

Merece la pena apreciar qué desaparece con la primera garantía. En un entorno donde una devolución de llamada puede ejecutarse mientras otra va por la mitad, cualquier lectura del estado compartido obliga a preguntarse si lo que se lee es coherente, y la respuesta habitual son banderas de ocupado, copias defensivas y comprobaciones repetidas que ensucian el código y que aun así dejan huecos. Aquí ninguna de esas precauciones tiene sentido: cuando update empieza, el modelo que recibe está completo y comprometido, y cuando termina, el suyo lo estará también. La concurrencia no se gestiona mejor, se ha vuelto inexpresable en el único punto donde podía hacer daño.

Un intérprete diminuto es lo que queda cuando el lenguaje resuelve antes lo demás

Merece la pena preguntarse por qué este runtime es tan pequeño, porque la respuesta explica más sobre el diseño de lenguajes que sobre Elm. Los entornos de interfaz maduros acumulan en tiempo de ejecución una maquinaria considerable: planificadores que deciden qué actualización puede interrumpirse y cuál no, estructuras que llevan la cuenta del estado local de cada componente, comprobaciones defensivas para detectar que alguien mutó un valor que se suponía inmutable, heurísticas para averiguar si un fragmento de interfaz sigue siendo el mismo tras una recolocación, y capas de compatibilidad para todas las formas en que un usuario puede salirse del camino previsto. Nada de eso es un error de ingeniería; es la deuda inevitable de haber concedido libertades que después hay que administrar. Elm no administra esas libertades porque no las concedió. No hay estado local en los nodos porque el estado vive entero en una celda; no hay comprobaciones de mutación porque mutar es inexpresable; no hay planificación por prioridades porque una actualización es una función pura que termina siempre y cuya interrupción no aportaría nada; no hay heurísticas de identidad de componente porque un nodo no tiene identidad salvo la que se le declara. Cada restricción del lenguaje se convierte así en una pieza que el intérprete no necesita tener, y el resultado es un bucle que cabe en una página y que un programador puede sostener completo en la cabeza. La lección general es incómoda para la intuición habitual: la potencia percibida de un entorno y el tamaño de su maquinaria de ejecución tienden a crecer juntos, y ese crecimiento se paga en peso descargado, en trabajo por fotograma y, sobre todo, en la imposibilidad de explicar con precisión qué hará el sistema en un caso raro. Un runtime pequeño no es una carencia de funcionalidad, es la sombra que proyecta un conjunto de garantías tomadas en serio.

⚔️ Abre la caja y comprueba lo que dice
  1. Dibuja el bucle completo de memoria e identifica el instante exacto en que la celda del modelo cambia de valor.
  2. Provoca la llegada de un mensaje durante el procesamiento de otro mediante un puerto y razona qué garantiza que no se solapen.
  3. Dispara veinte mensajes en un mismo fotograma y cuenta con el perfilador cuántas evaluaciones de la vista ocurren.
  4. Deja una suscripción de tiempo activa, pasa la pestaña a segundo plano y explica qué ves al volver y por qué.
  5. Encadena dos peticiones cuyo orden importe y resuélvelo primero con el modelo y después con Task.andThen; compara ambas soluciones.
  6. Argumenta en contra de agrupar el dibujado por fotograma: enumera qué se gana con un repintado inmediato y qué se pierde.