Mac和iOS代碼中的核心是Objective runtime機制藐守,而runtime的核心是objc_msgSend方法温鸽,objc_msgSend的核心是方法緩存機制。今天我們將探索Apple如何在線程安全的情況下改變方法緩存內(nèi)存的大小和釋放脾猛,而同時又不影響性能断国。
1. 消息發(fā)送概念
objc_msgSend的工作原理就是為發(fā)送的方法找尋到適合的方法實現(xiàn)贤姆,并跳轉(zhuǎn)到這個方法實現(xiàn)上。偽碼大致如下:
IMP loopUp(id obj, SEL selector) {
Class cls = object_getClass(obj);
while(cls) {
for(int i=0; i<cls->numMethods; i++) {
Method m = c->methods [i];
if (m.selector == selector) {
return m.imp;
}
}
cls = cls->superClass;
}
return _objc_msgForward;
}
2. 方法緩存
如果尋找一個方法的實現(xiàn)是全局搜索稳衬,那么必將是非常慢的霞捡。方法緩存就是解決方法,它把用過的方法存到哈希表中薄疚。每個類存一個哈希表碧信,這張表中存有方法和和對應的實現(xiàn)。objc_msgSend使用匯編語言快速搜索這個哈希表街夭,這個搜索的事件數(shù)量級是納秒級的砰碴。當然第一次由于沒有緩存,所以會比較慢板丽。但是第二次有了緩存之后將會非吵释鳎快。
一提到緩存埃碱,一般就指的是快速獲得最近使用的資源的有限內(nèi)存猖辫。比如說,圖片緩存砚殿,從網(wǎng)絡上獲取到圖片后啃憎,一般會緩存下來。等到下次使用的時候似炎,就不用從網(wǎng)絡上獲取辛萍,而直接從緩存中獲取悯姊。而且這個緩存會有大小限制,因此到達限制后贩毕,新圖片會替換最老的圖片悯许。
這對很多問題是一種解決方法,但是它會有一些性能問題耳幢。比如岸晦,緩存是40張圖片,但是剛好你的應用經(jīng)常用到第41張新圖片睛藻,這樣緩存就會變得不怎么有效启上。
如果是我們自己的應用,我們可以調(diào)節(jié)緩存的大小來應對這種問題店印。但是Objective-C runtime卻無法這樣做冈在。因為方法緩存對性能是非常重要的,所以runtime沒有對緩存大小做限制并會擴展緩存大小來緩存所有被發(fā)送的方法按摘。
3. 大小改變包券、釋放和線程
改變緩存的大小從概念上來講非常簡單。偽碼如下:
bucket_t *newCache = malloc(newSize);
copyEntries(newCache, class->cache);
free(class->cache);
class->cache = newCache;
Objective-C runtime中的實現(xiàn)其實更加簡單炫贤,它沒有復制舊緩存的內(nèi)容到新緩存中去溅固。畢竟,它只是一個緩存兰珍,沒必要保留其中的數(shù)據(jù)侍郭。因此,偽碼變成下面這樣:
free(class->cache);
class->cache = malloc(newSize);
上面的代碼對于單線程環(huán)境來說是足夠了掠河。但是Objective-C runtime是支持多線程的亮元,這就需要確保所有的代碼都是線程安全的。任何類的緩存在任何時候都有可能被多個線程同時獲取唠摹,因此代碼必須要處理到這種場景爆捞。
正如上面所說,存在這樣一種場景勾拉,一個線程在釋放舊緩存之后和指派新緩存之前這段時間內(nèi)煮甥,另一個線程如果來讀取,會讀到一個無效的緩存指針望艺。這會導致它只指向垃圾數(shù)據(jù)或者直接閃退苛秕。
我們?nèi)绾谓鉀Q這個問題呢?典型的做法就加鎖找默。偽碼如下:
lock(class->lock);
free(class->cache);
class->cache = malloc(newSize);
unlock(class->lock);
這樣讀寫操作前必須獲得鎖。但是這意味著objc_msgSend必須獲得鎖吼驶,查詢緩存惩激,釋放鎖店煞。每次都獲得和釋放鎖會極大降低性能,畢竟查詢緩存的時間是納秒級的风钻。
我們想著以其他方式去避免這種場景顷蟀。比如,假如我們先申請和指派新的內(nèi)存骡技,然后釋放舊的緩存鸣个?
bucket_t *oldCache = class->cache;;
class->cache = malloc(newSize);
free(oldCache);
這樣也許有一點幫助,但這并不能解決問題布朦。另一個線程可能取回一個舊的緩存指針囤萤,然后它在獲得內(nèi)容之前被系統(tǒng)回收。舊的緩存在另一個線程再一次跑起來之前被釋放是趴,引起之前相同的問題涛舍。
那么延遲釋放呢?比如:
bucket_t *oldCache = class->cache;;
class->cache = malloc(newSize);
after ( 5, ^{
free(oldCache);
});
這樣也同樣會產(chǎn)生上面那個問題唆途,只是剛好發(fā)生在5s后富雅。
如果定死一個時間延遲釋放不好的,那就一直等待知道不出現(xiàn)這種場景肛搬。讓我們增加一個計數(shù)器没佑,偽碼如下:
gInMsgSend++;
lookUpCache(class->cache);
gInMsgSend--;
如果考慮到線程安全的話,這個計數(shù)器得是atomic温赔。
使用計數(shù)器的話蛤奢,緩存重新申請的偽碼應該是這樣:
bucket_t *oldCache = class->cache;
class->cache = malloc(newSize);
while(gInMsgSend)
; //spin
free(oldCache);
值得注意的是此時不需要阻塞objc_msgSend的執(zhí)行。只要緩存釋放代碼在替換了舊緩存指針之后让腹,就可以確認在objc_msgSend在任何時刻都是空的远剩。它能夠繼續(xù)釋放舊的緩存指針。另一個線程可能在舊緩存指針被釋放的時候調(diào)用objc_msgSend骇窍,但是這個新的調(diào)用不可能看到舊的指針瓜晤,因此它是安全的。
不停的循環(huán)可能效率不高和不夠優(yōu)雅腹纳。沒必要急著釋放舊的緩存痢掠。在不耗費大量時間的情況下釋放這些內(nèi)存會比較好。讓我們保持一個沒有釋放的舊緩存列表嘲恍,同時每次空閑下來時足画,就嘗試釋放所有列表中的舊緩存。
bucket_t *oldCache = class->cache;
class->cache = malloc(newSize);
append(gOldCacheList, oldCache);
if(!gInMsgSend) {
for (cache in gOldCacheList) {
free(cache);
}
gOldCacheList.clear();
}
以上的版本跟Objective runtime的實現(xiàn)非常接近佃牛。
5. 零花費標識
在這中間有兩部分極度不對稱淹辞。objc_msgSend側(cè)每秒跑數(shù)億次并且需要盡可能快。另一方面俘侠,改變緩存大小是一個少見的操作象缀,且通常隨著程序的運行變得越來越少見蔬将。一旦程序達到平穩(wěn)狀態(tài),不再加載新代碼或者編輯消息列表央星,并且緩存變得足夠大時霞怀,改變緩存大小的操作將不再發(fā)生。在那之前莉给,它可能發(fā)生數(shù)百或者數(shù)千次直到緩存增大到符合需求的大小毙石,但是和objc_msgSend操作相比,它是非常少的颓遏,而且對性能的影響也是很小的徐矩。
因為這種不對稱,最好在消息發(fā)送側(cè)盡可能小州泊,即使它會使緩存釋放部分變慢丧蘸。在每個緩存空閑操作中以100萬個CPU周期為代價削減objc_msgSend中的一個CPU周期,這是一個極大的勝利遥皂。
即使一個全局的計數(shù)器是花費巨大的力喷。這是objc_msgSend種的兩個額外的內(nèi)存訪問,會增加大量的開銷演训。他們需要具備atomic和使用memory barrier弟孟。幸好,Objective-C runtime有一種技術(shù)可以使objc_msgSend得花銷降到0样悟,其代價是使緩存釋放變的更慢拂募。
全局計數(shù)器的目的是追蹤任何線程是否在特定的代碼區(qū)域內(nèi)。線程已經(jīng)有了跟蹤他們當前正在運行的代碼的東西:程序計數(shù)器窟她。這是跟蹤當前指令內(nèi)存地址的CPU寄存器陈症。我們可以檢查每個線程的程序計數(shù)器,看它是否在objc_msgSend中震糖,而不是用全局計數(shù)器录肯。如果所有線程都在外面,那么釋放舊緩存是安全的吊说。偽碼如下:
BOOL ThreadsInMsgSend(void) {
for(thread in GetAllThreads()) {
uniptr_t pc = thread.GetPC();
if (pc >= objc_msgsend_startAddresss && pc <= objc_msgSend_endAddress) {
return YES;
}
}
return NO;
}
bucket_t *oldCache = class->cache;
class->cache = malloc(newSize);
append(gOldCacheList, oldCache);
if(!ThreadsInMsgSend) {
for (cache in gOldCacheList) {
free(cache);
}
gOldCacheList.clear();
}
objc_msgSend根本沒做任何特殊的事情论咏。它可以直接訪問緩存,而不用擔心標記訪問颁井。偽碼如下:
lookUpCache(class->cache);
6. 真實的代碼
Apple的實現(xiàn)能在runtime函數(shù)_collecting_in_critical中看到厅贪,它在objc-cache.mm中。
至關(guān)重要的程序計數(shù)器存儲在全局變量中:
OBJC_EXPORT uinptr_t objc_entryPoints[];
OBJC_EXPORT uinptr_t objc_exit Points[];
實際上有多個objc_msgSend實現(xiàn)(像struct返回)雅宾,內(nèi)部的cache_getImp函數(shù)也直接訪問緩存养涮。他們都需要被檢查從而安全釋放緩存。
函數(shù)本身不帶任何參數(shù),并返回int单寂,它只是作為一個布爾標志來表示是否有任何線程處于關(guān)鍵函數(shù)中:
static int _collecting_in_critical(void) {
我將跳過這個函數(shù)中不那么有趣的代碼贬芥,以便集中精力于最好的部分吐辙。如果你想看到整個實現(xiàn)宣决,請參閱opensource.apple.com。
獲取線程信息的API位于內(nèi)核級昏苏。 task_threads獲得一個給定任務中的所有線程的列表(內(nèi)核進程的術(shù)語)尊沸,這個代碼使用它來獲取在自己的進程中的線程:
ret = task_threads(mach_task_self(), &threads, &number);
這將返回線程中的thread_t值的數(shù)組,以及數(shù)量中的線程數(shù)贤惯。然后循環(huán)它們:
for (count = 0; count < number; count++) {
為一個線程提取程序計數(shù)器是在一個單獨的函數(shù)中完成的洼专,我們簡短的看以下:
pc = _get_pc_for_thread (threads[count]);
然后在入口點和出口點循環(huán),并與每個點進行比較:
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)用者:
return result;
}
_get_pc_for_thread函數(shù)是如何工作的屁商?這是一個比較簡單的代碼,調(diào)用thread_get_state來獲得目標線程的寄存器狀態(tài)颈墅。它在一個單獨的函數(shù)中的主要原因是因為寄存器狀態(tài)結(jié)構(gòu)是特定于架構(gòu)的蜡镶,因為每個架構(gòu)都有不同的寄存器。這意味著這個函數(shù)需要為每個支持的體系架構(gòu)單獨實現(xiàn)恤筛,盡管實現(xiàn)幾乎完全相同官还。這是x86-64的實現(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是x86-64上PC的寄存器名稱; R代表“注冊”毒坛,IP代表“指令指針”望伦。
入口點和出口點本身在匯編語言文件中定義。他們看起來像這樣:
.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ù)的使用方法和在上面的假設例子中非常類似煎殷。在釋放緩存垃圾的代碼之前調(diào)用它屯伞。runtime實際上有兩個獨立的模式:一個是如果其他線程處于關(guān)鍵函數(shù),則留下緩存垃圾豪直;另一個是不停循環(huán)直到清除完緩存垃圾劣摇,并總是釋放緩存垃圾:
// 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
第一種模式,把垃圾留到下一次顶伞,用于正常緩存大小調(diào)整饵撑。循環(huán)模式總是釋放在運行時的方法產(chǎn)生的緩存垃圾,并且刷新所有類的所有緩存唆貌,因為這通常會產(chǎn)生大量的垃圾滑潘。從代碼中可以看出,只有在啟用將所有消息發(fā)送到文件的調(diào)試日志記錄工具時才會發(fā)生這種情況锨咙。它會刷新緩存语卤,因為消息緩存會干擾日志記錄。
7. 結(jié)論
性能和線程安全往往彼此相互矛盾。訪問和共享數(shù)據(jù)往往存在不對稱性粹舵,這使得線程安全性更高钮孵。全局標志或計數(shù)器能夠指出哪些操作訪問數(shù)據(jù)是不安全的。在Objective-C runtime中眼滤,Apple更進一步的使用每個線程的程序計數(shù)器來指出線程何時執(zhí)行不安全操作巴席。這是一個專門的案例,很難看到這個技術(shù)在哪里會用到诅需,但是分析它很有趣漾唉。