程序员IOS开发IOS面试专题

iOS 底层拾遗:objc_msgSend 与方法缓存

2019-10-25  本文已影响0人  波儿菜

前言

Runtime 消息发送与转发流程总是大家关注的重点,却常常忽略方法缓存机制这个显著提升 objc_msgSend 性能的幕后功臣。

本文会通过源码梳理消息发送与转发流程,重点分析方法缓存机制的实现细节。行文过程中会涉及到一些汇编代码,不过不影响理解核心逻辑。

源码基于 Runtime 750,arm64 架构。

一、从 objc_msgSend 谈起

注意:arm64 汇编代码会出现很多p字母,实际上是一个宏,64 位下是x,32 位下是wp就是寄存器。

在分析缓存机制之前,先梳理一下消息发送与转发的流程,找到何时进行缓存的存储与读取。

objc_msgSend

objc_msgSend 代码如下:

    ENTRY _objc_msgSend
    UNWIND _objc_msgSend, NoFram

    ...// 处理对象是 tagged pointer 或 nil 的情况(x0 存的是 objc_object 对象地址)

    ldr p13, [x0]            // p13 = isa 把 x0 指向内存的前 64 位放到 p13(即是 objc_object 的 isa 成员变量)
    GetClassFromIsa_p16 p13  // p16 = class 通过 isa 找到 class
LGetIsaDone:
    CacheLookup NORMAL       // 从方法缓存或方法列表中找到 IMP 并调用
    ...

在 64 位系统下GetClassFromIsa_p16宏代码为:

.macro GetClassFromIsa_p16
    ...
    and p16, $0, #ISA_MASK  // #define ISA_MASK 0x0000000ffffffff8ULL
    ...

$0获取宏的第一个参数,调用时传的p13,即是isa。这一步做的操作就是使用ISA_MASK掩码找到isa变量中的Class并放入p16isaunion isa_t类型,在很多系统中已经不是单纯的指向Class,还包含了内存管理等信息,所以需要用掩码来获取)。

CacheLookup

CacheLookup包含读取方法缓存的核心逻辑,代码后面分析。

目前只需要知道它会查询当前Class的方法缓存,主要产生两种结果:若缓存命中,返回IMP或调用IMP;若缓存未命中,调用__objc_msgSend_uncached (找到IMP会调用) 或__objc_msgLookup_uncached (找到IMP不会调用) 方法。

    STATIC_ENTRY __objc_msgSend_uncached
    UNWIND __objc_msgSend_uncached, FrameWithNoSaves

    MethodTableLookup
    TailCallFunctionPointer x17

    END_ENTRY __objc_msgSend_uncached

MethodTableLookup后面就是较为复杂的方法查询逻辑了,若找到了IMP会放到x17寄存器中,然后把x17的值传递给TailCallFunctionPointer宏调用方法。

MethodTableLookup

.macro MethodTableLookup
    // push frame
    SignLR
    stp fp, lr, [sp, #-16]!
    mov fp, sp

    ...// save registers: x0..x8, q0..q7

    // receiver and selector already in x0 and x1
    mov x2, x16
    bl  __class_lookupMethodAndLoadCache3

    // IMP in x0
    mov x17, x0
    
    ...// restore registers

    mov sp, fp
    ldp fp, lr, [sp], #16
    AuthenticateLR
.endmacro

由于这个宏内部要跳转函数,意味着lr的变化,所以开辟栈空间后需要把之前的fp/lr值存储到栈上便于复位状态。笔者删除了save registersrestore registers的逻辑,其实就是将各个寄存器的值先存储到栈上,内部函数帧释放时便于复位寄存器的值。

在调用完__class_lookupMethodAndLoadCache3后会把返回在x0IMP值复制到x17中。

__class_lookupMethodAndLoadCache3是一个 C 函数,跳转之前把x16的值复制到x2中(x16目前存储的就是GetClassFromIsa_p16代码找到的对象的Class),那么此时寄存器布局就是:x0 -> receiver / x1 -> selector / x2 -> class,也就对应了这个方法的参数列表:

IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls) {
    return lookUpImpOrForward(cls, sel, obj, 
                              YES/*initialize*/, NO/*cache*/, YES/*resolver*/);
}

lookUpImpOrForward

lookUpImpOrForward方法比较复杂,简化逻辑如下:

IMP lookUpImpOrForward(Class cls, SEL sel, id inst, 
                       bool initialize, bool cache, bool resolver) {
    IMP imp = nil;
    bool triedResolver = NO;
    ...
    // cache 为 YES 查找方法缓存
    if (cache) {
        imp = cache_getImp(cls, sel);
        if (imp) return imp;
    }
    // 加锁
    runtimeLock.lock();
    // 若需要,进行类的初始化以及调用 +initialize 等工作
    ...

retry:
    // 在当前类方法缓存中查找 IMP
    imp = cache_getImp(cls, sel);
    if (imp) goto done;
    // 在当前类方法列表中查找 IMP
    if (找到 IMP) {
        把 IMP 存方法缓存
        goto done;
    }
    // 在父类的方法缓存/方法列表中查找 IMP
    while (Class cur = cls->superClass; cur != nil; cur = cur->superClass) {
        if (在方法缓存中找到 IMP) {
            if (IMP == _objc_msgForward_impcache) { break; }
            把 IMP 存入当前类 cls 的方法缓存
            goto done;
        }
        if (在方法列表中找到 IMP) {
            把 IMP 存入当前类 cls 的方法缓存
            goto done;
        }
    }
    // 没有找到 IMP,尝试进行动态消息处理
    if (resolver  &&  !triedResolver) {
        runtimeLock.unlock();
        _class_resolveMethod(cls, sel, inst);
        runtimeLock.lock();
        triedResolver = YES;
        goto retry;
    }
    // 若动态消息处理失败,IMP 指向一个函数并将 IMP 存方法缓存
    imp = (IMP)_objc_msgForward_impcache;
    cache_fill(cls, sel, imp, inst);

done:
    runtimeLock.unlock();
    return imp;
}

方法缓存的存取

方法缓存存储符合一般逻辑,只要找到了IMP就会进行缓存,加入方法缓存都会调用cache_fill方法。需要注意的是,如果是从父类链中找到的方法,仍然会加入当前类的缓存列表,这样能大大提高查找在父类链中方法的效率。

可能读者会疑惑这个方法为什么还会去取缓存?前面一堆汇编方法走到这里的时候理论上当前类是已经没有对应SEL的方法缓存了。前面个cache_getImp方法是因为lookUpImpOrForward函数会被其它函数调用,并不在前面笔者分析的流程中;而retry:下面的cache_getImp是因为在动态消息处理的时候可能会插入相关IMP然后goto retry

方法列表的查询

类的方法列表的查询通过getMethodNoSuper_nolock-> search_method_list方法处理,具体的逻辑不展开了,只需知道若方法列表是排过序的会使用二分搜索去查;否则就是一个简单的遍历查询。所以在没有方法缓存的情况下方法的查询效率是很低的,时间复杂度要么是 O(logn) 要么是 O(n)。

消息转发的逻辑

_class_resolveMethod方法前面调用了unlock()lock(),关闭了类的保护状态,便于开发者改变类的方法列表等。

_class_resolveMethod会向对象发送+resolveInstanceMethod(实例对象)或+resolveClassMethod(类对象)方法,开发者可以在这两个方法中为类动态加入IMP_class_resolveMethod出栈后走goto retry会重新尝试查找方法的逻辑。

当然,若开发者没有做处理,IMP仍然找不到,通过!triedResolver避免二次动态消息处理,然后就会让imp = (IMP)_objc_msgForward_impcache。如此一来,当lookUpImpOrForward函数帧释放时,在上层看来仍然是找到IMP了,这个方法就是_objc_msgForward_impcache。那么在前面分析的__objc_msgSend_uncached方法就仍然会调用这个IMP,接下来就是真正的消息转发阶段了。

    STATIC_ENTRY __objc_msgForward_impcache
    b   __objc_msgForward
    END_ENTRY __objc_msgForward_impcache

    ENTRY __objc_msgForward
    adrp    x17, __objc_forward_handler@PAGE
    ldr p17, [x17, __objc_forward_handler@PAGEOFF]
    TailCallFunctionPointer x17
    END_ENTRY __objc_msgForward

可以发现通过页地址加页偏移的方式,拿到__objc_forward_handler的地址并调用,它是一个函数指针,在OBJC2下有默认实现:

__attribute__((noreturn)) void 
objc_defaultForwardHandler(id self, SEL sel)
{
    _objc_fatal("%c[%s %s]: unrecognized selector sent to instance %p "
                "(no message forward handler is installed)", 
                class_isMetaClass(object_getClass(self)) ? '+' : '-', 
                object_getClassName(self), sel_getName(sel), self);
}
void *_objc_forward_handler = (void*)objc_defaultForwardHandler;

最终看到了熟悉的unrecognized selector sent to instance描述。

而对于开发者熟悉的-forwardingTargetForSelector:重定向方法、-forwardInvocation:转发方法,Runtime 源码中没有啥痕迹,在文件后面只有一个更改_objc_forward_handler指针的函数(笔者玩儿不动了,可以猜测方法重定向和方法转发是通过改变这个指针做逻辑的,感兴趣可以查看杨帝的逆向分析消息转发文章:Objective-C 消息发送与转发机制原理):

void objc_setForwardHandler(void *fwd, void *fwd_stret) {
    _objc_forward_handler = fwd;
    ...
}

小结

到目前为止,整个消息发送机制算是比较清晰了,在按图索骥的过程中,发现了不少方法缓存的存取操作,主要是cache_getImpcache_fill函数。当然,方法缓存还有清理操作,后面再谈。接下来的部分就着重分析方法缓存的实现细节。

二、方法缓存的数据结构基础

cache_t是方法缓存的数据结构,在objc_classcache变量偏移64*2位:

struct objc_class : objc_object {
    // Class ISA;
    Class superclass;
    cache_t cache; 
    class_data_bits_t bits; 
...

bits存储了类的属性、协议、方法等,这里不展开描述。cache_t的结构也很简单:

struct cache_t {
    struct bucket_t *_buckets;  // bucket_t 数组
    mask_t _mask;               // 容量缓存个数减1
    mask_t _occupied;           // 有效缓存个数
...

咋一看就像是一个散列表,这和weak弱引用的底层数据结构(weak_table_t/weak_entry_t)如出一辙。bucket_t在 arm64 下代码如下:

struct bucket_t {
    MethodCacheIMP _imp;
    cache_key_t _key;
...

MethodCacheIMP就是IMP别名,cache_key_t就是unsigned long

三、方法缓存的写入

cache_fill

cache_fill是方法缓存写入的入口方法:

void cache_fill(Class cls, SEL sel, IMP imp, id receiver) {
    mutex_locker_t lock(cacheUpdateLock);
    cache_fill_nolock(cls, sel, imp, receiver);
}

这个lock看起来很奇怪,进去一看实际上是这样一个类:

    class locker : nocopy_t {
        mutex_tt& lock;
    public:
        locker(mutex_tt& newLock) 
            : lock(newLock) { lock.lock(); }
        ~locker() { lock.unlock(); }
    };

locker构造时加锁,析构时解锁,正好保护了方法作用域内的方法调用。这和 EasyReact 中大量使用的__attribute__((cleanup(AnyFUNC), unused))如出一辙,都是为了实现自动解锁的效果。

cache_fill_nolock

cache_fill_nolock是写入的核心逻辑(为了简短有所修改):

static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver)
{
    ...
    // 在类初始化之前不允许写入缓存
    if (!cls->isInitialized()) return;
    // 在走到这里的时候,可能在占有 cacheUpdateLock 的时候缓存已经被其它线程写入了,所以先查询一次缓存
    if (cache_getImp(cls, sel)) return;

    cache_t *cache = getCache(cls);
    cache_key_t key = getKey(sel);
    mask_t newOccupied = cache->occupied() + 1;
    mask_t capacity = cache->capacity();

    if (cache->isConstantEmptyCache()) {
        // 如果缓存是只读的,重新分配内存
        cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);
    } else if (newOccupied > capacity / 4 * 3) {
        // 如果有效缓存数量超过了 3/4 就进行扩容
        cache->expand();
    }

    // 在散列表中找到一个空置的 bucket 写入数据
    bucket_t *bucket = cache->find(key, receiver);
    if (bucket->key() == 0) cache->incrementOccupied();
    bucket->set(key, imp);
}

锁的抢占

cache_fill方法虽然已经加了锁,但有可能多个线程同时访问,且它们都是往同一个Class添加同一个SEL,若有一个线程占有锁后更新成功,其它线程在空转或挂起一段时间后,就没必要再次写入缓存了,所以if (cache_getImp(cls, sel)) return;这句话是必要的。

这也是个保险措施,因为调用方可能在没有判断Class的某个SEL是否有缓存的时候就调用该方法。

散列表内存分配

void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity)
{
    bool freeOld = canBeFreed();
    bucket_t *oldBuckets = buckets();
    bucket_t *newBuckets = allocateBuckets(newCapacity);
    ...
    setBucketsAndMask(newBuckets, newCapacity - 1);
    if (freeOld) {
        cache_collect_free(oldBuckets, oldCapacity);
        cache_collect(false);
    }
}

直接将旧的bucket_t数组释放了,然后创建新的数组,开辟内存方法allocateBuckets很简单,就是开辟newCapacity * sizeof(bucket_t)的空间。那么可以确定的是,方法缓存散列表每次分配内存都会放弃之前的缓存

后面的赋值方法蛮有意思:

#define mega_barrier() \
    __asm__ __volatile__( \
        "dsb    ish" \
        : : : "memory")

void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask) {
    mega_barrier();
    _buckets = newBuckets;
    mega_barrier();
    _mask = newMask;
    _occupied = 0;
}

因为抛弃了之前的缓存,所以_occupied置为 0。mega_barrier这个内联汇编使用__volatile__关键字阻止编译器缓存变量到寄存器不写回,使用memory内存屏障避免 CPU 使用寄存器来优化执行指令,使用dsb ish隔离指令在它前面的存储器访问操作都执行完毕后,才执行在它后面的指令。这一个使尽浑身解数的宏是为了干嘛呢?

对于cache_t来说,读取_buckets_mask都是没有加锁的,那么就一定要保证_buckets的实际长度始终大于_mask,最坏的情况不过只是访问不到已有的缓存,不然在进行 hash 运算后很可能访问到错误或非法的内存。

那么第二个mega_barrier()就是为了保证新的_buckets始终会在新的_mask之前赋好值。当然这有个前提,就是新_buckets的长度始终大于旧的。在cache_t算法中并没有削减_buckets内存的逻辑,只有一个清空_buckets数组每个bucketkey/imp的逻辑(清空后内存为 readonly),所以这个前提是能保证的。

在前面cache_fill_nolock方法的if (cache->isConstantEmptyCache())分支正是内存被清空后标记为 readonly 的逻辑,重新分配内存时会开辟一个INIT_CACHE_SIZE (8) 长度的空间,可能有读者会疑问这个时候不就是新_buckets的长度小于旧的么?

其实不然,在清空_buckets时虽然没有削减内存,但_occupied(有效缓存数量)会置为 0,也就是说这种情况下是不会有其它线程访问的。

第一个mega_barrier()就比较梦幻了,笔者可能理解有误:

newBuckets指针开辟内存到赋值给_buckets的模拟如下:

1、开辟堆内存(地址 0x111)
2、x0 = 0x111
3、_buckets = x0

由于内存访问比寄存器访问慢,很可能被操作系统优化成这样:

1、x0 = 0x111
2、_buckets = x0
3、开辟堆内存(地址 0x111)

那么第三步执行之前_buckets已经有值了,但这个内存还是非法的,所以mega_barrier()起到了关键作用,让第 2 部执行之前必须把开辟堆内存的操作执行完毕。

散列表内存释放

canBeFreed()就是判断这个旧的_buckets是不是清理过后只读的,若不是就可以释放(清理逻辑后面分析)。

释放有两步操作:

第一步cache_collect_free(oldBuckets, oldCapacity);是将待释放的oldBuckets插入一个全局的二维数组:

static bucket_t **garbage_refs = 0;

具体的算法不多说了,反正就是garbage_refs满了时会以两倍的容量扩容。

第二步cache_collect(false);内部会判断garbage_refs的大小,若小于32*1024什么也不做。否则会进入一个循环判断,若进程中没有缓存的访问操作才进行真正的内存释放。

这么做的目的应该也是为了访问安全,保证在对一块cache_t内存访问时不会去释放这块内存。

可以看出,为了访问cache_t的成员变量时不加锁,付出了很大的努力,但是对于这样一个高频访问的缓存机制,这些努力都是值得的。

散列表的扩容

void cache_t::expand() {
    ...
    uint32_t oldCapacity = capacity();
    uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE;
    // 越界处理
    if ((uint32_t)(mask_t)newCapacity != newCapacity) {
        newCapacity = oldCapacity;
    }
    reallocate(oldCapacity, newCapacity);
}

cache_t_mask成员变量是mask_t类型的,定义为:

#if __LP64__
typedef uint32_t mask_t;  // x86_64 & arm64 asm are less efficient with 16-bits
#else
typedef uint16_t mask_t;
#endif

如注释所说,64 位系统使用 32 位的整形效率较高。上面newCapacity是使用uint32_t运算的,所以若mask_t是 16 位时可能越界,若越界就放弃扩容,只是调用reallocate重新分配和之前等大的内存。

由于之前分析分配内存方法reallocate总是创建新的内存放弃旧的,所以每次扩容都会放弃旧的缓存。可能会担心放弃旧缓存导致消息发送效率下降,其实散列表容量是以两倍的速度扩展的,初始也是 8 个,对于大部分类来说,拓展少许的几次就够了。

扩容时放弃之前的缓存能带来另外一个好处:不用把旧缓存依次按照 hash 算法写入散列表(因为扩容后散列表的容量会变化,将直接影响 hash 值会被掩码截取的对象,所以不得不使用 hash
算法重新插入所有对象),试想若不放弃旧缓存,那将旧缓存同步到新散列表至少有 O(n) 时间消耗,这个过程必然缓存的读取变得不再安全。

散列表的写入

写入操作的核心操作就是通过cache_tfind函数读取一个可用的bucket_t

bucket_t * cache_t::find(cache_key_t k, id receiver) {
    bucket_t *b = buckets();
    mask_t m = mask();
    mask_t begin = cache_hash(k, m);
    mask_t i = begin;
    do {
        if (b[i].key() == 0  ||  b[i].key() == k) {
            return &b[i];
        }
    } while ((i = cache_next(i, m)) != begin);
    ...
}

cache_hash散列算法就是简单的操作:(mask_t)(key & mask),然后直接到数组中找出bucket.key()比较,若key为 0 或与目标一致就返回这个bucket的地址。

当发生 hash 碰撞时,就使用cache_next将 hash 值累加 1,以此轮询直到找到空位。cache_next代码为(i+1) & mask,就算 hash 值累加到数组最大值还未找到空位,又会回到数组头部继续寻找。由于在容量达到 3/4 时散列表就会扩容,所以这个find操作是必然能找到空位的。

由于bucket.key() == 0表示这个bucket为空,所以在上层方法中有这样一句代码(_occupied++):

if (bucket->key() == 0) cache->incrementOccupied();

四、方法缓存的读取

调用objc_msgSend或者cache_getImp中都会调用CacheLookup宏,它们的区别是调用时传的参数不同:

objc_msgSend -> CacheLookup NORMAL
cache_getImp -> CacheLookup GETIMP

下面分析一下CacheLookup的上半截核心代码:

    .macro CacheLookup
        // p1 = SEL, p16 = isa
1        ldp    p10, p11, [x16, #CACHE] // p10 = buckets, p11 = occupied|mask
    #if !__LP64__
         and    w11, w11, 0xffff    // p11 = mask
    #endif
2        and    w12, w1, w11        // x12 = _cmd & mask
3        add    p12, p10, p12, LSL #(1+PTRSHIFT)
                         // p12 = buckets + ((_cmd & mask) << (1+PTRSHIFT))

4        ldp    p17, p9, [x12]      // {imp, sel} = *bucket
5    1: cmp p9, p1          // if (bucket->sel != _cmd)
6        b.ne   2f          //     scan more
7        CacheHit $0            // call or return imp

     2: // not hit: p12 = not-hit bucket
8        CheckMiss $0           // miss if bucket->sel == 0
9        cmp    p12, p10        // wrap if bucket == buckets
10       b.eq   3f
11       ldp    p17, p9, [x12, #-BUCKET_SIZE]!  // {imp, sel} = *--bucket
12       b  1b          // loop
         ...

实际上注释就已经把整个逻辑说明得比较明白了,下面笔者进行一些解释让读者看起来更容易(注意起始的寄存器状态p1 = SEL, p16 = isa):

    STATIC_ENTRY _cache_getImp
    GetClassFromIsa_p16 p0
    CacheLookup GETIMP
LGetImpMiss:
    mov p0, #0  // 复位
    ret
    END_ENTRY _cache_getImp

CacheLookup下半截做了些什么

3:  // wrap: p12 = first bucket, w11 = mask
    add p12, p12, w11, UXTW #(1+PTRSHIFT)
    // p12 = buckets + (mask << 1+PTRSHIFT)
    ...(省略了循环逻辑)

p12指向散列表末尾,然后做了和前面一样的向前遍历查询。

仔细看前面跳转到3:的指令,若到了这里说明通过 hash key 找到的SEL始终不为 0,但是也不等于目标SEL,也就是始终是 hash 冲突状态,向前遍历完散列表都没有找到目标SEL

那么,这部分会从散列表尾遍历到散列表头:

散列表头  (上半截遍历部分)  hash key  (未遍历部分)  散列表尾

可能有读者会觉得这个遍历会重复查询上半截代码遍历过的部分,实际上不会。由于散列表会在满 3/4 时就扩容,所以把3:之前未遍历的部分找完就肯定能拿到缓存或者丢失(SEL == 目标SEL == 0),那循环就会被打破。

五、方法缓存的清理

缓存清理分两种模式,一种是清理散列表的内容,而不是削减散列表的容量;一种是直接释放整个散列表。

清理内容

void cache_erase_nolock(Class cls) {
    ...
    cache_t *cache = getCache(cls);

    mask_t capacity = cache->capacity();
    if (capacity > 0  &&  cache->occupied() > 0) {
        auto oldBuckets = cache->buckets();
        auto buckets = emptyBucketsForCapacity(capacity);
        cache->setBucketsAndMask(buckets, capacity - 1); // also clears occupied
        cache_collect_free(oldBuckets, capacity);
        cache_collect(false);
    }
}

主要是将旧的oldBuckets释放掉,然后通过emptyBucketsForCapacity函数获取新的容量相同的buckets数组,这个方法获取的数组在语言上没有限制只读,但需要把它理解为只读数组

emptyBucketsForCapacity的大致逻辑:

还记得前面的cache->isConstantEmptyCache()调用判断缓存是否只读么?这个函数实际上就是调用了emptyBucketsForCapacity判断这个缓存数组是否属于只读数组。

为什么要做这么复杂的逻辑来清空一个数组?其实在前面的散列表内存分配一节已经解释了,就是为了保证缓存散列表的读安全。

搜索一下源码,随便列举几个需要调用这个清空方法的地方:

需要清空的情况一句话概括:可能会导致缓存失效时。

直接释放

cache_delete先会通过isConstantEmptyCache函数判断数组内容是否为只读的,若不是只读则调用free直接释放。可能有读者担心这个释放会让方法缓存的读取变得不安全,实际上不会,因为笔者只看到free_class时会调用。

后语

方法缓存机制为了极致的效率而不给读取逻辑加锁,为了让读取安全做了很多额外复杂工作,不过带来的收益是很大的,因为方法缓存读取频率极高。

objc_msgSend 的逻辑无疑是比较复杂的,涉及了不少汇编与操作系统的知识,不过按图索骥分析起来也不是一件很困难的事,在这最后笔者不得不说一句:

iOS 太难了。

上一篇下一篇

猜你喜欢

热点阅读