RunLoop en iOS: qué es, modos de funcionamiento y ciclo de eventos

Autor: IT Sectr Publicado: 2026-03-16 Tiempo de lectura: 11 min

RunLoop — un ciclo de procesamiento de eventos en iOS, implementado por los objetos CFRunLoop (Core Foundation) y NSRunLoop (Foundation). Este mecanismo espera eventos (toques, temporizadores, fuentes de entrada, notificaciones) y los envía a los manejadores correspondientes en el hilo. Cada hilo en iOS tiene como máximo un RunLoop, pero se crea automáticamente solo para el Main Thread. Según la Documentación de CFRunLoop de Apple, RunLoop es fundamental para temporizadores, animaciones y monitoreo de fuentes en hilos en segundo plano.

Puntos clave

  • RunLoop — un bucle de eventos que procesa eventos en un hilo: toques, temporizadores, fuentes de entrada
  • Main Thread tiene un RunLoop automático (CFRunLoopGetMain()), los hilos en segundo plano deben iniciarse manualmente
  • Tres modos: .default (principal), .tracking (desplazamiento), .common (combina default + tracking)
  • NSTimer y CADisplayLink no funcionan sin un RunLoop activo en el hilo
  • Los observers de RunLoop permiten reaccionar a la entrada/salida de modos y al inicio/fin del procesamiento

Qué es RunLoop

RunLoop es un objeto de infraestructura de Core Foundation que organiza el procesamiento de eventos en un hilo. En esencia, es un bucle infinito (while true) que espera la llegada de eventos (sources) y los pasa a los manejadores. Cuando no hay eventos, RunLoop pone el hilo en reposo, ahorrando batería. Cuando llega un evento, el hilo se despierta, lo procesa y vuelve a dormir. RunLoop existe solo en iOS/macOS (XNU + Core Foundation) — en Android, su función la realiza Looper.

Cada hilo tiene como máximo un RunLoop, que se crea de forma perezosa (lazy) en el primer acceso. Para el Main Thread, RunLoop se crea automáticamente al iniciar la aplicación. Para hilos en segundo plano, RunLoop no se crea hasta que se llama a CFRunLoopGetCurrent() o RunLoop.current. El RunLoop principal de la aplicación es responsable de procesar eventos táctiles, el renderizado de pantalla, ejecutar bloques de DispatchQueue.main y servir las capas de Core Animation.

RunLoop no es un hilo — es un mecanismo dentro de un hilo. Un hilo puede existir sin RunLoop (si realiza una tarea síncrona y finaliza), pero un RunLoop no puede existir sin un hilo. Cuando un hilo con un RunLoop activo no tiene eventos, no bloquea la CPU sino que entra en estado de espera — esta es una diferencia clave con un bucle de espera activa que consume el 100% de la CPU.

Cómo funciona RunLoop: anatomía del ciclo de eventos

RunLoop procesa dos tipos de fuentes de eventos: Input Sources (fuentes de entrada) y Timer Sources (temporizadores). Las Input Sources entregan eventos asíncronos: toques, movimientos del ratón, datos de socket, mensajes de otros hilos (performSelector:onThread:). Las Timer Sources entregan eventos síncronos programados: NSTimer, CADisplayLink. También existen Observers — puntos de entrada para monitorear el estado de RunLoop.

El ciclo de RunLoop consta de fases secuenciales: entrada a un modo (kCFRunLoopEntry), procesamiento de temporizadores (kCFRunLoopBeforeTimers), procesamiento de fuentes de entrada (kCFRunLoopBeforeSources), manejo de fuentes (kCFRunLoopAfterWaiting), espera (sleep) y salida del modo (kCFRunLoopExit). Si no se procesa ningún evento durante la iteración actual, RunLoop pone el hilo en reposo indefinidamente hasta que un nuevo evento lo despierte.

swift
import Foundation

// Demostración de las fases de RunLoop mediante Observer
func observeRunLoopActivities() {
    let observer = CFRunLoopObserverCreateWithHandler(
        nil,
        CFOptionFlags([[.entry, .beforeTimers, .beforeSources,
                           .afterWaiting, .exit]]),
        true,           // repeats
        0               // priority
    ) { observer, activity in
        switch activity {
        case .entry:
            print("Entry — RunLoop activado")
        case .beforeTimers:
            print("BeforeTimers — procesamiento de temporizadores")
        case .beforeSources:
            print("BeforeSources — procesamiento de fuentes")
        case .afterWaiting:
            print("AfterWaiting — despertar después del sueño")
        case .exit:
            print("Exit — RunLoop terminado")
        default:
            break
        }
    }

    CFRunLoopAddObserver(
        CFRunLoopGetCurrent(),
        observer,
        .commonModes
    )
}

// Ejemplo: RunLoop procesa un temporizador en el hilo principal
func timerOnMainRunLoop() {
    Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
        print("Tick: \(Date())")
    }

    // RunLoop.current.run() en Main Thread es llamado por UIApplicationMain
    // automáticamente — no es necesario iniciarlo manualmente
    RunLoop.current.run() // Esta llamada no retornará en Main Thread
}

El ejemplo observeRunLoopActivities registra un Observer en el RunLoop principal que registra cada fase del ciclo. Esto es útil para depuración: si ves un intervalo largo entre .beforeTimers y .afterWaiting, significa que RunLoop está bloqueado por una operación en el Main Thread. timerOnMainRunLoop muestra cómo NSTimer funciona automáticamente en el RunLoop principal — al crear Timer.scheduledTimer, añade el temporizador al RunLoop actual por defecto (modo .default).

RunLoop vs Looper (Android)

Looper de Android es un análogo de RunLoop. Looper.prepare() crea una cola de mensajes (MessageQueue) en un hilo, Looper.loop() inicia un bucle de procesamiento infinito. Handler envía mensajes y Runnables a esta cola. La principal diferencia: RunLoop admite modos, mientras que Looper de Android no. Looper procesa todos los mensajes sin filtrar por modo, lo que lo hace más simple pero menos flexible para escenarios con prioridades (por ejemplo, el desplazamiento en iOS se maneja en modo .tracking separadamente de otros eventos).

Modos de RunLoop: default, tracking, common

RunLoop Mode es un conjunto de fuentes, temporizadores y observers que están activos en un momento dado. Los modos permiten aislar el procesamiento de eventos por prioridad. Cuando un usuario desplaza un UITableView, RunLoop cambia al modo .tracking, en el que solo se procesan eventos de desplazamiento y temporizadores/animaciones relacionados. Todas las demás fuentes (por ejemplo, NSURLConnection) se suspenden hasta que se sale del modo de desplazamiento.

Tres modos principales: .default (NSDefaultRunLoopMode) — el modo principal en el que se procesan todos los eventos excepto el desplazamiento; .tracking (UITrackingRunLoopMode) — se activa durante el desplazamiento o la navegación por gestos; .common (NSRunLoopCommonModes) — no es un modo separado sino un conjunto de alias que incluye .default + .tracking. Añadir una fuente a .commonModes la añade automáticamente a todos los modos del conjunto.

ModoConstante Core FoundationConstante FoundationCuándo está activo
.defaultkCFRunLoopDefaultModeRunLoop.Mode.defaultEstado normal, sin desplazamiento
.trackingUITrackingRunLoopModeRunLoop.Mode.trackingDesplazamiento, gesture recognizers
.commonkCFRunLoopCommonModesRunLoop.Mode.commonPseudo-modo: default + tracking
.initialRunkCFRunLoopInitialRunRunLoopModePrimer inicio de RunLoop

Por qué NSTimer no se dispara durante el desplazamiento

Un problema clásico: NSTimer añadido al modo .default deja de dispararse durante el desplazamiento porque RunLoop cambia al modo .tracking y no procesa temporizadores de .default. La solución es añadir el temporizador a .commonModes: RunLoop.current.add(timer, forMode: .common). Esto hace que el Timer se dispare tanto en .default como en .tracking. Una alternativa es usar DispatchQueue.main.async en lugar de NSTimer, ya que GCD funciona a nivel de hilos, no a nivel de modos de RunLoop.

RunLoop en hilos en segundo plano

Los hilos en segundo plano no tienen un RunLoop por defecto. Si necesitas ejecutar NSTimer, procesar NSInputStream/NSOutputStream o responder a performSelector: en un hilo en segundo plano, debes crear e iniciar un RunLoop manualmente. Sin un RunLoop, los temporizadores y performSelector: nunca se dispararán — el hilo ejecutará el código y finalizará sin esperar eventos.

Para crear un RunLoop en un hilo en segundo plano, simplemente llama a RunLoop.current.run() al final del trabajo del hilo. Esta llamada bloquea el hilo indefinidamente, procesando eventos. Para detenerlo, usa CFRunLoopStop(CFRunLoopGetCurrent()). Importante: RunLoop.current crea un RunLoop de forma perezosa en el primer acceso — si no se llama a run(), no procesará eventos. Patrón: configurar fuentes -> añadir a RunLoop -> llamar a run().

swift
import Foundation

// Hilo en segundo plano con su propio RunLoop
class BackgroundRunLoopManager {

    private let thread: Thread
    private var isRunning = false

    init() {
        thread = Thread { [weak self] in
            // RunLoop se crea automáticamente al llamar a RunLoop.current
            let runLoop = RunLoop.current

            // Añadir un puerto para mantener RunLoop activo
            runLoop.add(Port(), forMode: .default)

            // Iniciar el procesamiento de eventos
            var isFinished = false
            while !isFinished {
                // run(mode:before:) devuelve true si se procesó un evento
                isFinished = !runLoop.run(mode: .default, before: Date.distantFuture)
            }
        }
        thread.name = "com.app.background-runloop"
    }

    func start() {
        thread.start()
        isRunning = true
    }

    func stop() {
        // Deteniendo RunLoop en un hilo en segundo plano
        self.perform(
            #selector(BackgroundRunLoopManager.stopRunLoop),
            on: thread,
            with: nil,
            waitUntilDone: false
        )
    }

    @objc
    private func stopRunLoop() {
        CFRunLoopStop(CFRunLoopGetCurrent())
        isRunning = false
    }
}

// Uso: temporizador en un RunLoop en segundo plano
let manager = BackgroundRunLoopManager()
manager.start()

// Enviar una tarea a un RunLoop en segundo plano mediante performSelector
manager.perform(
    #selector(BackgroundRunLoopManager.backgroundTask),
    on: manager.thread,
    with: nil,
    waitUntilDone: false
)

BackgroundRunLoopManager crea un hilo en segundo plano con un RunLoop persistente. Añadir un Port() vacío es necesario para que RunLoop no termine inmediatamente — sin fuentes, RunLoop.run() devuelve false y sale. performSelector:onThread: envía un mensaje al RunLoop en segundo plano — se procesará cuando RunLoop entre en la fase BeforeSources. Stop llama a CFRunLoopStop en el hilo en segundo plano, terminando el ciclo.

NSTimer crea un evento de temporizador que RunLoop procesa en la fase BeforeTimers. Los temporizadores pueden ser repetitivos (repeating) y no repetitivos (non-repeating). NSTimer no garantiza precisión de disparo: si RunLoop está bloqueado por una operación larga, el temporizador se disparará después del desbloqueo, y todos los disparos perdidos se fusionarán en uno (para temporizadores repetitivos — como máximo un disparo "recuperado").

CADisplayLink es un temporizador especializado sincronizado con la frecuencia de actualización de la pantalla (60/120/144 Hz). Se utiliza para animaciones y actualizaciones de video. CADisplayLink se añade a RunLoop y se dispara antes de cada fotograma de renderizado (antes de que Core Animation envíe la capa para renderizar). Si se pierde un fotograma (display link no se disparó en 16 ms), la siguiente llamada ocurre en el siguiente ciclo VSync.

swift
import UIKit

class AnimationController {

    private var displayLink: CADisplayLink?
    private var displayLinkTimer: Timer?
    private var startTime: CFTimeInterval = 0

    // CADisplayLink — animación con vsync
    func startDisplayLinkAnimation() {
        displayLink = CADisplayLink(target: self,
                                       selector: #selector(step))
        // Añadir al modo .common — funciona incluso durante el desplazamiento
        displayLink?.add(to: .current, forMode: .common)
        startTime = CACurrentMediaTime()
    }

    @objc
    private func step(displayLink: CADisplayLink) {
        let elapsed = CACurrentMediaTime() - startTime
        // Se llama en cada fotograma (60 FPS → cada 16.6 ms)
        print("Frame at \(elapsed) seconds")

        if elapsed > 5.0 {
            displayLink.invalidate() // detener después de 5 segundos
        }
    }

    // NSTimer — tarea periódica
    func startTimerInCommonMode() {
        displayLinkTimer?.invalidate()
        displayLinkTimer = Timer.scheduledTimer(
            withTimeInterval: 1.0,
            repeats: true
        ) { [weak self] timer in
            print("Timer tick")
        }

        // CLAVE: añadir a .common, de lo contrario el temporizador se congelará durante el desplazamiento
        RunLoop.current.add(displayLinkTimer!, forMode: .common)
    }

    func stop() {
        displayLink?.invalidate()
        displayLinkTimer?.invalidate()
    }
}

En AnimationController, CADisplayLink se añade al modo .common, lo que garantiza que step se llame en cada fotograma independientemente del desplazamiento. displayLink.add(to: .current, forMode: .common) — el patrón estándar para animaciones que no deben interrumpirse durante el desplazamiento. NSTimer también se añade al modo .common para que funcione durante el desplazamiento. Sin esto, el temporizador solo se dispararía en modo .default.

Observers de RunLoop: monitoreo de eventos del ciclo

CFRunLoopObserver es un mecanismo para rastrear las fases de RunLoop. Con un Observer, puedes recibir notificaciones sobre la entrada a un modo, el inicio del procesamiento de temporizadores, el inicio del procesamiento de fuentes, el despertar después del sueño y la salida del modo. Los Observers son utilizados por los frameworks para sus propias necesidades: Core Animation los usa para renderizar capas antes de que RunLoop entre en reposo, UIKit — para actualizar el diseño después del procesamiento de eventos.

Un desarrollador también puede añadir Observers para sus propios fines. Por ejemplo: medir el tiempo de procesamiento de eventos (perfilado), ejecutar operaciones diferidas antes de que RunLoop entre en reposo (cuando la UI ya está actualizada y el usuario no interactúa), guardar datos automáticamente durante la inactividad prolongada. Un Observer se registra a través de CFRunLoopAddObserver con un modo y una máscara de bits de las actividades rastreadas.

Los puntos de Observer más útiles: .afterWaiting — se ejecuta después de que RunLoop se despierta y puede contener código que debe ejecutarse después del procesamiento de eventos; .beforeTimers — antes del procesamiento de temporizadores, permite medir el tiempo transcurrido desde el procesamiento anterior; .exit — se dispara cuando RunLoop se detiene, útil para limpiar recursos del hilo en segundo plano.

CFRunLoopStop y terminación del ciclo

CFRunLoopStop es una función que termina forzosamente la iteración actual de RunLoop. Cuando se llama a CFRunLoopStop(CFRunLoopGetCurrent()), RunLoop termina el procesamiento del evento actual y sale de run(), devolviendo false. Esta es la forma estándar de detener un RunLoop en un hilo en segundo plano. En el Main Thread, no se recomienda CFRunLoopStop — el RunLoop principal debe ejecutarse durante toda la vida de la aplicación. Para hilos en segundo plano, después de CFRunLoopStop, el hilo puede terminar o continuar ejecutando el código después de run().

Preguntas frecuentes

Qué es RunLoop en iOS?

RunLoop es un ciclo de procesamiento de eventos en iOS, implementado por CFRunLoop (Core Foundation) y NSRunLoop (Foundation). Espera eventos (toques, temporizadores, fuentes de entrada) y los envía a los manejadores en el hilo. Cada hilo puede tener un RunLoop, pero se crea automáticamente solo para el Main Thread. RunLoop gestiona modos (.default, .tracking, .common), aislando el procesamiento por prioridad.

Por qué NSTimer no funciona durante el desplazamiento?

NSTimer se añade por defecto al modo .default de RunLoop. Cuando el usuario se desplaza, RunLoop cambia al modo .tracking y no procesa temporizadores de .default. Solución: añade el temporizador al modo .common mediante RunLoop.current.add(timer, forMode: .common). .common combina .default y .tracking, por lo que el temporizador se dispara en ambos modos.

Es necesario iniciar un RunLoop en un hilo en segundo plano?

Solo si el hilo en segundo plano usa temporizadores (NSTimer), performSelector:onThread:, NSInputStream/NSOutputStream o eventos Source. Si el hilo realiza una tarea síncrona (descarga de archivos, cálculos) y finaliza — RunLoop no es necesario. Para iniciarlo, llama a RunLoop.current.run() después de configurar las fuentes. Para detenerlo — CFRunLoopStop(CFRunLoopGetCurrent()).

En qué se diferencia RunLoop de GCD DispatchQueue?

RunLoop funciona a nivel de hilo y procesa eventos secuencialmente con soporte de modos. DispatchQueue es una abstracción de pool de hilos — las tareas se ejecutan en cualquier hilo disponible. GCD no admite modos y vive independientemente de RunLoop. DispatchQueue.main usa el RunLoop principal para ejecutar bloques — este es el único punto de intersección. Para tareas en segundo plano, se prefiere GCD.

Cómo se relaciona CADisplayLink con RunLoop?

CADisplayLink es un temporizador sincronizado con VSync (frecuencia de actualización de la pantalla). Se añade a RunLoop y se dispara antes de cada fotograma de renderizado en la fase BeforeTimers. CADisplayLink solo funciona en el Main Thread, ya que el renderizado de pantalla ocurre allí. Para animaciones continuas durante el desplazamiento, añádelo al modo .common: displayLink.add(to: .current, forMode: .common).

Resumen

  • RunLoop — bucle de eventos de iOS: procesa toques, temporizadores, fuentes de entrada en un hilo; Main Thread tiene un RunLoop automático
  • Tres modos: .default (general), .tracking (desplazamiento), .common (default + tracking) — controlan el filtrado de eventos
  • NSTimer en .default no se dispara durante el desplazamiento — solución: añadir al modo .common mediante RunLoop.current.add(timer, forMode: .common)
  • Hilos en segundo plano no tienen RunLoop por defecto — para temporizadores y performSelector: se requiere inicio manual mediante RunLoop.current.run()
  • CADisplayLink — un temporizador para cada fotograma VSync, esencial para animaciones suaves; añadir a .common para que funcione durante el desplazamiento
  • Observer de RunLoop — monitorea fases: Entry, BeforeTimers, BeforeSources, AfterWaiting, Exit; se usa para perfilado
  • RunLoop ≠ Looper: RunLoop de iOS admite modos y temporizadores, Looper de Android es más simple — sin modos, Handler + MessageQueue

Desarrollaremos una aplicación móvil llave en mano

IT Sectr crea aplicaciones para iOS y Android para startups y empresas desde 2017. Le asesoraremos y le propondremos la mejor solución.

Discutir el proyecto

Lea también