1. 程式人生 > IOS開發 >iOS 底層拾遺:AutoreleasePool

iOS 底層拾遺:AutoreleasePool

前言

在陽神的 黑幕背後的Autorelease 文章中已經把 AutoreleasePool 核心邏輯講明白了,不過多是結論性的東西,筆者通讀原始碼以探究更多的細節,驗證一下老生常談的一些結論。

原始碼基於 Runtime 750。

一、@autoreleasepool {} 幹了些什麼

main.m 檔案程式碼:

int main(int argc,const char * argv[]) {
    @autoreleasepool {}
    return 0;
}
複製程式碼

使用 clang -rewrite-objc main.m 檢視經過編譯器前端處理的程式碼:

struct __AtAutoreleasePool {
  __AtAutoreleasePool
() {atautoreleasepoolobj = objc_autoreleasePoolPush();} ~__AtAutoreleasePool() {objc_autoreleasePoolPop(atautoreleasepoolobj);} void * atautoreleasepoolobj; }; int main(int argc,const char * argv[]) { /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; } return 0; } 複製程式碼

可以看出@autoreleasepool{}

會建立一個__AtAutoreleasePool型別的區域性變數幷包含在當前作用域,__AtAutoreleasePool構造和析構時分別呼叫了兩個方法,所以簡化過程如下:

void *context = objc_autoreleasePoolPush()
// 物件呼叫 autorelease 裝入自動釋放池
objc_autoreleasePoolPop(context)
複製程式碼

可以猜測 push 和 pop 操作是實現自動釋放的關鍵。

二、AutoreleasePoolPage 記憶體分佈

官方文件 中提到了,主執行緒以及非顯式建立的執行緒(比如 GCD)都會有一個 event loop (RunLoop 就是具體實現),在 loop 的每一個迴圈週期的開始和結束會分別呼叫自動釋放池的 push 和 pop 方法,由此來實現自動的記憶體管理。

objc_autoreleasePoolPush()objc_autoreleasePoolPop(...)實際上會呼叫到AutoreleasePoolPage類的push()pop()方法,先看一下這個類的資料結構:

class AutoreleasePoolPage {
    ...
    magic_t const magic;
    id *next;
    pthread_t const thread;
    AutoreleasePoolPage * const parent;
    AutoreleasePoolPage *child;

    static void * operator new(size_t size) {
        return malloc_zone_memalign(malloc_default_zone(),SIZE,SIZE);
    }
    id * begin() {
        return (id *) ((uint8_t *)this+sizeof(*this));
    }
    id * end() {
        return (id *) ((uint8_t *)this+SIZE);
    } 
    ...
}
複製程式碼
  • parentchild正是指向前驅和後繼指標,自動釋放池就是一個以AutoreleasePoolPage為節點的雙向連結串列(後文驗證)。
  • thread是指當前 page 所對應的執行緒。
  • magic用於校驗記憶體是否損壞。
  • next指向當前可插入物件的地址。

記憶體對齊

重寫了new運算子,使用了malloc_zone_memalign(...)進行記憶體分配:

extern void *malloc_zone_memalign(malloc_zone_t *zone,size_t alignment,size_t size) ;
    /* 
     * Allocates a new pointer of size size whose address is an exact multiple of alignment.
     * alignment must be a power of two and at least as large as sizeof(void *).
     * zone must be non-NULL.
     */
複製程式碼

註釋說得很清楚了,這個方法以alignment對齊的地址分配size的記憶體空間。呼叫時兩個引數都使用了SIZE巨集,實際上就是虛擬記憶體頁的大小:

#define I386_PGBYTES            4096
複製程式碼

一個 page 的記憶體空間設定過小會導致更多的開闢空間操作降低效率,大量的parent/child指標變數也會佔用可觀的記憶體;空間設定過大可能會導致一個 page 的利用率低浪費過多記憶體。設定為 4096 是比較考究的,在保證記憶體對齊的情況下最大化利用空間避免記憶體碎片。這麼做過後 page 的地址總是 4096 的整數倍,可以讓某些運算更便捷(比如後文會說的通過指標地址尋找對應的 page)。

begin() 與 end()

AutoreleasePoolPage本身的大小遠不及 4096,而超出的空間正是用來存放“期望被自動管理的物件”。begin()end()方法標記了這個範圍。

sizeof(*this)表示AutoreleasePoolPage本身的大小,那麼(uint8_t *)this+sizeof(*this)就是最低地址,(uint8_t *)this+SIZE就是最高地址。逐個插入物件時,next指標從begin()end()逐個移動,後面的full()方法就是指next == end()empty()就是指next == begin()

值得注意的是next/end()/begin()等都是id *型別的,即指向指標的指標,進行 +1 -1 運算時移動的是一個id大小的距離。

三、push 邏輯

push()方法會呼叫autoreleaseFast(POOL_BOUNDARY)

    static inline id *autoreleaseFast(id obj)
    {
        AutoreleasePoolPage *page = hotPage();
        if (page && !page->full()) {
            return page->add(obj);
        } else if (page) {
            return autoreleaseFullPage(obj,page);
        } else {
            return autoreleaseNoPage(obj);
        }
    }
複製程式碼

hotPage 指的是當前可插入物件的 page,放到後面一點分析,先來看插入物件的邏輯,分三種情況:

1、當 page 存在且沒滿時,直接新增物件:

    id *add(id obj)
    {
        assert(!full());
        unprotect();
        id *ret = next;  // faster than `return next-1` because of aliasing
        *next++ = obj;
        protect();
        return ret;
    }
複製程式碼

unprotect()/protect()內部使用了int mprotect(void *a,size_t b,int c),設定記憶體起點a長度b的記憶體區域為c型別的訪問限制:

    inline void protect() {
#if PROTECT_AUTORELEASEPOOL
        mprotect(this,PROT_READ);
        check();
#endif
    }
    inline void unprotect() {
#if PROTECT_AUTORELEASEPOOL
        check();
        mprotect(this,PROT_READ | PROT_WRITE);
#endif
    }
複製程式碼

unprotect()設定為可讀可寫,protect()設定為只讀,所以這裡的目的是保證 page 寫安全。不過有#define PROTECT_AUTORELEASEPOOL 0定義說明目前版本還沒有開放這個保護功能。

2、當 page 存在且滿了時,拓展 page 節點並新增物件:

    static __attribute__((noinline))
    id *autoreleaseFullPage(id obj,AutoreleasePoolPage *page)
    {   ...
        do {
            if (page->child) page = page->child;
            else page = new AutoreleasePoolPage(page);
        } while (page->full());

        setHotPage(page);
        return page->add(obj);
    }
複製程式碼

迴圈的邏輯:從 child 方向找到未滿的 page,若找不到則建立一個新 page 拼接到連結串列尾部(AutoreleasePoolPage 構造方法會把傳入的 page 引數作為 parent 前驅物件)。後面再設定最新的 page 為 hotpage 並將 obj 新增進 page。

3、當 page 不存在時,初始化一個

    static __attribute__((noinline))
    id *autoreleaseNoPage(id obj) 
    {   ...
        AutoreleasePoolPage *page = new AutoreleasePoolPage(nil);
        setHotPage(page);
        ...
        return page->add(obj);
    }
複製程式碼

這個方法核心就是建立第一個 page 然後加入執行緒區域性儲存。

hotPage

從上面的push()方法分析可知,被自動管理的物件會不斷插入雙向連結串列從前到後第一個未滿 page ,hotPage()其實就是指向這個 page,還有個coldPage()方法是根據hotPage()找到第一個 page。

既然自動釋放池是由AutoreleasePoolPage組成的雙向連結串列,那這個連結串列該如何訪問呢?可能常規的思路是建立一個全域性變數來訪問它,不過這裡使用了另外一個方式:

    static inline AutoreleasePoolPage *hotPage() 
    {
        AutoreleasePoolPage *result = (AutoreleasePoolPage *)
            tls_get_direct(key);
        // EMPTY_POOL_PLACEHOLDER 表示沒有 page
        if ((id *)result == EMPTY_POOL_PLACEHOLDER) return nil;
        if (result) result->fastcheck();
        return result;
    }
    static inline void setHotPage(AutoreleasePoolPage *page) 
    {
        if (page) page->fastcheck();
        tls_set_direct(key,(void *)page);
    }
複製程式碼

tls_get_direct(...)tls_set_direct(...)內部就是使用執行緒的區域性儲存(TLS: Thread Local Storage)將 page 儲存起來,這樣可以避免維護額外的空間來記錄尾部的 page。由此也驗證了自動釋放池與執行緒一一對應的關係。

在 YYKit 中有一個使用廣泛的技巧:將某個物件最後使用時放在非同步執行緒,如果這個物件釋放就(可能?)會在這個非同步執行緒,從而降低主執行緒壓力。實際上就是編譯器插入 autorelease 程式碼將物件加入到非同步執行緒的自動釋放池,而如果非同步執行緒的釋放池先於主執行緒的釋放池pop()而呼叫物件的release()方法,那麼這個物件如果釋放就會在非同步執行緒。所以筆者認為這個優化並非絕對有效(這裡衍生出一個問題:一個物件被多個自動釋放池管理,若物件釋放這些釋放池怎麼避免的野指標問題?)。

POOL_BOUNDARY

push()方法呼叫autoreleaseFast(POOL_BOUNDARY)時傳入的是一個 POOL_BOUNDARY 並非需要被管理的物件,它的定義如下:

#   define POOL_BOUNDARY nil
複製程式碼

在呼叫autoreleaseFast(obj)方法會返回指向obj指標的指標,它是一個id *型別,也就是說,這個返回值關心的只是obj指標的地址,而不是obj值的地址,obj指標的地址就是對應AutoreleasePoolPage物件記憶體中的某段區域。

再看一下上層呼叫:

void *context = objc_autoreleasePoolPush()
...
objc_autoreleasePoolPop(context)
複製程式碼

pop 時會將這個obj指標的地址傳入進去。pop 的邏輯是把 hotPage 裡面裝的物件依次移除併發送 release 訊息(後面會詳細分析),當前 page 移除完了,繼續移除 parent 節點內的物件,以此反覆,而移除物件操作何時停止就是到這個obj指標的地址。

所以,push 操作加入一個 POOL_BOUNDARY 實際上就是加一個邊界,pop 操作時根據邊界判斷範圍,這就是一個入棧與出棧的過程。

magic 校驗

多次出現的check()方法如下:

    void check(bool die = true)  {
        if (!magic.check() || !pthread_equal(thread,pthread_self())) busted(die);
    }
    void fastcheck(bool die = true)  {
//補充:#define CHECK_AUTORELEASEPOOL (DEBUG)
#if CHECK_AUTORELEASEPOOL
        check(die);
#else
        if (! magic.fastcheck()) busted(die);
#endif
    }
複製程式碼

可以看到,它們都呼叫了magic的 check 方法,在 DEBUG 時還會去檢查當前執行緒是否與 page 的執行緒一致。

magicmagic_t型別的,這個結構體主要是有個uint32_t m[4];陣列,構造時記憶體直接會寫為0xA1A1A1A1 AUTORELEASE!,然後check()邏輯就是判斷構造時的值是否發生了改變,若發生改變說明這個 page 已經被破壞。

四、autorelease 邏輯

上層物件呼叫 autorelease 方法會呼叫到AutoreleasePoolPage的以下方法:

    static inline id autorelease(id obj)
    {
        assert(obj);
        assert(!obj->isTaggedPointer());
        id *dest __unused = autoreleaseFast(obj);
        assert(!dest  ||  dest == EMPTY_POOL_PLACEHOLDER  ||  *dest == obj);
        return obj;
    }
複製程式碼

顯然,最終還是會呼叫前面解析的autoreleaseFast(...)方法進行物件插入。由此也可以推斷,在一個 Thread 沒有 Runloop 自動執行自動釋放池的 push 和 pop 時,物件進行 autorelease 時若發現沒有自動釋放池節點會自動建立 page 並加入執行緒區域性儲存(參考前面的autoreleaseNoPage(...)方法分析)。

五、pop 邏輯

objc_autoreleasePoolPop(context)context引數是objc_autoreleasePoolPush()返回的,實際上就是POOL_BOUNDARY對應的在AutoreleasePoolPage中的地址。最終會呼叫到pop()方法:

    static inline void pop(void *token) 
    {
        AutoreleasePoolPage *page;
        id *stop;
        ...
        // 拿到 token 邊界對應的 page
        page = pageForPointer(token);
        stop = (id *)token;
        ...
        // pop 內部物件直到 stop 邊界
        page->releaseUntil(stop);
        ...
        // 刪除空的 child 連結串列節點,如果當前頁物件超過一半,保留下一個空節點
        if (page->lessThanHalfFull()) {
            page->child->kill();
        }
        else if (page->child->child) {
            page->child->child->kill();
        }
    }
複製程式碼

pop()的邏輯應該很好理解了,token引數就是邊界,下面分別分析步驟:

找到邊界對應的 page

    static AutoreleasePoolPage *pageForPointer(const void *p) {
        return pageForPointer((uintptr_t)p);
    }
    static AutoreleasePoolPage *pageForPointer(uintptr_t p) {
        AutoreleasePoolPage *result;
        uintptr_t offset = p % SIZE;
        ....
        result = (AutoreleasePoolPage *)(p - offset);
        result->fastcheck();
        return result;
    }
複製程式碼

看上面個函式,const void *p是指標的指標,((uintptr_t)p)才表示POOL_BOUNDARY指標在對應 page 中的地址。

看下面個函式,前面分析過記憶體對齊的處理,那麼 page 的起始地址必然是 SIZE (也就是頁大小 4096) 的倍數,那麼p % SIZE就得到了這個p在 page 中的地址偏移,最後通過p - offset就拿到了 page 的起始地址,這個處理比較秀。

移除被管理物件併發送 release 訊息

    void releaseUntil(id *stop)  {
        while (this->next != stop) {
            AutoreleasePoolPage *page = hotPage();
            // 如果當前 page 空了,指向 parent
            while (page->empty()) {
                page = page->parent;
                setHotPage(page);
            }
            // 將即將要移除物件對應 page 中的記憶體置為 SCRIBBLE
            page->unprotect();
            id obj = *--page->next;
            memset((void*)page->next,SCRIBBLE,sizeof(*page->next));
            page->protect();
            // 呼叫物件的 release 方法
            if (obj != POOL_BOUNDARY) {
                objc_release(obj);
            }
        }
        // 把當前 page 設定 hotpage(呼叫時 this 就是對應期望釋放邊界的 page)
        setHotPage(this);
        ...
    }
複製程式碼

清除 child

    void kill() {
        AutoreleasePoolPage *page = this;
        while (page->child) page = page->child;
        AutoreleasePoolPage *deathptr;
        do {
            deathptr = page;
            page = page->parent;
            if (page) {
                page->unprotect();
                page->child = nil;
                page->protect();
            }
            delete deathptr;
        } while (deathptr != this);
    }
複製程式碼

這個邏輯一目瞭然了:找到當前 page 的 child 方向尾部 page,然後反向挨著釋放並且把其 parent 節點的 child 指標置空。前面也說明了unprotectprotect內部並沒有開啟寫入安全保護。

後語

以上就是自動釋放池大部分原始碼的分析了,這部分原始碼沒有涉及彙編並且程式碼量比較少,所以看起來相對容易。多理解一些記憶體管理底層有利於理解各種上層特性、定位記憶體難題,也有助於寫出更穩定的程式碼。並且在這個過程中,不可避免需要接觸作業系統和編譯原理相關知識,也算是能培養通識效能力。

讀原始碼遠比記結論重要,遇到某些優秀的程式碼細節往往令人驚喜,不失為一種樂趣。