一个 Swift Crash 引发的讨论...
背景
周日晚上,看到 Vong 在文章《聊聊最近遇到的一个 Crash》 中表示遇到了一个 Swift 特有的 Crash, 最后只解决了问题,没有找到根本原因。我就把他拉到了最近刚组的 Swift 老炮交流群 里面,想着这个问题还蛮有意思的,估计会有不少人感兴趣。果然,最后炸出了不少潜水的群友。经过一番讨论之后,最后 @Cyandev 发现了原因。
因为在讨论过程中发现了几个有意思的东西,所以想写一篇简短的笔记,来大家分享一下。
问题描述
我先简单描述一下问题,问题发生于下面这种写法,在运行时会直接 crash:
class Manager: NSObject {
deinit {
_ = String(format: "%p", self)
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let _ = Manager()
}
}
如果我们把它改成 OC 的写法,则不会发生 Crash:
@interface Manager : NSObject
@end
@implementation Manager
- (void)dealloc {
NSString *ret = [NSString stringWithFormat:@"%@", self];
}
@end
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
Manager *manager = [[Manager alloc] init];
}
@end
解决问题
解决 Crash 最快捷的方式就是查看堆栈,崩溃堆栈如下:
Thread 1 Queue : com.apple.main-thread (serial)
#0 0x00007fff50bbe94b in objc_release ()
#1 0x00007fff50bc0077 in AutoreleasePoolPage::releaseUntil(objc_object**) ()
#2 0x00007fff50bbff96 in objc_autoreleasePoolPop ()
#3 0x00007fff36cb775f in -[FBSWorkspace _calloutQueue_executeCalloutFromSource:withBlock:] ()
#4 0x00007fff36cd2a52 in __86-[FBSWorkspaceScenesClient sceneID:createWithParameters:transitionContext:completion:]_block_invoke ()
#5 0x0000000101b0ee8e in _dispatch_client_callout ()
#6 0x0000000101b11da2 in _dispatch_block_invoke_direct ()
#7 0x00007fff36cf86e9 in __FBSSERIALQUEUE_IS_CALLING_OUT_TO_A_BLOCK__ ()
#8 0x00007fff36cf83d7 in -[FBSSerialQueue _queue_performNextIfPossible] ()
#9 0x00007fff36cf88e6 in -[FBSSerialQueue _performNextFromRunLoopSource] ()
#10 0x00007fff23da0d31 in __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ ()
#11 0x00007fff23da0c5c in __CFRunLoopDoSource0 ()
#12 0x00007fff23da048c in __CFRunLoopDoSources0 ()
#13 0x00007fff23d9b02e in __CFRunLoopRun ()
#14 0x00007fff23d9a944 in CFRunLoopRunSpecific ()
#15 0x00007fff38ba6c1a in GSEventRunModal ()
#16 0x00007fff48c8b9ec in UIApplicationMain ()
首先,看堆栈可以知道,崩溃的时间点在于 runloop 结束的时候,autoreleasepool 释放对象的时候,对象已经被释放了。导致调用 objc_release
直接 Crash 了。
当看到这个问题的时候,有几个假设:
Swift 的 ARC 有 bug,导致对象提前释放,autoreleasepool 在释放的时候出问题
过度调用了
autorelease
, 导致加入 autoreleasepool 的对象是一个已经被释放的对象
当然,假设一应该是不太可能发生的,所以顺着假设二,我们可以先给 objc_autorelease
打一个断点。果然,奇怪的事情发生了,在调用 _ = String(format: "%p", self)
这段代码调用的时候,我们看到了下面这个堆栈:
Thread 1 Queue : com.apple.main-thread (serial)
#0 0x00007fff50bbeeb0 in objc_autorelease ()
#1 0x00007fff516daf1d in protocol witness for CVarArg._cVarArgEncoding.getter in conformance NSObject ()
#2 0x00007fff5123ca65 in __VaListBuilder.append(_:) ()
#3 0x00007fff51565eab in String.init(format:_:) ()
#4 0x00000001036b9670 in Manager.__deallocating_deinit at ViewController.swift:13
#5 0x00000001036b9a5b in @objc Manager.__deallocating_deinit ()
#6 0x00007fff50bbea16 in objc_object::sidetable_release(bool) ()
#7 0x00000001036b9bc0 in ViewController.viewDidLoad() at ViewController.swift:25
#8 0x00000001036b9c3b in @objc ViewController.viewDidLoad() ()
从堆栈中,可以清晰的看到:
Manager
在调用 deinit
的时候会调用 String.init(format:_:)
这个函数。
而 String.init(format:_:)
这个函数的本质其实是 Swift.String.init(format: __shared Swift.String, _: Swift.CVarArg...) -> Swift.String
而 String.init(format:_:)
这个函数中又调用了 __VaListBuilder.append(_:)
这个方法,我们从 Swift 源码库可以找到 __VaListBuilder.append(_:)
的实现如下(模拟器上逻辑不一致,见评论):
https://github.com/apple/swift/blob/55e7050ffc35489398246671e4029efcdd527c55/stdlib/public/core/VarArgs.swift#L616
final internal class __VaListBuilder {
internal func append(_ arg: CVarArg) {
appendWords(arg._cVarArgEncoding)
}
}
// 此处为 iOS 端实现的精简代码
可以从源码中可以看到 protocol witness for CVarArg._cVarArgEncoding.getter in conformance NSObject () 的意思其实是调用了入参对象的 _cVarArgEncoding
属性。我们继续从Swift 源码库找到 _cVarArgEncoding
的实现,如下:
https://github.com/apple/swift/blob/ca87af103bb70edd33fe1f35859a6d98c2aea462/stdlib/public/Darwin/ObjectiveC/ObjectiveC.swift#L249
extension NSObject : CVarArg {
public var _cVarArgEncoding: [Int] {
_autorelease(self)
return _encodeBitsAsWords(self)
}
}
真相大白!String
的 init 方法中会主动调用 _autorelease
函数!!!!
结论
因此,问题的原因在于对象析构的时候调用了 autorelease
把对象再次放到了 autoreleasepool 中去, 而 runloop 的 autoreleasepool 的对象释放是延迟执行的,因此在 autoreleasepool 的对象释放由于对象已经析构,所以造成了 Crash
事情远远没有那么简单
Question1:
为什么 Vong 说,在外面包一层 autoreleasepool 就没问题了呢?
因为主动写 autoreleasepool 的时候,autoreleasepool 的释放时机不再依赖于 runloop 了,而是函数执行结束之前释放。也就是 deinit
函数执行之前,autoreleasepool 会调用 AutoreleasePoolPage::pop
:
static inline void pop(void *token) {
AutoreleasePoolPage *page;
id *stop;
page = pageForPointer(token);
stop = (id *)token;
if (*stop != POOL_SENTINEL) {
_objc_fatal("invalid or prematurely-freed autorelease pool %p; ",
token);
}
page->releaseUntil(stop);
if (page->child) {
if (page->lessThanHalfFull()) {
page->child->kill();
}
else if (page->child->child) {
page->child->child->kill();
}
}
}
我们可以在源码中看到,在 AutoreleasePoolPage::pop
会调用 page->releaseUntil(stop);
:
static uint8_t const SCRIBBLE = 0xA3;
void releaseUntil(id *stop) {
while (this->next != stop) {
AutoreleasePoolPage *page = hotPage();
while (page->empty()) {
page = page->parent;
setHotPage(page);
}
id obj = *--page->next;
memset((void*)page->next, SCRIBBLE, sizeof(*page->next));
if (obj != POOL_SENTINEL) {
objc_release(obj);
}
}
setHotPage(this);
}
而在 page->releaseUntil(stop);
中最后调用的其实是objc_release()
:
void objc_release(id obj) {
if (!obj || OBJC_IS_TAGGED_PTR(obj)) {
return;
}
if (((class_t *)obj->isa)->hasCustomRR()) {
return (void)[obj release];
}
return bypass_msgSend_release(obj);
}
// 这句话的意思就是调用 bypass_msgSend_release 会直接调用 -[NSObject release]
void bypass_msgSend_release(NSObject *obj) asm("-[NSObject release]");
- (oneway void)release {
if (_objc_rootReleaseWasZero(self) == false) {
return;
}
[self dealloc];
}
bool _objc_rootReleaseWasZero(id obj) {
assert(obj);
assert(!UseGC);
if (OBJC_IS_TAGGED_PTR(obj)) return false;
SideTable *table = SideTable::tableForPointer(obj);
bool do_dealloc = false;
if (OSSpinLockTry(&table->slock)) {
RefcountMap::iterator it = table->refcnts.find(DISGUISE(obj));
if (it == table->refcnts.end()) {
do_dealloc = true;
table->refcnts[DISGUISE(obj)] = 1;
} else if (it->second == 0) {
do_dealloc = true;
it->second = 1;
} else {
it->second -= 2;
}
OSSpinLockUnlock(&table->slock);
return do_dealloc;
}
return _objc_rootReleaseWasZero_slow(obj);
}
从最后的 objc_release()
的实现中可以看到,最后在调用 release
的时候会根据 _objc_rootReleaseWasZero(self) == false
来判断是否正在执行 dealloc
而避免出现问题。
Question2:
为什么如果把 class Manager: NSObject 改成 class Manager: UIViewController 又不会 Crash 了呢?
这时候 @倾寒 提议说看一下堆栈,开始用 objc_autorelease
并没有发现什么不一样,但是当我们改成 autorelease
的时候,奇怪的事情又发生了
下面是 -[NSObject autorelease]
的汇编实现
libobjc.A.dylib`-[NSObject autorelease]:
-> 0x7fff50bbe26b <+0>: jmp 0x7fff50bbe475 ; _objc_rootAutorelease
下面是 -[UIView autorelease]
的汇编实现:
UIKitCore`-[UIView(UIKitManual) autorelease]:
-> 0x7fff4919c4ae <+0>: pushq %rbp
0x7fff4919c4af <+1>: movq %rsp, %rbp
0x7fff4919c4b2 <+4>: pushq %rbx
0x7fff4919c4b3 <+5>: subq $0x18, %rsp
0x7fff4919c4b7 <+9>: movq %rdi, %rbx
0x7fff4919c4ba <+12>: movq 0x405cca2f(%rip), %rsi ; "_isDeallocating"
0x7fff4919c4c1 <+19>: callq *0x3d711381(%rip) ; (void *)0x00007fff50ba4400: objc_msgSend
0x7fff4919c4c7 <+25>: testb %al, %al
0x7fff4919c4c9 <+27>: je 0x7fff4919c4d6 ; <+40>
0x7fff4919c4cb <+29>: movq %rbx, %rdi
0x7fff4919c4ce <+32>: callq *0x3d71137c(%rip) ; (void *)0x00007fff50bbe940: objc_release
0x7fff4919c4d4 <+38>: jmp 0x7fff4919c4f7 ; <+73>
0x7fff4919c4d6 <+40>: leaq -0x18(%rbp), %rdi
0x7fff4919c4da <+44>: movq %rbx, (%rdi)
0x7fff4919c4dd <+47>: movq 0x40612e54(%rip), %rax ; (void *)0x00007fff8980f958: UIView
0x7fff4919c4e4 <+54>: movq %rax, 0x8(%rdi)
0x7fff4919c4e8 <+58>: movq 0x405cd761(%rip), %rsi ; "autorelease"
0x7fff4919c4ef <+65>: callq 0x7fff49241c5e ; symbol stub for: objc_msgSendSuper2
0x7fff4919c4f4 <+70>: movq %rax, %rbx
0x7fff4919c4f7 <+73>: movq %rbx, %rax
0x7fff4919c4fa <+76>: addq $0x18, %rsp
0x7fff4919c4fe <+80>: popq %rbx
0x7fff4919c4ff <+81>: popq %rbp
0x7fff4919c500 <+82>: retq
看不懂汇编的没关系,看到 _isDeallocating
这个字符串其实就基本已经可以得出结论了,也就是 UIKit 改写了 autorelease
的实现,在调用的时候主动判断了对象是否正在执行析构函数,这种骚操作简直是见怪不怪了!估计是 UIKit 团队也被这个问题坑过了!
总结
从源码中去发现问题原因还是一件很有意思的事情,最后根据前面的探索总结几个经验吧:
不要再对象析构函数中调用
autorelease
-[NSObject autorelease]
和-[UIView autorelease]
的底层实现并不一致,参考苹果的做法,我们也可以重写一下-[NSObject autorelease]
避免奇怪的事情发生。
关注我们
我们是「老司机技术周报」,每周会发布一份关于 iOS 的周报,也会定期分享一些和 iOS 相关的技术。欢迎关注。