Crash-操作系统的自我保护
当运行程序出现内存访问异常(访问野指针、数组越界)、内存不足(linux 下内存不足会出发oom_killer杀死当前进程)等异常情况,操作系统为自身的稳定运行会将异常程序KILL(Crash)掉以避免引起更大的问题。Crash是操作系统的一种自我保护机制,在程序Crash之前会收到操作系统发出的SIGSEGV、SIGKILL等信号。操作系统在发出这些指令之后会立即KILL掉异常应用程序。在日常的Crash清理中,通常会有茫茫多的SIGSEGV、SIGKILL Crash问题难以定位。
日常开发中另一类,难以定位的Crash将矛头指向了某些系统函数,如比较常见的objc_msgSend崩溃,我们将在 Foundation 与 UIKit 等的系统库中的方法称之为系统方法。作为一个乐观者,我们有理由相信系统方法和操作系统本身的稳定性。Crash的原因大概率(肯定)是我们自己代码出了问题,这些问题通常是由于欠妥的内存管理导致的。
从一个 objc_msgSend Crash开始
一起来看一个objc msgSend Crash的堆栈,该堆栈信息崩溃在了第6行,错误信息为:Thread 10:EXC BAD _ ACCESS( code =1,address=0x1a15cbeb8)
1 | libobjc.A.dylib`objc_msgSend: |
可以肯定的是,问题绝不会是出现在objcmsgSend函数的实现上,objc msgSend可以说是OC的灵魂函数。如果这个函数存在漏洞那OC程序也就不太可能运行的起来了。关于objc msgSend这个函数,Apple已经提供了源码arm64下的源码,为了更高的效率objc msgSend是用汇编实现的。
结合objc_msgSend的源码我们可以分析以上崩溃信息每一行都做了啥:
第二行 0x1903a01c0: cmp x0, #0
检查iSA指针是否为nil或tagged pointer,arm64下苹果爸爸提出了提出了Tagged Pointer的概念,优化了NSNumber、NSDate存储和操作效率。简单来说就是Tagged Pointer对象的指针不再指向任何内存地址,而是被拆成两部分:一部分直接保存数据,另一部分作为特殊标记。所以这里需要对Tagged Pointer做判断。更多关于Tagged Pointer 猛戳深入理解Tagged Pointer
第三行 0x1903a01c4: b.le 0x1903a0230
跳转操作,如果是tagged pointer 或 nil 跳转至0x1903a0230进行处理,我们这里显然不是程序会继续往下执行。
第四行 0x1903a01c8: ldr x13, [x0, 0]
将对象的iSA指针,放入x13寄存器中。
第五行 0x1903a01cc: and x9, x13, #0x1fffffff8
将类对象的真正指针存入x9寄存器,arm64下iSA指针除了存储类对象地址还存储了对象是否存在弱引用、是否正在销毁、对象的引用计数等信息,为取到真正的类对象地址,需将iSA指针同一个特定的立即数#0x1fffffff8进行AND操作
第六行 0x1903a01d0: ldp x10, x11, [x9, #16]
将类对象存储的方法缓存列表的地址存入寄存器x10。在msgSend的过程中会先从类对象的mathod_cache_list中寻找相应的方法。(注意:程序在这里发生了Crash)。
下图简单展示了上述汇编代码的调用过程:
类对象的指针和方法缓存列表的偏移是一定的,假设我们拿到了正确的iSA指针,我们就可以通过这行代码得出正确的方法缓存列表的地址。在这里程序抛出了异常Crash掉了,那就可以肯定我们拿到了错误的iSA指针(野指针)。
通过分析,我们可以确定是我们程序内某些异常的内存操作导致了Crash,系统函数(objc_msgSend)却为我们背了锅。我们小心翼翼的使用weak、strong来管理内存,依旧无法避免内存问题,那么问题究竟出现在什么地方?
危险的nonatomic
在刚刚接触iOS开发的时候,我们被告知:系统会默认将属性声明为atomic,但atomic在保障get、set操作原子性的同时伤害了性能,所以我们要将属性声明为nonatomic。那么atomic到底在保护什么?里面又做了什么有害性能的操作?
setter源码
我们将目光汇集在runtime源码objc-accessors.mm文件的reallySetProperty函数中。通过阅读这个函数,可以很详细的了解到在atomic和nonatomic下不同的setter机制。为方便阅读,下述代码对reallySetProperty做了一定的简化。
1 | static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic) |
可以发现,atomic 与 nonatomic 的差别在是否对 oldValue = slot; 与 slot = newValue; 两个赋值语句加了自旋锁。那么上面这两个问题的答案是显而易见的。atomic通过对写操作加入自旋锁保障了多线程情况下写操作的安全,同时导致了性能的损失。
nonatomic可能产生的内存问题
多次release原始值
下图展示了nonatomic下两个线程同时调用setter方法时的场景:
很明显,如果不同时保证这两个赋值操作的原子性,必然有概率导致 *slot 中的原始值被 release 两次,而这样就会导致 Crash 的发生。因此,可以得出结论,多线程环境下对的nonatomic修饰的属性进行赋值操作有导致程序Crash的概率。
错误release新创建的对象
仔细观察上图展示的问题,假设在thread 1释放obj1占用的内存后又立刻对其进行重新分配值newObj。此时,thread 2 的局部变量oldValue与newObj指向同一块内存空间。会有什么问题产生?wow!oldValue会对刚刚创建的newObj进行release操作!这就给程序带来了更大的不确定性,此时对于newObj的引用计数永远比指向newObj的强引用数少1。也就是在某些未知的情况下newObj就会被释放,导致内存错误。当然,也有可能导致上述的objc_msgSend问题。
atomic 万能药?
那么,atomic是解决多线程内存管理问题的万能药么?答案是否定的。一起来看以下代码
1 | @property(atomic,strong)NSArray * array; |
即使我们将array的内存管理语义设为atomic,同时在访问objectAtIndex:之前加上判断,Thread 2还是会Crash。原因是由于前后两行代码之间array所指向的内存区域被线程1修改了。
atomic通过加锁确保了对于属性setter、getter操作的原子性。setter、getter操作的是属性的指针值,对于属性指针所指向的内存地址并不能起到保护作用。
More
关于多线程下的内存管理还有很多东西要讲,比如:
- memory barrier
- 为何标量类型可以用assign
- Atomic Operations
- 函数式编程中纯函数的概念
To Be Continue…