Objective-C Runtime中的并發(fā)內(nèi)存分配
本文由翻譯自mikeash的博客,原文:Concurrent Memory Deallocation in the Objective-C Runtime
譯者:lynulzy(社區(qū)ID,博客) 校對(duì):唧唧歪歪(博客)
Objective-C的Runtime機(jī)制是Mac和iOS程序中的核心,而objc_msgSend函數(shù)是Runtime的核心,進(jìn)言之,這個(gè)函數(shù)的核心正是方法緩存。今天將代領(lǐng)大家探索蘋果是如何以一種線程安全且不影響程序性能的方式來(lái)調(diào)整和分配方法緩存所用內(nèi)存的,其所用的技術(shù)也許是在其他關(guān)于線程安全的資料中從未使用的。
消息轉(zhuǎn)發(fā)的概念
Objc_msgSend方法的工作方式是為發(fā)送過來(lái)的方法查找恰當(dāng)?shù)姆椒▽?shí)現(xiàn),并且跳轉(zhuǎn)到該實(shí)現(xiàn)中的方式工作。用官方的說法來(lái)講,查找方法的過程是這樣的:
- IMP lookUp(id obj, SEL selector) {
- Class c = object_getClass(obj);
- while(c) {
- for(int i = 0; i < c->numMethods; i++) {
- Method m = c->methods[i];
- if(m.selector == selector) {
- return m.imp;
- }
- }
- c = c->superclass;
- }
- return _objc_msgForward;
- }
【注】代碼中的一些變量名已經(jīng)替換,如果你對(duì)原始代碼有興趣可以去下載一份Objective-C runtime的[源碼](http://www.opensource.apple.com/source/objc4/)
方法緩存
在Objective-C的程序中,消息發(fā)送隨處可見,如果對(duì)每一條消息都執(zhí)行完整的消息搜索,那將會(huì)使程序變得異常遲鈍。
解決方法就是緩存,每一個(gè)類都擁有一個(gè)哈希表與之關(guān)聯(lián),這個(gè)哈希表將選擇器映射到方法實(shí)現(xiàn)。使用哈希表的初衷正是為了***限度的提高讀取速度,同時(shí) objc_msgSend 使用了極其細(xì)致且高效的匯編源碼來(lái)快速執(zhí)行哈希表的檢索,這使得在緩存模式下的消息發(fā)送僅維持在一個(gè)個(gè)位數(shù)的納秒量級(jí)上。當(dāng)然,任何消息在***次使用的時(shí)候很慢,以后將會(huì)非??臁?/p>
我們提到的緩存,是用來(lái)提高多次讀取最近使用資源的速度的,它通常也是有大小限制的。例如,你可能會(huì)緩存從網(wǎng)絡(luò)上加載的圖片,這樣連續(xù)2次讀取圖片就不會(huì)請(qǐng)求網(wǎng)絡(luò)2次了。但是,你又不想使用太多內(nèi)存,所以你可能會(huì)給緩存圖片的數(shù)量設(shè)置一個(gè)***值,當(dāng)達(dá)到***值,且又有新的圖片進(jìn)來(lái)時(shí),就可以把最舊的圖片刪掉。
在大多數(shù)情況下這是一種很好的解決方案,但是在一些隱蔽的情況下可能會(huì)有較差的表現(xiàn)。舉個(gè)例子,如果你把圖片緩存?zhèn)€數(shù)設(shè)置為40個(gè),但是應(yīng)用卻以41張一組這樣的規(guī)模循環(huán)圖片的話,你會(huì)忽然發(fā)現(xiàn)你的緩存策略失效了。
對(duì)于我們自己的應(yīng)用,我們可以測(cè)試和調(diào)整緩存的大小以避免這種情況發(fā)生,但是Objective-C的Runtime機(jī)制并沒有這種條件。因?yàn)榉椒ň彺鎸?duì)于性能極其嚴(yán)苛,并且每個(gè)條目都相對(duì)較小。runtime并不會(huì)強(qiáng)制的限制緩存區(qū)的大小,相反,它會(huì)在需要的時(shí)候擴(kuò)充緩存區(qū)以保存所有已經(jīng)被發(fā)送的消息。
請(qǐng)注意,緩存有時(shí)候會(huì)刷新;有一些操作會(huì)造成緩存數(shù)據(jù)過期,如在處理過程中加載入更多的代碼,或者改變一個(gè)類的方法列表,恰當(dāng)?shù)木彺鎱^(qū)會(huì)被銷毀并且允許再次填充的。
改變緩存大小、分配內(nèi)存以及線程問題
在概念上,改變緩存的大小簡(jiǎn)單,就像這樣
- bucket_t *newCache = malloc(newSize);
- copyEntries(newCache, class->cache);
- free(class->cache);
- class->cache = newCache;
Objective-C runtime 實(shí)際上在這里采用了一些捷徑,但是不會(huì)將舊的條目拷貝到新的緩存區(qū)!畢竟它僅僅是緩存而已,沒有必要保存數(shù)據(jù),這些條目將在消息發(fā)送的時(shí)候再次被填充,所以,真實(shí)情況是這樣的:
- free(class->cache);
- class->cache = malloc(newSize);
在單線程環(huán)境下,你需要做的僅有這些,那么這篇文章也本該很短。當(dāng)然Objective - C runtime也必須要支持多線程,也就是說所有這些代碼都必須是線程安全的。任何給出的類的緩存,都可以從多個(gè)線程中被同時(shí)訪問,所以這些代碼必須考慮周全,確保可以應(yīng)付這種場(chǎng)景。
寫到這里的代碼是無(wú)法處理多線程的情況的。在`釋放舊的緩存到分配新的緩存之前`這段時(shí)間內(nèi),其他線程也許會(huì)訪問這些已經(jīng)失效的緩存指針,這會(huì)造成它使用的數(shù)據(jù)是垃圾數(shù)據(jù),或者由于指定的內(nèi)存并未映像物理地址出而立刻崩潰。
我們?cè)撊绾谓鉀Q這種問題?典型的保存共享數(shù)據(jù)的一種方法是加鎖,代碼如下:
- lock(class->lock);
- free(class->cache);
- class->cache = malloc(newSize);
- unlock(class->lock);
為此,包括讀取在內(nèi)的所有訪問都會(huì)被這個(gè)鎖控制。也就是說 *Objc_msgSend* 方法需要獲得這個(gè)鎖,查找緩存,然后放鎖。每次進(jìn)行加鎖,解鎖操作都會(huì)增加許多開銷,考慮到緩存每次對(duì)自己的檢索只需要幾納秒時(shí)間,這對(duì)性能的影響太大了。
我們也許會(huì)嘗試通過一些其他方式關(guān)閉這個(gè)時(shí)間窗口(*釋放舊緩存到分配新緩存這個(gè)時(shí)間窗*)。例如,對(duì)緩存先分配地址并賦值,然后再去釋放舊的緩存如何?
- bucket_t *oldCache = class->cache;
- class->cache = malloc(newSize);
- free(oldCache);
這會(huì)有一些幫助,但是并不能解決這個(gè)問題。另外一個(gè)線程也許會(huì)檢索舊的緩存指針,然后在他可以訪問內(nèi)容之前通過系統(tǒng)先行占取這塊緩存。這塊舊的緩存在其他的線程再次運(yùn)行之前被銷毀,之前的問題再次出現(xiàn)。
如果加一個(gè)像這樣的延遲呢?
- bucket_t *oldCache = class->cache;
- class->cache = malloc(newSize);
- after(5 /* seconds */, ^{
- free(oldCache);
- });
這幾乎是可行的。但還是有下面的情況,一個(gè)線程剛好被系統(tǒng)搶占了緩存,并且被搶占的時(shí)間足夠長(zhǎng),這樣延遲5秒的釋放就會(huì)先觸發(fā)。這使得崩潰的可能微乎其微,但也不能完全保證不會(huì)發(fā)生。
不采用一個(gè)隨機(jī)的延遲時(shí)間,一直等待到時(shí)間窗完全騰出來(lái)會(huì)怎么樣呢?我們對(duì)Objc_msgSend加一個(gè)計(jì)數(shù)器:
- gInMsgSend++;
- lookUpCache(class->cache);
- gInMsgSend--;
一個(gè)恰當(dāng)?shù)木€程安全版本需要用到計(jì)數(shù)器的原子性,合適的內(nèi)存`阻隔`來(lái)確保依賴加載/存儲(chǔ)顯示正常。本文的目的不是討論這些,想象它們已經(jīng)存在就好了。
在計(jì)數(shù)器的幫助下,緩存的再分配像是這樣:
- bucket_t *oldCache = class->cache;
- class->cache = malloc(newSize);
- while(gInMsgSend)
- ; // spin
- free(oldCache);
注意到這里沒有必要阻塞執(zhí)行objc_msgSend方法就可以正常工作。一旦釋放緩存的代碼確定在它替換了緩存指針之后,objc_msgSend中沒有東西了,這段代碼就會(huì)繼續(xù)向下執(zhí)行,釋放舊的緩存區(qū)。其他線程可能會(huì)在舊的緩存區(qū)指針釋放的時(shí)候調(diào)用 Objc_msgSend 方法,但是這個(gè)相對(duì)較新的調(diào)用將不能再使用舊的指針,因此這種條件下是線程安全的。
不斷的循環(huán)是低效率且不夠優(yōu)美的。釋放緩存并沒有那么緊急。釋放內(nèi)存是好的,如果要花些時(shí)間也沒什么問題。與其低效的循環(huán),不如讓我們保存一份未釋放緩存列表,每次當(dāng)緩存釋放的時(shí)候會(huì)將所有等待中的操作全部執(zhí)行完畢,上代碼:
- bucket_t *oldCache = class->cache;
- class->cache = malloc(newSize);
- append(gOldCachesList, oldCache);
- if(!gInMsgSend) {
- for(cache in gOldCachesList) {
- free(cache);
- }
- gOldCachesList.clear();
- }
當(dāng)一個(gè)新的發(fā)送消息在處理的過程中,這個(gè)操作不會(huì)立刻釋放舊的緩存,但這并不是問題。當(dāng)再次訪問它、訪問之后的時(shí)候、或者將來(lái)的某個(gè)時(shí)間點(diǎn)會(huì)被釋放。
這個(gè)版本已經(jīng)相當(dāng)接近Objective-C Runtime機(jī)制的實(shí)際運(yùn)行原理了。#p#
零耗費(fèi)標(biāo)志
這兩個(gè)交互的部分存在這極大的不對(duì)稱。Objc_msgSend這邊, 可能每秒會(huì)運(yùn)行百萬(wàn)次,并且的確是需要盡可能地快。***的情況是單次調(diào)用的運(yùn)行時(shí)間只需要幾納秒。另一方面,改變緩存區(qū)的大小是一個(gè)較少的操作,并且隨著app的持續(xù)運(yùn)行將會(huì)變得越來(lái)越少。一旦應(yīng)用達(dá)到了一種穩(wěn)態(tài),不在加載新的代碼,或者編輯消息列表,并且緩存變得足夠大而且能滿足所需的時(shí)候,緩存塊大小的重新計(jì)算操作將不會(huì)再發(fā)生。但在此之前,這個(gè)操作在緩存區(qū)增大到它所需的大小時(shí)或許會(huì)發(fā)生個(gè)幾百或者幾千次,但是與Objc_msgSend相比而言是極其小的,并且性能敏感性也更低。
由于這種不對(duì)稱性,在消息發(fā)送方應(yīng)該放盡可能少的任務(wù),即使這會(huì)使緩存釋放部分會(huì)變慢一些。在objc_msgSend的***別CPU循環(huán)中每削減一個(gè)CPU運(yùn)行循環(huán)累積下帶來(lái)的優(yōu)勢(shì)與釋放操作是一個(gè)以巨大優(yōu)勢(shì)的凈贏。
即使全局計(jì)數(shù)器花費(fèi)太大。在objc_msgSend方法中的這兩個(gè)附加的內(nèi)存訪問操作將仍然帶來(lái)很大的開銷。它們需要保持原子性并且使用內(nèi)存隔離會(huì)使情況更糟。
幸運(yùn)的是,Objective-C runtime機(jī)制有一個(gè)技術(shù)是以犧牲緩存釋放的速度來(lái)將objc_msgSend的開銷降為0。
假設(shè)全局計(jì)數(shù)器的目的在于追蹤任何在一個(gè)特定代碼區(qū)塊內(nèi)的線程。這些線程已經(jīng)有已有一些來(lái)監(jiān)測(cè)當(dāng)前它們是在哪段代碼中執(zhí)行,它就是程序計(jì)數(shù)器(program counter)。這是一個(gè)CPU內(nèi)部的寄存器,其功能在于記錄當(dāng)前指令的內(nèi)存地址。與全局計(jì)數(shù)器相比,我們可以檢查每個(gè)線程的程序計(jì)數(shù)器來(lái)確認(rèn)他是否在執(zhí)行objc_msgSend
。如果所有線程都沒有執(zhí)行objc_msgSend方法,那么對(duì)它而言,釋放緩存就是安全的,代碼實(shí)現(xiàn)如下:
- BOOL ThreadsInMsgSend(void) {
- for(thread in GetAllThreads()) {
- uintptr_t pc = thread.GetPC();
- if(pc >= objc_msgSend_startAddress && pc cache;
- class->cache = malloc(newSize);
- append(gOldCachesList, oldCache);
- if(!ThreadsInMsgSend()) {
- for(cache in gOldCachesList) {
- free(cache);
- }
- gOldCachesList.clear();
- }
然后,objc_msgSend不必做任何其他的事情。它可以直接訪問緩存區(qū),而不用給讀取加個(gè)標(biāo)志,就像下面這樣:
- lookUpCache(class->cache);
由于緩存釋放需要檢查進(jìn)程中的每個(gè)線程的狀態(tài),因此它是相對(duì)低效的。但是如果objc_msgSend只用考慮單線程的環(huán)境下,它的執(zhí)行效率將會(huì)非常高。這值得做出權(quán)衡。這基本上就是蘋果的Runtime機(jī)制如何工作的。
實(shí)際的代碼
到底蘋果如何實(shí)現(xiàn)上述的技術(shù)可以在runtime的實(shí)現(xiàn)文件[objc-cache.mm]文件中的函數(shù) _collection_in_critical 中找到。
關(guān)鍵的PC位置存儲(chǔ)在全局變量中:
- OBJC_EXPORT uintptr_t objc_entryPoints[];
- OBJC_EXPORT uintptr_t objc_exitPoints[];
實(shí)際上objc_msgSend有多種實(shí)現(xiàn)(比如返回結(jié)構(gòu)體版本的),并且內(nèi)部的cache_getImp 函數(shù)也會(huì)直接訪問緩存。這些都需要被檢查,以確保釋放緩存的安全性。
函數(shù)本身不需要參數(shù),返回值是 **int**類型的,使用起來(lái)就像一個(gè)標(biāo)志位一樣,用來(lái)標(biāo)識(shí)在一個(gè)關(guān)鍵函數(shù)中是否有多個(gè)線程:
- static int _collectiong_in_critical(void)
- {
為了專注于更好的代碼,我將會(huì)略過這個(gè)函數(shù)中一些無(wú)聊的代碼。如果你想看全部的代碼,在[這里](http://www.opensource.apple.com/source/objc4/objc4-646/runtime/objc-cache.mm)可以找到。
獲得線程信息的API位于mach層面。task_threads 獲得了給定任務(wù)中所有線程的線程列表,并且這些代碼使用它來(lái)獲得其所在進(jìn)程中的其他線程。
- ret = task_threads(mach_task_self(), &threads, &number);
它返回了一組包含了多個(gè)thread_t值的threads數(shù)組,并且可以獲得數(shù)組元素的個(gè)數(shù),然后它會(huì)遍歷這些元素
- for (count = 0; count < number; count++)
- {
取得一個(gè)線程的PC的操作在另外一個(gè)獨(dú)立的函數(shù)中,我們可以簡(jiǎn)單看下:
- pc = _get_pc_for_thread (threads[count]);
然后遍歷這些入口和出口,然后比較各個(gè)元素
- for (region = 0; objc_entryPoints[region] != 0; region++)
- {
- if ((pc >= objc_entryPoints[region]) &&
- (pc <= objc_exitPoints[region]))
- {
- result = TRUE;
- goto done;
- }
- }
在循環(huán)結(jié)束后向調(diào)用者返回結(jié)果
- return result;
_get_pc_for_thread這個(gè)函數(shù)如工作?這是相對(duì)簡(jiǎn)單代碼,它通過調(diào)用thread_get_state方法來(lái)獲得目標(biāo)線程的寄存器狀態(tài)。它位于一個(gè)獨(dú)立的函數(shù)中的主要原因是寄存器狀態(tài)的結(jié)構(gòu)是特定于系統(tǒng)架構(gòu)的,因?yàn)槊總€(gè)架構(gòu)下有著不同的寄存器。這就意味著這個(gè)函數(shù)對(duì)于每種支持的架構(gòu)需要一個(gè)獨(dú)立的實(shí)現(xiàn),盡管每種實(shí)現(xiàn)都幾乎是一樣的。這里有一個(gè)關(guān)于x86-64架構(gòu)下的實(shí)現(xiàn)
- static uintptr_t _get_pc_for_thread(thread_t thread)
- {
- x86_thread_state64_t state;
- unsigned int count = x86_THREAD_STATE64_COUNT;
- kern_return_t okay = thread_get_state (thread, x86_THREAD_STATE64, (thread_state_t)&state, &count);
- return (okay == KERN_SUCCESS) ? state.__rip : PC_SENTINEL;
- }
注意到`rip`是PC在x86-64架構(gòu)下的名字,其中R代表"register",IP代表"instruction pointer";
入口點(diǎn)和出口點(diǎn)他們本身是在一個(gè)匯編語(yǔ)言文件中定義的,這個(gè)文件中同時(shí)還包含了問題中的一些其他函數(shù),
- .private_extern _objc_entryPoints
- _objc_entryPoints:
- .quad _cache_getImp
- .quad _objc_msgSend
- .quad _objc_msgSend_fpret
- .quad _objc_msgSend_fp2ret
- .quad _objc_msgSend_stret
- .quad _objc_msgSendSuper
- .quad _objc_msgSendSuper_stret
- .quad _objc_msgSendSuper2
- .quad _objc_msgSendSuper2_stret
- .quad 0
- .private_extern _objc_exitPoints
- _objc_exitPoints:
- .quad LExit_cache_getImp
- .quad LExit_objc_msgSend
- .quad LExit_objc_msgSend_fpret
- .quad LExit_objc_msgSend_fp2ret
- .quad LExit_objc_msgSend_stret
- .quad LExit_objc_msgSendSuper
- .quad LExit_objc_msgSendSuper_stret
- .quad LExit_objc_msgSendSuper2
- .quad LExit_objc_msgSendSuper2_stret
- .quad 0
_collecting_in_critical 與我們上面假設(shè)的例子中的用法相似。它在釋放殘留的內(nèi)存垃圾之前調(diào)用。runtime實(shí)際上有兩種獨(dú)立的模式:一種是留下垃圾知道下次再有其他線程進(jìn)入臨界函數(shù)。另一個(gè)是不斷的循環(huán)直到清除干凈,而且通常會(huì)同時(shí)釋放這些垃圾內(nèi)存。
- // Synchronize collection with objc_msgSend and other cache readers
- if (!collectALot) {
- if (_collecting_in_critical ()) {
- // objc_msgSend (or other cache reader) is currently looking in
- // the cache and might still be using some garbage.
- if (PrintCaches) {
- _objc_inform ("CACHES: not collecting; "
- "objc_msgSend in progress");
- }
- return;
- }
- }
- else {
- // No excuses.
- while (_collecting_in_critical())
- ;
- }
- // free garbage here
***種留下垃圾的模式是用于普通的緩存區(qū)重新計(jì)算的。通常會(huì)釋放垃圾的循環(huán)的模式用于runtime的清除所有類的所有緩存,這很顯然會(huì)產(chǎn)生喝多垃圾。通過對(duì)代碼的分析,這僅會(huì)在打印所有調(diào)試信息的調(diào)試設(shè)備這種情況下才會(huì)發(fā)生。它會(huì)清除緩存,正是于消息緩存會(huì)干涉日志輸出。
結(jié)論
性能和線程安全是一個(gè)矛盾體。不同的代碼快訪問共享數(shù)據(jù)要求更高的線程安全性這也是不平衡的。一個(gè)全局的標(biāo)志或者計(jì)數(shù)器是一種利用這種特點(diǎn)的一種方法。在Objective-C的runtime機(jī)制中,蘋果采用了比這種策略更深層次的方法,它通過使用每個(gè)線程的程序計(jì)數(shù)器(PC)隱式的表明了什么時(shí)候一個(gè)線程正在執(zhí)行一種不安全的操作。這是一個(gè)特例,并且其他地方很難看到這種方法的用武之地,但它本身很奇妙。