你好,歡迎來到IOS教程網

 Ios教程網 >> IOS編程開發 >> IOS開發基礎 >> 從源代碼看 ObjC 中消息的發送

從源代碼看 ObjC 中消息的發送

編輯:IOS開發基礎

Message-in-the-bottle600.jpg

本文授權轉載,作者:左書祺(關注倉庫,及時獲得更新:iOS-Source-Code-Analyze)

因為ObjC的runtime只能在Mac OS下才能編譯,所以文章中的代碼都是在Mac OS,也就是x86_64架構下運行的,對於在arm64中運行的代碼會特別說明。

寫在前面

如果你點開這篇文章,相信你對Objective-C比較熟悉,並且有多年使用Objective-C編程的經驗,這篇文章會假設你知道:

  1. 在Objective-C中的“方法調用”其實應該叫做消息傳遞

  2. [receivermessage]會被翻譯為objc_msgSend(receiver,@selector(message))

  3. 在消息的響應鏈中可能會調用-resolveInstanceMethod:或者-forwardInvocation:等方法

  4. 關於選擇子SEL的知識

    如果對於上述的知識不夠了解,可以看一下這篇文章Objective-C Runtime,但是其中關於objc_class的結構體的代碼已經過時了,不過不影響閱讀以及理解。

  5. 方法在內存中存儲的位置,《深入解析ObjC中方法的結構》。文章中不會刻意區別方法和函數、消息傳遞和方法調用之間的區別。

  6. 能翻牆(會有一個Youtube的鏈接)

概述

關於Objective-C中的消息傳遞的文章真的是太多了,而這篇文章又與其它文章有什麼不同呢?

由於這個系列的文章都是對Objective-C源代碼的分析,所以會從Objective-C源代碼中分析並合理地推測一些關於消息傳遞的問題。

關於@selector()你需要知道的

因為在Objective-C中,所有的消息傳遞中的“消息“都會被轉換成一個selector作為objc_msgSend函數的參數:

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

這裡面使用@selector(hello)生成的選擇子SEL是這一節中關注的重點。

我們需要預先解決的問題是:使用@selector(hello)生成的選擇子,是否會因為類的不同而不同?各位讀者可以自己思考一下。

先放出結論:使用@selector()生成的選擇子不會因為類的不同而改變,其內存地址在編譯期間就已經確定了。也就是說向不同的類發送相同的消息時,其生成的選擇子是完全相同的。

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

接下來,我們開始驗證這一結論的正確性,這是程序主要包含的代碼:

//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中輸入:

1463032066691606.png

這裡面我們打印了兩個選擇子的地址@selector(hello)以及@selector(undefined_hello_method),需要注意的是:

@selector(hello)是在編譯期間就聲明的選擇子,而後者在編譯期間並不存在,undefined_hello_method選擇子由於是在運行時生成的,所以內存地址明顯比hello大很多

如果我們修改程序的代碼:

1463032112608293.png

在這裡,由於我們在代碼中顯示地寫出了@selector(undefined_hello_method),所以在lldb中再次打印這個sel內存地址跟之前相比有了很大的改變。

更重要的是,我沒有通過指針的操作來獲取hello選擇子的內存地址,而只是通過@selector(hello)就可以返回一個選擇子。

從上面的這些現象,可以推斷出選擇子有以下的特性:

  1. Objective-C為我們維護了一個巨大的選擇子表

  2. 在使用@selector()時會從這個選擇子表中根據選擇子的名字查找對應的SEL。如果沒有找到,則會生成一個SEL並添加到表中

  3. 在編譯期間會掃描全部的頭文件和實現文件將其中的方法以及使用@selector()生成的選擇子加入到選擇子表中

在運行時初始化之前,打印hello選擇子的的內存地址:

1463032153484376.png

message.h文件

Objective-C中objc_msgSend的實現並沒有開源,它只存在於message.h這個頭文件中。

/**
*@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中觀察整個消息發送的過程中調用棧的變化,再來看一下程序的代碼:

//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:

1463032237534640.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)的調用棧,而不是調用其它選擇子的調用棧。

1463032329433780.png

由圖中的變量區域可以了解,傳入的選擇子為"hello",對應的類是XXObject。所以我們可以確信這就是當調用hello方法時執行的函數。在Xcode左側能看到方法的調用棧:

0lookUpImpOrForward
1_class_lookupMethodAndLoadCache3
2objc_msgSend
3main
4start

調用棧在這裡告訴我們:lookUpImpOrForward並不是objc_msgSend直接調用的,而是通過_class_lookupMethodAndLoadCache3方法:

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. 解鎖、返回實現

無鎖的緩存查找

下面是在沒有加鎖的時候對緩存進行查找,提高緩存使用的性能:

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方法,我會在之後的文章中對類的初始化進行分析。

if(!cls->isRealized()){
rwlock_writer_tlock(runtimeLock);
realizeClass(cls);
}
if(initialize&&!cls->isInitialized()){
_class_initialize(_class_getNonMetaClass(cls,inst));
}

加鎖

加鎖這一部分只有一行簡單的代碼,其主要目的保證方法查找以及緩存填充(cache-fill)的原子性,保證在運行以下代碼時不會有新方法添加導致緩存被沖洗(flush)。

runtimeLock.read();

在當前類中查找實現

實現很簡單,先調用了cache_getImp從某個類的cache屬性中獲取選擇子對應的實現:

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方法),所以會進入下面的代碼塊,從類的方法列表中尋找方法的實現:

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:

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的實現也特別簡單:

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方法來完成的:

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. 搜索方法列表

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)的過程:

if(resolver&&!triedResolver){
_class_resolveMethod(cls,sel,inst);
triedResolver=YES;
gotoretry;
}

這部分代碼調用_class_resolveMethod來解析沒有找到實現的方法。

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中選擇一個進行調用。

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還為我們提供了最後一次翻身的機會,進行方法轉發:

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

返回實現_objc_msgForward_impcache,然後加入緩存。

====

這樣就結束了整個方法第一次的調用過程,緩存沒有命中,但是在當前類的方法列表中找到了hello方法的實現,調用了該方法。

objc-message-first-call-hello

緩存命中

如果使用對應的選擇子時,緩存命中了,那麼情況就大不相同了,我們修改主程序中的代碼:

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的內容:

(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的緩存清空:

(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:

#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

  1. 上一頁:
  2. 下一頁:
蘋果刷機越獄教程| IOS教程問題解答| IOS技巧綜合| IOS7技巧| IOS8教程
Copyright © Ios教程網 All Rights Reserved