关闭

从源代码看 ObjC 中消息的发送

标签: RunTime 消息机制
377人阅读 评论(0) 收藏 举报
分类:

本文授权转自:draveness

因为ObjC的runtime只能在MacOS下才能编译,所以文章中的代码都是在MacOS,也就是x86_64架构下运行的,对于在arm64中运行的代码会特别说明。

写在前面

如果你点开这篇文章,相信你对Objective-C比较熟悉,并且有多年使用Objective-C编程的经验,这篇文章会假设你知道:

  1. 在Objective-C中的“方法调用”其实应该叫做消息传递

  2. [receivermessage]会被翻译为objc_msgSend(receiver,@selector(message))

  3. 在消息的响应链中可能会调用-resolveInstanceMethod:或者-forwardInvocation:等方法

  4. 关于选择子SEL的知识

    如果对于上述的知识不够了解,可以看一下这篇文章Objective-CRuntime,但是其中关于objc_class的结构体的代码已经过时了,不过不影响阅读以及理解。

  5. 方法在内存中存储的位置,深入解析ObjC中方法的结构

    文章中不会刻意区别方法和函数、消息传递和方法调用之间的区别。

  6. 能翻墙(会有一个Youtube的链接)

概述

关于Objective-C中的消息传递的文章真的是太多了,而这篇文章又与其它文章有什么不同呢?

由于这个系列的文章都是对Objective-C源代码的分析,所以会从Objective-C源代码中分析并合理地推测一些关于消息传递的问题。

关于@selector()你需要知道的

因为在Objective-C中,所有的消息传递中的“消息“都会被转换成一个selector作为objc_msgSend函数的参数:

1
[objecthello]->objc_msgSend(object,@selector(hello))

这里面使用@selector(hello)生成的选择子SEL是这一节中关注的重点。

我们需要预先解决的问题是:使用@selector(hello)生成的选择子,是否会因为类的不同而不同?各位读者可以自己思考一下。

先放出结论:使用@selector()生成的选择子不会因为类的不同而改变,其内存地址在编译期间就已经确定了。也就是说向不同的类发送相同的消息时,其生成的选择子是完全相同的。

1
2
3
4
XXObject*xx=[[XXObjectalloc]init]
YYObject*yy=[[YYObjectalloc]init]
objc_msgSend(xx,@selector(hello))
objc_msgSend(yy,@selector(hello))

接下来,我们开始验证这一结论的正确性,这是程序主要包含的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//XXObject.h
#import@interfaceXXObject:NSObject
-(void)hello;
@end
//XXObject.m
#import"XXObject.h"
@implementationXXObject
-(void)hello{
NSLog(@"Hello");
}
@end
//main.m
#import#import"XXObject.h"
intmain(intargc,constchar*argv[]){
@autoreleasepool{
XXObject*object=[[XXObjectalloc]init];
[objecthello];
}
return0;
}

在主函数任意位置打一个断点,比如->[objecthello];这里,然后在lldb中输入:

2016-04-25-objc-message-selector.png

这里面我们打印了两个选择子的地址@selector(hello)以及@selector(undefined_hello_method),需要注意的是:

@selector(hello)是在编译期间就声明的选择子,而后者在编译期间并不存在,undefined_hello_method选择子由于是在运行时生成的,所以内存地址明显比hello大很多

如果我们修改程序的代码:

2016-04-25-objc-message-selector-undefined.png

在这里,由于我们在代码中显示地写出了@selector(undefined_hello_method),所以在lldb中再次打印这个sel内存地址跟之前相比有了很大的改变。

更重要的是,我没有通过指针的操作来获取hello选择子的内存地址,而只是通过@selector(hello)就可以返回一个选择子。

从上面的这些现象,可以推断出选择子有以下的特性:

  1. Objective-C为我们维护了一个巨大的选择子表

  2. 在使用@selector()时会从这个选择子表中根据选择子的名字查找对应的SEL。如果没有找到,则会生成一个SEL并添加到表中

  3. 在编译期间会扫描全部的头文件和实现文件将其中的方法以及使用@selector()生成的选择子加入到选择子表中

在运行时初始化之前,打印hello选择子的的内存地址:

2016-04-25-objc-message-find-selector-before-init.png

message.h文件

Objective-C中objc_msgSend的实现并没有开源,它只存在于message.h这个头文件中。

1
2
3
4
5
6
7
8
/**
*@noteWhenitencountersamethodcall,thecompilergeneratesacalltooneofthe
*functions\cobjc_msgSend,\cobjc_msgSend_stret,\cobjc_msgSendSuper,or\cobjc_msgSendSuper_stret.
*Messagessenttoanobject’ssuperclass(usingthe\csuperkeyword)aresentusing\cobjc_msgSendSuper;
*othermessagesaresentusing\cobjc_msgSend.Methodsthathavedatastructuresasreturnvalues
*aresentusing\cobjc_msgSendSuper_stretand\cobjc_msgSend_stret.
*/
OBJC_EXPORTidobjc_msgSend(idself,SELop,...)

在这个头文件的注释中对消息发送的一系列方法解释得非常清楚:

当编译器遇到一个方法调用时,它会将方法的调用翻译成以下函数中的一个objc_msgSend、objc_msgSend_stret、objc_msgSendSuper和objc_msgSendSuper_stret。发送给对象的父类的消息会使用objc_msgSendSuper有数据结构作为返回值的方法会使用objc_msgSendSuper_stret或objc_msgSend_stret其它的消息都是使用objc_msgSend发送的

在这篇文章中,我们只会对消息发送的过程进行分析,而不会对上述消息发送方法的区别进行分析,默认都使用objc_msgSend函数。

objc_msgSend调用栈

这一小节会以向XXObject的实例发送hello消息为例,在Xcode中观察整个消息发送的过程中调用栈的变化,再来看一下程序的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//XXObject.h
#import@interfaceXXObject:NSObject
-(void)hello;
@end
//XXObject.m
#import"XXObject.h"
@implementationXXObject
-(void)hello{
NSLog(@"Hello");
}
@end
//main.m
#import#import"XXObject.h"
intmain(intargc,constchar*argv[]){
@autoreleasepool{
XXObject*object=[[XXObjectalloc]init];
[objecthello];
}
return0;
}

在调用hello方法的这一行打一个断点,当我们尝试进入(Stepin)这个方法只会直接跳入这个方法的实现,而不会进入objc_msgSend:

2016-04-25-objc-message-wrong-step-in.gif

因为objc_msgSend是一个私有方法,我们没有办法进入它的实现,但是,我们却可以在objc_msgSend的调用栈中“截下”这个函数调用的过程。

调用objc_msgSend时,传入了self以及SEL参数。

既然要执行对应的方法,肯定要寻找选择子对应的实现。

在objc-runtime-new.mm文件中有一个函数lookUpImpOrForward,这个函数的作用就是查找方法的实现,于是运行程序,在运行到hello这一行时,激活lookUpImpOrForward函数中的断点。

2016-04-25-objc-message-youtube-preview.jpg

由于转成gif实在是太大了,笔者试着用各种方法生成动图,然而效果也不是很理想,只能贴一个Youtube的视频链接,不过对于能够翻墙的开发者们,应该也不是什么问题吧(手动微笑)

如果跟着视频看这个方法的调用栈有些混乱的话,也是正常的。在下一个节中会对其调用栈进行详细的分析。

解析objc_msgSend

对objc_msgSend解析总共分两个步骤,我们会向XXObject的实例发送两次hello消息,分别模拟无缓存和有缓存两种情况下的调用栈。

无缓存

在->[objecthello]这里增加一个断点,当程序运行到这一行时,再向lookUpImpOrForward函数的第一行添加断点,确保是捕获@selector(hello)的调用栈,而不是调用其它选择子的调用栈。

2016-04-25-objc-message-first-call-hello.png

由图中的变量区域可以了解,传入的选择子为"hello",对应的类是XXObject。所以我们可以确信这就是当调用hello方法时执行的函数。在Xcode左侧能看到方法的调用栈:

1
2
3
4
5
0lookUpImpOrForward
1_class_lookupMethodAndLoadCache3
2objc_msgSend
3main
4start

调用栈在这里告诉我们:lookUpImpOrForward并不是objc_msgSend直接调用的,而是通过_class_lookupMethodAndLoadCache3方法:

1
2
3
4
5
IMP_class_lookupMethodAndLoadCache3(idobj,SELsel,Classcls)
{
returnlookUpImpOrForward(cls,sel,obj,
YES/*initialize*/,NO/*cache*/,YES/*resolver*/);
}

这是一个仅提供给派发器(dispatcher)用于方法查找的函数,其它的代码都应该使用lookUpImpOrNil()(不会进行方法转发)。_class_lookupMethodAndLoadCache3会传入cache=NO避免在没有加锁的时候对缓存进行查找,因为派发器已经做过这件事情了。

实现的查找lookUpImpOrForward

由于实现的查找方法lookUpImpOrForward涉及很多函数的调用,所以我们将它分成以下几个部分来分析:

  1. 无锁的缓存查找

  2. 如果类没有实现(isRealized)或者初始化(isInitialized),实现或者初始化类

  3. 加锁

  4. 缓存以及当前类中方法的查找

  5. 尝试查找父类的缓存以及方法列表

  6. 没有找到实现,尝试方法解析器

  7. 进行消息转发

  8. 解锁、返回实现

无锁的缓存查找

下面是在没有加锁的时候对缓存进行查找,提高缓存使用的性能:

1
2
3
4
5
6
runtimeLock.assertUnlocked();
//Optimisticcachelookup
if(cache){
imp=cache_getImp(cls,sel);
if(imp)returnimp;
}

不过因为_class_lookupMethodAndLoadCache3传入的cache=NO,所以这里会直接跳过if中代码的执行,在objc_msgSend中已经使用汇编代码查找过了。

类的实现和初始化

在Objective-C运行时初始化的过程中会对其中的类进行第一次初始化也就是执行realizeClass方法,为类分配可读写结构体class_rw_t的空间,并返回正确的类结构体。

而_class_initialize方法会调用类的initialize方法,我会在之后的文章中对类的初始化进行分析。

1
2
3
4
5
6
7
if(!cls->isRealized()){
rwlock_writer_tlock(runtimeLock);
realizeClass(cls);
}
if(initialize&&!cls->isInitialized()){
_class_initialize(_class_getNonMetaClass(cls,inst));
}

加锁

加锁这一部分只有一行简单的代码,其主要目的保证方法查找以及缓存填充(cache-fill)的原子性,保证在运行以下代码时不会有新方法添加导致缓存被冲洗(flush)。

1
runtimeLock.read();

在当前类中查找实现

实现很简单,先调用了cache_getImp从某个类的cache属性中获取选择子对应的实现:

1
2
imp=cache_getImp(cls,sel);
if(imp)gotodone;
  • 2016-04-25-objc-message-cache-struct.png

不过cache_getImp的实现目测是不开源的,同时也是汇编写的,在我们尝试stepin的时候进入了如下的汇编代码。

objc-message-step-in-cache-getimp

它会进入一个CacheLookup的标签,获取实现,使用汇编的原因还是因为要加速整个实现查找的过程,其原理推测是在类的cache中寻找对应的实现,只是做了一些性能上的优化。

如果查找到实现,就会跳转到done标签,因为我们在这个小结中的假设是无缓存的(第一次调用hello方法),所以会进入下面的代码块,从类的方法列表中寻找方法的实现:

1
2
3
4
5
6
meth=getMethodNoSuper_nolock(cls,sel);
if(meth){
log_and_fill_cache(cls,meth->imp,sel,inst,cls);
imp=meth->imp;
gotodone;
}

调用getMethodNoSuper_nolock方法查找对应的方法的结构体指针method_t:

1
2
3
4
5
6
7
8
9
10
11
staticmethod_t*getMethodNoSuper_nolock(Classcls,SELsel){
for(automlists=cls->data()->methods.beginLists(),
end=cls->data()->methods.endLists();
mlists!=end;
++mlists)
{
method_t*m=search_method_list(*mlists,sel);
if(m)returnm;
}
returnnil;
}

因为类中数据的方法列表methods是一个二维数组method_array_t,写一个for循环遍历整个方法列表,而这个search_method_list的实现也特别简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
staticmethod_t*search_method_list(constmethod_list_t*mlist,SELsel)
{
intmethodListIsFixedUp=mlist->isFixedUp();
intmethodListHasExpectedSize=mlist->entsize()==sizeof(method_t);
if(__builtin_expect(methodListIsFixedUp&&methodListHasExpectedSize,1)){
returnfindMethodInSortedMethodList(sel,mlist);
}else{
for(auto&meth:*mlist){
if(meth.name==sel)return&meth;
}
}
returnnil;
}

findMethodInSortedMethodList方法对有序方法列表进行线性探测,返回方法结构体method_t。

如果在这里找到了方法的实现,将它加入类的缓存中,这个操作最后是由cache_fill_nolock方法来完成的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
staticvoidcache_fill_nolock(Classcls,SELsel,IMPimp,idreceiver)
{
if(!cls->isInitialized())return;
if(cache_getImp(cls,sel))return;
cache_t*cache=getCache(cls);
cache_key_tkey=getKey(sel);
mask_tnewOccupied=cache->occupied()+1;
mask_tcapacity=cache->capacity();
if(cache->isConstantEmptyCache()){
cache->reallocate(capacity,capacity?:INIT_CACHE_SIZE);
}elseif(newOccupiedexpand();
}
bucket_t*bucket=cache->find(key,receiver);
if(bucket->key()==0)cache->incrementOccupied();
bucket->set(key,imp);
}

如果缓存中的内容大于容量的3/4就会扩充缓存,使缓存的大小翻倍。

在缓存翻倍的过程中,当前类全部的缓存都会被清空,Objective-C出于性能的考虑不会将原有缓存的bucket_t拷贝到新初始化的内存中。

找到第一个空的bucket_t,以(SEL,IMP)的形式填充进去。

在父类中寻找实现

这一部分与上面的实现基本上是一样的,只是多了一个循环用来判断根类:

  1. 查找缓存

  2. 搜索方法列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
curClass=cls;
while((curClass=curClass->superclass)){
imp=cache_getImp(curClass,sel);
if(imp){
if(imp!=(IMP)_objc_msgForward_impcache){
log_and_fill_cache(cls,imp,sel,inst,curClass);
gotodone;
}else{
break;
}
}
meth=getMethodNoSuper_nolock(curClass,sel);
if(meth){
log_and_fill_cache(cls,meth->imp,sel,inst,curClass);
imp=meth->imp;
gotodone;
}
}

与当前类寻找实现的区别是:在父类中寻找到的_objc_msgForward_impcache实现会交给当前类来处理。

方法决议

选择子在当前类和父类中都没有找到实现,就进入了方法决议(methodresolve)的过程:

1
2
3
4
5
if(resolver&&!triedResolver){
_class_resolveMethod(cls,sel,inst);
triedResolver=YES;
gotoretry;
}

这部分代码调用_class_resolveMethod来解析没有找到实现的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void_class_resolveMethod(Classcls,SELsel,idinst)
{
if(!cls->isMetaClass()){
_class_resolveInstanceMethod(cls,sel,inst);
}
else{
_class_resolveClassMethod(cls,sel,inst);
if(!lookUpImpOrNil(cls,sel,inst,
NO/*initialize*/,YES/*cache*/,NO/*resolver*/))
{
_class_resolveInstanceMethod(cls,sel,inst);
}
}
}

根据当前的类是不是元类在_class_resolveInstanceMethod和_class_resolveClassMethod中选择一个进行调用。

1
2
3
4
5
6
7
8
9
10
11
12
staticvoid_class_resolveInstanceMethod(Classcls,SELsel,idinst){
if(!lookUpImpOrNil(cls->ISA(),SEL_resolveInstanceMethod,cls,
NO/*initialize*/,YES/*cache*/,NO/*resolver*/)){
//没有找到resolveInstanceMethod:方法,直接返回。
return;
}
BOOL(*msg)(Class,SEL,SEL)=(__typeof__(msg))objc_msgSend;
boolresolved=msg(cls,SEL_resolveInstanceMethod,sel);
//缓存结果,以防止下次在调用resolveInstanceMethod:方法影响性能。
IMPimp=lookUpImpOrNil(cls,sel,inst,
NO/*initialize*/,YES/*cache*/,NO/*resolver*/);
}

这两个方法的实现其实就是判断当前类是否实现了resolveInstanceMethod:或者resolveClassMethod:方法,然后用objc_msgSend执行上述方法,并传入需要决议的选择子。

关于resolveInstanceMethod之后可能会写一篇文章专门介绍,不过关于这个方法的文章也确实不少,在Google上搜索会有很多的文章。

在执行了resolveInstanceMethod:之后,会跳转到retry标签,重新执行查找方法实现的流程,只不过不会再调用resolveInstanceMethod:方法了(将triedResolver标记为YES)。

消息转发

在缓存、当前类、父类以及resolveInstanceMethod:都没有解决实现查找的问题时,Objective-C还为我们提供了最后一次翻身的机会,进行方法转发:

1
2
imp=(IMP)_objc_msgForward_impcache;
cache_fill(cls,sel,imp,inst);

返回实现_objc_msgForward_impcache,然后加入缓存。

====

这样就结束了整个方法第一次的调用过程,缓存没有命中,但是在当前类的方法列表中找到了hello方法的实现,调用了该方法。

objc-message-first-call-hello

缓存命中

如果使用对应的选择子时,缓存命中了,那么情况就大不相同了,我们修改主程序中的代码:

1
2
3
4
5
6
7
8
intmain(intargc,constchar*argv[]){
@autoreleasepool{
XXObject*object=[[XXObjectalloc]init];
[objecthello];
[objecthello];
}
return0;
}

然后在第二次调用hello方法时,加一个断点:

objc-message-objc-msgSend-with-cache

objc_msgSend并没有走lookupImpOrForward这个方法,而是直接结束,打印了另一个hello字符串。

我们如何确定objc_msgSend的实现到底是什么呢?其实我们没有办法来确认它的实现,因为这个函数的实现使用汇编写的,并且实现是不开源的。

不过,我们需要确定它是否真的访问了类中的缓存来加速实现寻找的过程。

好,现在重新运行程序至第二个hello方法调用之前:

objc-message-before-flush-cache

打印缓存中bucket的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
(lldb)p(objc_class*)[XXObjectclass]
(objc_class*)$0=0x0000000100001230
(lldb)p(cache_t*)0x0000000100001240
(cache_t*)$1=0x0000000100001240
(lldb)p*$1
(cache_t)$2={
_buckets=0x0000000100604bd0
_mask=3
_occupied=2
}
(lldb)p$2.capacity()
(mask_t)$3=4
(lldb)p$2.buckets()[0]
(bucket_t)$4={
_key=0
_imp=0x0000000000000000
}
(lldb)p$2.buckets()[1]
(bucket_t)$5={
_key=0
_imp=0x0000000000000000
}
(lldb)p$2.buckets()[2]
(bucket_t)$6={
_key=4294971294
_imp=0x0000000100000e60(debug-objc`-[XXObjecthello]atXXObject.m:17)
}
(lldb)p$2.buckets()[3]
(bucket_t)$7={
_key=4300169955
_imp=0x00000001000622e0(libobjc.A.dylib`-[NSObjectinit]atNSObject.mm:2216)
}

在这个缓存中只有对hello和init方法实现的缓存,我们要将其中hello的缓存清空:

1
2
3
4
5
(lldb)expr$2.buckets()[2]=$2.buckets()[1]
(bucket_t)$8={
_key=0
_imp=0x0000000000000000
}

objc-message-after-flush-cache

这样XXObject中就不存在hello方法对应实现的缓存了。然后继续运行程序:

objc-message-after-flush-cache

虽然第二次调用hello方法,但是因为我们清除了hello的缓存,所以,会再次进入lookupImpOrForward方法。

下面会换一种方法验证猜测:在hello调用之前添加缓存。

添加一个新的实现cached_imp:

1
2
3
4
5
6
7
8
9
10
11
12
#import#import#import"XXObject.h"
intmain(intargc,constchar*argv[]){
@autoreleasepool{
__unusedIMPcached_imp=imp_implementationWithBlock(^(){
NSLog(@"CachedHello");
});
XXObject*object=[[XXObjectalloc]init];
[objecthello];
[objecthello];
}
return0;
}

我们将以@selector(hello),cached_imp为键值对,将其添加到类结构体的缓存中,这里的实现cached_imp有一些区别,它会打印@"CachedHello"而不是@"Hello"字符串:

在第一个hello方法调用之前将实现加入缓存:

objc-message-after-flush-cache

可以看到,我们虽然没有改变hello方法的实现,但是在objc_msgSend的消息发送链路中,使用错误的缓存实现cached_imp拦截了实现的查找,打印出了CachedHello。

由此可以推定,objc_msgSend在实现中确实检查了缓存。如果没有缓存会调用lookupImpOrForward进行方法查找。

为了提高消息传递的效率,ObjC对objc_msgSend以及cache_getImp使用了汇编语言来编写。

小结

这篇文章与其说是讲ObjC中的消息发送的过程,不如说是讲方法的实现是如何查找的。

Objective-C中实现查找的路径还是比较符合直觉的:

缓存命中

查找当前类的缓存及方法

查找父类的缓存及方法

方法决议

消息转发

文章中关于方法调用栈的视频最开始是用gif做的,不过由于gif时间较长,试了很多的gif转换器,都没有得到一个较好的质量和合适的大小,所以最后选择用一个Youtube的视频。

参考资料

深入解析ObjC中方法的结构

Objective-CRuntime

Let'sBuildobjc_msgSend

8
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:251612次
    • 积分:7577
    • 等级:
    • 排名:第2987名
    • 原创:319篇
    • 转载:206篇
    • 译文:0篇
    • 评论:30条
    文章分类
    最新评论
    苹果官方文档