01 · Punto de partida
De preguntar a que nos avisen
En el tema 14 el bucle principal preguntaba por cada cosa que podía pasar, una y otra vez. Es simple, pero cada fuente nueva agrega una pregunta al bucle, el bucle crece, y todo lo que no tiene que ver con la nueva fuente igual paga su costo. Compará los dos esquemas:
/* Sondeo: el programa pregunta, y cada fuente nueva es otra pregunta en el bucle */
for (;;) {
if (boton_apretado()) { atender_boton(); }
if (uart_hay_dato()) { atender_uart(); }
if (tiempo_vencido()) { atender_tiempo(); }
if (sensor_listo()) { atender_sensor(); } /* ...y así hasta tener 30 preguntas */
}
/* Orientado a eventos: el programa espera, y quien detecta algo avisa */
for (;;) {
Evento e = esperar_evento(); /* duerme hasta que haya uno */
despachar(&e); /* lo entrega a quien le corresponde */
}
Es el «principio de Hollywood»: no nos llame, nosotros lo llamamos. La diferencia, que parece de estilo, tiene consecuencias arquitectónicas profundas:
- El procesador duerme cuando no pasa nada. El bucle de sondeo gira siempre; el orientado a eventos se detiene en
esperar_evento()hasta la próxima interrupción (tema 34, bajo consumo). - Agregar una fuente no toca al resto. Una fuente nueva produce eventos nuevos y se agrega un manejador; las demás no cambian.
- Hay un único punto por donde pasa todo. Eso permite registrar, ordenar, priorizar y reproducir lo que ocurrió, algo imposible cuando las decisiones están desperdigadas en banderas.
- Los manejadores son cortos por construcción. Cada uno atiende un hecho y devuelve el control; el modelo mismo desalienta las esperas.
Ya usaste la idea sin llamarla por su nombre: la cola de eventos del tema 25, la tubería de tareas conectadas por colas del tema 21 y el buzón del tema 23 son eventos. Este tema le da forma de arquitectura.
02 · Las piezas
Anatomía de un sistema orientado a eventos
Casi todos los sistemas de este tipo tienen las mismas cuatro piezas, con independencia del lenguaje o del RTOS:
| Pieza | Responsabilidad | Lo que no debe hacer |
|---|---|---|
| Productores | Detectar un hecho (una interrupción, una tarea, un temporizador) y convertirlo en un evento en la cola | Decidir qué hacer con él; esperar a que alguien lo procese |
| Cola de eventos | Guardar los eventos en orden hasta que el despachador pueda atenderlos; absorber ráfagas | Crecer sin límite; perder eventos sin avisar |
| Despachador | Tomar un evento por vez y entregarlo al manejador correspondiente, hasta terminarlo (run to completion) | Tener lógica de negocio propia; bloquearse fuera de la espera de la cola |
| Manejadores | Reaccionar al evento: cambiar de estado, ejecutar acciones, producir otros eventos | Tardar; esperar; llamar a otro manejador directamente |
El costo de cada pieza y su conexión con lo que ya sabés: los productores son ISR cortas (tema 15) y tareas; la cola es el buffer circular del tema 16 o la cola de un RTOS (tema 21); el despachador es el bucle del tema 25; y los manejadores son máquinas de estados (temas 24 y 25).
03 · El material y el conducto
Los eventos y la cola
Un evento bien diseñado es pequeño, de tamaño fijo, inmutable y descriptivo. Se compone de una señal (qué pasó) y, si hace falta, unos pocos datos:
typedef enum {
SIG_BOTON_PRESIONADO, SIG_FC_ABIERTO, SIG_FC_CERRADO, SIG_OBSTACULO_DETECTADO,
SIG_TIEMPO_VENCIDO, SIG_BYTE_RECIBIDO, SIG_TEMPERATURA_NUEVA, SIG_CANT
} Senal;
typedef struct {
Senal senal; /* qué pasó */
union { /* los datos que acompañan al hecho, si los hay */
int32_t valor; /* una medida (milésimas de °C) */
uint8_t byte; /* un byte de la UART */
uint8_t id_timer; /* qué temporizador venció */
} dato;
} Evento; /* 8 bytes: se copia por la cola, sin punteros ni dueños dudosos */
El criterio de nombres importa más de lo que parece. Un evento es un hecho ocurrido (BOTON_PRESIONADO, TEMPERATURA_NUEVA), no una orden (ABRIR_PORTON). La diferencia es de responsabilidad: el productor del hecho no sabe qué se hará con él, y por eso no se acopla a quien lo atiende. Cuando el nombre es una orden, el productor ya decidió por el consumidor, y la arquitectura es en realidad una llamada a función disfrazada.
| Regla | Por qué |
|---|---|
| Nombre en pasado o descriptivo del hecho | El productor no impone qué hacer: se evita el acoplamiento |
| Tamaño pequeño y fijo (4 a 16 bytes) | Se copia por la cola en tiempo constante; la cola es un arreglo simple |
| Datos mínimos: solo lo que el hecho no permite reconstruir | Menos copias y menos formas de equivocarse |
| Inmutable una vez publicado | Los manejadores pueden leerlo sin riesgo de carreras |
| Para datos grandes, un puntero a un buffer de un pool con dueño definido | Copiar 256 bytes por cada evento es caro; pero quien recibe debe liberar el buffer, y eso hay que escribirlo |
La cola es el conducto. Ya la construiste en el tema 25 como un buffer circular con una sección crítica en la inserción; con un RTOS, es la cola del sistema. Tres decisiones de diseño la gobiernan:
- Capacidad. Absorbe las ráfagas: debe cubrir los eventos que pueden llegar juntos o durante el mayor intervalo en que el despachador no consume. La capacidad decide cuántos eventos se pierden; la duración de los manejadores decide cuánto tardan en atenderse. En la simulación de Python de este tema, una ráfaga de 21 eventos simultáneos pierde 5 con una cola de 16 sin importar lo rápidos que sean los manejadores; con una cola de 32 no se pierde ninguno, pero con manejadores de 8 ms el último evento espera 168 ms.
- Política de desborde. Si la cola se llena: descartar el evento nuevo, descartar el más viejo, o bloquear al productor (imposible si es una ISR). Se elige según el significado (una medida vieja vale menos que una nueva; una orden del usuario no debe perderse) y siempre se cuentan las pérdidas.
- Prioridades. Una cola FIFO entrega en orden de llegada, y un evento de emergencia queda detrás de los que ya esperaban. Si algunos eventos son urgentes, hay dos soluciones: una cola con prioridades (el urgente se inserta adelante) o dos colas, una de eventos urgentes que el despachador vacía primero. El laboratorio muestra la diferencia con un evento de emergencia.
04 · El corazón
El despachador y el tiempo
El despachador es un bucle muy corto. Toma un evento, lo entrega y repite; si no hay ninguno, duerme. Con el procesamiento de uno por vez y hasta completar (run to completion) garantiza que dos manejadores nunca se ejecutan a la vez ni se interrumpen entre sí, de modo que los datos de cada manejador no necesitan protección alguna: el mismo argumento de las tareas cooperativas del tema 19.
typedef void (*Manejador)(const Evento *e);
static const Manejador MANEJADORES[SIG_CANT]; /* qué función atiende cada señal (o cada objeto) */
void despachar(const Evento *e) {
Manejador m = MANEJADORES[e->senal];
if (m) m(e); /* sin manejador: el evento se ignora, y se registra */
}
int main(void) {
iniciar();
for (;;) {
Evento e;
if (evento_tomar(&e)) despachar(&e);
else esperar_interrupcion(); /* wfi: duerme hasta que algo vuelva a publicar */
}
}
De esa estructura se desprende la regla que gobierna todo el diseño: la latencia de un evento es, en el peor caso, la suma de las duraciones de los manejadores que tiene delante. Si un manejador tarda 8 ms, todo evento que llegue en ese lapso espera 8 ms más, sea cual sea su urgencia. Es el mismo bloqueo del tema 19; la forma de reducirlo es la misma: manejadores cortos, trabajo largo troceado o delegado, y eventos urgentes con prioridad.
Dos reglas más del despachador. Primera, un manejador no llama a otro manejador ni procesa eventos adentro: si necesita provocar otra reacción, publica un evento nuevo. Éste queda en la cola, detrás de los que ya estaban, y se atenderá cuando el actual termine. Segunda, el despachador no hace esperas salvo la de la cola vacía; un manejador que necesita tiempo (un retardo, un tiempo máximo) lo pide a un temporizador, y el temporizador responde con un evento.
Los temporizadores son, de hecho, el tercer gran productor de eventos. Se implementan con un servicio que cuenta los ticks del sistema y publica un evento cuando cada temporizador vence:
typedef struct { uint32_t restante, periodo; uint8_t id; bool activo; } Temporizador;
static Temporizador temps[8];
void temporizador_armar(uint8_t id, uint32_t ms, bool periodico) {
Temporizador *t = &temps[id];
t->restante = ms; t->periodo = periodico ? ms : 0; t->id = id; t->activo = true;
}
void temporizador_cancelar(uint8_t id) { temps[id].activo = false; }
void temporizadores_tick(void) { /* una vez por milisegundo: desde la ISR del tick (tema 17) */
for (unsigned i = 0; i < 8; i++) {
Temporizador *t = &temps[i];
if (t->activo && --t->restante == 0) {
Evento e = { SIG_TIEMPO_VENCIDO, { .id_timer = t->id } };
evento_publicar(&e); /* un evento más en la cola, como cualquier otro */
if (t->periodo) t->restante = t->periodo; else t->activo = false;
}
}
}
La consecuencia es elegante: la máquina de estados del portón no sabe de relojes. Arma un temporizador al entrar a ABIERTO y recibe TIEMPO_VENCIDO diez segundos después, en pie de igualdad con un botón o un fin de carrera. Todo lo que le pasa llega por la misma puerta.
05 · Desacoplar
Separar el evento de la acción
La idea más valiosa de este tema es una pregunta que conviene hacerse siempre: ¿quién sabe qué? Un sensor que mide la temperatura no debería saber que existe una pantalla, un registrador y una alarma. Si el código del sensor llama directamente a pantalla_mostrar(), registro_guardar() y alarma_evaluar(), el sensor queda acoplado a las tres: agregar una cuarta reacción obliga a modificar el sensor, probarlo de nuevo, y arriesgarse a romper lo que funcionaba.
El esquema orientado a eventos separa los dos lados con el patrón de publicación y suscripción (publish/subscribe). El productor publica un evento («hay una temperatura nueva») sin saber quién escucha, y los interesados se suscriben a esa señal:
#define MAX_SUSCRIPCIONES 16
typedef void (*Suscriptor)(const Evento *e);
typedef struct { Senal senal; Suscriptor atiende; } Suscripcion;
static Suscripcion suscripciones[MAX_SUSCRIPCIONES];
static unsigned cuantas;
bool bus_suscribir(Senal s, Suscriptor f) {
if (cuantas == MAX_SUSCRIPCIONES) return false;
suscripciones[cuantas++] = (Suscripcion){ s, f };
return true;
}
void bus_distribuir(const Evento *e) { /* lo llama el despachador */
for (unsigned i = 0; i < cuantas; i++)
if (suscripciones[i].senal == e->senal) suscripciones[i].atiende(e);
}
/* al iniciar el sistema: cada módulo declara qué le interesa */
bus_suscribir(SIG_TEMPERATURA_NUEVA, pantalla_mostrar);
bus_suscribir(SIG_TEMPERATURA_NUEVA, alarma_evaluar);
bus_suscribir(SIG_TEMPERATURA_NUEVA, registro_guardar); /* agregar este NO toca al sensor */
El programa de Python lo ilustra: el sensor publica dos lecturas con dos suscriptores; después se suscribe un tercero (el registro) y el sensor sigue publicando exactamente igual. El registro recibe las lecturas siguientes sin que el sensor haya cambiado una línea.
| Tipo | Ejemplo | Quién decide qué hacer | Acoplamiento |
|---|---|---|---|
| Notificación (hecho) | TEMPERATURA_NUEVA | Cada receptor | Mínimo: el emisor no sabe quién escucha |
| Orden (comando) | ENCENDER_CALEFACTOR | El emisor | El emisor conoce al receptor y lo que debe hacer; es una llamada a función disfrazada |
Las órdenes son legítimas cuando existe un dueño claro de un recurso (el guardián de la UART recibe ENVIAR_LINEA, tema 21), pero deben ser la excepción. La regla es: publicá hechos, no órdenes, y que cada módulo decida qué hacer con los hechos que le interesan.
La publicación y suscripción tiene sus costos, que hay que decidir con los ojos abiertos. Agrega una indirección: leer el código del sensor ya no te dice quién reacciona, y hay que consultar el mapa de suscripciones. El orden de entrega entre suscriptores no debe darse por supuesto. Y un suscriptor que publica a su vez otro evento puede, mal diseñado, producir cadenas o ciclos de eventos. La defensa es documentar el mapa de eventos del sistema (quién publica qué, quién escucha qué) en una tabla que se actualiza con el código.
06 · Cada módulo con su cola
Objetos activos
Cuando el sistema crece, una sola cola y un solo despachador se vuelven un cuello de botella: un manejador lento retrasa a todos. La respuesta de la programación orientada a eventos es el objeto activo (active object): un módulo que reúne una máquina de estados, su propia cola de eventos y su propio hilo de ejecución (una tarea del RTOS). Los objetos se comunican solo enviándose eventos, nunca compartiendo datos ni llamándose funciones:
typedef struct ObjetoActivo {
QueueHandle_t cola; /* su cola de entrada */
void (*procesar)(struct ObjetoActivo *yo, const Evento *e); /* su máquina de estados */
Estado estado; /* su estado: privado */
} ObjetoActivo;
static void tarea_del_objeto(void *arg) {
ObjetoActivo *ao = arg;
Evento e;
for (;;) {
xQueueReceive(ao->cola, &e, portMAX_DELAY); /* única espera permitida */
ao->procesar(ao, &e); /* de a uno, hasta completar */
}
}
bool ao_enviar(ObjetoActivo *ao, const Evento *e) { /* así se "habla" con un objeto: nunca se lo llama */
return xQueueSend(ao->cola, e, 0) == pdPASS; /* sin esperar; si está llena, se cuenta y se decide */
}
Es, en el fondo, el diseño del taller del tema 23 con otro nombre: cada tarea tenía su cola de entrada, sus datos privados y mensajes etiquetados. Las ventajas son concretas: no hay datos compartidos (cada objeto es dueño de lo suyo, con lo que desaparece la clase de errores del tema 22), cada objeto tiene el plazo y la prioridad que le corresponden, y los objetos son probables aislados: se le envían eventos y se observa lo que responde. Las reglas de oro son pocas: un objeto nunca espera otra cosa que su cola; nunca toca los datos de otro; y toda colaboración se resuelve con eventos, incluso las respuestas (si el objeto A pide algo a B, B contesta con un evento que A procesa cuando le llegue, no con un valor de retorno).
El costo es el de las tareas: una pila por objeto (tema 20), y la dificultad de razonar sobre una conversación asíncrona, donde las respuestas pueden llegar después de otros eventos y en un orden que no era el esperado. Un objeto que bloquea esperando la respuesta de otro (con un tiempo ilimitado) reintroduce el interbloqueo. No hace falta un objeto activo por cada módulo: se usan donde hay plazos, ritmos o responsabilidades realmente distintos. En un microcontrolador sin RTOS, los objetos pueden compartir una sola cola y un solo despachador, a costa de perder el aislamiento de plazos.
07 · Reducir la repetición
Estados jerárquicos
Cuando una máquina de estados crece aparece una repetición característica. Supongamos que el portón debe reaccionar a un evento EMERGENCIA (un botón de parada) en cualquiera de sus cinco estados: detener el motor y pasar a FALLO. En la máquina plana hay que escribir la misma transición cinco veces, una por estado, y cada estado nuevo que se agregue exige acordarse de agregarla. Una olvidada es un portón que no responde a la emergencia en ese estado.
La solución, de David Harel (los statecharts), es la jerarquía: se agrupan los cinco estados dentro de un superestado OPERATIVO, y se escribe EMERGENCIA → FALLO una sola vez, en el superestado. La regla de búsqueda es simple: cuando llega un evento, se busca la transición primero en el estado actual; si no la tiene, se busca en su superestado, y así hacia arriba. Los estados internos heredan el comportamiento de su padre, y pueden sobrescribirlo si necesitan una reacción propia.
#define NINGUNO 0xFF
typedef enum { E_CERRADO, E_ABRIENDO, E_ABIERTO, E_CERRANDO, E_DETENIDO, E_OPERATIVO, E_FALLO, E_CANT } Estado;
static const uint8_t PADRE[E_CANT] = { /* quién contiene a quién */
[E_CERRADO] = E_OPERATIVO, [E_ABRIENDO] = E_OPERATIVO, [E_ABIERTO] = E_OPERATIVO,
[E_CERRANDO] = E_OPERATIVO, [E_DETENIDO] = E_OPERATIVO,
[E_OPERATIVO] = NINGUNO, [E_FALLO] = NINGUNO,
};
/* la tabla del tema 25, con una fila más: OPERATIVO maneja EMERGENCIA una sola vez */
static const Transicion TABLA[E_CANT][EV_CANT] = {
/* ...las 9 transiciones de las hojas... */
[E_OPERATIVO] = { [EV_EMERGENCIA] = T(E_FALLO, NULL) }, /* SALIDA de OPERATIVO: motor_parar() */
[E_FALLO] = { [EV_REINICIO] = T(E_DETENIDO, NULL) },
};
static const Transicion *buscar(Estado s, Evento ev, Estado *donde) {
for (; s != NINGUNO; s = PADRE[s]) { /* sube por los superestados */
const Transicion *t = &TABLA[s][ev];
if (t->valida) { *donde = s; return t; }
}
return NULL; /* nadie lo maneja: se ignora */
}
Y la parte delicada: qué acciones de entrada y de salida se ejecutan al cruzar niveles. La regla es la misma de siempre llevada a la jerarquía: se sale de adentro hacia afuera hasta el ancestro común de origen y destino, y se entra de afuera hacia adentro hasta el destino. Moverse entre ABIERTO y CERRANDO (los dos dentro de OPERATIVO) no sale de OPERATIVO; pasar de CERRANDO a FALLO sale de CERRANDO y luego de OPERATIVO, y entra a FALLO. Esa propiedad hace útil al superestado: si OPERATIVO tiene como acción de salida motor_parar(), el motor se detiene siempre que se abandona el modo operativo, por cualquier camino, y no hay que repetirlo en ninguna transición.
static bool es_ancestro_o_igual(Estado a, Estado de) {
for (Estado s = de; s != NINGUNO; s = PADRE[s]) if (s == a) return true;
return false;
}
static Estado ancestro_comun(Estado origen, Estado destino) { /* el primer ancestro de 'origen' que contiene a 'destino' */
for (Estado s = PADRE[origen]; s != NINGUNO; s = PADRE[s]) if (es_ancestro_o_igual(s, destino)) return s;
return NINGUNO;
}
static void entrar_desde(Estado lca, Estado destino) { /* entra de afuera hacia adentro */
if (destino == lca || destino == NINGUNO) return;
entrar_desde(lca, PADRE[destino]);
if (DEF[destino].entrada) DEF[destino].entrada();
}
static void ir_a(Estado donde, Estado destino, Accion accion) {
Estado lca = ancestro_comun(donde, destino);
for (Estado s = estado; s != lca; s = PADRE[s]) /* salidas: de adentro hacia afuera */
if (DEF[s].salida) DEF[s].salida();
if (accion) accion();
entrar_desde(lca, destino); /* entradas: de afuera hacia adentro */
estado = destino;
}
El programa de Python implementa las dos versiones de la misma máquina, plana y jerárquica, y las compara: la plana escribe 15 transiciones y la jerárquica 11, y producen exactamente el mismo comportamiento (mismos estados y mismas acciones en el mismo orden) en las 19 607 secuencias de hasta 5 eventos. La jerarquía no cambia el comportamiento: cambia cuánto hay que escribir y cuánto se puede olvidar.
Algunas precisiones. Las jerarquías no deben anidarse sin necesidad: dos o tres niveles son lo normal, y cada nivel agrega una búsqueda hacia arriba y complejidad de lectura. El superestado sirve para el comportamiento común (emergencias, apagado, errores, reinicios). Y cuando dos comportamientos son independientes (el motor y el indicador luminoso), la jerarquía no es la herramienta: son dos máquinas, o dos objetos activos, que se hablan por eventos. Existen además otras extensiones de los statecharts (estados de historia que recuerdan el último subestado activo, regiones ortogonales), útiles pero que no son necesarias en la mayoría de los productos pequeños.
08 · Lo que sale mal
Diseño y trampas
| Trampa | Cómo se ve | Defensa |
|---|---|---|
| Cola llena sin política | Los eventos desaparecen en las ráfagas | Dimensionar con la cuenta; política de desborde explícita; contador de pérdidas visible |
| Manejadores lentos | Los eventos urgentes esperan detrás de un trabajo largo | Manejadores cortos; trocear; delegar a un objeto activo de menor prioridad |
| Evento urgente en una cola FIFO | La emergencia se atiende después de 20 eventos corrientes | Cola con prioridades o cola urgente aparte |
| Procesar un evento dentro de un manejador | La máquina se llama a sí misma con el estado a medio cambiar | Publicar el evento nuevo; se atiende cuando el actual termine |
| Tormentas y ciclos de eventos | El evento A provoca B que provoca A; la cola se satura y el procesador no hace otra cosa | Mapa de eventos; el que publica no suscribe a su propia consecuencia; limitar la cadena |
| Datos grandes en el evento sin dueño | Un buffer liberado dos veces o nunca | Pool de buffers con regla clara de quién libera |
| Respuesta síncrona esperada | Un objeto se bloquea esperando la respuesta de otro, que a su vez lo espera | Respuestas como eventos; tiempos máximos |
| Imposible seguir el flujo | No hay pila de llamadas que muestre quién produjo el evento | Registro circular de eventos (señal, instante, quién lo publicó); depuración con trazas |
| Orden entre suscriptores | Dos suscriptores dependen de que uno corra antes que otro | No suponer el orden; si importa, encadenar con un evento |
Una última advertencia sobre el uso de la arquitectura misma: no todo programa necesita ser orientado a eventos. Un firmware chico, con dos o tres actividades, está mejor con un bucle y una máquina de estados. El estilo de eventos rinde cuando hay muchas fuentes, muchas reacciones y un sistema que va a seguir creciendo; antes de eso, es infraestructura que cuesta más de lo que ahorra.
09 · Analogía con programación
Cola, bus y jerarquía en Python
El programa tiene tres partes. La primera escribe la máquina del portón con una emergencia en las dos versiones, plana (la emergencia repetida en cada estado) y jerárquica (una vez, en el superestado OPERATIVO, con la acción de salida que detiene el motor), y verifica que son indistinguibles. La segunda implementa un bus de publicación y suscripción mínimo y agrega un suscriptor sin tocar al productor. La tercera simula una cola de eventos con una ráfaga y mide pérdidas y esperas según la capacidad y la duración de los manejadores.
Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.
from itertools import product
EVENTOS = ["BOTON", "FC_ABIERTO", "FC_CERRADO", "OBSTACULO", "TIMEOUT", "EMERGENCIA", "REINICIO"]
BASE = {("CERRADO", "BOTON"): ("ABRIENDO", "abrir"),
("ABRIENDO", "FC_ABIERTO"): ("ABIERTO", "parar"), ("ABRIENDO", "BOTON"): ("DETENIDO", "parar"),
("ABIERTO", "BOTON"): ("CERRANDO", "cerrar"), ("ABIERTO", "TIMEOUT"): ("CERRANDO", "cerrar"),
("CERRANDO", "FC_CERRADO"): ("CERRADO", "parar"), ("CERRANDO", "BOTON"): ("DETENIDO", "parar"),
("CERRANDO", "OBSTACULO"): ("ABRIENDO", "abrir"), ("DETENIDO", "BOTON"): ("CERRANDO", "cerrar")}
HOJAS = ["CERRADO", "ABRIENDO", "ABIERTO", "CERRANDO", "DETENIDO"]
# ---- Parte 1: la misma máquina, plana y jerárquica ----------------------------------------------------------------
# Plana: EMERGENCIA hay que repetirla en cada uno de los 5 estados.
PLANA = dict(BASE)
for h in HOJAS:
PLANA[(h, "EMERGENCIA")] = ("FALLO", "parar")
PLANA[("FALLO", "REINICIO")] = ("DETENIDO", None)
SALIDA_PLANA = {"FALLO": "alarma_off"}
ENTRADA_PLANA = {"FALLO": "alarma_on"}
# Jerárquica: las 5 hojas están dentro del superestado OPERATIVO, que maneja EMERGENCIA UNA sola vez.
PADRE = {h: "OPERATIVO" for h in HOJAS}
JERARQUICA = dict(BASE)
JERARQUICA[("OPERATIVO", "EMERGENCIA")] = ("FALLO", None)
JERARQUICA[("FALLO", "REINICIO")] = ("DETENIDO", None)
SALIDA_J = {"OPERATIVO": "parar", "FALLO": "alarma_off"} # salir del modo operativo, por cualquier camino, detiene el motor
ENTRADA_J = {"FALLO": "alarma_on"}
def cadena(x):
c = [x]
while c[-1] in PADRE: c.append(PADRE[c[-1]])
return c
def paso_plano(estado, ev):
t = PLANA.get((estado, ev))
if not t: return estado, []
sig, acc = t
acciones = ([SALIDA_PLANA[estado]] if estado in SALIDA_PLANA else []) + ([acc] if acc else []) + ([ENTRADA_PLANA[sig]] if sig in ENTRADA_PLANA else [])
return sig, acciones
def paso_jerarquico(estado, ev):
s = estado # se busca la transición subiendo por los superestados
while s is not None and (s, ev) not in JERARQUICA:
s = PADRE.get(s)
if s is None: return estado, []
sig, acc = JERARQUICA[(s, ev)]
cs, ct = cadena(s), cadena(sig)
lca = next((c for c in cs[1:] if c in ct), None) # ancestro común: hasta ahí se sale, desde ahí se entra
acciones = []
for x in cadena(estado): # salidas, de adentro hacia afuera
if x == lca: break
if x in SALIDA_J: acciones.append(SALIDA_J[x])
if acc: acciones.append(acc)
for x in reversed(ct): # entradas, de afuera hacia adentro
if x == lca: continue
if lca is not None and x in cadena(lca): continue
if x in ENTRADA_J: acciones.append(ENTRADA_J[x])
return sig, acciones
def traza(paso, eventos):
estado, salida = "CERRADO", []
for ev in eventos:
estado, acciones = paso(estado, ev)
salida.append((estado, tuple(acciones)))
return salida
print("Parte 1: máquina plana contra jerárquica")
print(f" transiciones escritas: plana = {len(PLANA)}, jerárquica = {len(JERARQUICA)}")
total = distintas = 0
for largo in range(1, 6):
for seq in product(EVENTOS, repeat=largo):
total += 1
distintas += traza(paso_plano, seq) != traza(paso_jerarquico, seq)
print(f" secuencias de hasta 5 eventos comparadas (estado y acciones, en orden): {total}; con diferencias: {distintas}")
for ev_seq in (["BOTON", "FC_ABIERTO", "BOTON", "EMERGENCIA", "REINICIO", "BOTON"], ["EMERGENCIA", "BOTON", "REINICIO"]):
print(" secuencia:", " ".join(ev_seq))
estado = "CERRADO"
for ev in ev_seq:
estado, acc = paso_jerarquico(estado, ev)
print(f" {ev:11s} -> {estado:9s} {acc}")
# ---- Parte 2: separar quien produce el evento de quien lo atiende (publicación y suscripción) -----------------------
class Bus:
def __init__(self): self.suscriptores = {}
def suscribir(self, tipo, manejador): self.suscriptores.setdefault(tipo, []).append(manejador)
def publicar(self, tipo, dato):
for m in self.suscriptores.get(tipo, []): m(dato)
bus, recibido = Bus(), {"alarma": [], "pantalla": [], "registro": []}
bus.suscribir("TEMPERATURA", lambda t: recibido["alarma"].append("ALARMA" if t >= 30000 else "ok"))
bus.suscribir("TEMPERATURA", lambda t: recibido["pantalla"].append(f"{t / 1000:.1f}"))
def sensor_publica(muestras): # el productor no sabe quién escucha
for m in muestras: bus.publicar("TEMPERATURA", m)
sensor_publica([29500, 30200])
bus.suscribir("TEMPERATURA", lambda t: recibido["registro"].append(t)) # un suscriptor nuevo, SIN tocar al sensor
sensor_publica([30800, 29900])
print()
print("Parte 2: publicación y suscripción")
for nombre, datos in recibido.items():
print(f" {nombre:9s} recibió {len(datos)} eventos: {datos}")
# ---- Parte 3: la cola de eventos absorbe ráfagas, pero cada manejador cuesta tiempo -----------------------------
def cola(duracion_manejador_ms, capacidad=16):
"""Un evento cada 10 ms, y una ráfaga de 20 eventos juntos a los 100 ms. Un solo despachador, de a uno."""
llegadas = [10.0 * k for k in range(1, 31)] + [100.0] * 20
llegadas.sort()
t, en_cola, perdidos, max_cola, max_espera, i = 0.0, [], 0, 0, 0.0, 0
while i < len(llegadas) or en_cola:
while i < len(llegadas) and llegadas[i] <= t:
if len(en_cola) < capacidad: en_cola.append(llegadas[i])
else: perdidos += 1
i += 1
max_cola = max(max_cola, len(en_cola))
if en_cola:
max_espera = max(max_espera, t - en_cola.pop(0) + duracion_manejador_ms)
t += duracion_manejador_ms
else:
t = llegadas[i]
return max_cola, perdidos, max_espera
print()
print("Parte 3: una ráfaga de 20 eventos a los 100 ms, más un evento cada 10 ms")
for capacidad in (16, 32):
for dur in (0.5, 2.0, 8.0):
mc, per, esp = cola(dur, capacidad)
print(f" cola de {capacidad:2d}, manejador de {dur:3.1f} ms: cola máxima {mc:2d}, perdidos {per:2d}, espera máxima {esp:6.1f} ms")
Resultado, parte 1: la máquina plana escribe 15 transiciones y la jerárquica 11; con 19 607 secuencias de hasta 5 eventos (siete eventos distintos), comparando estados y acciones en orden, no hay ninguna diferencia. En el ejemplo, la emergencia estando en CERRANDO produce ['parar', 'alarma_on'] en ambas: en la plana, porque la transición lleva la acción parar repetida; en la jerárquica, porque al salir del superestado OPERATIVO se ejecuta su acción de salida. Estando ya en FALLO, el botón se ignora y el reinicio lleva a DETENIDO con alarma_off. Parte 2: el sensor publica 4 lecturas; la alarma y la pantalla reciben las 4; el registro, suscripto después de las dos primeras, recibe solo las últimas 2, sin que el productor haya cambiado. Parte 3: con una cola de 16, la ráfaga de 21 eventos simultáneos pierde 5 eventos sea cual sea el manejador (0,5, 2 u 8 ms); con una cola de 32 no se pierde ninguno, pero la espera máxima es de 10,5, 42 o 168 ms según el manejador. La capacidad decide las pérdidas; la duración de los manejadores decide la latencia.
Probá agregar un octavo estado dentro de OPERATIVO (por ejemplo PARCIAL) en la versión jerárquica: hereda la emergencia sin escribir nada. En la plana, tendrías que acordarte. Es exactamente el tipo de olvido que la jerarquía elimina.
10 · Exploración interactiva
Cola y jerarquía
El laboratorio combina dos ideas del tema. Los eventos se publican en una cola (no se procesan hasta que lo pidas) y un despachador los toma de a uno. Y la máquina del portón puede estar plana o jerárquica, con el superestado OPERATIVO que resuelve EMERGENCIA y detiene el motor al salir. Podés elegir también una cola FIFO o una cola que da prioridad a la emergencia.
Un despachador, una cola y una máquina jerárquica
Cola de eventos (el primero es el próximo en procesarse)
(vacía)
Tamaño de la descripción
—
Bitácora del despachador (últimos 10)
—
Publicá eventos: quedan en la cola hasta que los proceses.
Recorrido guiado
- Publicá en orden
BOTON,FC_ABIERTO,BOTON,EMERGENCIA,REINICIO,BOTONy presioná «Procesar todos» con la estructura jerárquica. Cuando llega la emergencia estando enCERRANDO, la bitácora muestra[parar, alarma_on]y la aclaración de que la resolvió el superestadoOPERATIVO. ElREINICIOlleva aDETENIDOy apaga la alarma. - Reiniciá y repetí con la estructura plana: el comportamiento es idéntico, evento por evento. La única diferencia está en «Tamaño de la descripción»: 15 transiciones escritas contra 11. La jerarquía no cambia lo que hace el portón, sino cuánto hay que escribir (y cuánto se puede olvidar).
- Procesá de a uno: publicá tres botones y presioná «Procesar un evento» cada vez. Cada evento se completa antes de tomar el siguiente, y la cola muestra lo que queda esperando. Es run to completion.
- Con la cola FIFO publicá cuatro
BOTONy después unaEMERGENCIA, y procesá todo: el portón pasa porABRIENDO,DETENIDO,CERRANDOyDETENIDOantes de atender la emergencia, que recién llega al quinto turno. Para un evento urgente, esperar a cuatro es un defecto de diseño. - Reiniciá, cambiá la cola a con prioridad y repetí: la
EMERGENCIApasa al frente, se atiende primero, el portón (detenido por la emergencia enFALLO) ignora los cuatro botones que quedaban. La prioridad cambia qué se procesa primero, no lo que cada manejador hace. - Con la estructura jerárquica, publicá
EMERGENCIAestando enCERRADO, enABRIENDOy enDETENIDO(reiniciando entre cada una): siempre llega aFALLOconpararyalarma_on. Es una única transición escrita que sirve para todos los estados internos.
Desafío: ¿qué cambiaría si EMERGENCIA debiera ignorarse mientras el portón está CERRADO?
En la versión jerárquica habría que agregar en CERRADO una transición propia que capture EMERGENCIA sin hacer nada (un estado interno puede sobrescribir lo heredado), de modo que la búsqueda se detenga ahí y no suba al superestado. En la plana bastaría con quitar una de las cinco transiciones. Ambas dan el mismo resultado: la jerarquía hace el caso general más corto y la excepción más explícita, y la excepción queda escrita en el lugar donde ocurre.
11 · Método de trabajo
Método para diseñar por eventos
- Hacé el catálogo de eventos: todo lo que ocurre en el mundo y en el sistema y a lo que se debe reaccionar. Para cada uno, el nombre (un hecho), los datos mínimos, quién lo produce y a qué ritmo máximo.
- Hacé el mapa de suscripciones: quién escucha cada evento. Una tabla de dos columnas que se mantiene viva.
- Separá los hechos de las órdenes. Revisá cada evento con nombre imperativo: ¿es realmente una orden a un dueño de recurso, o un hecho mal nombrado?
- Agrupá en objetos las responsabilidades que tienen sus datos, su máquina de estados y su ritmo propios. Un objeto por cada una; no más.
- Dimensioná cada cola: ráfaga máxima, tasa media, mayor intervalo sin atender; política de desborde y contador de pérdidas.
- Definí qué eventos son urgentes y cómo se les da prioridad: cola con prioridades, cola aparte o un objeto de mayor prioridad.
- Simplificá las máquinas repetitivas con superestados donde un mismo evento se trata igual en muchos estados (emergencia, apagado, error).
- Mantené los manejadores cortos y calculá el peor caso de latencia: la suma de los manejadores que pueden estar delante.
- Agregá observabilidad: un registro circular de eventos con su instante y su origen, para reconstruir lo ocurrido.
- Probá enviando eventos: cada objeto se prueba aislado, alimentándolo con secuencias y observando estados y eventos de salida; el sistema completo, con secuencias de ráfagas y casos malos.
12 · Comprensión
Confusiones frecuentes
«Orientado a eventos es lo mismo que usar interrupciones»
Las interrupciones son una fuente posible de eventos, pero la arquitectura es otra cosa: un despachador y una cola que desacoplan a quien detecta de quien reacciona. Se puede programar por eventos sin ninguna interrupción (generándolos en el bucle) y se puede usar interrupciones con un diseño totalmente acoplado.
«Un evento es una llamada a función con otro nombre»
Una llamada es síncrona y el llamador conoce al llamado. Un evento es asíncrono (se atiende después, de a uno) y el productor no sabe quién lo atiende. Esa diferencia es la que permite agregar reacciones sin tocar al productor.
«Con una cola grande no puedo perder eventos»
Una cola grande absorbe ráfagas, no cambia el balance sostenido: si los eventos llegan más rápido de lo que se despachan, cualquier cola termina llena. Y un exceso de capacidad esconde el problema real, que es un manejador lento.
«La jerarquía hace que la máquina sea más difícil de entender»
Mal usada, sí (muchos niveles). Bien usada, es lo contrario: hace visible el comportamiento común y deja a cada estado solo lo que lo distingue. Lo difícil de entender es una máquina plana con la misma transición repetida diez veces, en la que nadie recuerda si en una de ellas se cambió algo.
«Publicar y suscribir es mejor que llamar directamente, siempre»
Tiene un costo de indirección, de orden de entrega y de seguimiento. Para dos módulos que siempre van a estar juntos, una llamada directa es más clara. Se desacopla donde se espera crecimiento o variación, no por ritual.
«Cada objeto activo necesita su tarea»
Es la forma más aislada, pero no la única: varios objetos pueden compartir una cola y un despachador (en un RTOS, una tarea), a costa de que un manejador lento retrase a los otros. Se separan en tareas los que tienen plazos o ritmos realmente distintos.
«Los manejadores pueden esperar un poco; el despachador sigue»
No hay otro hilo que siga: mientras un manejador espera, el despachador está detenido, y todos los eventos de la cola esperan con él. Un manejador que necesita tiempo pide un temporizador y retoma cuando llega el evento.
13 · Práctica guiada
Predecí antes de comprobar
1. Hecho u orden
Clasificá estos nombres de evento como hecho o como orden, y corregí los que haga falta: BOTON_PRESIONADO, ENCENDER_LUZ, BATERIA_BAJA, MOSTRAR_ERROR, TEMPERATURA_NUEVA.
Ver solución
Hechos: BOTON_PRESIONADO, BATERIA_BAJA, TEMPERATURA_NUEVA. Órdenes: ENCENDER_LUZ y MOSTRAR_ERROR. Se corrigen publicando el hecho que las provoca: en lugar de ENCENDER_LUZ, un hecho como PUERTA_ABIERTA o MOVIMIENTO_DETECTADO, que el módulo de iluminación decide cómo atender; en lugar de MOSTRAR_ERROR, un FALLO_DETECTADO (con el código del error) al que la pantalla se suscribe. Las órdenes solo se justifican cuando hay un dueño único del recurso, como la tarea guardián de la UART.
2. Latencia en el peor caso
Un despachador atiende tres tipos de manejador: A (0,5 ms), B (3 ms) y C (12 ms). Llega un evento urgente justo cuando C empieza, y en la cola ya hay un B y un A. ¿Cuánto espera el urgente antes de empezar a atenderse, con una cola FIFO? ¿Y con prioridad?
Ver solución
Con FIFO: espera a que termine C (12 ms, que no se puede interrumpir), después B (3 ms) y después A (0,5 ms): 15,5 ms antes de empezar. Con prioridad: pasa al frente de la cola, pero el manejador en ejecución no se interrumpe, así que espera solo el resto de C: hasta 12 ms. La prioridad en la cola ayuda con lo que está esperando, no con lo que ya está corriendo; para reducir ese bloqueo hay que acortar o trocear el manejador de 12 ms, o pasarlo a otro objeto activo de menor prioridad en un RTOS.
3. Tamaño de la cola
Una UART a 115200 baudios genera un evento por byte (11 520 por segundo). El despachador puede estar ocupado hasta 6 ms con un manejador largo. ¿Qué capacidad mínima necesita la cola de eventos solo para absorber los bytes?
Ver solución
11 520 B/s × 0,006 s = 69,1 → 70 eventos como mínimo, más los de otras fuentes y un margen: 128 sería una elección razonable. Un evento por byte es caro (8 bytes cada uno: 1 KB de cola); en la práctica, la ISR de la UART suele llenar un buffer de bytes (tema 16) y publicar un solo evento «hay datos» o «llegó una línea». Reducir los eventos es otra forma de dimensionar la cola.
4. Cuántas transiciones escribir
Una máquina plana tiene 8 estados y los eventos APAGAR y ERROR deben llevar siempre a los estados APAGADO y FALLO respectivamente, desde cualquiera de los 8 estados normales. ¿Cuántas transiciones hay que escribir en la versión plana y en la jerárquica para esos dos eventos?
Ver solución
Plana: 8 estados × 2 eventos = 16 transiciones. Jerárquica, con un superestado ENCENDIDO que contiene a los 8: 2 transiciones (una por evento, en el superestado). Y agregar un noveno estado interno no cuesta nada en la jerárquica, mientras que en la plana son 2 transiciones más, que es muy fácil olvidar.
5. Orden de las acciones al cruzar niveles
El superestado OPERATIVO tiene acción de salida motor_parar y FALLO tiene acción de entrada alarma_on. La máquina está en ABIERTO y llega EMERGENCIA. ¿Qué acciones se ejecutan y en qué orden? ¿Y si el evento fuera BOTON, que lleva de ABIERTO a CERRANDO?
Ver solución
Con EMERGENCIA: se sale de ABIERTO (sin acción), se sale de OPERATIVO (motor_parar), no hay acción de transición y se entra a FALLO (alarma_on): orden motor_parar, alarma_on. Con BOTON de ABIERTO a CERRANDO: el ancestro común es OPERATIVO, por lo que no se sale de OPERATIVO: no se ejecuta motor_parar ni nada de OPERATIVO; solo la acción de la transición (motor_cerrar). Esa distinción es lo que permite poner comportamiento «de modo» en el superestado sin que se dispare en cada movimiento interno.
6. Quién conoce a quién
Un módulo de registro de datos necesita guardar cada temperatura nueva. Hoy el sensor llama a registro_guardar(). Describí el cambio para pasar a eventos y dos cosas que ganás y una que perdés.
Ver solución
El sensor deja de llamar a registro_guardar() y publica un hecho, TEMPERATURA_NUEVA, con la medida; el registro se suscribe a esa señal al iniciarse. Se gana: (a) el sensor ya no depende del registro, y se puede agregar o quitar el registro (o una pantalla, o una alarma) sin tocarlo ni volver a probarlo; (b) el sensor y el registro se pueden probar por separado, alimentando eventos o recogiéndolos. Se pierde: la lectura directa del código: al leer el sensor, ya no se ve quién reacciona, y hay que consultar el mapa de suscripciones; además, hay latencia (la cola) y la posibilidad de perder el evento si la cola se llena.
14 · Síntesis
Ideas para recordar
- En la programación orientada a eventos el programa espera y le avisan: los productores publican hechos en una cola, un despachador los toma de a uno y los entrega a los manejadores, que son máquinas de estados. Cuando no hay eventos, el procesador duerme.
- Un evento es un hecho pequeño, de tamaño fijo e inmutable, con nombre descriptivo (no una orden). Los datos grandes viajan por punteros a un pool con dueño definido.
- La cola absorbe ráfagas: su capacidad decide cuántos eventos se pierden; la duración de los manejadores decide cuánto tardan en atenderse. Hace falta una política de desborde, un contador de pérdidas y, para lo urgente, prioridad.
- El despachador procesa un evento por vez y hasta completar; los manejadores son cortos, no esperan ni se llaman entre sí: si necesitan otra reacción, publican un evento. La latencia en el peor caso es la suma de los manejadores que están delante. El tiempo es otro productor: los temporizadores publican eventos.
- Separar quien produce el evento de quien lo atiende (publicación y suscripción) permite agregar reacciones sin tocar al productor. Se publican hechos, no órdenes; se documenta el mapa de eventos y no se supone el orden de entrega.
- Un objeto activo reúne una máquina de estados, su cola y su hilo; se comunican solo con eventos, sin datos compartidos. Se usan donde hay plazos o ritmos distintos.
- Los estados jerárquicos agrupan estados bajo un superestado que maneja una sola vez el comportamiento común (emergencia, error). La búsqueda sube por los padres; se sale de adentro hacia afuera hasta el ancestro común y se entra de afuera hacia adentro. La máquina plana (15 transiciones) y la jerárquica (11) se comportan igual en las 19 607 secuencias probadas.
- Las trampas: colas llenas sin política, manejadores lentos, eventos urgentes en una cola FIFO, tormentas de eventos, esperar respuestas síncronas, y dificultad para seguir el flujo (se resuelve con un registro de eventos). No todo programa necesita este estilo.
¿Listo para el tema 27?
Antes de avanzar, tomá un producto que conozcas y escribí su catálogo de eventos (nombre, datos, productor, ritmo) y su mapa de suscripciones; decidí qué máquina de estados tendría un superestado y por qué; y calculá la capacidad de la cola más exigente. Si podés justificar cada decisión, estás listo para el taller.
En el próximo tema, un taller, aplicamos todo el bloque a la lógica de un dispositivo real: una cerradura o un lavarropas, con su diagrama de estados, su implementación por eventos y sus pruebas.