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

ValenciaK87 8年前發布 | 18K 次閱讀 iOS開發 移動開發 Objective-C

 

關注倉庫,及時獲得更新: iOS-Source-Code-Analyze

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

寫在前面

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

  1. 在 Objective-C 中的“方法調用”其實應該叫做消息傳遞
  2. [receiver message] 會被翻譯為 objc_msgSend(receiver, @selector(message))
  3. 在消息的響應鏈中 可能 會調用 - resolveInstanceMethod: - forwardInvocation: 等方法
  4. 關于選擇子 SEL 的知識

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

  5. 方法在內存中存儲的位置, 深入解析 ObjC 中方法的結構

    文章中不會刻意區別方法和函數、消息傳遞和方法調用之間的區別。

  6. 能KX上網(會有一個 油Tube 的鏈接)

概述

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

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

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

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

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

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

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

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

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

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

// XXObject.h
#import <Foundation/Foundation.h>

@interface XXObject : NSObject

- (void)hello;

@end

// XXObject.m
#import "XXObject.h"

@implementation XXObject

- (void)hello {
    NSLog(@"Hello");
}

@end
// main.m
#import <Foundation/Foundation.h>
#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) 就可以返回一個選擇子。

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

  1. Objective-C 為我們維護了一個巨大的選擇子表
  2. 在使用 @selector() 時會從這個選擇子表中根據選擇子的名字查找對應的 SEL 。如果沒有找到,則會生成一個 SEL 并添加到表中
  3. 在編譯期間會掃描全部的頭文件和實現文件將其中的方法以及使用 @selector() 生成的選擇子加入到選擇子表中

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

message.h 文件

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

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

// XXObject.h
#import <Foundation/Foundation.h>

@interface XXObject : NSObject

- (void)hello;

@end

// XXObject.m
#import "XXObject.h"

@implementation XXObject

- (void)hello {
    NSLog(@"Hello");
}

@end
// main.m
#import <Foundation/Foundation.h>
#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 實在是太大了,筆者試著用各種方法生成動圖,然而效果也不是很理想,只能貼一個 油Tube 的視頻鏈接,不過對于能夠KX上網的開發者們,應該也不是什么問題吧(手動微笑)

如果跟著視頻看這個方法的調用棧有些混亂的話,也是正常的。在下一個節中會對其調用棧進行詳細的分析。

解析 objc_msgSend

對 objc_msgSend 解析總共分兩個步驟,我們會向 XXObject 的實例發送兩次 hello 消息,分別模擬無緩存和有緩存兩種情況下的調用棧。

無緩存

在 -> [object hello] 這里增加一個斷點, 當程序運行到這一行時 ,再向 lookUpImpOrForward 函數的第一行添加斷點,確保是捕獲 @selector(hello) 的調用棧,而不是調用其它選擇子的調用棧。

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

0 lookUpImpOrForward  
1 _class_lookupMethodAndLoadCache3  
2 objc_msgSend  
3 main  
4 start  

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

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 涉及很多函數的調用,所以我們將它分成以下幾個部分來分析:

  1. 無鎖的緩存查找
  2. 如果類沒有實現(isRealized)或者初始化(isInitialized),實現或者初始化類
  3. 加鎖
  4. 緩存以及當前類中方法的查找
  5. 嘗試查找父類的緩存以及方法列表
  6. 沒有找到實現,嘗試方法解析器
  7. 進行消息轉發
  8. 解鎖、返回實現

無鎖的緩存查找

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

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

if (!cls->isRealized()) {  
    rwlock_writer_t lock(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) goto done;  

不過 cache_getImp 的實現目測是不開源的,同時也是匯編寫的,在我們嘗試 step in 的時候進入了如下的匯編代碼。

它會進入一個 CacheLookup 的標簽,獲取實現,使用匯編的原因還是因為要加速整個實現查找的過程,其原理推測是在類的 cache 中尋找對應的實現,只是做了一些性能上的優化。

如果查找到實現,就會跳轉到 done 標簽,因為我們在這個小結中的假設是無緩存的(第一次調用 hello 方法),所以會進入下面的代碼塊,從類的方法列表中尋找方法的實現:

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 :

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

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

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 <= capacity / 4 * 3) {

    } else {
        cache->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. 搜索方法列表
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)的過程:

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

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

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

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

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

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

====

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

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

緩存命中

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

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

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

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

這樣 XXObject 中就不存在 hello 方法對應實現的緩存了。然后繼續運行程序:

雖然第二次調用 hello 方法,但是因為我們清除了 hello 的緩存,所以,會再次進入 lookupImpOrForward 方法。

下面會換一種方法驗證猜測: 在 hello 調用之前添加緩存

添加一個新的實現 cached_imp :

#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#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 中實現查找的路徑還是比較符合直覺的:

  1. 緩存命中
  2. 查找當前類的緩存及方法
  3. 查找父類的緩存及方法
  4. 方法決議
  5. 消息轉發

文章中關于方法調用棧的視頻最開始是用 gif 做的,不過由于 gif 時間較長,試了很多的 gif 轉換器,都沒有得到一個較好的質量和合適的大小,所以最后選擇用一個 油Tube 的視頻。

參考資料

來自: http://draveness.me/message/ 

 本文由用戶 ValenciaK87 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!