RunLoop в iOS: что это, режимы работы и цикл событий

Автор: IT Sectr Опубликовано: 2026-03-16 Время чтения: 11 мин

RunLoop — цикл обработки событий в iOS, реализованный объектами CFRunLoop (Core Foundation) и NSRunLoop (Foundation). Это механизм, который ожидает события (касания, таймеры, источники ввода, уведомления) и диспатчит их соответствующим обработчикам на потоке. Каждый поток в iOS имеет максимум один RunLoop, но он автоматически создаётся только для Main Thread. По данным Apple CFRunLoop Documentation, RunLoop критичен для работы таймеров, анимаций и мониторинга источников в фоновых потоках.

Главное

  • RunLoop — event loop, который обрабатывает события на потоке: касания, таймеры, источники ввода
  • Main Thread имеет автоматический RunLoop (CFRunLoopGetMain()), фоновые потоки — нужно запускать вручную
  • Три режима: .default (основной), .tracking (скролл), .common (объединяет default+tracking)
  • NSTimer и CADisplayLink не работают без активного RunLoop на потоке
  • RunLoop observers позволяют реагировать на вход/выход из режимов и начало/конец обработки

Что такое RunLoop

RunLoop — это инфраструктурный объект Core Foundation, который организует обработку событий на потоке. По своей сути это бесконечный цикл (while true), который ожидает поступления событий (sources) и передаёт их обработчикам. Когда событий нет, RunLoop переводит поток в спящий режим (sleep), экономя энергию батареи. При поступлении события поток пробуждается, обрабатывает его и снова засыпает. RunLoop существует только в iOS/macOS (XNU + Core Foundation) — в Android его роль выполняет Looper.

Каждый поток имеет не более одного RunLoop, который создаётся лениво (lazy) при первом обращении. Для Main Thread RunLoop создаётся автоматически при запуске приложения. Для фоновых потоков RunLoop не создаётся, пока не вызван CFRunLoopGetCurrent() или RunLoop.current. Главный RunLoop приложения отвечает за обработку touch-событий, экранную отрисовку, выполнение блоков DispatchQueue.main и обслуживание слоёв Core Animation.

RunLoop не является потоком — это механизм внутри потока. Поток может существовать без RunLoop (если выполняет синхронную задачу и завершается), но RunLoop не может существовать без потока. Когда поток с активным RunLoop не имеет событий, он не блокирует CPU, а находится в состоянии ожидания (waiting) — это ключевое отличие от busy-wait цикла, который тратит 100% CPU.

Как работает RunLoop: anatomy цикла событий

RunLoop обрабатывает два типа источников событий: Input Sources (источники ввода) и Timer Sources (таймеры). Input Sources доставляют асинхронные события: касания, движения мыши, данные из сокета, сообщения от других потоков (performSelector:onThread:). Timer Sources доставляют синхронные события по расписанию: NSTimer, CADisplayLink. Также существуют Observers — точки входа для мониторинга состояния RunLoop.

Цикл RunLoop состоит из последовательных фаз: вход в режим (kCFRunLoopEntry), обработка таймеров (kCFRunLoopBeforeTimers), обработка источников ввода (kCFRunLoopBeforeSources), обработка источников (kCFRunLoopAfterWaiting), ожидание (sleep), выход из режима (kCFRunLoopExit). Если за текущую итерацию не обработано ни одного события, RunLoop переводит поток в sleep на неопределённое время до пробуждения новым событием.

swift
import Foundation

// Демонстрация фаз RunLoop через 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 активирован")
        case .beforeTimers:
            print("BeforeTimers — обработка таймеров")
        case .beforeSources:
            print("BeforeSources — обработка источников")
        case .afterWaiting:
            print("AfterWaiting — пробуждение после sleep")
        case .exit:
            print("Exit — RunLoop завершён")
        default:
            break
        }
    }

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

// Пример: RunLoop обрабатывает таймер на главном потоке
func timerOnMainRunLoop() {
    Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
        print("Tick: \(Date())")
    }

    // RunLoop.current.run() на Main Thread вызывается UIApplicationMain
    // автоматически — не нужно запускать вручную
    RunLoop.current.run() // Этот вызов не вернётся на Main Thread
}

Пример observeRunLoopActivities регистрирует Observer на главном RunLoop, который логирует каждую фазу цикла. Это полезно для отладки: если вы видите длительный промежуток между .beforeTimers и .afterWaiting, значит RunLoop заблокирован операцией на Main Thread. timerOnMainRunLoop показывает, как NSTimer автоматически работает на главном RunLoop — при создании Timer.scheduledTimer добавляет таймер в текущий RunLoop по умолчанию (.default mode).

RunLoop vs Looper (Android)

Android Looper — аналог RunLoop. Looper.prepare() создаёт очередь сообщений (MessageQueue) на потоке, Looper.loop() запускает бесконечный цикл обработки. Handler отправляет сообщения и Runnable в эту очередь. Основное отличие: RunLoop поддерживает режимы (modes), а Android Looper — нет. Looper обрабатывает все сообщения без фильтрации по режиму, что делает его проще, но менее гибким для сценариев с приоритетами (например, скролл в iOS обрабатывается в .tracking mode отдельно от других событий).

RunLoop Modes: default, tracking, common

RunLoop Mode — это набор источников, таймеров и наблюдателей, которые активны в данный момент. Режимы позволяют изолировать обработку событий по приоритетам. Когда пользователь скроллит UITableView, RunLoop переключается в режим .tracking, в котором обрабатываются только события скролла и соответствующие таймеры/анимации. Все остальные источники (например, NSURLConnection) приостанавливаются до выхода из режима скролла.

Три основных режима: .default (NSDefaultRunLoopMode) — основной режим, в котором обрабатываются все события, кроме скролла; .tracking (UITrackingRunLoopMode) — активируется при скролле или жестовой навигации; .common (NSRunLoopCommonModes) — не отдельный режим, а набор псевдонимов, который включает .default + .tracking. Добавление источника в .commonModes автоматически добавляет его во все режимы набора.

РежимКонстанта Core FoundationКонстанта FoundationКогда активен
.defaultkCFRunLoopDefaultModeRunLoop.Mode.defaultОбычное состояние, без скролла
.trackingUITrackingRunLoopModeRunLoop.Mode.trackingСкролл, gesture recognizers
.commonkCFRunLoopCommonModesRunLoop.Mode.commonПсевдо-режим: default + tracking
.initialRunkCFRunLoopInitialRunRunLoopModeПервый запуск RunLoop

Почему NSTimer не срабатывает во время скролла

Классическая проблема: NSTimer, добавленный в .default mode, перестаёт срабатывать во время скролла, потому что RunLoop переключается в .tracking mode и не обрабатывает таймеры из .default. Решение — добавить таймер в .commonModes: RunLoop.current.add(timer, forMode: .common). Это заставит Timer срабатывать и в .default, и в .tracking. Альтернатива — использовать DispatchQueue.main.async вместо NSTimer, так как GCD работает на уровне потоков, а не RunLoop modes.

RunLoop в фоновых потоках

Фоновые потоки не имеют RunLoop по умолчанию. Если в фоновом потоке нужно запустить NSTimer, обрабатывать NSInputStream/NSOutputStream или реагировать на performSelector:, необходимо вручную создать и запустить RunLoop. Без RunLoop таймер и performSelector: никогда не сработают — поток запустит код и завершится, не дожидаясь событий.

Для создания RunLoop в фоновом потоке достаточно вызвать RunLoop.current.run() в конце работы потока. Этот вызов блокирует поток бесконечно, обрабатывая события. Для остановки используйте CFRunLoopStop(CFRunLoopGetCurrent()). Важно: RunLoop.current создаёт RunLoop лениво при первом обращении — если не вызвать run(), он не будет обрабатывать события. Паттерн: настройка источников -> добавление в RunLoop -> вызов run().

swift
import Foundation

// Фоновый поток с собственным RunLoop
class BackgroundRunLoopManager {

    private let thread: Thread
    private var isRunning = false

    init() {
        thread = Thread { [weak self] in
            // RunLoop создаётся автоматически при вызове RunLoop.current
            let runLoop = RunLoop.current

            // Добавляем порт для поддержания RunLoop активным
            runLoop.add(Port(), forMode: .default)

            // Запускаем обработку событий
            var isFinished = false
            while !isFinished {
                // run(mode:before:) возвращает true, если обработано событие
                isFinished = !runLoop.run(mode: .default, before: Date.distantFuture)
            }
        }
        thread.name = "com.app.background-runloop"
    }

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

    func stop() {
        // Остановка RunLoop на фоновом потоке
        self.perform(
            #selector(BackgroundRunLoopManager.stopRunLoop),
            on: thread,
            with: nil,
            waitUntilDone: false
        )
    }

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

// Использование: таймер на фоновом RunLoop
let manager = BackgroundRunLoopManager()
manager.start()

// Отправка задачи на фоновый RunLoop через performSelector
manager.perform(
    #selector(BackgroundRunLoopManager.backgroundTask),
    on: manager.thread,
    with: nil,
    waitUntilDone: false
)

BackgroundRunLoopManager создаёт фоновый поток с постоянным RunLoop. Добавление пустого Port() необходимо, чтобы RunLoop не завершался сразу — без источников RunLoop.run() возвращает false и выходит. performSelector:onThread: отправляет сообщение на фоновый RunLoop — оно будет обработано, когда RunLoop войдёт в фазу BeforeSources. Stop вызывает CFRunLoopStop на фоновом потоке, завершая цикл.

NSTimer создаёт событие-таймер, которое RunLoop обрабатывает в фазе BeforeTimers. Таймеры бывают repeating (повторяющиеся) и non-repeating (однократные). NSTimer не гарантирует точность срабатывания: если RunLoop заблокирован длительной операцией, таймер сработает после разблокировки, и все пропущенные срабатывания объединятся в одно (для repeating таймера — не более одного "нагнанного" срабатывания).

CADisplayLink — специализированный таймер, синхронизированный с частотой обновления экрана (60/120/144 Гц). Он используется для анимаций и обновления видео. CADisplayLink добавляется в RunLoop и срабатывает перед каждым кадром отрисовки (до того, как Core Animation отправит слой на рендеринг). Если кадр пропущен (display link не успел сработать за 16 мс), следующий вызов происходит в следующем цикле VSync.

swift
import UIKit

class AnimationController {

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

    // CADisplayLink — анимация с vsync
    func startDisplayLinkAnimation() {
        displayLink = CADisplayLink(target: self,
                                       selector: #selector(step))
        // Добавление в .common режим — работает и при скролле
        displayLink?.add(to: .current, forMode: .common)
        startTime = CACurrentMediaTime()
    }

    @objc
    private func step(displayLink: CADisplayLink) {
        let elapsed = CACurrentMediaTime() - startTime
        // Вызывается каждый кадр (60 FPS → каждые 16.6 мс)
        print("Frame at \(elapsed) seconds")

        if elapsed > 5.0 {
            displayLink.invalidate() // остановка через 5 секунд
        }
    }

    // NSTimer — периодическая задача
    func startTimerInCommonMode() {
        displayLinkTimer?.invalidate()
        displayLinkTimer = Timer.scheduledTimer(
            withTimeInterval: 1.0,
            repeats: true
        ) { [weak self] timer in
            print("Timer tick")
        }

        // КЛЮЧ: добавляем в .common, иначе таймер замрёт при скролле
        RunLoop.current.add(displayLinkTimer!, forMode: .common)
    }

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

В AnimationController CADisplayLink добавлен в .common mode, что гарантирует вызов step на каждом кадре независимо от скролла. displayLink.add(to: .current, forMode: .common) — стандартный паттерн для анимаций, которые не должны прерываться при скролле. NSTimer также добавлен в .common mode, чтобы тикать во время скролла. Без этого таймер срабатывал бы только в .default mode.

RunLoop Observers: мониторинг событий цикла

CFRunLoopObserver — механизм для отслеживания фаз RunLoop. С помощью Observer можно получать уведомления о входе в режим, начале обработки таймеров, начале обработки источников, пробуждении после сна, выходе из режима. Observer-ы используются фреймворками для своих нужд: Core Animation использует их для отрисовки слоёв перед сном RunLoop, UIKit — для обновления layout после обработки событий.

Разработчик также может добавлять Observer-ы для своих целей. Например: измерение времени обработки событий (профилирование), выполение отложенных операций перед сном RunLoop (когда UI уже обновлён и пользователь не взаимодействует), автоматическое сохранение данных при длительном бездействии. Observer регистрируется через CFRunLoopAddObserver с указанием режима и битовой маски отслеживаемых активностей.

Наиболее полезные точки для Observer: .afterWaiting — выполняется после пробуждения RunLoop и может содержать код, который должен запускаться после обработки события; .beforeTimers — перед обработкой таймеров, позволяет измерить время, прошедшее с предыдущей обработки; .exit — срабатывает при остановке RunLoop, полезно для очистки ресурсов фонового потока.

CFRunLoopStop и завершение цикла

CFRunLoopStop — функция, которая принудительно завершает текущую итерацию RunLoop. При вызове CFRunLoopStop(CFRunLoopGetCurrent()) RunLoop завершает обработку текущего события и выходит из run(), возвращая false. Это стандартный способ остановки RunLoop на фоновом потоке. На Main Thread CFRunLoopStop не рекомендуется — главный RunLoop должен работать всё время жизни приложения. Для фоновых потоков после CFRunLoopStop поток может завершиться или продолжить выполнение следующего кода после run().

Часто задаваемые вопросы

Что такое RunLoop в iOS?

RunLoop — цикл обработки событий в iOS, реализованный CFRunLoop (Core Foundation) и NSRunLoop (Foundation). Он ожидает события (касания, таймеры, источники ввода) и диспатчит их обработчикам на потоке. Каждый поток может иметь один RunLoop, но автоматически он создаётся только для Main Thread. RunLoop управляет режимами (.default, .tracking, .common), изолируя обработку по приоритетам.

Почему NSTimer не работает во время скролла?

NSTimer по умолчанию добавляется в .default режим RunLoop. Когда пользователь скроллит, RunLoop переключается в .tracking режим и не обрабатывает таймеры из .default. Решение: добавьте таймер в .common режим через RunLoop.current.add(timer, forMode: .common). .common объединяет .default и .tracking, поэтому таймер срабатывает в обоих режимах.

Нужно ли запускать RunLoop в фоновом потоке?

Только если фоновый поток использует таймеры (NSTimer), performSelector:onThread:, NSInputStream/NSOutputStream или Source-события. Если поток выполняет синхронную задачу (загрузка файла, вычисления) и завершается — RunLoop не нужен. Для запуска вызовите RunLoop.current.run() после настройки источников. Для остановки — CFRunLoopStop(CFRunLoopGetCurrent()).

Чем отличается RunLoop от GCD DispatchQueue?

RunLoop работает на уровне потока и обрабатывает события последовательно, с поддержкой режимов (modes). DispatchQueue — абстракция пула потоков, задачи выполняются на любом свободном потоке. GCD не поддерживает режимы и живёт независимо от RunLoop. DispatchQueue.main использует главный RunLoop для выполнения блоков — это единственная точка пересечения. Для фоновых задач предпочтительнее GCD.

Как CADisplayLink связан с RunLoop?

CADisplayLink — это таймер, синхронизированный с VSync (частотой обновления экрана). Он добавляется в RunLoop и срабатывает перед каждым кадром отрисовки в фазе BeforeTimers. CADisplayLink работает только на Main Thread, так как экранная отрисовка происходит там. Для непрерывных анимаций при скролле добавляйте его в .common режим: displayLink.add(to: .current, forMode: .common).

Итоги

  • RunLoop — event loop iOS: обрабатывает касания, таймеры, источники ввода на потоке; Main Thread имеет автоматический RunLoop
  • Три режима: .default (общий), .tracking (скролл), .common (default + tracking) — управляют фильтрацией событий
  • NSTimer в .default не срабатывает при скролле — решение: добавление в .common режим через RunLoop.current.add(timer, forMode: .common)
  • Фоновые потоки не имеют RunLoop по умолчанию — для таймеров и performSelector: требуется ручной запуск через RunLoop.current.run()
  • CADisplayLink — таймер на каждый кадр VSync, обязателен для плавных анимаций; добавляется в .common для работы при скролле
  • RunLoop Observer — мониторинг фаз: Entry, BeforeTimers, BeforeSources, AfterWaiting, Exit; используется для профилирования
  • RunLoop ≠ Looper: iOS RunLoop поддерживает режимы и таймеры, Android Looper проще — без режимов, Handler + MessageQueue

Мы разработаем мобильное приложение под ключ

IT Sectr создаёт приложения для iOS и Android для стартапов и бизнеса с 2017 года. Мы проконсультируем вас и предложим наилучшее решение.

Обсудить проект

Читайте также