查看原文
其他

一个 Swift Crash 引发的讨论...

编辑小王 老司机技术 2022-08-26

背景

周日晚上,看到 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 中去, 而 runloopautoreleasepool 的对象释放是延迟执行的,因此在 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 相关的技术。欢迎关注。


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存