聊聊最近遇到的一个Crash

  • Crash
  • 发布于2020年09月20日

卖个关子

// 代码段1
class Manager: NSObject {
    deinit {
         print("deinit " + String(format: "%p", self))
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        var x: Manager? = Manager()
        x = nil
    }
}
// 代码段2
@interface Manager : NSObject

@end

@implementation Manager

- (void)dealloc {
    NSLog(@"dealloc: %@", [NSString stringWithFormat:@"%p", self]);
}

@end

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    Manager *manager = [Manager new];
}

@end

以上两段代码运行起来后会分别会发生什么?编译报错?正常输出?输出异常?Crash?

相信看了标题,肯定会有人说会 Crash,具体是代码段1还是代码段2,还是都会 Crash 呢?且往下看~

场景一

现象

线上遇到这类 Crash,基本上都是系统堆栈,如下图

Untitled

排查

唯一的业务堆栈是 #5 那一行,而那一行对应的代码是 xxx = nil,并且这个类的 dealloc 方法就这一行代码。

  • 首先排除了多线程的问题,因为都是在主线程;
  • 另外在崩溃日志的附加信息里有一个共有的规律就是都是收到内存警告后触发,但是尝试模拟内存警告,也无法。因为 cell 是在一个常驻页面上,只有内存警告才会触发 dealloc
  • 尝试符号断点在 objc_release 上,分析对应的汇编代码,也没啥收获;
  • 崩溃附加信息里记录曾经收到过内存警告;
  • 继续尝试在 xxx 对应类的 dealloc 方法,并前后加上 log,上线后抓取崩溃的附加信息发现 xxxdealloc 前后的 log 都有执行;看来也不是 xxxdealloc 导致的。

经过上面的排查还是没法定位到具体原因,后面某天其他组同事重现了这个问题,拿到本地日志后,发现 Crash 前最后的日志是 cell 持有的某个 Swift 对象 deinit 输出的 log。于是 Review 了对应类的代码,发现没啥问题。然后找对应同学构建这个类生效的场景,再模拟内存警告,然后就必现了,堆栈和上报的是一样的。然后通过开启 Xcode 僵尸对象,检测到确实是那个类出现了野指针。

Untitled 1

上面说到的模拟内存警告,如果是模拟器可以通过 Debug→Simulate Memory Warning 来模拟内存警告,如图所示

Untitled 2

而如果是真机的话,需要使用一个私有方法,即 [[UIApplication sharedApplication] _performMemoryWarning] ,这个方法需要动态调用。

再回到上面卖的那个关子,答案是只有代码段1会 Crash,代码段2正常输出。

代码段1运行起来之后,报了一个 objc_release 的 crash

Untitled 3

开启僵尸对象,输出如下 log

*** -[SwiftDemo.Manager release]: message sent to deallocated instance 0x6000013c4500

代码段2正常输出,没有Crash

Untitled 4

代码段1就是上面 someInstance类刨去业务代码后的最简化场景。Cell 复用时为了判断是哪一个 cell 的某个逻辑触发的,写了一个类似下面的 log 函数:

func log(_ des: String) {
		print("log: \(des), address=\(String(format: "%p", self))")
}

然后 deinit 时某个业务会调用上面的 log 函数,然后在退出页面或者收到内存警告时触发了类似代码段1对应的 Crash。

通过堆栈以及结论来倒推,应该是 someInstance dealloc之后,runloop 将要进行休眠时触发了 autoreleasepool pop 操作,然后对 pool 内的对象发送 release 消息,但此时someInstance 已经释放,所以出现了野指针。那么问题来了,someInstance 什么时候被加入到自动释放池的呢?从代码层面上来看,猜测是构建 log 时,String(format:) 生成了自动释放的 String 对象,该对象加入到了自动释放池中。

真的是自动释放池导致的么?因为代码中没有显式创建自动释放池,那这里肯定是主线程默认的自动释放池。既然默认的 autoreleasepool 会在 runloop 休眠才将池子里的对象执行release操作,那是不是直接在 deinit 包一层 autoreleasepool 就可以让这个临时对象出了作用域就会立即释放呢?动手试试~

class Manager: NSObject {
    deinit {
				// 显式创建一个自动释放池
        autoreleasepool {
            print("deinit " + String(format: "%p", self))
        }
    }
}

添加如上代码,发现log能正常输出,也不会崩溃了。

但是为什么同样的代码在 Swift 就会崩溃,而使用 Objective-C 就不会有问题呢?带着这个疑问,和同事一起翻了下 Swift 关于 String String(format:)方法的相关源码。发现这个方法里会对参数有一个持有关系。

截取源码中的几个片段

// https://github.com/apple/swift/blob/cc78af105faeb85a2b3d915f1959c75a919ea3dc/stdlib/public/Darwin/Foundation/NSStringAPI.swift#L447-L459
// 片段1
public init(format: __shared String, locale: __shared Locale?, arguments: __shared [CVarArg]) {
#if DEPLOYMENT_RUNTIME_SWIFT
    self = withVaList(arguments) {
      String._unconditionallyBridgeFromObjectiveC(
        NSString(format: format, locale: locale?._bridgeToObjectiveC(), arguments: $0)
      )
    }
#else
    self = withVaList(arguments) {
      NSString(format: format, locale: locale, arguments: $0) as String
    }
#endif
}

// https://github.com/apple/swift/blob/da61cc8cdf7aa2bfb3ab03200c52c4d371dc6751/stdlib/public/core/VarArgs.swift#L145-L152
// 片段2
@inlinable // c-abi
public func withVaList<R>(_ args: [CVarArg],
  _ body: (CVaListPointer) -> R) -> R {
  let builder = __VaListBuilder()
  for a in args {
    builder.append(a)
  }
  return _withVaList(builder, body)
}

// https://github.com/apple/swift/blob/da61cc8cdf7aa2bfb3ab03200c52c4d371dc6751/stdlib/public/core/VarArgs.swift#L155-L163
// 片段3
@inlinable // c-abi
internal func _withVaList<R>(
  _ builder: __VaListBuilder,
  _ body: (CVaListPointer) -> R
) -> R {
  let result = body(builder.va_list())
  _fixLifetime(builder)
  return result
}

// https://github.com/apple/swift/blob/6636815568efa8af5a62bbd68d585691d981a82b/stdlib/public/core/LifetimeManager.swift#L49-L52
// 片段4
@_transparent
public func _fixLifetime<T>(_ x: T) {
  Builtin.fixLifetime(x)
}

可以看到 Swift 中的 String 最终还是会调用 NSString 来生成字符串,那理论上应该和上面 Objective-C 代码片段一样,不会 Crash。但是仔细看会发现上面在调用 NSString 的相关方法前,会先创建一个 __VaListBuilder,这个类的源码可以查看这里。内部会持有传进去的参数,应该是这里出了些问题,具体原因暂时还没有结论。但感觉这里应该是Swift的坑,当然也有可能是我的理解有问题。

总结

总之目前来看,Swift deinit 中在使用 print 时尽量还是少用 %p, %@ ,然后参数传 self 这种形式,避免产生一些难以捉摸的 Crash。

最后附上上述场景出现的环境:

Xcode 11.7 (11E801a)、MacOS 10.15.6、Swift 5.0

本博客所有文章除特别声明外,均采用CC 4.0许可协议。转载请注明出处和作者。

关注微信公共号Vong或在微博上关注@Vong_HUST,永远不会错过新内容! 您的支持和鼓励将为我的博客写作增添更多的动力!

动态更新