引言
在继续深入之前一起来考虑几个问题:
- 64/32位操作系统,64/32指的是什么?
- OC中标量类型属性修饰符为什么是assign?
- i++是原子操作么?
- atomic保障了什么?
CPU的寻址操作
我们知道,CPU是通过地址总线寻址,然后通过数据总线存取数据的。
地址总线
地址总线的位数决定CPU的寻址范围,若CPU的地址总线宽度是32位,那么CPU的寻址范围是4GB,所以最多支持4G内存。
数据总线
数据总线的位数决定CPU单次通信能交换的信息数量。若数据总线宽度是32位,那么单次交换的信息量为4字节,64位的8字节。这也是为什么C语言中的指针64位下所占空间为8字节,32位下所占空间为4字节。
我们通常说的64位处理器/32位处理器指的是数据总线的宽度为64位/32位。
多线程下的寻址操作
多线程下的内存管理是一个复杂的课题,在继续深入之前,关于CPU多线程下的寻址操作我们需要明确几点:
- 地址总线、数据总线都只有一根,所以寻址是串行操作,不存在多个线程同时寻址同一个地址。
- 64位下,对于标量类型int、double单次寻址就可以取出,对其存取我们可以理解为原子操作,是多线程安全的。
- 32位下单次寻址只可取出4个字节,这种情况下double需要两次寻址才可取出。这种情况下不是线程安全的。
属性&内存
以下代码我们为一个SomeObj对象声明了若干属性,并在SomeObj的初始化方法内打印各个实例变量在内存中的地址:
1 | //属性声明 |
我们调用init方法,打印各个实例变量的内存地址:
1 | 2018-03-25 16:51:09.284092+0800 MemoryTest[22641:3042100] address for int value:0x600000241598 |
可以看到,在初始化对象时编译器会在类的内存空间内自动分配实例变量的内存,这些内存会随着对象的销毁而释放。从这个角度看,指针类型和标量类型并没有区别(事实当然不是这样)。需要注意的是这里分配的是指针本身所占的8个字节而不是指针所指向的对象。
我们可以将内存中存储的数据类型大致分为标量类型、指针的值、指针所指向的内存空间三类,接下来将围绕这三种情况进行展开,一些特殊类型如tagged pointer这里就不做讨论。
标量类型
结合上面提到的,我们可以得出以下结论:
- 由于CPU单次寻址就可取出标量类型,对于标量类型的存取操作天然是原子的、线程安全的。
- 由于标量类型的存取操作天然是原子的,所以在未重写标量属性的getter/setter方法的情况下getter/setter方法也是原子的。
- 标量类型的内存会随着类对象的销毁而释放,这也是为何对于标量类型的属性可以用assign修饰的原因。
那么标量类型的数据在多线程下就高枕无忧了么?举个栗子,编程中最基本的操作之一是递增整数。这是一项非常普遍的任务,可以用几个等效的操作完成:
1 | _int_value = 3; |
然而,看起来像一个操作 - 递增整型变量 - 实际上包含三个不同的步骤:
1 | Get _int_value (3) |
单线程下get-add-set这些操作是可以保障顺序执行的。在多线程情况下,就有可能产生线程安全问题。最经典的例子就是火车站多窗口售票系统,在操作余票的时候通过加锁保障线程安全。
OSAtomic
尽管,锁是同步两个线程的利器。不可否认的是,锁也是一种相对昂贵的操作。非阻塞性同步是解决多线程同步更优雅的方式。libkern/OSAtomic.h 提供了很多强大的多线程编程工具,虽然它是内核头文件的一部分,但它也可以用于内核和驱动程序编程之外。
我们将上例中提到的“_int_value++”以Atomic Operations改写后,该操作就是原子的:
1 | OSAtomicIncrement64(&(_int_value)); |
事实上,真正的情况还要更复杂:为了提高性能,现代CPU/编译器会按照输入数据和执行单元的可用性的顺序执行指令而不是程序中的原始顺序执行(乱序执行)。
乱序执行 & 内存屏障
程序是工作在OS/编译器/物理硬件共同营造的虚拟环境中的。程序运行环境有一定的规则以确保程序稳定运行,不同的OS/编译器/CPU有各种不同的实现方式但是规则本身是不变的。
编译器和CPU在满足前面的规则的时候,总是玩各种小九九,在满足前面”承诺“的规则的前提下,(非有意地)破坏没有承诺的规则。
CPU:每个CPU都有自己的缓存,为提高数据读写速度,CPU会同自己的缓存交换数据而不是直接读写内存。
编译器:为充分利用寄存器和CPU流水线编译器可能会重排指令顺序。
举个栗子
1 | a=1; |
上面这个程序序列,作用于程序运行环境的时候,环境规则能承诺的是计算c的时候,a肯定等于1,b肯定等于2。最后打印的时候,c肯定等于3。
但它没有承诺的是:
- a,b,c是内存上的地址(也可以是寄存器一类的东西)
- a首先变成1,然后b才变成2
- 如果其他设备或者CPU修改这些内存地址,反应是什么
- 等等
内存屏障
我们可以通过设置内存屏障来避免CPU/编译器类似的优化,内存屏障可以有两个作用:
- 阻止屏障两侧的指令重排。
- 强制CPU直接读取内存(volatile)
上述的libkern/OSAtomic.h同样提供了内存屏障版本的API:
1 | OSAtomicIncrement64Barrier(&(_int_value)); |
对于Objective C的实现来说,几乎所有的加锁操作最后都会设置memory barrier。官方文档表述如下:
Note: Most types of locks also incorporate a memory barrier to ensure that any preceding load and store instructions are completed before entering the critical section.
GCD也提供了对于内存屏障的支持,利用dispatch_barrier_async(),可以轻易的实现安全的可变数组、可变字典(读写锁)。
指针的值
C语言指针
以C语言来说,32位操作系统下指针的大小为4字节,64位操作下指针的大小为8字节。根据上面的理论,CPU单次寻址可以取出指针,所以指针的存取操作时原子的、线程安全的。
OC指针
那么以OC为例呢?
1 | NSObject* obj = [[NSObject alloc] init]; |
事实上,答案是否定的,或者说从ARC的角度来看答案是否定的。仅仅从指针赋值的角度来看上述操作的确是原子的,但是为支持ARC上述赋值操作会调用在runtime中引入相应的副作用操作:
1 | void |
考虑:A、B、C三个线程几乎同时对obj进行赋值操作,可能会有什么问题?可能会对同一“prev”调用多次objc_release(prev);从而导致Crash。一个比较类似的Crash是(可能不那么类似):如果多个线程同时设置UIImageView的image,很可能应用程序会崩溃,因为当前设置的图像可能会被释放两次。
同样的对于weak类型指针会调用相应的:
1 | id |
atomic
显然OC中ARC下的指针赋值操作是非原子的。而OC也提供了atomic关键字来保障属性getter/setter操作的“原子性”,调用getter/setter方法同样会调用到runtime的相应方法:
1 | static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy) |
可以看到atomic 并没有真正保障getter/setter的原子性,而是通过加锁保障了不会对同一个oldValue 调用多次objc_release(oldValue);操作,从而保障了线程安全,同时也导致了性能的损耗。
内存地址
那么,atomic是解决多线程内存管理问题的万能药么?答案是否定的。一起来看以下代码:
1 | @property(atomic,strong)NSArray * array; |
即使我们将array的内存管理语义设为atomic,同时在访问objectAtIndex:之前加上判断,Thread 2还是会Crash。原因是由于前后两行代码之间array所指向的内存区域被线程1修改了。
atomic通过加锁确保了对于属性setter、getter访问的线程安全问题。setter、getter操作的是属性的指针值,对于属性指针所指向的内存地址并不能起到保护作用。
More
多线程是一个复杂的课题,从pThread、NSThread、GCD、NSOperation,API的选择、优劣到多线程开发面临的挑战竞态条件、死锁、线程饥饿、优先级反转,再到如何编写性能优异安全的多线程代码。
To be continue…