Practical Uses of RunLoop in iOS: Thread Keep‑Alive, Stopping, Lazy Image Loading, Lag Detection, Crash Protection, and Integration in the Matrix Framework
This article explains how iOS RunLoop can be leveraged for thread keep‑alive, controlled termination, lazy image loading, various lag‑detection techniques, crash protection strategies, and demonstrates its integration within the open‑source Matrix framework, providing concrete Swift and Objective‑C code examples.
RunLoop is a core component of iOS that implements the event‑loop model required for any single‑threaded UI system. Building on previous articles about its low‑level principles, this piece focuses on practical scenarios where developers can intentionally use RunLoop.
Thread Keep‑Alive
Long‑living threads are often needed for tasks such as sending heartbeat packets or processing audio without blocking the main thread. AFNetworking 1.0 used RunLoop to keep such threads alive.
var thread: Thread!
func createLiveThread() {
thread = Thread.init(block: {
let port = NSMachPort.init()
RunLoop.current.add(port, forMode: .default)
RunLoop.current.run()
})
thread.start()
}Note that a RunLoop mode must contain at least one port, timer, or observer; otherwise the RunLoop exits after a single iteration.
Stopping RunLoop
There are two ways to stop a RunLoop: set a timeout or explicitly notify it to exit. Apple recommends the timeout approach.
Setting a Timeout
In a thread‑keep‑alive scenario the timeout is variable. By initially setting the RunLoop timeout to .distantFuture and later resetting it to the current date, the loop can be stopped indirectly.
var thread: Thread?
var isStopped: Bool = false
func createLiveThread() {
thread = Thread.init(block: { [weak self] in
guard let self = self else { return }
let port = NSMachPort.init()
RunLoop.current.add(port, forMode: .default)
while !self.isStopped {
RunLoop.current.run(mode: .default, before: Date.distantFuture)
}
})
thread?.start()
}
func stop() {
self.perform(#selector(self.stopThread), on: thread!, with: nil, waitUntilDone: false)
}
@objc func stopThread() {
self.isStopped = true
RunLoop.current.run(mode: .default, before: Date.init())
self.thread = nil
}Direct Stop via CFRunLoopStop
CoreFoundation provides CFRunLoopStop() , but it only stops the current iteration, not the entire loop. Removing all sources/timers/observers is unreliable because the system may add hidden sources.
Although removing a run loop’s input sources and timers may also cause the run loop to exit, this is not a reliable way to stop a run loop. Some system routines add input sources to a run loop to handle needed events. Because your code might not be aware of these input sources, it would be unable to remove them, which would prevent the run loop from exiting.
Lazy Image Loading
When scrolling a UIScrollView / UITableView / UICollectionView , decoding images on the main thread can cause jank. By scheduling image decoding in the RunLoop’s default mode instead of the tracking mode, performance improves.
func setupImageView() {
self.performSelector(onMainThread: #selector(self.setupImage),
with: nil,
waitUntilDone: false,
modes: [RunLoop.Mode.default.rawValue])
}
@objc func setupImage() {
imageView.setImage()
}Lag Detection
Three common lag‑monitoring approaches all involve RunLoop.
CADisplayLink (FPS)
Tools like YYFPSLabel use a CADisplayLink to calculate frames‑per‑second. The example below shows a weak‑proxy to avoid retain cycles.
class WeakProxy: NSObject {
private weak var target: NSObjectProtocol?
init(target: NSObjectProtocol) { self.target = target; super.init() }
override func responds(to aSelector: Selector!) -> Bool {
return (target?.responds(to: aSelector) ?? false) || super.responds(to: aSelector)
}
override func forwardingTarget(for aSelector: Selector!) -> Any? { return target }
}
class FPSLabel: UILabel {
var link: CADisplayLink!
var count: Int = 0
var lastTime: TimeInterval = 0.0
fileprivate let defaultSize = CGSize(width: 80, height: 20)
override init(frame: CGRect) {
super.init(frame: frame)
if frame.size.width == 0 || frame.size.height == 0 { self.frame.size = defaultSize }
layer.cornerRadius = 5.0
clipsToBounds = true
textAlignment = .center
isUserInteractionEnabled = false
backgroundColor = UIColor.white.withAlphaComponent(0.7)
link = CADisplayLink.init(target: WeakProxy.init(target: self), selector: #selector(FPSLabel.tick(link:)))
link.add(to: RunLoop.main, forMode: .common)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
deinit { link.invalidate() }
@objc func tick(link: CADisplayLink) {
guard lastTime != 0 else { lastTime = link.timestamp; return }
count += 1
let timeDuration = link.timestamp - lastTime
guard timeDuration >= 1.0 else { return }
let fps = Double(count) / timeDuration
count = 0
lastTime = link.timestamp
let progress = fps / 60.0
let color = UIColor(hue: CGFloat(0.27 * (progress - 0.2)), saturation: 1, brightness: 0.9, alpha: 1)
self.text = "\(Int(round(fps))) FPS"
self.textColor = color
}
}Sub‑Thread Ping
A background thread repeatedly dispatches a tiny task to the main queue and sleeps for a short interval. If the main queue does not execute the task before the sleep ends, a lag is inferred.
class PingMonitor {
static let timeoutInterval: TimeInterval = 0.2
static let queueIdentifier = "com.queue.PingMonitor"
private var queue = DispatchQueue(label: queueIdentifier)
private var isMonitor = false
private var semaphore = DispatchSemaphore(value: 0)
func startMonitor() {
guard !isMonitor else { return }
isMonitor = true
queue.async {
while self.isMonitor {
var timeout = true
DispatchQueue.main.async {
timeout = false
self.semaphore.signal()
}
Thread.sleep(forTimeInterval: PingMonitor.timeoutInterval)
if timeout {
// TODO: capture crash stack, e.g., using PLCrashReporter
}
self.semaphore.wait()
}
}
}
}Real‑Time Monitoring via RunLoop Observers
By observing kCFRunLoopAfterWaiting and kCFRunLoopBeforeSources , one can detect when the main thread stays in a particular state for too long, indicating a stall.
class RunLoopMonitor {
private init() {}
static let shared = RunLoopMonitor()
var timeoutCount = 0
var runloopObserver: CFRunLoopObserver?
var runLoopActivity: CFRunLoopActivity?
var dispatchSemaphore: DispatchSemaphore?
func beginMonitor() {
let uptr = Unmanaged.passRetained(self).toOpaque()
let vptr = UnsafeMutableRawPointer(uptr)
var context = CFRunLoopObserverContext(version: 0, info: vptr, retain: nil, release: nil, copyDescription: nil)
runloopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
CFRunLoopActivity.allActivities.rawValue,
true,
0,
observerCallBack(),
&context)
CFRunLoopAddObserver(CFRunLoopGetMain(), runloopObserver, .commonModes)
dispatchSemaphore = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
while true {
let result = self.dispatchSemaphore?.wait(timeout: .now() + .milliseconds(80))
if result == .timedOut {
guard let activity = self.runLoopActivity else { continue }
if activity == .afterWaiting || activity == .beforeSources {
self.timeoutCount += 1
if self.timeoutCount >= 3 {
// Capture crash report using PLCrashReporter (omitted for brevity)
}
}
}
}
}
}
func end() {
guard let _ = runloopObserver else { return }
CFRunLoopRemoveObserver(CFRunLoopGetMain(), runloopObserver, .commonModes)
runloopObserver = nil
}
private func observerCallBack() -> CFRunLoopObserverCallBack {
return { (observer, activity, context) in
let weakSelf = Unmanaged
.fromOpaque(context!).takeUnretainedValue()
weakSelf.runLoopActivity = activity
weakSelf.dispatchSemaphore?.signal()
}
}
}Crash Protection
When an app performs illegal operations, the OS raises a signal that can terminate the process. By catching these signals and restarting the main RunLoop, the app can survive crashes.
let runloop = CFRunLoopGetCurrent()
guard let allModes = CFRunLoopCopyAllModes(runloop) as? [CFRunLoopMode] else { return }
while true {
for mode in allModes {
CFRunLoopRunInMode(mode, 0.001, false)
}
}Matrix Framework Integration
The open‑source Matrix performance‑monitoring framework includes a WCFPSMonitorPlugin that uses CADisplayLink similarly to the FPS label shown earlier.
- (void)startDisplayLink:(NSString *)scene {
FPSInfo(@"startDisplayLink");
m_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(onFrameCallback:)];
[m_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
...
}
- (void)onFrameCallback:(id)sender {
double nowTime = CFAbsoluteTimeGetCurrent();
double diff = (nowTime - m_lastTime) * 1000;
if (diff > self.pluginConfig.maxFrameInterval) {
// record long frame, possibly a stall
} else {
// reset counters if within threshold
}
m_lastTime = nowTime;
}Conclusion
The article introduced RunLoop usage starting from thread keep‑alive, then covered lag detection and crash protection techniques. RunLoop’s applications extend far beyond these examples, and readers are encouraged to share additional use‑cases.
Sohu Tech Products
A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.