跪拜 Guibai
← All articles
iOS

How Tencent Matrix Detects iOS Main Thread Hang: RunLoop State, Annealing Backoff, and a Real Report

By _瑞 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

A detector that notices a hang only after the main thread recovers often loses the exact stack that caused it. Matrix's out-of-band monitor, annealing backoff, and all-thread capture catch the stall while it is still happening without burying the evidence in duplicate reports.

Summary

Tencent Matrix builds its iOS hang detector around RunLoop state transitions rather than FPS counters. A RunLoop Observer marks the main thread as busy from the moment it is woken or about to handle timers/sources, and clears the marker only when RunLoop reaches BeforeWaiting. A separate monitor thread checks every second whether the busy marker is still set and how long it has been set; if the gap passes 2 seconds, Matrix treats it as a main-thread hang. This split keeps detection alive even when the main thread is blocked in business code or waiting on a lock.

Once a hang is confirmed, Matrix has to decide which evidence is worth keeping. In the default mode it immediately captures the main thread's stack, and if consecutive checks return the same stack it skips the duplicate and backs off the next check using an annealing sequence: 1, 2, 3, 5, 8, 13 seconds. With early sampling enabled, it captures main-thread stacks every 50ms into a 20-entry queue and later picks the sample with the longest run of identical stack-top addresses, using the newest on a tie.

The final report is not just one stack. Matrix enters the KSCrash report path and captures every thread in the process, so the main thread's scene stack is analyzed alongside other threads as auxiliary context. A real report shows a 2.106-second block, a threshold of 2 seconds, 12 threads, and a main thread parked in Thread.sleep(forTimeInterval: 4) called from a demo validation method. CADisplayLink and sub-thread ping both measure delay after the fact but miss or misattribute the actual stall site.

Takeaways
— Matrix uses RunLoop Observer callbacks to set a busy marker when the main thread enters kCFRunLoopAfterWaiting, kCFRunLoopBeforeTimers, or kCFRunLoopBeforeSources, and clears it only at kCFRunLoopBeforeWaiting.
— The default hang threshold is 2 seconds of continuous main-thread busy time.
— A dedicated monitor thread, not the main thread, performs the timeout check, so a blocked main thread cannot delay detection.
— Without early sampling, the monitor checks every 1 second; repeated identical stacks skip report generation and the wait interval grows by annealing: 1, 2, 3, 5, 8, 13 seconds.
— With early sampling, main-thread stacks are captured every 50ms into a 20-sample overwriting queue; the reported stack is the one with the most consecutive identical stack-top addresses, newest on ties.
— Final hang reports are written through KSCrash and include call stacks from all process threads, not only the main thread.
— A real example shows a 2.106-second block, a 2-second threshold, 12 threads, and the main thread stuck in Thread.sleep(forTimeInterval: 4) from HTHangValidationModel.blockMainThreadForFourSeconds().
Conclusions

Watching RunLoop transitions measures delay at the scheduling source, so it sidesteps render pipeline behavior, refresh rate, and low-power policies that distort FPS counters.

The early-sampling selection rule is a practical compromise: comparing only stack-top addresses catches the dominant blocking function without requiring the whole call chain to match every time.

Annealing is also a resource policy: writing full thread dumps while the main thread is already stuck compounds the problem, so suppressing repeated reports and lengthening the gap is part of making the monitor safe to run.

Concepts & terms
RunLoop Observer
A callback registered with Core Foundation's CFRunLoop that receives notifications at state transitions such as BeforeWaiting and AfterWaiting. Matrix uses it to timestamp the moment the main thread starts busy work and the moment it returns to sleep.
Annealing (Matrix)
The strategy of skipping a duplicate hang report and increasing the wait before the next stack capture when consecutive checks find the same stack. Wait times grow 1, 2, 3, 5, 8, 13 seconds until the stack changes.
Early sampling
A Matrix mode that captures the main thread's call stack every 50ms before a hang is confirmed, stores the samples in a 20-entry overwriting queue, and later picks one representative stack from just before or during the block.
CADisplayLink
An iOS timer tied to the display's VSync signal. It is useful for FPS readouts, but because it can be registered on the main RunLoop, its callbacks are delayed during a main-thread hang, so it is not reliable for capturing the offending stack.
KSCrash report
The report-writing path Matrix reuses for hang reports; it collects stack traces from all threads in the process and writes them to disk after a hang is confirmed.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗