在ARC環境中autoreleasepool(runloop)的研究

syik發表於2017-10-21

引言

最近有個大佬考察了我關於autoreleasepool的瞭解, 之前一直認為自己瞭解, 但是稍微一問深, 自己卻啞口無言. 仔細思考了下, 決定要將這個問題結合之前的知識從新梳理一下, 當然, 實踐是必不可少的.

  • main函式中的autoreleasepool的作用?
  • 系統的autoreleasepool我們自己建立的autoreleasepool釋放時機差別在哪?
  • 在ARC的環境中, 什麼情況下需要使用autoreleasepool? 不使用autoreleasepool變數什麼時候會被釋放?

帶著這三個問題, 一起進行一下下面的思考.

正文

對於autoreleasepool釋放時機, 我們很容易在網上搜到這樣的說法:

分兩種情況:手動干預釋放時機、系統自動去釋放。

手動干預釋放時機--指定autoreleasepool 就是所謂的:當前作用域大括號結束時釋放。

系統自動去釋放--不手動指定autoreleasepool

先不談上面是否完全正確, 基於以上認知, 當時我靈光一閃推測main函式中autoreleasepool的作用可能為下面兩種之一:

1.系統主執行緒中的預設的autoreleasepool.

2.整個App相對於iOS系統的一個autoreleasepool.

其他的解釋其實在網上可以搜到很多, 所以這裡我們可以做一個小實驗.

第一點其實很好驗證, 將main函式中的autoreleasepool註釋掉, 執行

for (int i = 0; i < 10e5 * 2; i++) {
    NSString *str = [NSString stringWithFormat:@"hi + %d", i];
}
NSLog(@"finished!");複製程式碼

實際結果表明, 記憶體波動並沒有什麼區別:

  • 未註釋Main函式中的autoreleasepool

  • 註釋Main函式中的autoreleasepool

所以我們可以認為第二種是對的嗎, 後來自己一想也覺得不對, 對於系統記憶體管理相關程式碼怎麼會在程式裡面呢, 不符合蘋果的風格. 結果很明顯我自己推測的都不對, 所以到底起什麼作用呢? 待會再細說, 先驗證一下釋放時機的問題.

同樣是上面一段函式, 在for迴圈中加入autoreleasepool:

for (int i = 0; i < 10e5 * 2; i++) {
    @autoreleasepool {
        NSString *str = [NSString stringWithFormat:@"hi + %d", i];
    }
}
NSLog(@"finished!");複製程式碼

我相信稍微瞭解一點的同學已經知道了執行結果:

為臨時變數分配的記憶體已經得到平穩的釋放, 所以結論就是最上面我們看到的認知? 其實本身每個Runloop已經預設會建立一個autoreleasepool了, 所以我們這裡新增相當於巢狀(便於理解)了一個, 並沒有弄清楚autoreleasepool自身的釋放時機. 下面做另外一個小測試:

這一次在程式碼中新增對Runloop的Observer, 及時獲取Runloop的狀態變化確認釋放時機, 程式碼如下:

// 新增一個監聽者
- (void)addRunLoopObserver {

    // 1. 建立監聽者
    CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {

        switch (activity) {
            case kCFRunLoopEntry:
                NSLog(@"進入RunLoop");
                break;
            case kCFRunLoopBeforeTimers:
                NSLog(@"即將處理Timer事件");
                break;
            case kCFRunLoopBeforeSources:
                NSLog(@"即將處理Source事件");
                break;
            case kCFRunLoopBeforeWaiting:
                NSLog(@"即將休眠");
                break;
            case kCFRunLoopAfterWaiting:
                NSLog(@"被喚醒");
                break;
            case kCFRunLoopExit:
                NSLog(@"退出RunLoop");
                break;
            default:
                break;
        }
    });

    // 2. 新增監聽者
    CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}複製程式碼

另外上面的方法執行連續執行兩次, 不手動新增autoreleasepool, 大概是這樣:

- (void)test1 {

    NSLog(@"test1 begin!");
    for (int i = 0; i < 10e5 * 2; i++) {
        //@autoreleasepool {
            NSString *str = [NSString stringWithFormat:@"hi + %d", i];
        //}
    }
    NSLog(@"test1 finished!");
}

- (void)test2 {

    NSLog(@"test2 begin!");
    for (int i = 0; i < 10e5 * 2; i++) {
        //@autoreleasepool {
            NSString *str = [NSString stringWithFormat:@"hi + %d", i];
        //}
    }
    NSLog(@"test2 finished!");
}複製程式碼

執行之後的效果是這樣的:

很清楚的看到Runloop沒有完成一次迴圈之前所有記憶體都未釋放, 即使區域性變數出了作用域也必須等待Runloop迴圈完成.

下面同樣, 手動新增autoreleasepool觀察釋放時機.

結果是意外也合理的. 即使Runloop未完成迴圈, 記憶體也即使釋放了

總結

@autoreleasepool{}複製程式碼

等價於

void *context = objc_autoreleasePoolPush();
// {}中的程式碼
objc_autoreleasePoolPop(context);複製程式碼

每次出了{}時objc_autoreleasePoolPop()就被呼叫, 所以直接釋放掉了. 當然, 系統自動建立的autoreleasepool也是一樣, 只是呼叫的時機不同: 執行緒與Runloop是一一對應, Runloop與系統建立的autoreleasepool也是一一對應, 所以不論是Runloop完成了一次迴圈還是執行緒被關閉時, autoreleasepool都會釋放, 當然手動新增的也會被管理, 上面為了方便理解, 說的是巢狀, 本質上是沒有巢狀這個說法的, 對@autoreleasepool{}本質的一些個人總結:

主要就是一個類:AutoreleasePoolPage

兩個函式: objc_autoreleasePoolPush()、objc_autoreleasePoolPop()

運作方式: autoreleasepool由若干個autoreleasePoolPage類以雙向連結串列的形式組合而成, 當程式執行到@autoreleasepool{時, objc_autoreleasePoolPush()將被呼叫, runtime會向當前的AutoreleasePoolPage中新增一個nil物件作為哨兵,
在{}中建立的物件會被依次記錄到AutoreleasePoolPage的棧頂指標,
當執行完@autoreleasepool{}時, objc_autoreleasePoolPop(哨兵)將被呼叫, runtime就會向AutoreleasePoolPage中記錄的物件傳送release訊息直到哨兵的位置, 即完成了一次完整的運作.

另外根據官方文件:

Threads

If you are making Cocoa calls outside of the Application Kit’s main thread—for example if you create a Foundation-only application or if you detach a thread—you need to create your own autorelease pool......

主執行緒中的自動釋放池是自動建立的, 文件中說子執行緒中的自動釋放池是需要手動建立的, 但實測, 其實我們常用的多執行緒管理方式(GCD, NSOprationQueue, NSThread)都已經幫我們處理好了, 其中NSThread在iOS7之後才自動建立執行緒中的AutoreleasePool, 這個在官方文件中找不到記錄, 參考StackOverflow: stackoverflow.com/questions/2…

另外網上有說法AutoreleasePool會影響效能, 其實看上面的函式執行的時間就可以發現, 並沒有影響, 甚至加入了AutoreleasePool執行快了2秒(不嚴謹).

回到最初的問題, main函式中的autoreleasepool的作用, 我翻閱了大量資料, 在StackOverflow上讚的比較高的回答是沒鳥用... 暫且只能先這樣認為了.. 希望有了解的同學可以講解一下~

在實際中的使用場景其實很明確了, 在程式中中有大量臨時變數的時候最好手動建立.

最常出現大量變數的時候顯然是迴圈/遍歷, 我們常用的for迴圈, 以及enumerate其實跟autoreleasepool也有關, for迴圈是不自動建立autoreleasepool的, 而enumerate中已經自動建立了autoreleasepool, 值得注意的是高併發enumerate常常會出一些意外的問題, 例如物件被提前釋放, 所以建議高併發情況下使用for迴圈(效能高於enumerate), 再手動新增autoreleasepool.

本人前幾篇文章中提到的一個App: 直播伴侶中就是手機端對彈幕進行高併發計算, 分詞, 對比.. 使用了autoreleasepool之後明顯在鬥魚彈幕伺服器"炸魚"時有所改善..歡迎Star: github.com/syik/Bullet…

相關文章