Objective-C Runtime中的并發(fā)內(nèi)存釋放

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ù)在哪里會用到诅需,但是分析它很有趣漾唉。

8. 參考

Concurrent Memory Deallocation in the Objective-C Runtime

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市堰塌,隨后出現(xiàn)的幾起案子赵刑,更是在濱河造成了極大的恐慌,老刑警劉巖场刑,帶你破解...
    沈念sama閱讀 218,284評論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件般此,死亡現(xiàn)場離奇詭異,居然都是意外死亡牵现,警方通過查閱死者的電腦和手機铐懊,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,115評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來施籍,“玉大人居扒,你說我怎么就攤上這事〕笊鳎” “怎么了喜喂?”我有些...
    開封第一講書人閱讀 164,614評論 0 354
  • 文/不壞的土叔 我叫張陵,是天一觀的道長竿裂。 經(jīng)常有香客問我玉吁,道長,這世上最難降的妖魔是什么腻异? 我笑而不...
    開封第一講書人閱讀 58,671評論 1 293
  • 正文 為了忘掉前任进副,我火速辦了婚禮,結(jié)果婚禮上悔常,老公的妹妹穿的比我還像新娘影斑。我一直安慰自己,他們只是感情好机打,可當我...
    茶點故事閱讀 67,699評論 6 392
  • 文/花漫 我一把揭開白布矫户。 她就那樣靜靜地躺著,像睡著了一般残邀。 火紅的嫁衣襯著肌膚如雪皆辽。 梳的紋絲不亂的頭發(fā)上柑蛇,一...
    開封第一講書人閱讀 51,562評論 1 305
  • 那天,我揣著相機與錄音驱闷,去河邊找鬼耻台。 笑死,一個胖子當著我的面吹牛空另,可吹牛的內(nèi)容都是我干的盆耽。 我是一名探鬼主播,決...
    沈念sama閱讀 40,309評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼痹换,長吁一口氣:“原來是場噩夢啊……” “哼征字!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起娇豫,我...
    開封第一講書人閱讀 39,223評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎畅厢,沒想到半個月后冯痢,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,668評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡框杜,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,859評論 3 336
  • 正文 我和宋清朗相戀三年浦楣,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片咪辱。...
    茶點故事閱讀 39,981評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡振劳,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出油狂,到底是詐尸還是另有隱情历恐,我是刑警寧澤,帶...
    沈念sama閱讀 35,705評論 5 347
  • 正文 年R本政府宣布专筷,位于F島的核電站弱贼,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏磷蛹。R本人自食惡果不足惜吮旅,卻給世界環(huán)境...
    茶點故事閱讀 41,310評論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望味咳。 院中可真熱鬧庇勃,春花似錦、人聲如沸槽驶。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,904評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽捺檬。三九已至再层,卻和暖如春贸铜,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背聂受。 一陣腳步聲響...
    開封第一講書人閱讀 33,023評論 1 270
  • 我被黑心中介騙來泰國打工蒿秦, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人蛋济。 一個月前我還...
    沈念sama閱讀 48,146評論 3 370
  • 正文 我出身青樓棍鳖,卻偏偏與公主長得像,于是被迫代替她去往敵國和親碗旅。 傳聞我的和親對象是個殘疾皇子渡处,可洞房花燭夜當晚...
    茶點故事閱讀 44,933評論 2 355

推薦閱讀更多精彩內(nèi)容

  • objc_getAssociatedObject返回與給定鍵的特定對象關(guān)聯(lián)的值。ID objc_getAssoci...
    有一種再見叫青春閱讀 1,582評論 0 7
  • 轉(zhuǎn)至元數(shù)據(jù)結(jié)尾創(chuàng)建: 董瀟偉祟辟,最新修改于: 十二月 23, 2016 轉(zhuǎn)至元數(shù)據(jù)起始第一章:isa和Class一....
    40c0490e5268閱讀 1,715評論 0 9
  • 本文詳細整理了 Cocoa 的 Runtime 系統(tǒng)的知識医瘫,它使得 Objective-C 如虎添翼,具備了靈活的...
    lylaut閱讀 800評論 0 4
  • 一些人一些事:你只是覺得這首歌很好聽旧困,覺得這部電影很好看醇份,覺得旅行很美好。并不是選擇去文藝吼具,而是自己喜歡的東西恰巧...
    梅子梅子閱讀 189評論 0 3
  • 2016年了 什么都長了 就是這勤快的本色沒長 我還能不能有點出息了
    e60022137b2c閱讀 336評論 0 0