從原始碼看 ObjC 中訊息的傳送

發表於2016-05-10
Blog: Draveness關注倉庫,及時獲得更新: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. 有梯子(會有一個 Youtube 的連結)

概述

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

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

objc-message-core

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

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

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

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

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

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

在主函式任意位置打一個斷點, 比如 -> [object hello]; 這裡,然後在 lldb 中輸入:
objc-message-selecto

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

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

如果我們修改程式的程式碼:
objc-message-selector-undefined

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

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

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

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

在執行時初始化之前,列印 hello 選擇子的的記憶體地址:
objc-message-find-selector-before-init

message.h 檔案

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

在這個標頭檔案的註釋中對訊息傳送的一系列方法解釋得非常清楚:

當編譯器遇到一個方法呼叫時,它會將方法的呼叫翻譯成以下函式中的一個 objc_msgSendobjc_msgSend_stretobjc_msgSendSuperobjc_msgSendSuper_stret。 傳送給物件的父類的訊息會使用 objc_msgSendSuper 有資料結構作為返回值的方法會使用 objc_msgSendSuper_stretobjc_msgSend_stret 其它的訊息都是使用 objc_msgSend 傳送的

在這篇文章中,我們只會對訊息傳送的過程進行分析,而不會對上述訊息傳送方法的區別進行分析,預設都使用 objc_msgSend 函式。

objc_msgSend 呼叫棧

這一小節會以向 XXObject 的例項傳送 hello 訊息為例,在 Xcode 中觀察整個訊息傳送的過程中呼叫棧的變化,再來看一下程式的程式碼:

在呼叫 hello 方法的這一行打一個斷點,當我們嘗試進入(Step in)這個方法只會直接跳入這個方法的實現,而不會進入 objc_msgSend
objc-message-wrong-step-in

因為 objc_msgSend 是一個私有方法,我們沒有辦法進入它的實現,但是,我們卻可以在 objc_msgSend 的呼叫棧中“截下”這個函式呼叫的過程。

呼叫 objc_msgSend 時,傳入了 self 以及 SEL 引數。

既然要執行對應的方法,肯定要尋找選擇子對應的實現。

objc-runtime-new.mm 檔案中有一個函式 lookUpImpOrForward,這個函式的作用就是查詢方法的實現,於是執行程式,在執行到 hello 這一行時,啟用 lookUpImpOrForward 函式中的斷點。

從原始碼看 ObjC 中訊息的傳送

由於轉成 gif 實在是太大了,筆者試著用各種方法生成動圖,然而效果也不是很理想,只能貼一個 Youtube 的視訊連結,不過對於有梯子的開發者們,應該也不是什麼問題吧(手動微笑)

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

解析 objc_msgSend

objc_msgSend 解析總共分兩個步驟,我們會向 XXObject 的例項傳送兩次 hello 訊息,分別模擬無快取和有快取兩種情況下的呼叫棧。

無快取

-> [object hello] 這裡增加一個斷點,當程式執行到這一行時,再向 lookUpImpOrForward 函式的第一行新增斷點,確保是捕獲 @selector(hello) 的呼叫棧,而不是呼叫其它選擇子的呼叫棧。
objc-message-first-call-hello

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

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

這是一個僅提供給派發器(dispatcher)用於方法查詢的函式,其它的程式碼都應該使用 lookUpImpOrNil()(不會進行方法轉發)。_class_lookupMethodAndLoadCache3 會傳入 cache = NO 避免在沒有加鎖的時候對快取進行查詢,因為派發器已經做過這件事情了。

實現的查詢 lookUpImpOrForward

由於實現的查詢方法 lookUpImpOrForward 涉及很多函式的呼叫,所以我們將它分成以下幾個部分來分析:

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

無鎖的快取查詢

下面是在沒有加鎖的時候對快取進行查詢,提高快取使用的效能:

不過因為 _class_lookupMethodAndLoadCache3 傳入的 cache = NO,所以這裡會直接跳過 if 中程式碼的執行,在 objc_msgSend 中已經使用匯編程式碼查詢過了。

類的實現和初始化

Objective-C 執行時 初始化的過程中會對其中的類進行第一次初始化也就是執行 realizeClass 方法,為類分配可讀寫結構體 class_rw_t 的空間,並返回正確的類結構體。

_class_initialize 方法會呼叫類的 initialize 方法,我會在之後的文章中對類的初始化進行分析。

加鎖

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

在當前類中查詢實現

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

objc-message-cache-struct

不過 cache_getImp 的實現目測是不開源的,同時也是彙編寫的,在我們嘗試 step in 的時候進入瞭如下的彙編程式碼。
objc-message-step-in-cache-getimp

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

如果查詢到實現,就會跳轉到 done 標籤,因為我們在這個小結中的假設是無快取的(第一次呼叫 hello 方法),所以會進入下面的程式碼塊,從類的方法列表中尋找方法的實現:

呼叫 getMethodNoSuper_nolock 方法查詢對應的方法的結構體指標 method_t

因為類中資料的方法列表 methods 是一個二維陣列 method_array_t,寫一個 for 迴圈遍歷整個方法列表,而這個 search_method_list 的實現也特別簡單:

findMethodInSortedMethodList 方法對有序方法列表進行線性探測,返回方法結構體 method_t

如果在這裡找到了方法的實現,將它加入類的快取中,這個操作最後是由 cache_fill_nolock 方法來完成的:

如果快取中的內容大於容量的 3/4 就會擴充快取,使快取的大小翻倍。

在快取翻倍的過程中,當前類全部的快取都會被清空,Objective-C 出於效能的考慮不會將原有快取的 bucket_t 拷貝到新初始化的記憶體中。

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

在父類中尋找實現

這一部分與上面的實現基本上是一樣的,只是多了一個迴圈用來判斷根類:

  1. 查詢快取
  2. 搜尋方法列表

與當前類尋找實現的區別是:在父類中尋找到的 _objc_msgForward_impcache 實現會交給當前類來處理。

方法決議

選擇子在當前類和父類中都沒有找到實現,就進入了方法決議(method resolve)的過程:

這部分程式碼呼叫 _class_resolveMethod 來解析沒有找到實現的方法。

根據當前的類是不是元類_class_resolveInstanceMethod_class_resolveClassMethod 中選擇一個進行呼叫。

這兩個方法的實現其實就是判斷當前類是否實現了 resolveInstanceMethod: 或者 resolveClassMethod: 方法,然後用 objc_msgSend 執行上述方法,並傳入需要決議的選擇子。

關於 resolveInstanceMethod 之後可能會寫一篇文章專門介紹,不過關於這個方法的文章也確實不少,在 Google 上搜尋會有很多的文章。

在執行了 resolveInstanceMethod: 之後,會跳轉到 retry 標籤,重新執行查詢方法實現的流程,只不過不會再呼叫 resolveInstanceMethod: 方法了(將 triedResolver 標記為 YES)。

訊息轉發

在快取、當前類、父類以及 resolveInstanceMethod: 都沒有解決實現查詢的問題時,Objective-C 還為我們提供了最後一次翻身的機會,進行方法轉發:

返回實現 _objc_msgForward_impcache,然後加入快取。

====

這樣就結束了整個方法第一次的呼叫過程,快取沒有命中,但是在當前類的方法列表中找到了 hello 方法的實現,呼叫了該方法。
objc-message-first-call-hello

快取命中

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

然後在第二次呼叫 hello 方法時,加一個斷點:
objc-message-objc-msgSend-with-cache

objc_msgSend 並沒有走 lookupImpOrForward 這個方法,而是直接結束,列印了另一個 hello 字串。

我們如何確定 objc_msgSend 的實現到底是什麼呢?其實我們沒有辦法來確認它的實現,因為這個函式的實現使用匯編寫的,並且實現是不開源的。

不過,我們需要確定它是否真的訪問了類中的快取來加速實現尋找的過程。

好,現在重新執行程式至第二個 hello 方法呼叫之前:
objc-message-before-flush-cache

列印快取中 bucket 的內容:

在這個快取中只有對 helloinit 方法實現的快取,我們要將其中 hello 的快取清空:

objc-message-after-flush-cache

這樣 XXObject 中就不存在 hello 方法對應實現的快取了。然後繼續執行程式:
objc-message-after-flush-cache-trap-in-lookup-again

雖然第二次呼叫 hello 方法,但是因為我們清除了 hello 的快取,所以,會再次進入 lookupImpOrForward 方法。

下面會換一種方法驗證猜測:在 hello 呼叫之前新增快取

新增一個新的實現 cached_imp

我們將以 @selector(hello), cached_imp 為鍵值對,將其新增到類結構體的快取中,這裡的實現 cached_imp 有一些區別,它會列印 @"Cached Hello" 而不是 @"Hello" 字串:

在第一個 hello 方法呼叫之前將實現加入快取:
objc-message-add-imp-to-cache

然後繼續執行程式碼:
objc-message-run-after-add-cache

可以看到,我們雖然沒有改變 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 轉換器,都沒有得到一個較好的質量和合適的大小,所以最後選擇用一個 Youtube 的視訊。

參考資料

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

Blog: Draveness

相關文章