RunLoop trong iOS: nó là gì, các chế độ hoạt động và vòng lặp sự kiện

Tác giả: IT Sectr Đã đăng: 2026-03-16 Thời gian đọc: 11 phút

RunLoop — vòng lặp xử lý sự kiện trong iOS, được triển khai bởi các đối tượng CFRunLoop (Core Foundation) và NSRunLoop (Foundation). Đây là cơ chế chờ đợi các sự kiện (chạm, timer, nguồn đầu vào, thông báo) và phân phối chúng đến các bộ xử lý tương ứng trên thread. Mỗi thread trong iOS có tối đa một RunLoop, nhưng nó chỉ được tạo tự động cho Main Thread. Theo Apple CFRunLoop Documentation, RunLoop rất quan trọng cho hoạt động của timer, hoạt ảnh và giám sát nguồn trong các thread nền.

Những điểm chính

  • RunLoop — event loop xử lý sự kiện trên thread: chạm, timer, nguồn đầu vào
  • Main Thread có RunLoop tự động (CFRunLoopGetMain()), các thread nền — cần khởi động thủ công
  • Ba chế độ: .default (chính), .tracking (cuộn), .common (kết hợp default+tracking)
  • NSTimer và CADisplayLink không hoạt động nếu không có RunLoop đang chạy trên thread
  • RunLoop observers cho phép phản ứng khi vào/ra khỏi chế độ và bắt đầu/kết thúc xử lý

RunLoop là gì

RunLoop — là đối tượng cơ sở hạ tầng của Core Foundation tổ chức việc xử lý sự kiện trên thread. Về bản chất, nó là một vòng lặp vô hạn (while true) chờ đợi các sự kiện (sources) đến và chuyển chúng đến bộ xử lý. Khi không có sự kiện, RunLoop đưa thread vào chế độ ngủ (sleep), tiết kiệm năng lượng pin. Khi có sự kiện đến, thread thức dậy, xử lý sự kiện và lại ngủ tiếp. RunLoop chỉ tồn tại trong iOS/macOS (XNU + Core Foundation) — trong Android, vai trò này do Looper đảm nhận.

Mỗi thread có không quá một RunLoop, được tạo theo kiểu lazy (lười) khi truy cập lần đầu. Đối với Main Thread, RunLoop được tạo tự động khi khởi động ứng dụng. Đối với các thread nền, RunLoop không được tạo cho đến khi CFRunLoopGetCurrent() hoặc RunLoop.current được gọi. RunLoop chính của ứng dụng chịu trách nhiệm xử lý các sự kiện chạm, vẽ màn hình, thực thi các khối DispatchQueue.main và duy trì các lớp Core Animation.

RunLoop không phải là thread — nó là cơ chế bên trong thread. Một thread có thể tồn tại mà không có RunLoop (nếu thực hiện tác vụ đồng bộ và kết thúc), nhưng RunLoop không thể tồn tại nếu không có thread. Khi một thread với RunLoop đang hoạt động không có sự kiện, nó không chặn CPU mà ở trạng thái chờ (waiting) — đây là điểm khác biệt chính so với vòng lặp busy-wait tiêu tốn 100% CPU.

RunLoop hoạt động như thế nào: giải phẫu vòng lặp sự kiện

RunLoop xử lý hai loại nguồn sự kiện: Input Sources (nguồn đầu vào) và Timer Sources (bộ hẹn giờ). Input Sources cung cấp các sự kiện bất đồng bộ: chạm, chuyển động chuột, dữ liệu từ socket, tin nhắn từ thread khác (performSelector:onThread:). Timer Sources cung cấp các sự kiện đồng bộ theo lịch trình: NSTimer, CADisplayLink. Ngoài ra còn có Observers — các điểm vào để giám sát trạng thái RunLoop.

Vòng lặp RunLoop bao gồm các pha tuần tự: vào chế độ (kCFRunLoopEntry), xử lý timer (kCFRunLoopBeforeTimers), xử lý nguồn đầu vào (kCFRunLoopBeforeSources), xử lý nguồn (kCFRunLoopAfterWaiting), chờ (sleep), thoát chế độ (kCFRunLoopExit). Nếu không có sự kiện nào được xử lý trong lần lặp hiện tại, RunLoop đưa thread vào ngủ trong khoảng thời gian không xác định cho đến khi được đánh thức bởi một sự kiện mới.

swift
import Foundation

// Minh họa các pha của RunLoop thông qua 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 được kích hoạt")
        case .beforeTimers:
            print("BeforeTimers — xử lý timer")
        case .beforeSources:
            print("BeforeSources — xử lý nguồn")
        case .afterWaiting:
            print("AfterWaiting — thức dậy sau khi ngủ")
        case .exit:
            print("Exit — RunLoop kết thúc")
        default:
            break
        }
    }

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

// Ví dụ: RunLoop xử lý một timer trên thread chính
func timerOnMainRunLoop() {
    Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
        print("Tick: \(Date())")
    }

    // RunLoop.current.run() trên Main Thread được UIApplicationMain gọi
    // tự động — không cần khởi động thủ công
    RunLoop.current.run() // Lời gọi này sẽ không trả về trên Main Thread
}

Ví dụ observeRunLoopActivities đăng ký một Observer trên RunLoop chính ghi lại từng pha của vòng lặp. Điều này hữu ích cho việc gỡ lỗi: nếu bạn thấy khoảng trống dài giữa .beforeTimers và .afterWaiting, có nghĩa là RunLoop đang bị chặn bởi một thao tác trên Main Thread. timerOnMainRunLoop cho thấy NSTimer tự động hoạt động trên RunLoop chính như thế nào — khi tạo Timer.scheduledTimer, timer được thêm vào RunLoop hiện tại ở chế độ mặc định (.default mode).

RunLoop vs Looper (Android)

Android Looper — tương tự như RunLoop. Looper.prepare() tạo hàng đợi tin nhắn (MessageQueue) trên thread, Looper.loop() khởi động vòng lặp xử lý vô hạn. Handler gửi tin nhắn và Runnable vào hàng đợi này. Sự khác biệt chính: RunLoop hỗ trợ các chế độ (modes), còn Android Looper thì không. Looper xử lý tất cả tin nhắn mà không lọc theo chế độ, điều này làm nó đơn giản hơn nhưng kém linh hoạt hơn cho các tình huống có ưu tiên (ví dụ: cuộn trong iOS được xử lý ở chế độ .tracking riêng biệt với các sự kiện khác).

Chế độ RunLoop: default, tracking, common

RunLoop Mode — là tập hợp các nguồn, timer và người quan sát đang hoạt động tại thời điểm đó. Các chế độ cho phép cách ly xử lý sự kiện theo ưu tiên. Khi người dùng cuộn UITableView, RunLoop chuyển sang chế độ .tracking, trong đó chỉ xử lý các sự kiện cuộn và timer/hoạt ảnh tương ứng. Tất cả các nguồn khác (ví dụ: NSURLConnection) bị tạm dừng cho đến khi thoát khỏi chế độ cuộn.

Ba chế độ chính: .default (NSDefaultRunLoopMode) — chế độ chính, xử lý tất cả sự kiện trừ cuộn; .tracking (UITrackingRunLoopMode) — được kích hoạt khi cuộn hoặc điều hướng bằng cử chỉ; .common (NSRunLoopCommonModes) — không phải chế độ riêng mà là tập bí danh bao gồm .default + .tracking. Thêm một nguồn vào .commonModes tự động thêm nó vào tất cả các chế độ trong tập.

Chế độHằng số Core FoundationHằng số FoundationKhi nào hoạt động
.defaultkCFRunLoopDefaultModeRunLoop.Mode.defaultTrạng thái bình thường, không cuộn
.trackingUITrackingRunLoopModeRunLoop.Mode.trackingCuộn, gesture recognizers
.commonkCFRunLoopCommonModesRunLoop.Mode.commonChế độ giả: default + tracking
.initialRunkCFRunLoopInitialRunRunLoopModeLần chạy RunLoop đầu tiên

Tại sao NSTimer không hoạt động trong khi cuộn

Vấn đề kinh điển: NSTimer, được thêm vào chế độ .default, ngừng hoạt động trong khi cuộn vì RunLoop chuyển sang chế độ .tracking và không xử lý timer từ .default. Giải pháp — thêm timer vào .commonModes: RunLoop.current.add(timer, forMode: .common). Điều này làm cho Timer hoạt động cả trong .default và .tracking. Giải pháp thay thế — sử dụng DispatchQueue.main.async thay vì NSTimer, vì GCD hoạt động ở cấp độ thread, không phải chế độ RunLoop.

RunLoop trong các thread nền

Các thread nền không có RunLoop theo mặc định. Nếu trong thread nền cần chạy NSTimer, xử lý NSInputStream/NSOutputStream hoặc phản ứng với performSelector:, cần phải tạo và khởi động RunLoop thủ công. Nếu không có RunLoop, timer và performSelector: sẽ không bao giờ hoạt động — thread sẽ chạy mã và kết thúc mà không chờ đợi sự kiện.

Để tạo RunLoop trong thread nền, chỉ cần gọi RunLoop.current.run() ở cuối công việc của thread. Lời gọi này chặn thread vô thời hạn để xử lý sự kiện. Để dừng, sử dụng CFRunLoopStop(CFRunLoopGetCurrent()). Quan trọng: RunLoop.current tạo RunLoop theo kiểu lazy khi truy cập lần đầu — nếu không gọi run(), nó sẽ không xử lý sự kiện. Mẫu: cấu hình nguồn -> thêm vào RunLoop -> gọi run().

swift
import Foundation

// Thread nền với RunLoop riêng
class BackgroundRunLoopManager {

    private let thread: Thread
    private var isRunning = false

    init() {
        thread = Thread { [weak self] in
            // RunLoop được tạo tự động khi gọi RunLoop.current
            let runLoop = RunLoop.current

            // Chúng ta thêm một Port trống để duy trì RunLoop hoạt động
            runLoop.add(Port(), forMode: .default)

            // Bắt đầu xử lý sự kiện
            var isFinished = false
            while !isFinished {
                // run(mode:before:) trả về true nếu đã xử lý sự kiện
                isFinished = !runLoop.run(mode: .default, before: Date.distantFuture)
            }
        }
        thread.name = "com.app.background-runloop"
    }

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

    func stop() {
        // Dừng RunLoop trên thread nền
        self.perform(
            #selector(BackgroundRunLoopManager.stopRunLoop),
            on: thread,
            with: nil,
            waitUntilDone: false
        )
    }

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

// Sử dụng: timer trên RunLoop nền
let manager = BackgroundRunLoopManager()
manager.start()

// Gửi tác vụ đến RunLoop nền qua performSelector
manager.perform(
    #selector(BackgroundRunLoopManager.backgroundTask),
    on: manager.thread,
    with: nil,
    waitUntilDone: false
)

BackgroundRunLoopManager tạo một thread nền với RunLoop vĩnh viễn. Việc thêm một Port() trống là cần thiết để RunLoop không kết thúc ngay lập tức — nếu không có nguồn, RunLoop.run() trả về false và thoát. performSelector:onThread: gửi tin nhắn đến RunLoop nền — nó sẽ được xử lý khi RunLoop vào pha BeforeSources. Stop gọi CFRunLoopStop trên thread nền, kết thúc vòng lặp.

NSTimer tạo một sự kiện timer mà RunLoop xử lý trong pha BeforeTimers. Timer có thể là repeating (lặp lại) và non-repeating (một lần). NSTimer không đảm bảo độ chính xác: nếu RunLoop bị chặn bởi một thao tác dài, timer sẽ hoạt động sau khi được giải phóng, và tất cả các lần chạy bị bỏ lỡ sẽ được gộp thành một (đối với timer repeating — tối đa một lần chạy “bắt kịp”).

CADisplayLink — timer chuyên biệt, đồng bộ với tốc độ làm mới màn hình (60/120/144 Hz). Nó được sử dụng cho hoạt ảnh và cập nhật video. CADisplayLink được thêm vào RunLoop và hoạt động trước mỗi khung hình render (trước khi Core Animation gửi layer đi render). Nếu một khung hình bị bỏ qua (display link không kịp hoạt động trong 16 ms), lần gọi tiếp theo xảy ra trong chu kỳ VSync tiếp theo.

swift
import UIKit

class AnimationController {

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

    // CADisplayLink — hoạt ảnh với vsync
    func startDisplayLinkAnimation() {
        displayLink = CADisplayLink(target: self,
                                       selector: #selector(step))
        // Thêm vào chế độ .common — hoạt động ngay cả khi cuộn
        displayLink?.add(to: .current, forMode: .common)
        startTime = CACurrentMediaTime()
    }

    @objc
    private func step(displayLink: CADisplayLink) {
        let elapsed = CACurrentMediaTime() - startTime
        // Được gọi mỗi khung hình (60 FPS → mỗi 16.6 ms)
        print("Frame at \(elapsed) seconds")

        if elapsed > 5.0 {
            displayLink.invalidate() // dừng sau 5 giây
        }
    }

    // NSTimer — tác vụ định kỳ
    func startTimerInCommonMode() {
        displayLinkTimer?.invalidate()
        displayLinkTimer = Timer.scheduledTimer(
            withTimeInterval: 1.0,
            repeats: true
        ) { [weak self] timer in
            print("Timer tick")
        }

        // QUAN TRỌNG: chúng ta thêm vào .common, nếu không timer sẽ đóng băng khi cuộn
        RunLoop.current.add(displayLinkTimer!, forMode: .common)
    }

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

Trong AnimationController, CADisplayLink được thêm vào chế độ .common, đảm bảo rằng step được gọi trên mỗi khung hình bất kể cuộn hay không. displayLink.add(to: .current, forMode: .common) — mẫu tiêu chuẩn cho các hoạt ảnh không nên bị gián đoạn khi cuộn. NSTimer cũng được thêm vào chế độ .common để nó hoạt động trong khi cuộn. Nếu không có điều này, timer sẽ chỉ hoạt động ở chế độ .default.

RunLoop Observers: giám sát sự kiện vòng lặp

CFRunLoopObserver — cơ chế theo dõi các pha của RunLoop. Với Observer, bạn có thể nhận thông báo về việc vào chế độ, bắt đầu xử lý timer, bắt đầu xử lý nguồn, thức dậy sau ngủ, thoát chế độ. Các Observer được framework sử dụng cho nhu cầu riêng: Core Animation dùng chúng để render layer trước khi RunLoop ngủ, UIKit — để cập nhật layout sau khi xử lý sự kiện.

Nhà phát triển cũng có thể thêm Observer cho mục đích riêng. Ví dụ: đo thời gian xử lý sự kiện (profiling), thực thi các thao tác trì hoãn trước khi RunLoop ngủ (khi UI đã được cập nhật và người dùng không tương tác), tự động lưu dữ liệu khi không hoạt động kéo dài. Observer được đăng ký thông qua CFRunLoopAddObserver với chỉ định chế độ và mặt nạ bit của các hoạt động được theo dõi.

Các điểm hữu ích nhất cho Observer: .afterWaiting — được thực thi sau khi RunLoop thức dậy và có thể chứa mã cần chạy sau khi xử lý sự kiện; .beforeTimers — trước khi xử lý timer, cho phép đo thời gian đã trôi qua kể từ lần xử lý trước; .exit — kích hoạt khi RunLoop dừng, hữu ích để dọn dẹp tài nguyên của thread nền.

CFRunLoopStop và kết thúc vòng lặp

CFRunLoopStop — hàm buộc kết thúc lần lặp hiện tại của RunLoop. Khi gọi CFRunLoopStop(CFRunLoopGetCurrent()), RunLoop kết thúc xử lý sự kiện hiện tại và thoát khỏi run(), trả về false. Đây là cách tiêu chuẩn để dừng RunLoop trên thread nền. Trên Main Thread, CFRunLoopStop không được khuyến nghị — RunLoop chính nên chạy trong suốt vòng đời ứng dụng. Đối với thread nền, sau CFRunLoopStop thread có thể kết thúc hoặc tiếp tục thực thi mã tiếp theo sau run().

Câu hỏi thường gặp

RunLoop trong iOS là gì?

RunLoop — vòng lặp xử lý sự kiện trong iOS, được triển khai bởi CFRunLoop (Core Foundation) và NSRunLoop (Foundation). Nó chờ đợi sự kiện (chạm, timer, nguồn đầu vào) và phân phối chúng đến bộ xử lý trên thread. Mỗi thread có thể có một RunLoop, nhưng tự động nó chỉ được tạo cho Main Thread. RunLoop quản lý các chế độ (.default, .tracking, .common), cách ly xử lý theo ưu tiên.

Tại sao NSTimer không hoạt động trong khi cuộn?

NSTimer theo mặc định được thêm vào chế độ .default của RunLoop. Khi người dùng cuộn, RunLoop chuyển sang chế độ .tracking và không xử lý timer từ .default. Giải pháp: thêm timer vào chế độ .common qua RunLoop.current.add(timer, forMode: .common). .common kết hợp .default và .tracking, do đó timer hoạt động trong cả hai chế độ.

Có cần khởi động RunLoop trong thread nền không?

Chỉ khi thread nền sử dụng timer (NSTimer), performSelector:onThread:, NSInputStream/NSOutputStream hoặc sự kiện Source. Nếu thread thực hiện tác vụ đồng bộ (tải file, tính toán) và kết thúc — không cần RunLoop. Để khởi động, gọi RunLoop.current.run() sau khi cấu hình nguồn. Để dừng — CFRunLoopStop(CFRunLoopGetCurrent()).

RunLoop khác GCD DispatchQueue như thế nào?

RunLoop hoạt động ở cấp độ thread và xử lý sự kiện tuần tự với hỗ trợ chế độ. DispatchQueue — trừu tượng hóa pool thread, các tác vụ được thực thi trên bất kỳ thread trống nào. GCD không hỗ trợ chế độ và sống độc lập với RunLoop. DispatchQueue.main sử dụng RunLoop chính để thực thi khối — đây là điểm giao nhau duy nhất. Đối với tác vụ nền, GCD được ưu tiên hơn.

CADisplayLink liên quan đến RunLoop như thế nào?

CADisplayLink — timer đồng bộ với VSync (tốc độ làm mới màn hình). Nó được thêm vào RunLoop và hoạt động trước mỗi khung hình render trong pha BeforeTimers. CADisplayLink chỉ hoạt động trên Main Thread vì việc render màn hình diễn ra ở đó. Để có hoạt ảnh liên tục khi cuộn, hãy thêm nó vào chế độ .common: displayLink.add(to: .current, forMode: .common).

Tổng kết

  • RunLoop — event loop của iOS: xử lý chạm, timer, nguồn đầu vào trên thread; Main Thread có RunLoop tự động
  • Ba chế độ: .default (chung), .tracking (cuộn), .common (default + tracking) — quản lý lọc sự kiện
  • NSTimer trong .default không hoạt động khi cuộn — giải pháp: thêm vào chế độ .common qua RunLoop.current.add(timer, forMode: .common)
  • Thread nền không có RunLoop theo mặc định — cho timer và performSelector: cần khởi động thủ công qua RunLoop.current.run()
  • CADisplayLink — timer mỗi khung hình VSync, bắt buộc cho hoạt ảnh mượt mà; thêm vào .common để hoạt động khi cuộn
  • RunLoop Observer — giám sát pha: Entry, BeforeTimers, BeforeSources, AfterWaiting, Exit; được dùng cho profiling
  • RunLoop ≠ Looper: RunLoop iOS hỗ trợ chế độ và timer, Looper Android đơn giản hơn — không có chế độ, Handler + MessageQueue

Chúng tôi sẽ phát triển ứng dụng di động chìa khóa trao tay

IT Sectr tạo các ứng dụng iOS và Android cho các công ty khởi nghiệp và doanh nghiệp từ năm 2017. Chúng tôi sẽ tư vấn và đề xuất giải pháp tốt nhất cho bạn.

Thảo luận dự án

Đọc thêm