Runtime原始碼 方法呼叫的過程

Ly夢k發表於2019-03-04

前言

Objective-C語言的一大特性就是動態的,根據官方文件的描述:在runtime之前,訊息和方法並不是繫結在一起的,編譯器會把方法呼叫轉換為objc_msgSend(receiver, selector),如果方法中帶有引數則轉換為objc_msgSend(receiver, selector, arg1, arg2, ...)接下來我們通過原始碼一窺究竟,在次之前我們先了解幾個基本概念

  • SEL
    在objc.h檔案中我們可以看到如下程式碼:
/// An opaque type that represents a method selector.
typedef struct objc_selector *SEL;
複製程式碼

SEL其實就是一個不透明的型別它代表一個方法選擇子,在編譯期,會根據方法名字生成一個ID。

  • IMP
    在objc.h檔案中我們可以看到IMP:
/// A pointer to the function of a method implementation. 
#if !OBJC_OLD_DISPATCH_PROTOTYPES
typedef void (*IMP)(void /* id, SEL, ... */ ); 
#else
typedef id _Nullable (*IMP)(id _Nonnull, SEL _Nonnull, ...); 
#endif
複製程式碼

他是一個函式指標,指向方法實現的首地址。

  • Method
/// An opaque type that represents a method in a class definition.
typedef struct objc_method *Method;  

struct objc_method {
    SEL _Nonnull method_name                                 OBJC2_UNAVAILABLE;
    char * _Nullable method_types                            OBJC2_UNAVAILABLE;
    IMP _Nonnull method_imp                                  OBJC2_UNAVAILABLE;
}                                                            OBJC2_UNAVAILABLE;
複製程式碼

它儲存了SEL到IMP和方法型別,所以我們可以通過SEL呼叫對應的IMP

方法呼叫的流程

objc_msgSend的訊息分發分為以下幾個步驟:
我們找到objc _msgSend原始碼,都是彙編,不過註釋比較詳盡

/********************************************************************
 *
 * id objc_msgSend(id self, SEL	_cmd,...);
 * IMP objc_msgLookup(id self, SEL _cmd, ...);
 *
 * objc_msgLookup ABI:
 * IMP returned in r11
 * Forwarding returned in Z flag
 * r10 reserved for our use but not used
 *
 ********************************************************************/
	
	.data
	.align 3
	.globl _objc_debug_taggedpointer_classes
_objc_debug_taggedpointer_classes:
	.fill 16, 8, 0
	.globl _objc_debug_taggedpointer_ext_classes
_objc_debug_taggedpointer_ext_classes:
	.fill 256, 8, 0

	ENTRY _objc_msgSend
	UNWIND _objc_msgSend, NoFrame
	MESSENGER_START

	NilTest	NORMAL

	GetIsaFast NORMAL		// r10 = self->isa
	CacheLookup NORMAL, CALL	// calls IMP on success

	NilTestReturnZero NORMAL

	GetIsaSupport NORMAL

// cache miss: go search the method lists
LCacheMiss:
	// isa still in r10
	MESSENGER_END_SLOW
	jmp	__objc_msgSend_uncached

	END_ENTRY _objc_msgSend

	
	ENTRY _objc_msgLookup

	NilTest	NORMAL

	GetIsaFast NORMAL		// r10 = self->isa
	CacheLookup NORMAL, LOOKUP	// returns IMP on success

	NilTestReturnIMP NORMAL

	GetIsaSupport NORMAL

// cache miss: go search the method lists
LCacheMiss:
	// isa still in r10
	jmp	__objc_msgLookup_uncached

	END_ENTRY _objc_msgLookup

	
	ENTRY _objc_msgSend_fixup
	int3
	END_ENTRY _objc_msgSend_fixup

	
	STATIC_ENTRY _objc_msgSend_fixedup
	// Load _cmd from the message_ref
	movq	8(%a2), %a2
	jmp	_objc_msgSend
	END_ENTRY _objc_msgSend_fixedup

複製程式碼

就此我們大概可以瞭解到其呼叫流程:

  • 判斷receiver是否為nil,也就是objc_msgSend的第一個引數self,也就是要呼叫的那個方法所屬物件

  • 從快取裡尋找,找到了則分發,否則

  • 利用objc-class.mm中_ class _lookupMethodAndLoadCache3方法去尋找selector

    • 如果支援GC,忽略掉非GC環境的方法(retain等)
    • 從本class的method list尋找selector,如果找到,填充到快取中,並返回selector,否則
    • 尋找父類的method list,並依次往上尋找,直到找到selector,填充到快取中,並返回selector,否則
    • 呼叫_class_resolveMethod,如果可以動態resolve為一個selector,不快取,方法返回,否則
    • 轉發這個selector,否則
    • 報錯,丟擲異常

這裡的**_ class _lookupMethodAndLoadCache3其實就是對lookUpImpOrForward**方法的呼叫:

/***********************************************************************
* _class_lookupMethodAndLoadCache.
* Method lookup for dispatchers ONLY. OTHER CODE SHOULD USE lookUpImp().
* This lookup avoids optimistic cache scan because the dispatcher 
* already tried that.
**********************************************************************/
IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls)
{        
    return lookUpImpOrForward(cls, sel, obj, 
                              YES/*initialize*/, NO/*cache*/, YES/*resolver*/);
}
複製程式碼

對第五個引數cache傳值為NO,因為在此之前已經做了一個查詢這裡CacheLookup NORMAL, CALL,這裡是對快取查詢的一個優化。

接下來看一下lookUpImpOrForward的一些關鍵實現細節

  • 快取查詢優化
 // Optimistic cache lookup
 if (cache)  
     methodPC = _cache_getImp(cls, sel);
     if (methodPC) return methodPC;    
 }
複製程式碼

這裡有個判斷,是否需要快取查詢,如果cache為NO則進入下一步

  • 檢查被釋放類
// Check for freed class
if (cls == _class_getFreedObjectClass())
	return (IMP) _freedHandler;
複製程式碼

_class _getFreedObjectClass的實現:

/***********************************************************************
* _class_getFreedObjectClass.  Return a pointer to the dummy freed
* object class.  Freed objects get their isa pointers replaced with
* a pointer to the freedObjectClass, so that we can catch usages of
* the freed object.
**********************************************************************/
static Class _class_getFreedObjectClass(void)
{
    return (Class)freedObjectClass;
}
複製程式碼

註釋寫到,這裡返回的被釋放物件的指標,不是太理解,備註這以後再看看

  • 懶載入+initialize
// Check for +initialize
    if (initialize  &&  !cls->isInitialized()) {
        _class_initialize (_class_getNonMetaClass(cls, inst));
        // If sel == initialize, _class_initialize will send +initialize and 
        // then the messenger will send +initialize again after this 
        // procedure finishes. Of course, if this is not being called 
        // from the messenger then it won`t happen. 2778172
    }
複製程式碼

在方法呼叫過程中,如果類沒有被初始化的時候,會呼叫_class_initialize對類進行初始化,關於+initialize可以看之前的Runtime原始碼 +load 和 +initialize

  • 加鎖保證原子性
	// The lock is held to make method-lookup + cache-fill atomic 
    // with respect to method addition. Otherwise, a category could 
    // be added but ignored indefinitely because the cache was re-filled 
    // with the old value after the cache flush on behalf of the category.
retry:
    methodListLock.lock();

    // Try this class`s cache.

    methodPC = _cache_getImp(cls, sel);
    if (methodPC) goto done;
複製程式碼

這裡又做了一次快取查詢,因為上一步執行了+initialize

加鎖這一部分只有一行簡單的程式碼,其主要目的保證方法查詢以及快取填充(cache-fill)的原子性,保證在執行以下程式碼時不會有新方法新增導致快取被沖洗(flush)。

  • 本類的方法列表查詢
// Try this class`s method lists.

meth = _class_getMethodNoSuper_nolock(cls, sel);
if (meth) {
log_and_fill_cache(cls, cls, meth, sel);
 methodPC = method_getImplementation(meth);
 goto done;
}
複製程式碼

這裡呼叫了log_ and_ fill_cache這個後面來看,接下里就是

  • 父類方法列表查詢
// Try superclass caches and method lists.

    curClass = cls;
    while ((curClass = curClass->superclass)) {
        // Superclass cache.
        meth = _cache_getMethod(curClass, sel, _objc_msgForward_impcache);
        if (meth) {
            if (meth != (Method)1) {
                // Found the method in a superclass. Cache it in this class.
                log_and_fill_cache(cls, curClass, meth, sel);
                methodPC = method_getImplementation(meth);
                goto done;
            }
            else {
                // Found a forward:: entry in a superclass.
                // Stop searching, but don`t cache yet; call method 
                // resolver for this class first.
                break;
            }
        }

        // Superclass method list.
        meth = _class_getMethodNoSuper_nolock(curClass, sel);
        if (meth) {
            log_and_fill_cache(cls, curClass, meth, sel);
            methodPC = method_getImplementation(meth);
            goto done;
        }
    }
複製程式碼

關於訊息在列表方法查詢的過程,根據官方文件如下:

messaging1

這裡沿著整合體系對父類的方法列表進行查詢,找到了就呼叫log_ and_ fill_cache

log_ and_ fill_cach的實現:
記錄:

/***********************************************************************
* log_and_fill_cache
* Log this method call. If the logger permits it, fill the method cache.
* cls is the method whose cache should be filled. 
* implementer is the class that owns the implementation in question.
**********************************************************************/
static void
log_and_fill_cache(Class cls, Class implementer, Method meth, SEL sel)
{
#if SUPPORT_MESSAGE_LOGGING
    if (objcMsgLogEnabled) {
        bool cacheIt = logMessageSend(implementer->isMetaClass(), 
                                      cls->nameForLogging(),
                                      implementer->nameForLogging(), 
                                      sel);
        if (!cacheIt) return;
    }
#endif
    _cache_fill (cls, meth, sel);
}
複製程式碼

內部呼叫了**_cache _fill**,填充快取:

/***********************************************************************
* _cache_fill.  Add the specified method to the specified class` cache.
* Returns NO if the cache entry wasn`t added: cache was busy, 
*  class is still being initialized, new entry is a duplicate.
*
* Called only from _class_lookupMethodAndLoadCache and
* class_respondsToMethod and _cache_addForwardEntry.
*
* Cache locks: cacheUpdateLock must not be held.
**********************************************************************/
bool _cache_fill(Class cls, Method smt, SEL sel)
{
    uintptr_t newOccupied;
    uintptr_t index;
    cache_entry **buckets;
    cache_entry *entry;
    Cache cache;

    cacheUpdateLock.assertUnlocked();

    // Never cache before +initialize is done
    if (!cls->isInitialized()) {
        return NO;
    }

    // Keep tally of cache additions
    totalCacheFills += 1;

    mutex_locker_t lock(cacheUpdateLock);

    entry = (cache_entry *)smt;

    cache = cls->cache;

    // Make sure the entry wasn`t added to the cache by some other thread 
    // before we grabbed the cacheUpdateLock.
    // Don`t use _cache_getMethod() because _cache_getMethod() doesn`t 
    // return forward:: entries.
    if (_cache_getImp(cls, sel)) {
        return NO; // entry is already cached, didn`t add new one
    }

    // Use the cache as-is if it is less than 3/4 full
    newOccupied = cache->occupied + 1;
    if ((newOccupied * 4) <= (cache->mask + 1) * 3) {
        // Cache is less than 3/4 full.
        cache->occupied = (unsigned int)newOccupied;
    } else {
        // Cache is too full. Expand it.
        cache = _cache_expand (cls);

        // Account for the addition
        cache->occupied += 1;
    }

    // Scan for the first unused slot and insert there.
    // There is guaranteed to be an empty slot because the 
    // minimum size is 4 and we resized at 3/4 full.
    buckets = (cache_entry **)cache->buckets;
    for (index = CACHE_HASH(sel, cache->mask); 
         buckets[index] != NULL; 
         index = (index+1) & cache->mask)
    {
        // empty
    }
    buckets[index] = entry;

    return YES; // successfully added new cache entry
}
複製程式碼

這裡還沒找到實現則進入下一步,動態方法解析和訊息轉發,關於訊息轉發的細節我們下篇再看。

方法快取

在上面截出的原始碼中我們多次看到了cache,下面我們就來看看這個,在runtime.hobjc-runtime-newcache的定義如下

struct objc_cache {
    unsigned int mask /* total = mask + 1 */                 OBJC2_UNAVAILABLE;
    unsigned int occupied                                    OBJC2_UNAVAILABLE;
    Method _Nullable buckets[1]                              OBJC2_UNAVAILABLE;
};
複製程式碼
struct cache_t {
  struct bucket_t *_buckets;
  mask_t _mask;
  mask_t _occupied;
  ...
}
複製程式碼

這就是cache在runtime層面的表示,裡面的欄位和代表的含義類似

  • buckets
    陣列表示的hash表,每個元素代表一個方法快取
  • mask
    當前能達到的最大index(從0開始),,所以快取的size(total)是mask+1
  • occupied
    被佔用的槽位,因為快取是以雜湊表的形式存在的,所以會有空槽,而occupied表示當前被佔用的數目

而在_ buckets中包含了一個個的cache_entrybucket_t(objc2.0的變更):

typedef struct {
    SEL name;     // same layout as struct old_method
    void *unused;
    IMP imp;  // same layout as struct old_method
} cache_entry;
複製程式碼

cache_entry定義也包含了三個欄位,分別是:

  • name,被快取的方法名字
  • unused,保留欄位,還沒被使用。
  • imp,方法實現
struct bucket_t {
private:
    cache_key_t _key;
    IMP _imp;
    ...
}
複製程式碼

而bucket_t則沒有了老的unused,包含了兩個欄位:

  • key,方法的標誌(和之前的name對應)
  • imp, 方法的實現

後記

從runtime的原始碼我們知道了方法呼叫的流程和方法快取,有些附帶的問題答案也就呼之欲出了:

  • 方法快取在元類的上,由第一節(Runtime原始碼 類、物件、isa)我們就知道在objc_class的isa指向了他的元類,所以每個類都只有一份方法快取,而不是每一個類的object都儲存一份。
  • 在方法呼叫的父類方法列表查詢過程中,如果命中了也會呼叫_cache_fill (cls, meth, sel);,所以即便是從父類取到的方法,也會存在類本身的方法快取裡。而當用一個父類物件去呼叫那個方法的時候,也會在父類的metaclass裡快取一份。
  • 快取容量限制,在上面的程式碼中我們注意到這個判斷:
// Use the cache as-is if it is less than 3/4 full
mask_t newOccupied = cache->occupied() + 1;
mask_t capacity = cache->capacity();
if (cache->isConstantEmptyCache()) {
    // Cache is read-only. Replace it.
    cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);
}
else if (newOccupied <= capacity / 4 * 3) {
     // Cache is less than 3/4 full. Use it as-is.
}
else {
     // Cache is too full. Expand it.
     cache->expand();
}
複製程式碼

當cache為空時建立;當新的被佔用槽數小於等於其容量的3/4時,直接使用;否則呼叫cache->expand();擴充容量:

void cache_t::expand()
{
    cacheUpdateLock.assertLocked();
    
    uint32_t oldCapacity = capacity();
    uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE;

    if ((uint32_t)(mask_t)newCapacity != newCapacity) {
        // mask overflow - can`t grow further
        // fixme this wastes one bit of mask
        newCapacity = oldCapacity;
    }

    reallocate(oldCapacity, newCapacity);
}
複製程式碼
  • 為什麼類的方法列表不直接做成雜湊表呢,做成list,還要單獨快取,多費事?
    雜湊表是沒有順序的,Objective-C的方法列表是一個list,是有順序的
    這個問題麼,我覺得有以下三個原因:
    • Objective-C在查詢方法的時候會順著list依次尋找,並且category的方法在原始方法list的前面,需要先被找到,如果直接用hash存方法,方法的順序就沒法保證。
    • list的方法還儲存了除了selector和imp之外其他很多屬性。
    • 雜湊表是有空槽的,會浪費空間。

相關資料:
美團酒旅博文:深入理解Objective-C:方法快取
官方文件:Messaging

相關文章