所谓的单例失效,指的是需要在代码上已经使用了单例模式,但在实际运行时需要使用单例的类却被创建了多个实例对象,与单例的预期不一致。本文旨在了解其失效背后的原因,以及总结其规律。
0x01 单例的原理
先来看一个简单的单例例子
1 | static Singleton*instance = nil; |
例子很简单,常规做法就是使用dispatch_once只会执行一次的特性来实现单例,在我们了解单例为什么失效前,先复习dispatch_once是怎么保证只会执行次。
0x011 dispatch_once
简单看一下dispatch_once的源码
1 | void dispatch_once(dispatch_once_t *val, void (^block)(void)){ |
源码很简单,从dispatch_once_f的实现上,可以看出真正控制单例的实现是在dispatch_atomic_cmpxchg的这个判断上。
dispatch_atomic_cmpxchg 是一个宏定义,原型为
__sync_bool_compare_and_swap((p), (o), (n))
其行为相当于以下实现,简单来说就是判断需要判断是值是不是oldValue,如果是,则更新为newValue。只不过__sync_bool_compare_and_swap是原子操作,可以不需要加锁就能实现线程安全的数据比较与交换操作。
1 | -(BOOL)compareSwap:(long*)ptr old:(long)oldValue new:(long)newValue{ |
而在dispatch_once中的ptr,也就是dispatch_once宏第一个参数,dispatch_once_t类型(原型就是long)的token。也就是说,dispatch_atomic_cmpxchg(val, 0l, 1l)先让token与0比较,如果token==0,表示没被执行过,允许回调,并将token置为1;如果token!=0,表示已经执行过了,就不再回调了,以此实现回调只跑一次,而这些操作都是原子操作,是线程安全的,也就实现了我们期望的单例。
0x02 为什么单例会失败
从上面的例子与原理可知,保证单例能顺利执行的依据只是一个简单的dispatch_once_t类型的token,虽然这个token被声明为static类型,其访问范围限制在当前声明的类当中,我们假设的其实就是static在内存中只会有一份,且只会创建一次,归根到底单例就是要保证这点,但如果在运行时这点不能保证呢?
0x021 失败的例子
有这么一个测试程序,它有几个依赖的库,其中蓝色为静态库,橙色为动态库,其依赖关系如下
其中,LibrarySingleton定义了一个单例,Library**与app都调用到LibrarySingleton的单例对象并打印,如LibraryA0包含一个getString方法
1 | -(NSString*)getString{ |
其他Library**也有类似的实现
而App则调用了这些库的getString方法
1 | LibraryA0* a0=[[LibraryA0 alloc]init]; |
运行起来后,其打印出的结果如下1
2
3
4A0=message from A0 by <Singleton: 0x600002c9c770>
A1=message from A1 by <Singleton: 0x600002c80090>
B0=message from B0 by <Singleton: 0x600002c8c1c0>
INS=<Singleton: 0x600002c9c770>
可以看到,我们预期的单例实际上被调用了三次,三次都创建了不同的实例,并被Library**引用。
0x022 分析
从dispatch_once的原理分析一节可知,单例能work的原因在于token在内存中是唯一的,但如果这个前提不存在了,token并非唯一,就有可能造成单例失败了。而可能造成token在内存中不唯一的可能就是代码,或者说是编码后的二进制代码并非是只有一份的。因为Library**都是动态库,但依赖的SingletonLibrary是静态库,动态库引用静态库编译时,是否会把静态库的代码都包含到动态库的结果中呢?
这个问题的答案可以从products的产物中确认。因为SingleLibrary中包含字符常量”singleton test init”,只需要看一下生成的动态库中是否包含这个常量就能确认了。
strings LibraryA0|grep “singleton test init”
显然是有的,也就是说动态库依赖静态库时,动态库中包含了静态库的代码。顺着这个思路,当在app中直接使用[Singleton instance]时得到了与A0一样的结果,这是由于链接顺序引起的。
0x023 动态链接
在app编译链接时配置如下
在app编译后准备链接时,由于LibraryA0是第一个参与链接的,且由上面可知,A0内部也是包含了静态库SingletonLibrary的代码以及导出的头文件的,所以即使没有最后一个SingletonLibrary app也能通过编译,事实上strings查找最终app里也并没有包含SingletonaLibrary的代码;且A0里的[Singleton instance]的符号在动态链接时是第一个出现的,后面即使再出现相同的符号也会被忽略,所以最终 app 获取到与A0相同的单例实例。
要是把SingletonLibrary的编译时链接顺序提前,则又得到了另一个结果
1 | A0=message from A0 by <Singleton: 0x600003a74440> |
单例被创建了四次,每个模块都有自己的一套SingletonLibrary。strings查看app可以看到里面也已经包含”singleton test init”了。
这是因为在app编译时,由于SingletonLibrary是第一个参与编译的,且它是静态库,按静态库的规则,是应该把它打到去最终的产物里的,因此,app中也包含了SingletonLibrary的代码,且调用的函数位置也已经确定了,这决定了最后运行时app调用的方法地址就是打进去本体的SingletonLibrary。
0x03 延伸一下
知道了多单例失败的原因后,但编译时什么情况下会出现这种异常呢。除了上面的情况外,不同动静态库的组合是否会有不同的表现,以下列举几种情况,以便总结分析规律。
调用顺序还是A0 –> A1 –> B0 –> 直接调用单例,app的链接顺序默认是A0 –> A1 –> B0 –> SingletonLibrary
test1
1 | 2021-05-02 22:27:23.315747+0800 SingletonTest[45830:1296489] getstring from A0 |
这里可以看出,所有的单例调用都调到了B0的地方去了,我的猜测是,由于链接顺序是 A0 –> A1 –> B0 –> SingletonLibrary, 虽然app中已经集成了SingletonLibrary的代码,但查找符号时会按照装载顺序的优先级进行装载,即B0内的SingletonLibrary优先于集成于app内的LibrarySingleton。
当调整app的链接顺序 A0 –> A1 –> SingletonLibrary –> B0 时,情况发生了变化
1 | 2021-05-02 22:27:23.315747+0800 SingletonTest[45830:1296489] getstring from A0 |
也是上面的理由,当SingletonLibaray早于B0链接时,调用时就会先加载集成于app内的SingletonLibrary,静态链接时就可以处理好调用时的地址偏移,但由于B0是第一个动态链接的库,B0内依然会集成SingletonLibarary的库,但其调用的单例方法入口偏移是以B0为base的。
test2
1 | 2021-05-02 22:33:34.639504+0800 SingletonTest[46052:1305664] getstring from A0 |
同样道理,SingletonLibrary处于链接流程的未尾,当app构建确认内部调用地址偏移时,先找到了位于A1内的SingletonLibrary,因为A1是动态库,其内部依然会集成静态库SingletonLibrary,于是就都会先找到A1里的单例;而B0同样也是因为构建时只能以B0为base作单例函数入口偏移的依据,所以跑起来后还是会调到B0里去,这就是单例失效的一个情况。
同样,如果把链接顺序改为 A0 –> A1 –> SingletonLibrary –> B0 ,可以想想会发生什么事。如果按上面的逻辑,那应该跟上面的输出是一样的。
再变一下,链接顺序为 A0 –> SingletonLibrary –> A1 –> B0,此时A0使用的应该是集成到app内部的单例,而A1会使用A1,B0会使用B0。
test3
1 | 2021-05-02 23:33:19.709674+0800 SingletonTest[47967:1389294] getstring from A0 |
这种情况库之间的依赖变了,由于 A0 ,A1 都是静态的,需要在编译后静态链接时就需要确定地址,而B0需要是动态库,但里面依然是会集成SingletonLibrary的,且是可以确定基于B0的单例入口地址的,所以A0,A1此时使用的是B0的单例实现。
同样,如果把链接顺序改为 A0 –> A1 –> SingletonLibrary –> B0, 那结果就是A0,A1变成读取app里的单例了,这点跟A0,A1有没有依赖B0没啥关系了,因为链接的顺序是SingletonLibrary优先于B0,可以基于内置静态库来确认地址。
test4
1 | 2021-05-02 23:35:14.953080+0800 SingletonTest[48157:1393241] getstring from A0 |
全部都是动态库时,在链接A0,A1时,因为A0,A1都是动态库,它们首先依赖的是B0,B0再依赖SingletonLibrary, 所以A0,A1内调用单例的地方需要确认方法地址时,会先去B0里找,而B0是会添加静态库的,所以B0内是可以确认到单例方法的入口地址的,从而决定 A1 ,A0 调用单例方法的地址。而app里直接调单例方法也是同样的原因,找到B0才找到可以使用的偏移地址。
同样,如果把链接顺序改为 A0 –> A1 –> SingletonLibrary –> B0,那可以想象,A1,A0调的都是App里集成的,B0还是调自己。
0x04 总结
- 使用哪个位置的单例,取决于链接的顺序,并且能按照链接顺序确认调用位置的
- 当动态库依赖动态库时,上一级动态库同样需要确认位置,此时会按这个动态库的依赖顺序去查找谁可以给它确定这个位置
- 静态库链接时,方法的调用位置也是按链接顺序确认,静态库能找到则取app内的地址偏移,先找到动态库的,就取base于动态库的地址偏移
单例失效这个问题出现在动态库与静态库混用,而且链接顺序前后有问题时,要想避免这个问题,要么就不要混用,要么就在使用之前按上面的库的依赖顺序以及app的链接顺序做一次梳理,以确保单例调用到的方法都是设计时预期的。