Deallocation 中的线程安全


原文成于2017年11月


背景

最近在浏览 Swift Talk 的过程中, 思考了一个很有意思的问题.

如果一个对象通过NotificationCenter注册了一个通知, 然后在这个对象的deinit方法中执行了removeObserver. 假如在对象调用deinit的过程中对应的事件发生了, 并产生了一个通知, 此时会有什么现象? 当事件发生/投递的线程和对象deinit的线程不是一个线程时, 会导致崩溃吗? Swift Talk #28最后5分钟内容

这个问题我觉得很有意思, 之前都没有意识到过.

事实上虽然这个问题是在"Swift Talk"中抛出, 但是并不是Swift独有的, OC 下的dealloc方法同样有这个问题. 另外, 虽然问题是以 Notification 为例, 但显然问题并不限于通知, 任何别的含有通知/回调机制的场景都会面临同样的问题.

分析

首先需要明确的是, 从安全角度来讲, 是有必要反注册的. 即使新的 API 也许不要求我们开发者显式调用removeObserver, 其内部也一定在合适的时机执行了等价的反注册. 下面都是以调用了removeObserver这个为前提进行讨论.

其次, 考虑一下, 如果这边有问题, 会是什么类型的问题? 我认为至少可能有以下2类问题

  • 线程不安全. 如果这个对象的类没有设计成线程安全, 那么本身在2个不同的线程调用任何成员方法都是不安全的, 因此deinit和回调方法两者自然会产生竞争.
  • 野指针. 注意到和普通的成员方法不同, deinit有特殊性. 一旦deinit开始调用, 意味着这个对象已经进入不稳定的状态, 此时这个对象的任何属性/ivar可能都开始瓦解. 一旦对这个对象的成员进行其他操作, 极有可能访问到无效的内存区域, 产生更严重的问题.

这篇文章, 主要是分析上述2个问题中的后者是否可能出现. 下面把问题分成几类来考虑.

无线程竞争

也就是说deinit调用和 notification 的投递发生在一个线程的情况.

可以直接给出结论: 没有问题. 这是因为如果两者是在一个线程发生, 那么一定有先来后到的顺序, deinit和投递 notification 不可能同时执行.

  • 如果deinit先执行, 那么removeObserver会先被调用, 此时一个良好
    NotificationCenter的实现就会断开对这个对象的任何引用, 也就保证了, 当后续事件产生时, 不可能再通知/回调这个对象.
  • 如果事件先产生并投递到这个对象, 再deinit. 只要这个对象对事件的消费/响应都是立即的(非 异步的), 自然没有任何问题.

有线程竞争

也就是说deinit调用和 notification 的投递不是运行在同一个线程.

为了专注分析, 我们假设这个对象的类已经是设计成线程安全的, 也就是说在不同的线程同时调用这个类的不同方法本身没有任何问题. 即假设"不会因为不在同一个线程调用,本身就导致线程不安全".

和上面"无线程竞争"区别的一点是, 讨论deinit和事件投递谁先谁后已经没有意义了, 因为这2个调用既然发生在2个线程, 一定有可能在执行时间上重叠. 我们应该讨论的是deinit
removeObserver调用期和事件投递的先后顺序.
同样为了专注分析, 我们还要假设的是NotificationCenter的实现也是线程安全的, 即"某个事件产生"和"这个事件的监听者调用removeObserver"发生在不同的线程时, 事件的投递不会出现问题. 一个合理的NotificationCenter的实现在遇到这样的情况时, 应该根据内部原子操作发生的顺序决定监听者能收到这个事件或收不到这个事件.

这时候, 我们就需要深入考虑NotificationCenter的实现了. 应该意识到一个显然的事实, 既然发生的事件可以通知到这个对象, 那么NotificationCenter一定有一个指针/引用指向这个对象, 因为如果连这个引用都不存在, 那么根本不可能和这个对象产生任何关联. 下文的分析就按这个引用的类型来进行划分.

强引用

如果NotificationCenter的实现, 是在addObserver之后, 保留了一个对观察者的强引用.

那么, 肯定不会产生野指针的问题. 因为只要NotificationCenter的实现者没有犯2, 自作主张去掉了对事件监听者的引用, 那么在调用removeObserver之前, 这个对象的引用计数一定是大于0的.
然而, 假若NotificationCenter的实现真的使用了强引用来保存监听者, 那么我们再也不能在监听者的deinit中执行removeObserver, 但凡偏要这么做, 因为强引用的缘故, 这个deinit永远没有机会被调用, 内存就泄露了. 我们不得不考虑在别的合适时机主动调用removeObserver, 这样对于类库的使用者来言是一种负担.
事实上官方NotificationCenter也没有实现成使用强引用来保存监听者.

弱引用

如果NotificationCenter的实现, 是在addObserver之后, 保留了一个对这个对象的弱引用.

弱应用和强引用不同, 那么可能发生问题中的现象吗? 如果NotificationCenter在事件产生后能通知到这个对象, 意味着在通知/回调的瞬间, 这个对象还是活着的. 那么当回调方法开始调用之后, 但处理完之前, 有可能所有对这个对象的强引用消失, 从而在另一个线程触发了deinit吗?

removeObserver调用和事件投递发生的先后分为两类.

  • 假如事件的投递先于deinitremoveObserver执行. 那么, 在事件产生的瞬间, 对象一定是活着的, 然后对象无论怎么处理这个事件, 只要NotificationCenter保证对象在回调结束前始终保证对监听者有一个 strong 引用, 那么在处理完成之前, 监听者deinit是不可能被调用的, 因此不会产生时序重叠. 而这是很容易保证的, 一方面无论是 Swift 还是 OC, 所有方法调用开始时的引用类型入参默认 +1 reference count, 因此NotificationCenter无论是直接向监听者发送方法, 还是把监听者作为参数传给回调方法, 在投递完成前都不会产生野指针问题.
  • 假如deinitremoveObserver的执行先于事件投递呢. …事实上, 这个 case 是不可能出现的, 但出现的原因和强引用的情况不同, 并不是"deinit不可能被调用", 而是事件不可能完成投递. ARC 保证, 一旦一个对象的开始销毁, 所有对这个对象的弱引用都会在这个对象的deinit调用之前, 率先被设置成nil. 换句话说, 一旦监听者的deinit开始被触发, 即使removeObserver没有在第一 时间被调用, NotificationCenter也没有任何可能性知晓监听者的存在了, 从而没有任何办法触发通知到这个对象, 问题自然也不可能产生.

事实上, 新版本的 iOS SDK (没记错的话是 >= 9.0) 中NotificationCenter就是使用了对监听者的 weak reference/类似技术来实现的. 这是皆大欢喜的局面了.

unsafe引用

最后来考虑一下当今 ARC 环境下很少出现, 但不是不可能的情况: NotificationCenter的实现, 是在addObserver之后, 保留了一个对这个对象的 unsafe 引用.
注意: 即使我们在100%使用 ARC 的情况下编写 iOS 程序, 仍然有可能遇到这个情况, 这是因为即使我 们保证我们的代码里不出现任何 unsafe 引用, 我们链接到的官方 Cocoa 类库的内部仍然有可能因为历史原因使用了 unsafe 引用. 事实上, 在旧版本的 SDK 中, NotificationCenter 应该就是这样的.

NotificationCenter保存对监听者的 unsafe 引用, 和使用弱引用相比, 区别在于: 如果对象被释放, NotificationCenter仍然有一个野指针指向这个对象的尸骸, 因此确实可能在deinit开始调用但在removeObserver被调用前, 事件投递到监听者, 但在处理这个事件的过程中this指针所指向的内存区域已经部分/全部失效了.
事实上, 在多线程环境下对触发了对象的deinit是 Cocoa 早期开发一个经典的崩溃来源, 这类被称为"The Dealloc Problem"的问题当时非常难以100%避免. (“The Dealloc Problem”多数情况发生在UIKit, 因为相当一部分UIKit组件不允许在非主线程被释放, 和此处问题背后的 principle 是相关的.)

当然也不是没有解决办法, 例如可以:

  • 消除多线程的可能性, 以NotificationCenter为例, 它有一个 API 允许我们指定消息投递发生的 queue. 即使消息产生在另一个 queue, NotificationCenter也会在我们指定的 queue 回调我们的方法, 如果我们可以保证对这个对象的所有操作也在这同一个线程中进行, 那么自然我们就从根本上避开了多线程的问题.
  • 另一个消除多线程的方法, 是在deinit中进行一些阻塞操作. 例如假设事件产生在 main thread, 但是这个对象的最后一个强引用消失在 non-main thread. 此时有些人会选择在对象的deinit中使
    DispatchQueue.sync() / NSObject.perform(_:on:with:waitUntilDone:) sync 到 main thread 结束来规避问题. 诚然这种方式也可以解决问题, 但也带来了 deadlock 的风险.
  • 最后一个方式是手动尽早消除NotificationCenter对监听者的引用, 例如尽早(在保证监听者一定存活的时候)调用removeObserver来断开两者的引用. 这种方式有时候只是尽可能减小问题发生的概率空间, 而没有完全消除问题, 因为并不是所有情况下你都可以保证你说的多早是不是够早.

总结

说了这么多, 并不是让我们人人自危, 让我们怀疑自己写的最普遍最平实的代码. 但确实值得我们作 一些思考, 而这些思考也许可以帮助加深对 reference counting, thread safety 的理解.

TL,DR:

  • 在使用需要主动反注册的回调机制时, 有必要思考一下调用反注册方法的时机. 如果在deinit中进行反注册, 并不是完全无风险的, 在涉及到多线程的情况下, 应该思考一下安全问题.
  • 在极大多数情况下, 如果涉及到多线程的回调, 我们只要保证监听方和被监听方各自的类是线程安全即可. 在不产生循环引用的前提下, 是在deinit中反注册还是在别的地方反注册并不是一个 big deal.
  • 在非 ARC 下, 需要作特殊注意, 在deinit中反注册有可能产生"The Dealloc Problem". 此时需要一些额外的手段来尽可能规避问题. 感谢上帝, 这类问题现在已经非常罕见了.