iOS:SideTable

时间:2021-7-5 作者:qvyue

本文源码来自于 objc4-756.2 版本;

一、SideTable

本文研究 sideTable 在 objc4 源码中的使用及其作用,从而解析 iOS 中引用计数器和弱引用的实现原理;

1. retain 操作

我们都知道,新版本的 objc 中引入了 Tagged Pointer,且 isa 采用 union 的方式进行构造,其中 isa 的结构体中有一个 extra_rchas_sidetable_rc,这两者共同记录引用计数器。

直接看看 objc_object::rootRetain() 方法,只看 extra_rc 超出之后 sidetable 相关的代码,删减之后如下:


uintptr_t carry;
    newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc++

if (carry) {
    // Leave half of the retain counts inline and prepare to copy the other half to the side table.
    transcribeToSideTable = true;
    newisa.extra_rc = RC_HALF;
    newisa.has_sidetable_rc = true;
}
if (slowpath(transcribeToSideTable)) {
        // Copy the other half of the retain counts to the side table.
        sidetable_addExtraRC_nolock(RC_HALF);
}

那么关键方法就是 sidetable_addExtraRC_nolock()

bool 
objc_object::sidetable_addExtraRC_nolock(size_t delta_rc)
{
    assert(isa.nonpointer);
    // 取出this对象所在的SideTable
    SideTable& table = SideTables()[this];
    // 取出SideTable中存储的refcnts,类型为Map
    size_t& refcntStorage = table.refcnts[this];
    // 记录原始的引用计数器
    size_t oldRefcnt = refcntStorage;

    // 容错处理
    assert((oldRefcnt & SIDE_TABLE_DEALLOCATING) == 0);
    assert((oldRefcnt & SIDE_TABLE_WEAKLY_REFERENCED) == 0);
    if (oldRefcnt & SIDE_TABLE_RC_PINNED) return true;

    uintptr_t carry;
    size_t newRefcnt = 
        addc(oldRefcnt, delta_rc 

这个函数的逻辑如下:

  1. 根据 this,也就是对象的地址从 SideTables 中取出一个 SideTable;
  2. 获取 SideTable 的 refcnts,这个成员变量是一个 Map;
  3. 存储旧的引用计数器;
  4. 进行 add 计算,并记录是否有溢出;
  5. 根据是否溢出计算并记录结果,最后返回;

那么,这里有几个点需要解开:

  1. 什么是 SideTables;
  2. 什么是 SideTable;
  3. 什么是 refcnts;
  4. add 的计算逻辑为什么需要位移?
  5. SideTable 中的溢出时如何处理的?

接下来,一一解决~~~

2. SideTables

直接来看 SideTables 的代码:

static StripedMap& SideTables() {
    return *reinterpret_cast*>(SideTableBuf);
}

首先,这是个静态函数,返回 StripedMap 类型,但是 & 是什么意思呢?这个是 C++ 语法,表示返回引用类型,看个例子:

iOS:SideTable
&的用法

& 的用法还有些限制,比如不能返回栈中的引用,否则会栈变量消失后会出现 error,还有一些其他的限制,有兴趣可以深究,这里只需要知道 & 表示返回引用类型,也就是可以通过 & func() 来获取函数返回值的指针,其他的不再赘述;

接着,比较懵逼的是 *reinterpret_cast ,其实这个是 C++ 的强制类型转换语法,不用深究,有兴趣的可以自行百度。

所以,总结下这段代码:

  1. SideTables() 使用 static 修饰,是一个静态函数;
  2. & 表示返回引用类型;
  3. reinterpret_cast 是一个强制类型转换符号;
  4. 函数最终的结果就是返回 SideTableBuf;

那么 SideTableBuf 又是什么?

3. SideTableBuf

直接看代码:

// We cannot use a C++ static initializer to initialize SideTables because
// libc calls us before our C++ initializers run. We also don't want a global 
// pointer to this struct because of the extra indirection.
// Do it the hard way.

alignas(StripedMap) static uint8_t 
    SideTableBuf[sizeof(StripedMap)];

首先看注释,说明了两点:

  1. SideTables 在 C++ 的 initializers 函数之前被调用,所以不能使用 C++ 初始化函数来初始化 SideTables,而 SideTables 本质就是 SideTableBuf;
  2. 不能使用全局指针来指向这个结构体,因为涉及到重定向问题;

其实还是比较懵逼为什么 SideTableBuf 要这么设计,原理有待考究~~~估计和初始化有关;

继续看 SideTableBuf,要点包括:

  1. alignas 表示对齐;
  2. StripedMap 的 size 为 4096(存疑,待验证);
  3. uint8_t 实际上是 unsigned char 类型,即占 1 个字节;

由此可以得出:

  • SideTableBuf 本质上是一个长度为 sizeof(StripedMap) 的 char 类型的数组;

同时也可以这么理解:

  • SideTableBuf 本质上就是一个大小为和 StripedMap 对象一致的内存块;

这也是为什么 SideTableBuf 可以用来表示 StripedMap 对象。本质上而言,SideTableBuf 就是指一个 StripedMap对象;

那么接下来就是搞清楚 StripedMap 是个什么东西了……

4. StripedMap

先上代码,删减一些方法之后的代码为:

template
class StripedMap {
#if TARGET_OS_IPHONE && !TARGET_OS_SIMULATOR
    enum { StripeCount = 8 };
#else
    enum { StripeCount = 64 };
#endif

    struct PaddedT {
        T value alignas(CacheLineSize);
    };

    PaddedT array[StripeCount];

    static unsigned int indexForPointer(const void *p) {
        uintptr_t addr = reinterpret_cast(p);
        return ((addr >> 4) ^ (addr >> 9)) % StripeCount;
    }

 public:
    T& operator[] (const void *p) { 
        return array[indexForPointer(p)].value; 
    }
    const T& operator[] (const void *p) const { 
        return const_cast>(this)[p]; 
    }
    ...省略了对象方法...
}

上述代码的逻辑为:

  1. 根据是否为 iphone 定义了一个 StripeCount,iphone 下为 8;
  2. 源码中 CacheLineSize 为 64,使用 T 定义了一个结构体,而 T 就是 SideTable 类型;
  3. 生成了一个长度为 8 类型为 SideTable 的数组;
  4. indexForPointer() 逻辑为根据传入的指针,经过一定的算法,计算出一个存储该指针的位置,因为使用了取模运算,所以值的范围是 0 ~ (StripeCount-1),所以不会出现数组越界;
  5. 后面的 operator 表示重写了运算符 [] 的逻辑,调用了 indexForPointer() 方法,这样使用起来更像一个数组;

至此,SideTables 的含义已经很清楚了:

  • SideTables 可以理解成一个类型为 StripedMap 静态全局对象,内部以数组的形式存储了 StripeCount 个 SideTable;

那么第一个问题已经解决,按照 sidetable_addExtraRC_nolock() 方法中的逻辑,先从 SideTables 数组中取出一个 SideTable,然后进行相关操作,所以现在就来看看 SideTable 是个啥~~~

5. SideTable

struct SideTable {
    spinlock_t slock;
    RefcountMap refcnts;
    weak_table_t weak_table;

    SideTable() {
        memset(&weak_table, 0, sizeof(weak_table));
    }

    ~SideTable() {
        _objc_fatal("Do not delete SideTable.");
    }
    ...省略对象方法...
}

可以看到,SideTable 有三个成员变量:

  1. spinlock_t:自旋锁,负责加锁相关逻辑;
  2. refcnts:存储引用计数器的 Map;
  3. weak_table:存储弱引用的表;

自旋锁暂不讨论,来看看 refcnts 的定义:

typedef objc::DenseMap,size_t,RefcountMapValuePurgeable> RefcountMap;

DenseMap 就是一个 hash Map,过于复杂,先不看。来看看基类 DenseMapBase 中的部分代码,如下,DenseMapBase中重写了操作符 []:

ValueT &operator[](const KeyT &Key) {
    return FindAndConstruct(Key).second;
  }

大意是通过传入的 Key 寻找对应的 Value。而 Key 是 DisguisedPtr 类型,Value 是 size_t 类型。即使用 obj.address :refCount 的形式来记录引用计数器;

回到最初的 sidetable_addExtraRC_nolock 方法中:

size_t& refcntStorage = table.refcnts[this];

上述代码就是通过 this ,即 object 对象的地址,取出 refcnts 这个哈希表中存储的引用计数器;

refcnts 可以理解成一个 Map,使用 address:refcount 的形式存储了很多个对象的引用计数器;

6. 引用计数器原理总结

iOS:SideTable
SideTables 和 SideTable
  1. iphone 中 SideTables() 本质是返回一个 SideTableBuf 对象,该对象存储 8 个 SideTable;
  2. 因为涉及到多线程和效率的问题,必定不可能只使用一个 SideTable 来存储对象相关的引用计数器和弱引用;
  3. Apple 通过对 object 的地址进行运算之后,对 SideTable 的个数进行取模运算,以此来决定将对象分配到哪个 SideTable 进行信息存储,因为有取模运算,不会出现数组溢出的情况;

总结:

  • objc 中当对象需要使用到 sideTable 时,会被分配到 8/64 个全局 sideTables 中的某一个表中存储相关的引用计数器或者弱引用信息;

7. weak_table

继续看弱引用如何实现的,从上文中可以看出,8/64 个 SideTable 对象中不仅保存了引用计数器相关的 Map,还保存了一个 weak_table,来看看 weak_table_t 源码:

/**
 * The global weak references table. Stores object ids as keys,
 * and weak_entry_t structs as their values.
 */
struct weak_table_t {
    weak_entry_t *weak_entries;
    size_t    num_entries;
    uintptr_t mask;
    uintptr_t max_hash_displacement;
};

注释也说明了 weak_table_t 是一个全局引用表,object 的地址作为 key,weak_entry_t 作为 Value。只不过这个全局引用表有 8 或者 64 个;

即:

  • weak_table 中以 weak_entry_t 的形式存储对象的弱引用;

那么具体是怎么存储的呢?这个 weak_entry_t 是什么,又是怎么用的呢,弱引用的存储逻辑是怎样的?

上述成员变量是 weak_entries,前面又带 *,感觉很像是一个指向类型为 weak_entry_t 的数组,如果是这样,那也正好和注释的描述相符,大胆猜测一下:

  • weak_table_t 中使用数组的形式来存储 weak_entry_t 对象,以此来表示该表中每个对象的弱引用情况;

接下来就是验证~~~

8. store_weak流程分析

要弄清楚 weak_table_t 和 weak_entry_t 的使用,就要从新增弱引用作为突破口,来看看 objc_storeWeak() 方法:

id
objc_storeWeak(id *location, id newObj)
{
    return storeWeak
        (location, (objc_object *)newObj);
}

storeWeak 的定义如下:

enum CrashIfDeallocating {
    DontCrashIfDeallocating = false, DoCrashIfDeallocating = true
};

template 
static id 
storeWeak(id *location, objc_object *newObj)
{
...省略...
}

这里不要被这些 template 吓到,storeWeak 只不过又是一个模板函数,这是 C++ 中的语法,可以暂不深究,有兴趣的可以去学习学习。

这里只需要知道,haveOldhaveNew 是作为参数来使用的,从上面的storeWeak 调用代码以及后文对这两个参数的使用,也可以看出个大概,不必纠结~~~

精简 storeWeak() 函数的代码如下:

SideTable *oldTable;
SideTable *newTable;

// 根据参数判断是否存在旧表决定使用哪个表进行存储
if (haveOld) {
    oldObj = *location;
    oldTable = &SideTables()[oldObj];
} else {
    oldTable = nil;
}
if (haveNew) {
    newTable = &SideTables()[newObj];
} else {
    newTable = nil;
}

...省略很多异常场景处理代码...

// 只看 new 的逻辑
if (haveNew) {
    // 在weak_table中新增弱引用
    // 如果失败则会返回 nil,成功则返回对象本身
    newObj = (objc_object *)weak_register_no_lock(&newTable->weak_table, (id)newObj, location, crashIfDeallocating);

    if (newObj  &&  !newObj->isTaggedPointer()) {
        // 成功则设置weakly_referenced为1;
        newObj->setWeaklyReferenced_nolock();
    }

    // 赋值
    *location = (id)newObj;
}

其中,location 是作为入参传递进来的,是被 __weak 修饰的指针本身,而 newObj 就是这个弱指针所指向的对象,伪代码如下:

__weak NSObject * location = newObj;

现在梳理下 storeWeak() 的逻辑:

  1. 根据 haveOld/haveNew 调用 SideTables() 方法,获取到 8/64 个全局 SideTable 中的某一个;
  2. 调用 weak_register_no_lock()方法将 newObj 添加到 SideTable 的 weak_table 中,如果失败则会返回 nil,成功则返回对象本身;
  3. 调用 setWeaklyReferenced_nolock() 方法,设置 isa 的 weakly_referenced为 1;
  4. 将 location 正式指向 newObj 进行赋值,但是注意此时并没有调用 retain 方法,所以引用计数器不会 + 1;

那么接下来看看 weak_register_no_lock() 方法,精简后如下:

id 
weak_register_no_lock(weak_table_t *weak_table, id referent_id, 
                      id *referrer_id, bool crashIfDeallocating)
{
    objc_object *referent = (objc_object *)referent_id;
    objc_object **referrer = (objc_object **)referrer_id;

    ...省略异常场景处理代码...

    // now remember it and where it is being stored
    weak_entry_t *entry;
    if ((entry = weak_entry_for_referent(weak_table, referent))) {
        // 存在 weak_entry_t 对象则直接新增
        append_referrer(entry, referrer);
    }  else {
        // 不存在则证明是第一次被弱引用,新建一个weak_entry_t对象
        weak_entry_t new_entry(referent, referrer);
        weak_grow_maybe(weak_table);
        weak_entry_insert(weak_table, &new_entry);
    }
    return referent_id;
}

精简之后的代码逻辑非常清晰:

  1. 存在 weak_entry_t 则证明该对象存在其他的弱引用,直接在原来的 weak_entry_t 最后新增一个 new_referrer
  2. 不存在 weak_entry_t 则证明该对象是第一次被弱引用,新增一个weak_entry_t后插入;

append_referrer() 等插入的函数就不赘述了,还涉及到内联和外联的操作和实现,有兴趣的可以自己看代码;

9. 总结

一张图总结吧:

iOS:SideTable
SideTable

二、SideTables 的初始化时机和流程

1. 初始化流程

SideTables 也就是 SideTableBuf, 是在 SideTableInit() 方法中初始化:

static void SideTableInit() {
    new (SideTableBuf) StripedMap();
}

来看看 SideTableInit 的调用顺序,代码就不贴了:

iOS:SideTable
SideTableInit调用顺序

map_images_nolock( )的代码太多了,就不贴了,只看下 arr_init( ) 的调用代码吧:

iOS:SideTable
arr_init

要想知道 SideTables 何时被初始化,那么关键就在于 map_images( ) 何时被调用,而这个函数应该相当熟悉了吧:

void _objc_init(void)
{
    static bool initialized = false;
    if (initialized) return;
    initialized = true;
    
    // fixme defer initialization until an objc-using image is found?
    environ_init();
    tls_init();
    static_init();
    lock_init();
    exception_init();

    _dyld_objc_notify_register(&map_images, load_images, unmap_image);
}

如上,_objc_init( ) 调用 dyld 的 Api 注册通知并绑定了三个函数:

  1. map_images:印射到内存中的回调;
  2. load_images:加载时的回调;
  3. unmap_image:从内存中移除时的回调;

_dyld_objc_notify_register 最终调用 registerObjCNotifiers 函数,dyld 中的源码如下:

void registerObjCNotifiers(_dyld_objc_notify_mapped mapped, _dyld_objc_notify_init init, _dyld_objc_notify_unmapped unmapped)
{
    // record functions to call
    sNotifyObjCMapped   = mapped;
    sNotifyObjCInit     = init;
    sNotifyObjCUnmapped = unmapped;

    // call 'mapped' function with all images mapped so far
    try {
        notifyBatchPartial(dyld_image_state_bound, true, NULL, false, true);
    }
    catch (const char* msg) {
        // ignore request to abort during registration
    }
}

如上图代码,mapped 的回调最终被赋值给了 sNotifyObjCMapped,而该函数的调用只存在于 notifyBatchPartial ( )中,且 state 为 dyld_image_state_bound

上述代码的注释也说的很清楚,回调赋值之后就立马尝试了一次 notifyBatchPartial( )的调用。

至此可以总结一下:

  1. map_images 函数在第一次注册 dyld 监听时被调用,会将所有具有 objc section 的 image 进行回传;
  2. 如果有新的 objc 相关的 image 被印射到内存,也会触发 map_images 的调用;
  3. SideTables 在第一次处理包含 objc section 的 image 时被初始化(只会被初始化一次,具体参见源码);
  4. arr_init 只调用一次,所以这个 SideTables 是整个生命周期只会生成一次,记录着所有对象的引用计数器和弱引用关系。这也是为什么注释中写道不能析构的原因;

其逻辑如下:

iOS:SideTable
SideTableInit( )

2. 补充

再次温习下 _dyld_objc_notify_register() 函数:

iOS:SideTable
_dyld_objc_notify_register

翻译注释中的几个要点:

  1. 该方法仅供 objc runtime 使用;
  2. Dyld 会以数组的形式将包含 objc 相关 section 的 image 进行回调,调用 mapped 方法;
  3. 上述的 image 都已经被自动增加了引用计数器,不需要再调用 dlopen() 方法来维持 image 不被 unmap;
  4. 新的包含 objc section 的 image 被 dlopen( )时也会调用 mapped 回调;
  5. 当将要调用 C++ 初始化方法时,init 回调将会被调用;

之前梳理过 dyld 流程复习一下:

  1. dyld 自举
  2. 加载共享缓存
  3. 实例化主程序
  4. 加载插入的动态库
  5. 链接主程序(递归加载依赖库、递归刷新层级、递归rebase、递归bind、weakbind暂不绑定)
  6. 链接插入的动态库;
  7. weak bind;
  8. 调用主程序初始化方法(依赖库初始化方法调用、主程序初始化方法调用)
  9. 寻找并调用main函数;

因为 libSystem 是依赖库,调用 libSystem 的初始化方法时,前面加载了所有的依赖库,所以此时的回调将会回调所有的包含 objc section 的 image 到 mapped 函数;

这种逻辑正好也是相称的,objc 的初始化流程大概是:

  1. 因为 ImageLoaderMachO::doModInitFunctions 符号断点不是第一次就进入 libSystem_initializer,所以可以确认某些层级高于 libsystem.B.dylib 库的初始化函数调用,这些库应该是非 OBJC 库;
  2. libsystem.B.dylib 库可以理解成一个包装库,相对于其他 objc 库而言,需要优先被初始化。此时函数 libSystem_initializer 被调用;
  3. _objc_init 被调用,使用 dyld_objc_register 绑定了三个 image 相关的回调并触发 map_image( ) 函数被调用,进而完成了 SideTables 的初始化(与此同时 map_image 也做了很多其他的初始化操作);
  4. 至此,在 objc 相关库(如Foundation、UIKit等)的初始化方法被调用之前,objc 的环境就已经被配置完成;
  5. 其他依赖库的初始化方法调用,触发 load_image 进而触发 +load 方法,此时会使用到 SideTables 等 objc 全局相关的配置;

3. 验证

来个符号断点:

iOS:SideTable
符号断点

看看结果:

iOS:SideTable
调用栈

再来个 + load 的调用栈:

iOS:SideTable
+load

三、疑问

1. 为什么 weak 能够自动置为 nil;

这个问题应该说的更具体一点:

__weak 修饰的对象在被析构之后,弱指针为何会被置为 nil?而 assign 修饰的指针则仍然存储原来的内存地址;

那么,这里就应该从对象的析构开始研究:

inline void
objc_object::rootDealloc()
{
    if (isTaggedPointer()) return;  // fixme necessary?

    if (fastpath(isa.nonpointer  &&  
                 !isa.weakly_referenced  &&  
                 !isa.has_assoc  &&  
                 !isa.has_cxx_dtor  &&  
                 !isa.has_sidetable_rc))
    {
        assert(!sidetable_present());
        free(this);
    } 
    else {
        object_dispose((id)this);
    }
}

如上,如果开启了指针优化、没有弱引用、没有关联对象、没有 c++ 析构函数、引用计数器未存储到 sidetable 中,则直接 free(this),否则进入object_dispose()

很显然,我们要寻找的逻辑肯定不符合上述的条件,继续用看看这个函数的代码:

id 
object_dispose(id obj)
{
    if (!obj) return nil;
    objc_destructInstance(obj);    
    free(obj);
    return nil;
}

objc_destructInstance 函数中做了很多处理,比如 c++ 析构函数的处理、关联对象的处理等,暂时不关心这些逻辑,只关心弱引用逻辑,顺着代码最终进入到这个函数:

inline void 
objc_object::clearDeallocating()
{
    if (slowpath(!isa.nonpointer)) {
        // Slow path for raw pointer isa.
        sidetable_clearDeallocating();
    }
    else if (slowpath(isa.weakly_referenced  ||  isa.has_sidetable_rc)) {
        // Slow path for non-pointer isa with weak refs and/or side table data.
        clearDeallocating_slow();
    }

    assert(!sidetable_present());
}

很显然 isa.weakly_referenced == 1,我们要的逻辑在 clearDeallocating_slow 中,最终进入到 weak_clear_no_lock 函数,在这里我们找到了答案:

iOS:SideTable
weak_clear_no_lock

即:弱引用标志为 1 的对象在析构时,会遍历 weak_table 中的 referrers 数组并将指针置为 nil。该数组正是存储了哪些指针对该对象进行了弱引用。

2. 数据结构

其实 SideTable 可以作为复习数据结构的一个很好的实践例子,后续有时间可以研究下 refMap、weak_table 等各种数据结构的具体实现,暂略~~~;

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:qvyue@qq.com 进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。