源码剖析 Objc 消息派发流程

Objc 的方法调用是运行时决定的,系统会根据 selector 动态地查找 IMP,那么这一过程究竟是怎样实现的?selector 是如何与 IMP 对应起来的?面对应用内部成千上万次的函数调用,它会不会造成性能瓶颈?基于这些疑问,本文将会从源码的角度来探究 Objc 的消息派发流程。

一切都源于 objc_msgSend

在 Objc 中我们这样去调用函数:

但是编译器会将其翻译成如下代码:

objc_msgSend 是处理方法调用的核心,Objc 中的函数调用都交由 objc_msgSend 执行,它会启动整个消息派发流程,寻找与 selector 相对应的函数实现,所以我们源码探究的切入点就是 objc_msgSend。从这里可以下载到 objc 的源码,本文用的是 objc4-680 版本。

objc_msgSend 是汇编语言写的,为什么是这样呢?一方面是性能上的考虑,动态查找 IMP 是一项耗时的过程,况且应用运行时会无数次的调用函数,因此需要 objc_msgSend 在执行速度上达到最优;另一方面是编程语言上的问题,因为程序员会设计出各式各样的函数,它们的参数也是数量不一、类型多变的,使用 C 语言很难写出函数原型来处理所有的情景,所以需要使用汇编来解决。

另一个需要注意的地方是函数的返回值,使用汇编可以解决可变参数的问题,但是函数返回值还是要区别对待。如果函数的返回值是 struct 或是 float,那么会有 objc_msgSend_stret 以及 objc_msgSend_fpret 版本与其对应,详情可见 message.h 文件。

以下是 ARM64 架构上的 objc_msgSend 代码(对应 objc-msg-arm64.s 文件):

objc_msgSend 首先会去检测对象指针是否为 nil 或是 tagged pointer,如果是 nil,则返回的 IMP 也是 nil;如果是 tagged pointer,则对其进行特殊处理得到 isa 指针。接下来便是根据 isa 拿到 class 指针,然后进入最关键的一步:CacheLookup。

方法缓存

为了提升 IMP 的查询速度,系统为其设计了缓存。objc_msgSend 进行消息派发的第一步便是去缓存中查找 IMP,具体的查询流程在 CacheLookup 中。在阅读 CacheLookup 代码前,我们需要弄清楚 Objc 的方法缓存究竟是什么。

方法缓存存在于类对象中,而且每个类都有一份。类的结构推荐大家看这篇文章:从 NSObject 的初始化了解 isa,本文就不再赘述。在上面代码中,我们可以看到方法缓存的类型是 cache_t,它的定义如下:

其中的 mask_t 以及 cache_key_t 定义如下:

在 Objc 中,方法缓存被设计成一张 hash 表,对应于 cache_t 结构体中的 _buckets 属性。cache_t 中的 _mask 属性代表 _buckets 数组的最大索引(size = _mask + 1),_occupied 代表当前已存放的缓存数量,当 _occupied / _buckets.size > 3 / 4 时,系统会对 _buckets 数组扩容。以下是 Objc 存放缓存的算法,我们来看下缓存是怎样被填充的:

系统首先会进行一些合法性检查以及判断是否需要对缓存进行扩容,接下来便是调用 cache->find(key, receiver) 函数寻找存放缓存的位置,最后调用 bucket->set(key, imp) 设置缓存。这样看来核心代码就在 cache->find 中了,我们来看它的实现:

系统通过 cache_hash 函数对 selector 进行简单的运算,得到 hash 表的索引,然后去 _buckets 中取出缓存,如果为空或是发现之前已经记录过,那么就返回缓存,否则就依次遍历 hash 表来寻找合适的位置。以上便是 Objc 用于存放缓存的核心算法,只要把相关的数据结构弄清楚了就很容易看懂,那么接下来就来看 CacheLookup 到底做了些什么事情:

虽然 CacheLookup 都是用汇编写的,但是有了上面的基础再配合代码注释,理解起来并不困难。大体上来说,还是先通过 selector 计算得到 hash 表的索引,然后去 buckets 中寻找缓存,若是有冲突就顺位向下寻找,如果最后找到了,就执行相应的 IMP,否则就交由 JumpMiss 处理:

由于最初我们是这样调用 CacheLookup 的:CacheLookup NORMAL ,所以当缓存没有命中时,系统会跳转到 __objc_msgSend_uncached_impcache 进行下一步的处理。

可以看到 __objc_msgSend_uncached_impcache 最终调用 __class_lookupMethodAndLoadCache3 函数来处理缓存失效的情况,同时它也是我们接下来要讲的消息派发的第二阶段:消息发送。

消息发送

消息发送是在缓存失效后启动的,主要的作用就是在类自身以及继承体系中寻找 IMP,与此外还给了程序员动态添加 IMP 的机会。前面我们提到系统在缓存失效后会调用 __class_lookupMethodAndLoadCache3 函数,但是 __class_lookupMethodAndLoadCache3 仅仅调用了 lookUpImpOrForward 函数(参见 objc-runtime-new.mm 文件):

看来 lookUpImpOrForward 函数是消息发送的核心,我们来看它到底做了些什么:

    1.在缓存中寻找 IMP。由于之前对缓存的查询以失败告终,所以 _class_lookupMethodAndLoadCache3 调用 lookUpImpOrForward 函数时传入的 cache 参数为 NO,因此这里并不会触发缓存的查询操作。

    2. 进行一些准备工作,realizeClass 用来申请 class_rw_t 的可读写空间,_class_initialize 是对类进行初始化。

    3. 准备工作结束后,开始消息发送的流程。

    系统先是调用 runtimeLock.read() 加锁,然后判断 sel 是否与垃圾回收相关,是的话就返回 _objc_ignored_method,否则就去缓存中查找,如果缓存未命中,就进入下一步。
    4. 调用 getMethodNoSuper_nolock 在类自身的方法列表中查找,如果找到,则调用 log_and_fill_cache 填充缓存并结束查询流程,否则进入下一步。

    log_and_fill_cache 最终会调用到 cache_fill_nolock 函数,也就是我们之前讲的存放缓存的那一套流程。
    5. 按照继承体系依次在父类的缓存以及方法列表中寻找 IMP。

    如果在父类中找到了 IMP,系统会将其填充到子类的方法缓存中(不会填充到父类的缓存),然后结束流程。如果在父类中没有找到 IMP 或者找到的 IMP 是 _objc_msgForward_impcache,那么系统会结束对父类的查询,然后进入下一步。
    6. 动态方法解析。

    系统使用 runtimeLock.unlockRead() 解锁,然后调用 _class_resolveMethod 函数给程序员一个动态添加 IMP 的机会:

    _class_resolveMethod 首先会判断 cls 是否是元类,如果不是元类,就去调用 _class_resolveInstanceMethod 来动态解析实例方法,否则就调用 _class_resolveClassMethod 动态解析类方法,如果 _class_resolveClassMethod 没有起到作用,就调用 _class_resolveInstanceMethod 再次解析。
    这里以 _class_resolveInstanceMethod 为例:

    实例方法的动态解析需要程序员实现 + (BOOL)resolveInstanceMethod:(SEL)sel; 方法,在其中为类添加 IMP, _class_resolveInstanceMethod 函数运行时会去调用这个方法,然后利用 lookUpImpOrNil 函数缓存结果。如果需要动态添加类方法,你需要实现 + (BOOL)resolveClassMethod:(SEL)sel;。
    动态解析结束后,goto retry; 会重复之前的查找流程,这会是消息发送流程的最后一次尝试。
    7. retry 结束后,如果 IMP 还是没有找到,那么系统就返回 _objc_msgForward_impcache,进入消息转发阶段。

消息转发

消息转发是 Objc 消息派发的第三步,也是最后一步,不过 _objc_msgForward_impcache 只是内部使用的函数指针,它需要被转为 _objc_msgForward 才可以被外部调用。

而 _objc_msgForward 也不是终点,它仅仅调用 __objc_forward_handler:

那么 __objc_forward_handler 究竟是什么,我们可以在 objc-runtime.mm 文件中找到它的默认实现:

用来设置 __objc_forward_handler 的函数是 objc_setForwardHandler,但是在苹果开源的代码中并没有看到它在哪里被调用,那么就在实际项目中打个断点看一下:

它是在 __CFInitialize 函数中被调用,调用时传入 __forwarding_prep_0___ 以及 __forwarding_prep_1___ 参数:

结合 objc_setForwardHandler 的代码,可以看出 __forwarding_prep_0___ 对应 _objc_forward_handler,而 __forwarding_prep_1___ 对应 _objc_forward_stret_handler。然而在源码中依旧没有看到 __forwarding_prep_0___,那么就反汇编看一下 __forwarding_prep_0___ 都做了些什么:

__forwarding_prep_0___ 内部调用 ___forwarding___ 函数,如果 ___forwarding___ 的结果不为空,则将结果返回,否则调用 objc_msgSend,下一步应该就是抛出 doesNotRecognizeSelector 错误了。使用 dis -n ___forwarding___ 命令可以查看 ___forwarding___ 的内部逻辑,这里就不截图了(图片大概有两个外接显示器那么高🤣),下面简单总结一下 ___forwarding___ 内部的流程:

  1. 如果用户实现了 forwardingTargetForSelector 函数,就用它拿到用于消息转发的新的 target,如果 target 不为空并且不是当前对象,那么就调用 objc_msgSend(target, sel, …) 函数,否则进入下一步。

  2. 判断当前对象是否为僵尸对象,是的话就会抛出异常:

  3. 如果用户实现了 methodSignatureForSelector 函数并且生成的签名不为空,系统就会根据它创建 NSInvocation 对象,并将其作为参数传递给 forwardInvocation 函数,否则进入下一步。

  4. 判断 selector 是否在 runtime 注册过,没有的话会出现这样的提错误信息:

    否则就是大家喜闻乐见的的 doesNotRecognizeSelector 错误,如果连 doesNotRecognizeSelector 也没有实现,错误信息会是这样:

    以上就是消息转发的流程。

总结

上面的内容涵盖了 Objc 消息派发的三部曲:1. 缓存查找;2. 消息发送;3. 消息转发,由于内容十分庞杂,所以这里总结下消息派发的流程来帮助大家理解:

enmmmmmmm,终于写完了,断断续续花了好多时间,中途还差点弃坑,不过最终还是完成了,真是可喜可贺,晚上给自己加个鸡腿🤣

发表评论

Close Menu