因為 ObjC 的 runtime 只能在 Mac OS 下才能編譯,所以文章中的程式碼都是在 Mac OS,也就是
x86_64
架構下執行的,對於在 arm64 中執行的程式碼會特別說明。
寫在前面
如果你點開這篇文章,相信你對 Objective-C 比較熟悉,並且有多年使用 Objective-C 程式設計的經驗,這篇文章會假設你知道:
- 在 Objective-C 中的“方法呼叫”其實應該叫做訊息傳遞
[receiver message]
會被翻譯為objc_msgSend(receiver, @selector(message))
- 在訊息的響應鏈中可能會呼叫
- resolveInstanceMethod:
或者- forwardInvocation:
等方法 - 關於選擇子 SEL 的知識
如果對於上述的知識不夠了解,可以看一下這篇文章 Objective-C Runtime,但是其中關於
objc_class
的結構體的程式碼已經過時了,不過不影響閱讀以及理解。 - 方法在記憶體中儲存的位置,深入解析 ObjC 中方法的結構
文章中不會刻意區別方法和函式、訊息傳遞和方法呼叫之間的區別。
- 有梯子(會有一個 Youtube 的連結)
概述
關於 Objective-C 中的訊息傳遞的文章真的是太多了,而這篇文章又與其它文章有什麼不同呢?
由於這個系列的文章都是對 Objective-C 原始碼的分析,所以會從 Objective-C 原始碼中分析併合理地推測一些關於訊息傳遞的問題。
關於 @selector() 你需要知道的
因為在 Objective-C 中,所有的訊息傳遞中的“訊息“都會被轉換成一個 selector
作為 objc_msgSend
函式的引數:
1 |
[object hello] -> objc_msgSend(object, @selector(hello)) |
這裡面使用 @selector(hello)
生成的選擇子 SEL 是這一節中關注的重點。
我們需要預先解決的問題是:使用 @selector(hello)
生成的選擇子,是否會因為類的不同而不同?各位讀者可以自己思考一下。
先放出結論:使用 @selector()
生成的選擇子不會因為類的不同而改變,其記憶體地址在編譯期間就已經確定了。也就是說向不同的類傳送相同的訊息時,其生成的選擇子是完全相同的。
1 2 3 4 |
XXObject *xx = [[XXObject alloc] init] YYObject *yy = [[YYObject alloc] 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 21 22 23 24 25 26 27 28 29 30 |
// XXObject.h #import @interface XXObject : NSObject - (void)hello; @end // XXObject.m #import "XXObject.h" @implementation XXObject - (void)hello { NSLog(@"Hello"); } @end // main.m #import #import "XXObject.h" int main(int argc, const char * argv[]) { @autoreleasepool { XXObject *object = [[XXObject alloc] init]; [object hello]; } return 0; } |
在主函式任意位置打一個斷點, 比如 -> [object hello];
這裡,然後在 lldb 中輸入:
這裡面我們列印了兩個選擇子的地址@selector(hello)
以及 @selector(undefined_hello_method)
,需要注意的是:
@selector(hello)
是在編譯期間就宣告的選擇子,而後者在編譯期間並不存在,undefined_hello_method
選擇子由於是在執行時生成的,所以記憶體地址明顯比hello
大很多
如果我們修改程式的程式碼:
在這裡,由於我們在程式碼中顯示地寫出了 @selector(undefined_hello_method)
,所以在 lldb 中再次列印這個 sel
記憶體地址跟之前相比有了很大的改變。
更重要的是,我沒有通過指標的操作來獲取 hello
選擇子的記憶體地址,而只是通過 @selector(hello)
就可以返回一個選擇子。
從上面的這些現象,可以推斷出選擇子有以下的特性:
- Objective-C 為我們維護了一個巨大的選擇子表
- 在使用
@selector()
時會從這個選擇子表中根據選擇子的名字查詢對應的SEL
。如果沒有找到,則會生成一個SEL
並新增到表中 - 在編譯期間會掃描全部的標頭檔案和實現檔案將其中的方法以及使用
@selector()
生成的選擇子加入到選擇子表中
在執行時初始化之前,列印 hello
選擇子的的記憶體地址:
message.h 檔案
Objective-C 中 objc_msgSend
的實現並沒有開源,它只存在於 message.h
這個標頭檔案中。
1 2 3 4 5 6 7 8 |
/** * <a href="http://www.jobbole.com/members/smartsl">@note</a> When it encounters a method call, the compiler generates a call to one of the * functions c objc_msgSend, c objc_msgSend_stret, c objc_msgSendSuper, or c objc_msgSendSuper_stret. * Messages sent to an object’s superclass (using the c super keyword) are sent using c objc_msgSendSuper; * other messages are sent using c objc_msgSend. Methods that have data structures as return values * are sent using c objc_msgSendSuper_stret and c objc_msgSend_stret. */ OBJC_EXPORT id objc_msgSend(id self, SEL op, ...) |
在這個標頭檔案的註釋中對訊息傳送的一系列方法解釋得非常清楚:
當編譯器遇到一個方法呼叫時,它會將方法的呼叫翻譯成以下函式中的一個
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 21 22 23 24 25 26 27 28 29 30 |
// XXObject.h #import @interface XXObject : NSObject - (void)hello; @end // XXObject.m #import "XXObject.h" @implementation XXObject - (void)hello { NSLog(@"Hello"); } @end // main.m #import #import "XXObject.h" int main(int argc, const char * argv[]) { @autoreleasepool { XXObject *object = [[XXObject alloc] init]; [object hello]; } return 0; } |
在呼叫 hello
方法的這一行打一個斷點,當我們嘗試進入(Step in)這個方法只會直接跳入這個方法的實現,而不會進入 objc_msgSend
:
因為 objc_msgSend
是一個私有方法,我們沒有辦法進入它的實現,但是,我們卻可以在 objc_msgSend
的呼叫棧中“截下”這個函式呼叫的過程。
呼叫 objc_msgSend
時,傳入了 self
以及 SEL
引數。
既然要執行對應的方法,肯定要尋找選擇子對應的實現。
在 objc-runtime-new.mm
檔案中有一個函式 lookUpImpOrForward
,這個函式的作用就是查詢方法的實現,於是執行程式,在執行到 hello
這一行時,啟用 lookUpImpOrForward
函式中的斷點。
由於轉成 gif 實在是太大了,筆者試著用各種方法生成動圖,然而效果也不是很理想,只能貼一個 Youtube 的視訊連結,不過對於有梯子的開發者們,應該也不是什麼問題吧(手動微笑)
如果跟著視訊看這個方法的呼叫棧有些混亂的話,也是正常的。在下一個節中會對其呼叫棧進行詳細的分析。
解析 objc_msgSend
對 objc_msgSend
解析總共分兩個步驟,我們會向 XXObject
的例項傳送兩次 hello
訊息,分別模擬無快取和有快取兩種情況下的呼叫棧。
無快取
在 -> [object hello]
這裡增加一個斷點,當程式執行到這一行時,再向 lookUpImpOrForward
函式的第一行新增斷點,確保是捕獲 @selector(hello)
的呼叫棧,而不是呼叫其它選擇子的呼叫棧。
由圖中的變數區域可以瞭解,傳入的選擇子為 "hello"
,對應的類是 XXObject
。所以我們可以確信這就是當呼叫 hello
方法時執行的函式。在 Xcode 左側能看到方法的呼叫棧:
1 2 3 4 5 |
0 lookUpImpOrForward 1 _class_lookupMethodAndLoadCache3 2 objc_msgSend 3 main 4 start |
呼叫棧在這裡告訴我們: lookUpImpOrForward
並不是 objc_msgSend
直接呼叫的,而是通過 _class_lookupMethodAndLoadCache3
方法:
1 2 3 4 5 |
IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls) { return lookUpImpOrForward(cls, sel, obj, YES/*initialize*/, NO/*cache*/, YES/*resolver*/); } |
這是一個僅提供給派發器(dispatcher)用於方法查詢的函式,其它的程式碼都應該使用 lookUpImpOrNil()
(不會進行方法轉發)。_class_lookupMethodAndLoadCache3
會傳入 cache = NO
避免在沒有加鎖的時候對快取進行查詢,因為派發器已經做過這件事情了。
實現的查詢 lookUpImpOrForward
由於實現的查詢方法 lookUpImpOrForward
涉及很多函式的呼叫,所以我們將它分成以下幾個部分來分析:
- 無鎖的快取查詢
- 如果類沒有實現(isRealized)或者初始化(isInitialized),實現或者初始化類
- 加鎖
- 快取以及當前類中方法的查詢
- 嘗試查詢父類的快取以及方法列表
- 沒有找到實現,嘗試方法解析器
- 進行訊息轉發
- 解鎖、返回實現
無鎖的快取查詢
下面是在沒有加鎖的時候對快取進行查詢,提高快取使用的效能:
1 2 3 4 5 6 7 |
runtimeLock.assertUnlocked(); // Optimistic cache lookup if (cache) { imp = cache_getImp(cls, sel); if (imp) return imp; } |
不過因為 _class_lookupMethodAndLoadCache3
傳入的 cache = NO
,所以這裡會直接跳過 if 中程式碼的執行,在 objc_msgSend
中已經使用匯編程式碼查詢過了。
類的實現和初始化
在 Objective-C 執行時 初始化的過程中會對其中的類進行第一次初始化也就是執行 realizeClass
方法,為類分配可讀寫結構體 class_rw_t
的空間,並返回正確的類結構體。
而 _class_initialize
方法會呼叫類的 initialize
方法,我會在之後的文章中對類的初始化進行分析。
1 2 3 4 5 6 7 8 |
if (!cls->isRealized()) { rwlock_writer_t lock(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) goto done; |
不過 cache_getImp
的實現目測是不開源的,同時也是彙編寫的,在我們嘗試 step in 的時候進入瞭如下的彙編程式碼。
它會進入一個 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; goto done; } |
呼叫 getMethodNoSuper_nolock
方法查詢對應的方法的結構體指標 method_t
:
1 2 3 4 5 6 7 8 9 10 11 12 |
static method_t *getMethodNoSuper_nolock(Class cls, SEL sel) { for (auto mlists = cls->data()->methods.beginLists(), end = cls->data()->methods.endLists(); mlists != end; ++mlists) { method_t *m = search_method_list(*mlists, sel); if (m) return m; } return nil; } |
因為類中資料的方法列表 methods
是一個二維陣列 method_array_t
,寫一個 for
迴圈遍歷整個方法列表,而這個 search_method_list
的實現也特別簡單:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
static method_t *search_method_list(const method_list_t *mlist, SEL sel) { int methodListIsFixedUp = mlist->isFixedUp(); int methodListHasExpectedSize = mlist->entsize() == sizeof(method_t); if (__builtin_expect(methodListIsFixedUp & methodListHasExpectedSize, 1)) { return findMethodInSortedMethodList(sel, mlist); } else { for (auto& meth : *mlist) { if (meth.name == sel) return &meth; } } return nil; } |
findMethodInSortedMethodList
方法對有序方法列表進行線性探測,返回方法結構體 method_t
。
如果在這裡找到了方法的實現,將它加入類的快取中,這個操作最後是由 cache_fill_nolock
方法來完成的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver) { if (!cls->isInitialized()) return; 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 expand(); } 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 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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); goto done; } else { break; } } meth = getMethodNoSuper_nolock(curClass, sel); if (meth) { log_and_fill_cache(cls, meth->imp, sel, inst, curClass); imp = meth->imp; goto done; } } |
與當前類尋找實現的區別是:在父類中尋找到的 _objc_msgForward_impcache
實現會交給當前類來處理。
方法決議
選擇子在當前類和父類中都沒有找到實現,就進入了方法決議(method resolve)的過程:
1 2 3 4 5 |
if (resolver & !triedResolver) { _class_resolveMethod(cls, sel, inst); triedResolver = YES; goto retry; } |
這部分程式碼呼叫 _class_resolveMethod
來解析沒有找到實現的方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
void _class_resolveMethod(Class cls, SEL sel, id inst) { 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 13 14 |
static void _class_resolveInstanceMethod(Class cls, SEL sel, id inst) { if (! lookUpImpOrNil(cls->ISA(), SEL_resolveInstanceMethod, cls, NO/*initialize*/, YES/*cache*/, NO/*resolver*/)) { // 沒有找到 resolveInstanceMethod: 方法,直接返回。 return; } BOOL (*msg)(Class, SEL, SEL) = (__typeof__(msg))objc_msgSend; bool resolved = msg(cls, SEL_resolveInstanceMethod, sel); // 快取結果,以防止下次在呼叫 resolveInstanceMethod: 方法影響效能。 IMP imp = 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
方法的實現,呼叫了該方法。
快取命中
如果使用對應的選擇子時,快取命中了,那麼情況就大不相同了,我們修改主程式中的程式碼:
1 2 3 4 5 6 7 8 |
int main(int argc, const char * argv[]) { @autoreleasepool { XXObject *object = [[XXObject alloc] init]; [object hello]; [object hello]; } return 0; } |
然後在第二次呼叫 hello
方法時,加一個斷點:
objc_msgSend
並沒有走 lookupImpOrForward
這個方法,而是直接結束,列印了另一個 hello
字串。
我們如何確定 objc_msgSend
的實現到底是什麼呢?其實我們沒有辦法來確認它的實現,因為這個函式的實現使用匯編寫的,並且實現是不開源的。
不過,我們需要確定它是否真的訪問了類中的快取來加速實現尋找的過程。
好,現在重新執行程式至第二個 hello
方法呼叫之前:
列印快取中 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 *)[XXObject class] (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`-[XXObject hello] at XXObject.m:17) } (lldb) p $2.buckets()[3] (bucket_t) $7 = { _key = 4300169955 _imp = 0x00000001000622e0 (libobjc.A.dylib`-[NSObject init] at NSObject.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 } |
這樣 XXObject
中就不存在 hello
方法對應實現的快取了。然後繼續執行程式:
雖然第二次呼叫 hello
方法,但是因為我們清除了 hello
的快取,所以,會再次進入 lookupImpOrForward
方法。
下面會換一種方法驗證猜測:在 hello 呼叫之前新增快取。
新增一個新的實現 cached_imp
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#import #import #import "XXObject.h" int main(int argc, const char * argv[]) { @autoreleasepool { __unused IMP cached_imp = imp_implementationWithBlock(^() { NSLog(@"Cached Hello"); }); XXObject *object = [[XXObject alloc] init]; [object hello]; [object hello]; } return 0; } |
我們將以 @selector(hello), cached_imp
為鍵值對,將其新增到類結構體的快取中,這裡的實現 cached_imp
有一些區別,它會列印 @"Cached Hello"
而不是 @"Hello"
字串:
在第一個 hello
方法呼叫之前將實現加入快取:
然後繼續執行程式碼:
可以看到,我們雖然沒有改變 hello
方法的實現,但是在 objc_msgSend 的訊息傳送鏈路中,使用錯誤的快取實現 cached_imp
攔截了實現的查詢,列印出了 Cached Hello
。
由此可以推定,objc_msgSend
在實現中確實檢查了快取。如果沒有快取會呼叫 lookupImpOrForward
進行方法查詢。
為了提高訊息傳遞的效率,ObjC 對 objc_msgSend
以及 cache_getImp
使用了組合語言來編寫。
如果你想了解有關 objc_msgSend
方法的彙編實現的資訊,可以看這篇文章 Let’s Build objc_msgSend
小結
這篇文章與其說是講 ObjC 中的訊息傳送的過程,不如說是講方法的實現是如何查詢的。
Objective-C 中實現查詢的路徑還是比較符合直覺的:
- 快取命中
- 查詢當前類的快取及方法
- 查詢父類的快取及方法
- 方法決議
- 訊息轉發
文章中關於方法呼叫棧的視訊最開始是用 gif 做的,不過由於 gif 時間較長,試了很多的 gif 轉換器,都沒有得到一個較好的質量和合適的大小,所以最後選擇用一個 Youtube 的視訊。
參考資料
關注倉庫,及時獲得更新:iOS-Source-Code-Analyze
Blog: Draveness