Main Thread vs JavaScriptCore: iOS 13 Deadlock Explained & Fixed
An in‑depth analysis reveals how a Main Thread and JavaScriptCore Heap Collector Thread can deadlock on iOS 13 due to RunLoop lock contention triggered by timer callbacks and AutoReleasePool memory reclamation, and provides a practical solution using scoped AutoReleasePool blocks to prevent the issue.
Background
The article investigates a deadlock that occurs between the Main Thread and the JavaScriptCore (JSC) Heap Collector Thread on iOS 13 when high‑frequency timers trigger JavaScript execution in a fast‑changing UI component.
Reproducing the Issue
Using a custom timer built on CoreFoundation’s CFTimer , the authors created a minimal reproducible scenario where the timer callback repeatedly creates JSValue objects. When the Main Thread’s RunLoop processes __CFRunLoopDoTimers , an AutoReleasePool triggers JSValue deallocation, which can coincide with the JSC Heap Collector Thread performing garbage collection, leading to a deadlock.
Deadlock Analysis
Four classic deadlock conditions were examined:
Mutual exclusion – both threads contend for the same RunLoop lock.
Hold‑and‑wait – each thread holds a lock while waiting for the other.
No pre‑emption – locks are not forcibly released.
Circular wait – the Main Thread waits on a condition variable held by the JSC thread, while the JSC thread waits for the RunLoop lock held by the Main Thread.
Stack traces from both threads confirmed that the Main Thread was inside __CFRunLoopDoTimers and the JSC thread was in its GC routine, each trying to acquire the same pthread_mutex_t protecting the RunLoop.
Solution
The fix is to ensure that JSValue objects are released before the RunLoop reaches the AutoReleasePool stage. Wrapping the timer callback in an explicit @autoreleasepool (or its Swift equivalent) forces early deallocation, breaking the circular wait.
Why NSTimer Behaves Differently
Investigation showed that NSTimer internally wraps its callback in an AutoReleasePool, so the memory is reclaimed earlier and the deadlock does not manifest. CFTimer lacks this wrapper, exposing the issue.
Insights & Recommendations
For any code that interacts with JavaScriptCore:
Wrap short‑lived JSContext / JSValue usage in an @autoreleasepool to force timely release.
Use JSManagedValue for long‑lived references.
Keep all JavaScriptCore operations on a single thread to avoid RunLoop lock contention.
Be aware that other RunLoop‑related APIs (e.g., performSelector , CFRunLoopPerformBlock ) can also trigger similar lock scenarios.
Related Links
Apple pthread internal header
Opaque pointers article
LLDB watchpoint commands
Apple ARM64 documentation
CFRunLoop source
Kuaishou Frontend Engineering
Explore the cutting‑edge tech behind Kuaishou's front‑end ecosystem
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.