iOSのRunLoop:その概要、動作モード、イベントループ

著者: IT Sectr 公開日: 2026-03-16 読了時間: 11 分

RunLoop — iOSのイベント処理ループで、CFRunLoop(Core Foundation)およびNSRunLoop(Foundation)オブジェクトによって実装されています。これはイベント(タッチ、タイマー、入力ソース、通知)を待機し、スレッド上の対応するハンドラにディスパッチするメカニズムです。iOSの各スレッドは最大1つのRunLoopを持ちますが、自動的に作成されるのはMain Threadのみです。Apple CFRunLoop Documentationによると、RunLoopはバックグラウンドスレッドでのタイマー、アニメーション、ソース監視の動作に不可欠です。

重要ポイント

  • RunLoop — スレッド上のイベント(タッチ、タイマー、入力ソース)を処理するイベントループ
  • Main Threadには自動RunLoop(CFRunLoopGetMain())があり、バックグラウンドスレッドは手動起動が必要
  • 3つのモード:.default(標準)、.tracking(スクロール)、.common(default+trackingを統合)
  • NSTimerとCADisplayLinkはスレッドにアクティブなRunLoopがないと動作しない
  • RunLoop observersを使用すると、モードの出入りや処理の開始/終了に反応できる

RunLoopとは

RunLoop — スレッド上のイベント処理を組織化するCore Foundationのインフラストラクチャオブジェクトです。本質的には無限ループ(while true)で、イベント(sources)の到着を待機し、ハンドラに渡します。イベントがない場合、RunLoopはスレッドをスリープモードに移行させ、バッテリー電力を節約します。イベントが到着するとスレッドが起き、処理を行い、再びスリープします。RunLoopはiOS/macOS(XNU + Core Foundation)にのみ存在します — AndroidではLooperがその役割を果たします。

各スレッドは最大1つのRunLoopを持ち、最初のアクセス時に遅延(lazy)作成されます。Main Threadの場合、RunLoopはアプリケーション起動時に自動的に作成されます。バックグラウンドスレッドの場合、CFRunLoopGetCurrent()またはRunLoop.currentが呼び出されるまでRunLoopは作成されません。メインRunLoopは、タッチイベントの処理、画面描画、DispatchQueue.mainブロックの実行、Core Animationレイヤーの管理を担当します。

RunLoopはスレッドではありません — スレッド内部のメカニズムです。スレッドはRunLoopなしで存在できますが(同期タスクを実行して終了する場合)、RunLoopはスレッドなしでは存在できません。アクティブなRunLoopを持つスレッドにイベントがない場合、CPUをブロックせず、待機(waiting)状態になります — これがCPUを100%消費するbusy-waitループとの重要な違いです。

RunLoopの仕組み:イベントループの構造

RunLoopは2種類のイベントソースを処理します:Input Sources(入力ソース)とTimer Sources(タイマー)。Input Sourcesは非同期イベントを配信します:タッチ、マウス移動、ソケットデータ、他のスレッドからのメッセージ(performSelector:onThread:)。Timer Sourcesはスケジュールに従って同期イベントを配信します:NSTimer、CADisplayLink。また、RunLoopの状態を監視するためのObserversも存在します。

RunLoopサイクルは順次フェーズで構成されます:モードへのエントリー(kCFRunLoopEntry)、タイマー処理(kCFRunLoopBeforeTimers)、入力ソース処理(kCFRunLoopBeforeSources)、ソース処理(kCFRunLoopAfterWaiting)、待機(sleep)、モードからの終了(kCFRunLoopExit)。現在のイテレーションでイベントが処理されなかった場合、RunLoopは新しいイベントがスレッドを起こすまで無期限にスリープ状態にします。

swift
import Foundation

// ObserverによるRunLoopフェーズのデモ
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 — スリープからの起床")
        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())")
    }

    // Main ThreadでのRunLoop.current.run()はUIApplicationMainによって呼び出される
    // 自動的に — 手動で起動する必要はない
    RunLoop.current.run() // この呼び出しはMain Threadに戻らない
}

サンプルobserveRunLoopActivitiesはメインRunLoopにObserverを登録し、サイクルの各フェーズをログに記録します。これはデバッグに便利です:.beforeTimersと.afterWaitingの間に長い間隔がある場合、Main Threadでの操作がRunLoopをブロックしていることを意味します。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モードで他のイベントと別に処理される)では柔軟性に欠けます。

RunLoopモード:default、tracking、common

RunLoop Mode — 現在アクティブなソース、タイマー、オブザーバーのセットです。モードを使用すると、優先順位に従ってイベント処理を分離できます。ユーザーがUITableViewをスクロールすると、RunLoopは.trackingモードに切り替わり、スクロールイベントと関連するタイマー/アニメーションのみが処理されます。他のすべてのソース(NSURLConnectionなど)は、スクロールモードが終了するまで一時停止されます。

3つの主要モード:.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
.initialRunkCFRunLoopInitialRunRunLoopModeRunLoopの初回起動

スクロール中にNSTimerが動作しない理由

古典的な問題:NSTimerを.defaultモードに追加すると、スクロール中にRunLoopが.trackingモードに切り替わり、.defaultのタイマーを処理しないため動作しなくなります。解決策 — タイマーを.commonModesに追加:RunLoop.current.add(timer, forMode: .common)。これにより、Timerが.defaultと.trackingの両方で動作するようになります。代替策 — NSTimerの代わりにDispatchQueue.main.asyncを使用する(GCDはスレッドレベルで動作し、RunLoopモードには依存しないため)。

バックグラウンドスレッドでの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.currentの呼び出し時にRunLoopが自動的に作成される
            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()

// performSelectorによるバックグラウンドRunLoopへのタスク送信
manager.perform(
    #selector(BackgroundRunLoopManager.backgroundTask),
    on: manager.thread,
    with: nil,
    waitUntilDone: false
)

BackgroundRunLoopManagerは永続的なRunLoopを持つバックグラウンドスレッドを作成します。空のPort()の追加が必要です — ソースがないとRunLoop.run()はfalseを返して終了します。performSelector:onThread:はバックグラウンドRunLoopにメッセージを送信します — RunLoopがBeforeSourcesフェーズに入ったときに処理されます。StopはバックグラウンドスレッドでCFRunLoopStopを呼び出し、ループを終了します。

NSTimerはRunLoopがBeforeTimersフェーズで処理するタイマーイベントを作成します。タイマーにはrepeating(繰り返し)とnon-repeating(1回限り)があります。NSTimerは精度を保証しません:RunLoopが長時間の操作でブロックされている場合、タイマーはブロック解除後に作動し、すべての見逃された作動は1つに統合されます(repeatingタイマーの場合は最大1つのキャッチアップ作動)。

CADisplayLink — 画面のリフレッシュレート(60/120/144 Hz)に同期された特殊なタイマーです。アニメーションやビデオ更新に使用されます。CADisplayLinkはRunLoopに追加され、各描画フレームの前に作動します(Core Animationがレイヤーをレンダリングに送る前)。フレームがスキップされた場合(display linkが16ms以内に作動できなかった場合)、次の呼び出しは次の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 msごと)
        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モードに追加され、スクロールに関係なく各フレームでのstep呼び出しが保証されます。displayLink.add(to: .current, forMode: .common) — スクロール時に中断してはいけないアニメーションの標準パターンです。NSTimerもスクロール中に動作するように.commonモードに追加されています。これがないと、タイマーは.defaultモードでのみ作動します。

RunLoop Observers:ループイベントの監視

CFRunLoopObserver — RunLoopのフェーズを追跡するメカニズムです。Observerを使用すると、モードへのエントリー、タイマー処理の開始、ソース処理の開始、スリープ後の起床、モードからの終了に関する通知を受け取ることができます。Observerはフレームワークが独自の目的で使用します:Core AnimationはRunLoopのスリープ前のレイヤー描画に使用し、UIKitはイベント処理後のレイアウト更新に使用します。

開発者も独自の目的で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()の後のコードの実行を続けることができます。

よくある質問

iOSのRunLoopとは何ですか?

RunLoop — iOSのイベント処理ループで、CFRunLoop(Core Foundation)とNSRunLoop(Foundation)によって実装されています。イベント(タッチ、タイマー、入力ソース)を待機し、スレッド上のハンドラにディスパッチします。各スレッドは1つのRunLoopを持つことができますが、自動的に作成されるのはMain Threadのみです。RunLoopはモード(.default、.tracking、.common)を管理し、優先順位に従って処理を分離します。

スクロール中にNSTimerが動作しないのはなぜですか?

NSTimerはデフォルトでRunLoopの.defaultモードに追加されます。ユーザーがスクロールすると、RunLoopは.trackingモードに切り替わり、.defaultのタイマーを処理しません。解決策:RunLoop.current.add(timer, forMode: .common)を使用してタイマーを.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 — iOSのイベントループ:スレッド上のタッチ、タイマー、入力ソースを処理;Main Threadは自動RunLoopを持つ
  • 3つのモード:.default(一般)、.tracking(スクロール)、.common(default + tracking)— イベントフィルタリングを制御
  • .defaultのNSTimerはスクロール時に作動しない — 解決策:RunLoop.current.add(timer, forMode: .common)で.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は2017年からスタートアップや企業向けにiOS・Androidアプリケーションを開発しています。私たちがご相談に乗り、最適なソリューションをご提案します。

プロジェクトについて相談

こちらもお読みください