Efectos y rendimiento: salir del hilo principal y medir de verdad
El reducer se ejecuta en el hilo principal por construcción, así que cada milisegundo de trabajo síncrono es un milisegundo robado al presupuesto del fotograma. Esta lección explica cómo sacar ese trabajo mediante efectos y dependencias, por qué en Swift moderno un `async` no garantiza abandonar el actor principal y qué hace falta para que lo abandone de verdad, cómo volver sin inundar el store con miles de acciones —agrupar, amortiguar, cancelar—, y cierra con la disciplina de medición: escenarios reproducibles, pruebas de rendimiento con métricas y señales, y el orden que impide optimizar lo que no dolía.
Hay una asimetría en TCA que conviene tener grabada antes de escribir una línea más: el reducer es síncrono y vive en el actor principal, mientras que el efecto es asíncrono y puede vivir donde quiera. Esa asimetría no es un detalle de implementación sino el contrato central de la arquitectura, porque es lo que permite que mutar el estado sea instantáneo, ordenado y libre de carreras. La factura de ese contrato es que todo lo que escribas dentro de la closure de Reduce compite directamente con el dibujo de la interfaz por el mismo hilo y el mismo presupuesto de ocho o dieciséis milisegundos. La consecuencia es una regla simple de enunciar y sorprendentemente difícil de sostener en un equipo: en el reducer se decide, en el efecto se trabaja. Esta lección trata la parte difícil, que no es enunciar la regla sino cumplirla en un lenguaje donde marcar algo como async ya no basta para abandonar el hilo principal, y donde volver del trabajo mal hecho puede costar más que el trabajo mismo.
- Justificar por qué el reducer se ejecuta en el actor principal y qué consecuencia impone a su contenido.
- Sacar trabajo pesado a dependencias y garantizar, no suponer, que se ejecuta fuera del actor principal.
- Devolver resultados agrupando, amortiguando y cancelando para no inundar el store de acciones.
- Diseñar una medición reproducible con métricas y señales, y ordenar las optimizaciones por evidencia.
El reducer corre en el hilo principal
El Store está aislado al actor principal y su reducer es una función síncrona que muta el estado en sitio. De ahí se sigue todo: no hay bloqueos que tomar porque no hay concurrencia que ordenar, el estado nunca se observa a medio actualizar, y las mutaciones ocurren en el orden exacto en que llegaron las acciones. A cambio, cualquier trabajo que pongas ahí bloquea el fotograma.
case .documentosRecibidos(let crudos):
// Todo esto ocurre en el hilo principal, entre dos fotogramas.
state.documentos = IdentifiedArray(
uniqueElements: crudos
.map(Documento.init)
.sorted { $0.modificado > $1.modificado }
)
return .none
Con cien elementos nadie lo nota; con veinte mil, ese case se come varios fotogramas enteros y el usuario ve un salto. Lo desconcertante es que el perfilador señalará a sorted y a la inicialización, que son código impecable: el defecto no está en cómo se hace el trabajo sino en dónde. El catálogo de lo que nunca debe aparecer dentro de Reduce es corto y casi siempre suficiente: decodificar, ordenar o filtrar colecciones grandes, formatear fechas y números en bucle, tocar el disco, redimensionar imágenes, comprimir, cifrar y cualquier llamada síncrona a una biblioteca de la que no sepas el coste.
Salir de verdad del hilo principal
La respuesta es devolver un Effect y hacer el trabajo dentro. La trampa moderna es creer que eso basta.
case .documentosRecibidos(let crudos):
return .run { send in
let ordenados = await procesador.preparar(crudos) // fuera del hilo principal
await send(.documentosPreparados(ordenados)) // vuelta al actor principal
}
La forma es correcta, pero la garantía depende de cómo esté declarado procesador.preparar. En Swift moderno, con el comportamiento de aislamiento por defecto que adoptan los proyectos recientes, una función async marcada como nonisolated se ejecuta en el actor de quien la llama, no en el conjunto de hilos cooperativo. Es decir: si la llamas desde un contexto aislado al actor principal, se queda en el actor principal, y el trabajo pesado que creías haber sacado sigue exactamente donde estaba. Para abandonarlo de verdad hace falta declararlo explícitamente, ya sea con el atributo que fuerza ejecución concurrente, con un actor propio que sirva de ejecutor, o con un Task.detached dentro de la implementación de la dependencia.
actor ProcesadorDeDocumentos {
func preparar(_ crudos: [DocumentoCrudo]) -> IdentifiedArrayOf<Documento> {
IdentifiedArray(
uniqueElements: crudos.map(Documento.init).sorted { $0.modificado > $1.modificado }
)
}
}
Un actor dedicado es la formulación más clara porque el aislamiento queda escrito en el tipo y no depende de banderas del compilador ni de la política de aislamiento vigente. Además da un sitio natural donde acumular estado auxiliar —cachés, índices, buffers— que no debía estar en el State, lo cual conecta con la lección anterior de forma nada casual.
Es el error de rendimiento más frecuente en bases de código que migraron a concurrencia estricta reciente. Comprueba tu suposición en lugar de confiar en ella: imprime Thread.isMainThread dentro de la función pesada, o pon un punto de interrupción y mira el hilo en el depurador. Si la respuesta es que sigues en el principal, el await te dio ordenación pero no paralelismo, y no has movido nada.
Volver sin inundar el store
El camino de vuelta tiene su propio coste y suele subestimarse. Cada await send(...) es una acción completa: recorre el árbol de reducers entero, muta el estado, dispara la observación y puede invalidar vistas. Emitir mil acciones desde un bucle en un efecto es mil recorridos del árbol, y da igual que el trabajo se hiciera en segundo plano.
// Mal: mil recorridos del arbol y mil rondas de invalidacion.
return .run { send in
for elemento in try await cliente.descargarTodo() {
await send(.elementoRecibido(elemento))
}
}
// Bien: un recorrido, una mutacion, una ronda de invalidacion.
return .run { send in
let todos = try await cliente.descargarTodo()
await send(.elementosRecibidos(todos))
}
Cuando la fuente es un flujo continuo y no se puede esperar al final, la técnica es acumular por ventana temporal y emitir por lotes, con el reloj inyectado para que el comportamiento siga siendo comprobable en un test.
return .run { send in
var lote: [Medida] = []
for await medida in sensor.flujo() {
lote.append(medida)
if lote.count >= 50 {
await send(.medidasRecibidas(lote))
lote.removeAll(keepingCapacity: true)
}
}
}
.cancellable(id: CancelID.sensor, cancelInFlight: true)
El identificador de cancelación no es opcional: un efecto de larga vida sin él se reinicia en cada aparición de la vista y acumula productores vivos que siguen enviando acciones a un store que ya nadie mira. El síntoma característico es una app que va bien el primer minuto y peor a cada navegación, con un coste que crece de forma escalonada y que ningún cambio en el reducer explica.
| Situación | Herramienta | Qué evita |
|---|---|---|
| Muchos resultados de una vez | Una sola acción con la colección | Recorridos y invalidaciones repetidas |
| Campo de texto que consulta | .debounce con reloj inyectado |
Una petición por pulsación |
| Flujo continuo de alta frecuencia | Acumular por ventana y emitir por lotes | Tormenta de acciones |
| Efecto que se relanza al aparecer | .cancellable(id:cancelInFlight:) |
Productores duplicados vivos |
| Bucle largo dentro de un efecto | await Task.yield() periódico |
Inanición de otras tareas |
flowchart LR A[Accion recibida en el reducer] --> D[Decidir y mutar campos baratos] D --> E[Devolver un Effect run] E --> P[Trabajo pesado en un actor dedicado] P --> B[Agrupar los resultados en un lote] B --> S[Un solo send de vuelta al actor principal] S --> M[Una mutacion y una ronda de invalidacion] style P fill:#a6e3a1,color:#11111b style S fill:#89b4fa,color:#11111b
Medir de verdad antes de optimizar
Todo lo anterior son hipótesis hasta que hay un número. Y hay tres números útiles, en este orden: el conteo de acciones y de evaluaciones de cuerpo, que es la medida más estable y la que señala la causa; el tiempo de la parte síncrona, que es lo que roba el fotograma; y el tiempo de tirón por segundo, que es lo único que el usuario percibe. Un cambio que mejora los milisegundos sin mover el conteo suele ser ruido de medición.
Escenario antes que herramienta
Escribe el escenario con principio y fin antes de abrir nada. Sin repetibilidad no hay comparación, y sin comparación no hay resultado.
Release y dispositivo real
La compilación de depuración y el simulador distorsionan en ambas direcciones. Mide donde vive el usuario, y en el aparato más modesto que soportes.
Un cambio, una medida
Dos cambios a la vez producen un resultado ininterpretable. Si mejora, no sabes cuál; si empeora, tampoco.
Fija una línea base
Una prueba de rendimiento con métricas y una línea base convierte una mejora puntual en una propiedad que el sistema de integración vigila por ti.
Hay además un orden de intervención que ahorra la mayor parte del trabajo y que casi nadie respeta, porque va de lo aburrido a lo interesante. Primero se elimina el trabajo que no hacía falta: acciones que se emiten de más, efectos que se relanzan sin motivo, cuerpos que leen lo que no dibujan. Segundo se reduce la frecuencia de lo que sí hace falta: amortiguar, agrupar, paginar. Tercero se mueve fuera del hilo principal lo que queda. Y solo en cuarto lugar se hace más rápido el algoritmo, que es lo único que suele apetecer y lo que menos veces resulta ser la causa. Saltar directamente al cuarto paso es la razón por la que tanta gente termina con un código más complicado y la misma cifra de fotogramas perdidos.
La última tarjeta merece desarrollarse porque es la que sobrevive al entusiasmo del primer día. Una prueba de rendimiento de XCTest mide un bloque bajo métricas de reloj, de CPU, de memoria o de intervalos de señal, repite la ejecución varias veces y compara contra una línea base almacenada, fallando si la desviación supera el margen. Aplicada a un escenario de dominio —construir el estado inicial con diez mil elementos, procesar un lote completo, enviar la ráfaga de acciones de un flujo típico— convierte el rendimiento en una propiedad verificada en cada integración en lugar de una anécdota de una tarde. Conviene ser honesto sobre su límite: mide trabajo de tu código, no fluidez de la interfaz, y por eso no sustituye al instrumento de tirones sino que lo complementa vigilando la parte que sí es determinista.
Vale la pena reconocer que la regla de esta lección —decidir en el reducer, trabajar en el efecto— no se inventó por rendimiento, y sin embargo lo resuelve casi entero. Su motivación original fue la testabilidad: si el reducer es una función pura y síncrona de estado y acción, se puede afirmar exactamente qué hace sin ejecutar nada más, y si todo lo impuro se declara como un valor devuelto, se puede sustituir sin tocar la lógica. El rendimiento apareció después, como corolario, y la coincidencia no es casual sino estructural. Lo que hace testeable a un reducer —ser corto, síncrono, sin esperas ni contacto con el mundo— es exactamente lo que lo hace barato de ejecutar entre dos fotogramas; y lo que hace sustituible a un efecto —ser un valor con un contrato explícito y una implementación intercambiable— es exactamente lo que permite moverlo a otro ejecutor sin que ninguna otra parte del sistema se entere. Cuando alguien mete un sorted sobre veinte mil elementos dentro de Reduce, no está cometiendo un error de rendimiento que casualmente también complica el test: está cometiendo un único error, el de confundir decidir con hacer, que se manifiesta simultáneamente como fotogramas perdidos, como un test que tarda, como una función que nadie sabe leer y como trabajo imposible de cancelar cuando el usuario cambia de pantalla. La generalización que merece llevarse más allá de TCA es esta: en un sistema bien estratificado, las propiedades deseables no son independientes entre sí, y por eso las violaciones tampoco lo son. Cuando una capa mezcla responsabilidades, no degrada una cosa sino todas a la vez, y —lo que resulta más útil en la práctica— cuando por fin se separa correctamente, todas mejoran de golpe con un solo cambio. Ese es el motivo real por el que merece la pena defender la frontera incluso cuando nada va lento todavía: no la estás defendiendo por velocidad, la estás defendiendo porque es la misma frontera que sostiene todo lo demás, y porque es mucho más barato mantenerla que reconstruirla el día que el perfilador te obligue.
- Busca en tus reducers el caso con más trabajo síncrono. Cronométralo con el operador de la lección anterior y anota los milisegundos que consume en el peor caso realista.
- Muévelo a una dependencia con un actor dedicado y comprueba con
Thread.isMainThread, o con el depurador, que la ejecución ocurre de verdad fuera del actor principal. Si sigue en el principal, arregla el aislamiento antes de continuar. - Localiza un efecto que emita acciones dentro de un bucle y conviértelo en un único envío con el lote completo. Mide la diferencia contando acciones, no milisegundos.
- Revisa todos tus efectos de larga vida y verifica que cada uno tiene identificador de cancelación. Provoca diez entradas y salidas de la pantalla y comprueba que no quedan productores vivos.
- Escribe una prueba de rendimiento con línea base para el escenario que arreglaste y hazla fallar a propósito reintroduciendo el trabajo en el reducer. Solo entonces tienes una defensa que sobrevive a tu memoria.