wandres.dev
SCXML Y EL ESTÁNDAR · la especificación

El algoritmo de interpretación: microsteps y macrosteps

El corazón normativo de SCXML es un bucle de treinta líneas que decide, ante cada evento, exactamente qué ocurre y en qué orden. Un microstep es la unidad atómica: salir de un conjunto de estados, ejecutar el contenido de las transiciones, entrar en otro conjunto. Un macrostep es la cadena de microsteps que se dispara hasta que la máquina vuelve a quedar estable, y es la única frontera en la que el mundo exterior tiene derecho a mirar. Entender esta doble granularidad —y la prioridad de la cola interna sobre la externa— explica por qué una máquina puede atravesar cinco configuraciones intermedias sin que ningún observador las vea nunca.

⏱ 22 min

Cuando envías un evento a una máquina de estado no ocurre una cosa: ocurre un procedimiento. La máquina consulta si hay transiciones habilitadas, resuelve los conflictos entre ellas, calcula qué estados abandona, ejecuta las acciones de salida de dentro hacia fuera, ejecuta el contenido de las transiciones, calcula qué estados ocupa, ejecuta las acciones de entrada de fuera hacia dentro, y entonces vuelve a preguntarse si el resultado ha habilitado nuevas transiciones. Ese ciclo puede repetirse muchas veces antes de que la máquina se quede quieta, y durante todo ese tiempo ningún observador externo puede leer nada, porque la máquina no está en un estado sino atravesándolos. Distinguir el paso atómico del ciclo completo —el microstep del macrostep— es la distinción central de la semántica formal, y la que separa entender una máquina de suponer cómo funciona.

🎯 Al terminar esta lección sabrás
  • Distinguir la cola interna de la externa y justificar por qué la interna tiene prioridad.
  • Descomponer un microstep en sus tres fases y fijar el orden de las acciones.
  • Definir el macrostep como ejecución hasta completar y como frontera de observabilidad.
  • Reconocer y evitar los macrosteps que no terminan.

Dos colas y una jerarquía de prioridades

Un intérprete conforme mantiene dos colas de eventos, y la diferencia entre ellas no es de origen sino de urgencia. La cola interna recibe todo lo que la propia máquina genera durante su ejecución: los eventos de raise, los de finalización de un estado compuesto, los errores de ejecución. La cola externa recibe lo que viene de fuera: la interacción del usuario, la respuesta de la red, el disparo de un temporizador, los mensajes de otros actores.

La regla es tajante. Mientras quede algo en la cola interna, la cola externa no se toca. Y por encima de ambas hay un tercer candidato con prioridad todavía mayor: las transiciones sin evento, las que en XState se declaran con always. El intérprete las evalúa antes de sacar nada de ninguna cola, porque expresan una condición que debe resolverse de inmediato para que la configuración sea legítima.

flowchart TD
A[inicio de iteracion] --> B{hay transiciones sin evento?}
B -->|si| M[ejecutar microstep]
M --> A
B -->|no| C{cola interna vacia?}
C -->|no| D[sacar evento interno]
D --> M
C -->|si| E[macrostep completo: configuracion estable]
E --> F[arrancar invocaciones pendientes]
F --> G[bloquear esperando evento externo]
G --> H[sacar evento externo y ejecutar microstep]
H --> A
style E fill:#a6e3a1,color:#11111b
style M fill:#89b4fa,color:#11111b
style G fill:#f9e2af,color:#11111b

Esa jerarquía —primero lo automático, luego lo interno, por último lo externo— tiene una consecuencia profunda: la máquina siempre termina de resolver sus asuntos propios antes de aceptar una nueva influencia del mundo. Es el equivalente en statecharts al principio de que no se atiende una interrupción mientras se está en una sección crítica. Sin esa disciplina, un evento externo llegado en mal momento podría observar una configuración a medio construir.

Nótese además que la cola externa no se drena: se consume de uno en uno. Cada vuelta del bucle exterior saca un único evento externo y le dedica un macrostep completo antes de mirar el siguiente. Los eventos que hayan llegado mientras tanto esperan su turno en orden de llegada, sin adelantamientos y sin fusiones. Esa serialización estricta es la que permite hablar de la traza de una máquina como una secuencia y no como un entrelazado, y es también la razón por la que enviar un evento a un actor nunca es una operación reentrante: el envío encola, no ejecuta.

ℹ️
Por que la cola interna gana

Si las dos colas tuvieran la misma prioridad, el efecto de un raise dependería de si algún evento externo llegó por casualidad entre medias. El comportamiento pasaría a depender del reloj y del scheduler, es decir, dejaría de ser determinista. Al dar prioridad absoluta a lo interno, SCXML garantiza que la secuencia de configuraciones que atraviesa una máquina ante una entrada dada sea siempre la misma, independientemente de la carga del sistema o del momento de llegada de los mensajes.

El microstep: salir, ejecutar, entrar

El microstep es la unidad indivisible del algoritmo, y su definición cabe en tres líneas. Dado un conjunto de transiciones habilitadas y ya libres de conflictos, se sale de los estados que corresponde abandonar, se ejecuta el contenido de las transiciones, y se entra en los estados que corresponde ocupar. Ni una fase más, ni un orden distinto.

// La forma del microstep, tal y como la fija el apendice normativo.
function microstep(transiciones: Transicion[]): void {
  exitStates(transiciones)              // fase 1: acciones de salida
  executeTransitionContent(transiciones) // fase 2: acciones de la transicion
  enterStates(transiciones)             // fase 3: acciones de entrada
}

Dentro de cada fase el orden también está fijado, y es el que hace que las jerarquías se comporten como uno espera. Al salir, los estados se abandonan del más profundo al más superficial: primero el hijo, luego el padre. Al entrar, el recorrido se invierte: primero el padre, luego el hijo. Es la disciplina de un constructor y un destructor, y garantiza que cuando el entry de un hijo se ejecuta, el entry de su padre ya ha establecido las condiciones que el hijo puede asumir.

⬆️

Fase 1 · salida

Se calcula el conjunto de salida a partir del dominio de cada transición, se guardan los valores de historia de los estados con hijos history, se cancelan las invocaciones vivas y se ejecutan los exit en orden inverso al documental.

➡️

Fase 2 · contenido

Se ejecutan las acciones declaradas en la propia transición, en orden documental. Ocurren cuando la máquina ya ha salido del origen pero todavía no ha entrado en el destino: es la única ventana con esa propiedad.

⬇️

Fase 3 · entrada

Se calcula el conjunto de entrada incluyendo los estados iniciales por defecto y los restaurados por historia, se ejecutan los entry en orden documental y se encolan los eventos de finalización si algún estado alcanza su final.

🧭

Invariante

Al terminar el microstep la configuración vuelve a ser consistente. Puede no ser estable —quizá haya habilitado transiciones sin evento—, pero es estructuralmente válida.

Que la fase 2 ocurra entre las otras dos no es un detalle menor. Significa que una acción declarada en la transición se ejecuta con el origen ya abandonado, de modo que no puede asumir recursos que el exit del origen acaba de liberar, y con el destino aún no ocupado, de modo que tampoco puede asumir lo que el entry del destino va a preparar. Esa ventana estrecha es exactamente donde deben vivir las acciones que pertenecen al paso y no a ninguno de los dos extremos.

Dentro de la fase de salida ocurren además tres cosas en un orden que también está fijado y que casi nunca se documenta en las guías prácticas. Primero se registran los valores de historia: cualquier estado history cuyo padre esté a punto de abandonarse guarda la configuración actual, y lo hace antes de que se ejecute ningún exit, porque después ya sería tarde. Segundo se detienen las invocaciones activas de los estados que se abandonan, de modo que las peticiones en vuelo reciben su señal de cancelación antes de que corra ninguna acción de usuario. Y solo entonces, tercero, se ejecutan los exit de dentro hacia fuera. Si alguna vez te has preguntado por qué un exit no puede leer un valor de historia que su propio estado acaba de perder, la respuesta está en ese orden.

📝
Un microstep no observa el evento a medias

Durante todo el microstep, la variable que representa el evento en curso permanece fija: las guardas se evaluaron con él, las acciones de salida lo ven, el contenido de la transición lo ve y las acciones de entrada también. No hay ningún punto intermedio en el que el evento cambie bajo los pies del código. Esa estabilidad es lo que permite escribir una acción de entrada que dependa del evento que la provocó sin preguntarse si todavía es el mismo.

El macrostep: ejecución hasta completar

Un microstep casi nunca viene solo. Al entrar en estados nuevos puede activarse una transición sin evento, o un entry puede levantar un evento interno, o un estado compuesto puede alcanzar su final y encolar su evento de finalización. Cada uno de esos efectos dispara otro microstep. La cadena continúa hasta que se cumplen dos condiciones simultáneas: no hay transiciones sin evento habilitadas y la cola interna está vacía. Esa cadena completa es el macrostep, y su final es lo que llamamos una configuración estable.

sequenceDiagram
participant E as Exterior
participant M as Maquina
E->>M: evento SUBMIT
Note over M: microstep 1 - entra en validando
Note over M: entry levanta VALIDATE
Note over M: microstep 2 - cola interna
Note over M: transicion sin evento a enviando
Note over M: microstep 3 - automatica
M-->>E: configuracion estable enviando
Note over E,M: solo aqui el observador puede leer

Lo que el diagrama no muestra, y conviene subrayar, es que la longitud de un macrostep no está acotada por nada salvo por el propio modelo. Puede constar de un solo microstep si el evento provoca una transición limpia, o de una decena si la máquina encadena transiciones automáticas, eventos levantados y finalizaciones de estados compuestos que a su vez habilitan más transiciones. Desde fuera todos esos casos son indistinguibles: un evento entra, una configuración estable sale.

La consecuencia práctica es la propiedad de ejecución hasta completar, y es la que da a los statecharts su carácter de sistema razonable. Entre el momento en que aceptas un evento externo y el momento en que vuelves a aceptar otro, la máquina es una caja cerrada. Ningún suscriptor recibe notificaciones de configuraciones intermedias, ningún selector se recalcula tres veces, ninguna vista renderiza el estado transitorio validando si el macrostep pasa por él y sale en el mismo ciclo. En XState esto se traduce en una regla que ahorra muchos errores: lo que reciben tus suscriptores nunca es un estado intermedio, sino siempre el punto de reposo.

Concepto Unidad Observable desde fuera Disparado por
microstep Un conjunto de transiciones sin conflicto No Un evento o una transición sin evento
macrostep Cadena de microsteps hasta la estabilidad Sí, solo su resultado Un evento externo o el arranque
Configuración estable Estado de reposo del intérprete Fin del macrostep

Hay un tercer matiz que suele escapársele a quien viene de otras librerías: las invocaciones se arrancan al final del macrostep, no en mitad de él. Si un estado se ocupa y se abandona dentro del mismo macrostep, su invoke nunca llega a ejecutarse. No es una optimización de XState: es lo que manda el algoritmo, y es sensato, porque arrancar una petición de red para cancelarla tres microsteps después sería puro desperdicio.

Conviene separar bien dos nociones que la palabra estable confunde. Una configuración es consistente cuando satisface las reglas estructurales del árbol, y eso ocurre al final de cada microstep. Una configuración es estable cuando además no queda nada por hacer, y eso solo ocurre al final del macrostep. Todas las configuraciones estables son consistentes, pero no al revés: las intermedias de una cadena automática son perfectamente legales como conjuntos de estados y aun así no son puntos de reposo. La distinción importa porque el algoritmo mantiene la consistencia como invariante de cada paso y la estabilidad como condición de salida del bucle, y son dos garantías de naturaleza distinta.

Cuando el macrostep no termina

La disciplina tiene un precio, y es real. Si dos transiciones sin evento se apuntan mutuamente con guardas siempre verdaderas, o si un entry levanta un evento que devuelve la máquina al mismo estado, el macrostep no converge nunca. El intérprete queda girando en un bucle que jamás llega a la línea donde se bloquea esperando el evento externo, y con él se lleva el hilo entero.

// Patologia clasica: dos transiciones sin evento que se realimentan.
const bucle = setup({}).createMachine({
  initial: 'a',
  states: {
    a: { always: { target: 'b' } },
    b: { always: { target: 'a' } }, // el macrostep no converge jamas
  },
})
⚠️
La guarda es la condicion de terminacion

Una transición sin evento sin guarda es un bucle esperando a ocurrir en cuanto exista un camino de vuelta. La regla práctica es que toda transición always debe tener una guarda cuyo predicado se vuelva falso como consecuencia del propio paso, exactamente igual que la condición de un bucle while debe ser modificada por su cuerpo. Si no puedes señalar qué acción del microstep hará falsa esa guarda, no has escrito una transición automática: has escrito un bucle infinito con sintaxis de statechart.

La atomicidad no es una optimizacion: es lo que hace pensable la maquina

Lo más fácil de malinterpretar de esta lección es leer el macrostep como un detalle de implementación —un lote de cambios agrupados por eficiencia, como el batching de renders de una interfaz—. No lo es, y confundirlo cuesta caro. El macrostep es una decisión epistemológica sobre qué se puede conocer de una máquina y cuándo. Al declarar que solo las configuraciones estables son observables, el algoritmo establece que las intermedias no forman parte del contrato: existen dentro del procedimiento pero no en el modelo, igual que los valores de los registros durante la ejecución de una instrucción no forman parte de la arquitectura visible del procesador. Y esa frontera es precisamente lo que permite razonar. Si cada microstep fuera observable, el número de estados que un lector tendría que considerar al analizar una máquina se multiplicaría por la longitud de sus cadenas automáticas, y con él la superficie de error de cualquier consumidor; peor aún, el comportamiento observable dependería de detalles de ordenación interna que ninguna especificación querría fijar. Al colapsar la cadena entera en un único paso atómico, el algoritmo convierte una máquina con transiciones automáticas, eventos levantados y jerarquías profundas en algo que se puede describir con una relación simple entre configuración estable, evento y configuración estable. Toda la complejidad sigue ahí, pero encapsulada bajo una frontera que el observador no puede cruzar y por tanto no necesita entender. Eso es lo que significa que una semántica sea buena: no que describa todo lo que pasa, sino que decida con precisión qué parte de lo que pasa cuenta, y proteja al que razona de todo lo demás.

⚔️ Instrumentar el bucle
  1. Escribe una máquina con un entry que haga raise de un evento que provoca una transición, y esa transición a un estado con un always. Cuenta cuántos microsteps componen el macrostep del evento inicial.
  2. Suscríbete al actor y registra cada notificación. Comprueba empíricamente que solo ves la configuración final del macrostep y no las intermedias.
  3. Coloca un invoke en un estado que se ocupa y se abandona dentro del mismo macrostep. Verifica que el servicio nunca arranca y explica por qué usando el orden del bucle principal.
  4. Construye deliberadamente un macrostep no convergente con dos always sin guarda y observa cómo lo reporta XState. Después arréglalo añadiendo un contador en el context como condición de terminación.
  5. Escribe una acción en la propia transición y otra en el entry del destino, ambas leyendo el mismo valor del context. Predice qué ve cada una antes de ejecutarlo y contrasta con las tres fases del microstep.