Go內(nèi)存管理源碼淺析

前一篇講了Go的調(diào)度機(jī)制和相關(guān)源碼恨狈,這里說一下內(nèi)存的管理策严,代碼片段也都是基于Go 1.12。

簡要的背景

一個(gè)程序要運(yùn)行起來放典,操作系統(tǒng)會(huì)分配一塊很大的虛擬內(nèi)存(或者說虛擬空間)供使用烦感,程序?qū)嶋H可能只使用很小的物理內(nèi)存巡社。可以通過ps去查看vss(虛擬)和rss(實(shí)際)的部分手趣。

虛擬內(nèi)存空間一般會(huì)分為代碼段晌该,數(shù)據(jù)段,堆绿渣,棧等:


內(nèi)存分段

程序執(zhí)行時(shí)朝群,函數(shù)中定義的各種變量,一般位于堆和棧中(上圖中黃色怯晕、綠色潜圃、白色)部分。為了能動(dòng)態(tài)分配內(nèi)存舟茶,讓程序運(yùn)行過程中靈活使用,操作系統(tǒng)提供了很多相關(guān)函數(shù),例如mmap/munmap吧凉,brk/sbrk隧出,madvise,set_thread_area/get_thread_area等阀捅。C語言中malloc胀瞪,free就是對這些基礎(chǔ)函數(shù)的封裝。

而其他高層語言中饲鄙,例如Go凄诞,把這些對內(nèi)存的操作完全屏蔽起來,寫程序的過程中根本感受不到忍级。

Go語言本身的實(shí)現(xiàn)中帆谍,內(nèi)存由runtime自主管理。runtime與操作系統(tǒng)的交互并沒有使用malloc這樣的函數(shù)轴咱,而是通過匯編(或cgo)直接調(diào)用了mmap等函數(shù)汛蝙。其內(nèi)存分配核心思想非常類似于TCMalloc,不過由于部分特性(gc等)的需求朴肺,在TCMalloc的算法和設(shè)計(jì)上也做了部分修改窖剑。

基本概念

Go程序在啟動(dòng)的時(shí)候會(huì)向操作系統(tǒng)申請一塊內(nèi)存,然后分塊管理戈稿。核心的結(jié)構(gòu)是mheap, mcentral, mcache, mspan西土。

  1. mheap
    全局,負(fù)責(zé)從os申請鞍盗、釋放內(nèi)存需了。
  2. mcentral
    全局,將內(nèi)存按mspan劃分橡疼,統(tǒng)一管理援所。訪問需加鎖。mcentral劃分為_NumSizeClasses(目前是67)種mspan欣除,每種又分為非空(nonempty住拭,代表可以分配)和空(empty,可能是已滿历帚,可能是已分配給mcache在使用滔岳,代表不能分配)。非空和空分別形成雙向鏈表挽牢,方便訪問谱煤。
  3. mcache
    每個(gè)P持有自己的mcache,于是獲取內(nèi)存的時(shí)候禽拔,無鎖訪問mcache刘离。mcache也劃分為_NumSizeClasses(目前是67)種mspan室叉。
    注:很多文章中,認(rèn)為mcache中的每種mspan也是鏈表硫惕,但是我從代碼中看來茧痕,好像mcache對每種mspan只會(huì)保存一個(gè)。這一點(diǎn)待更進(jìn)一步理解恼除。
  4. mspan
    設(shè)計(jì)的精華踪旷,類似tcmalloc的機(jī)制。將一個(gè)或多個(gè)內(nèi)存頁形成一種mspan豁辉,一種mspan只負(fù)責(zé)分配固定size的內(nèi)存(不足的時(shí)候令野,會(huì)向上補(bǔ)足,例如申請7byte徽级,會(huì)使用8 byte類型的mspan)气破。mspan的列表見sizeclasses.go,里面詳細(xì)記錄了每一種mspan的分配規(guī)則灰追,可存儲(chǔ)object多少堵幽,浪費(fèi)率等。
    這種設(shè)計(jì)可以保證分配內(nèi)存盡可能快弹澎,且能減少碎片的產(chǎn)生朴下。
mspan, mcache 和 mcentral

相關(guān)代碼

  1. mallocinit
    最初申請內(nèi)存的地方在runtime/mallocinit(malloc.go)中。
func mallocinit() {
    if class_to_size[_TinySizeClass] != _TinySize {
        throw("bad TinySizeClass")
    }

    testdefersizes()

    if heapArenaBitmapBytes&(heapArenaBitmapBytes-1) != 0 {
        // heapBits expects modular arithmetic on bitmap
        // addresses to work.
        throw("heapArenaBitmapBytes not a power of 2")
    }

    // Copy class sizes out for statistics table.
    for i := range class_to_size {
        memstats.by_size[i].size = uint32(class_to_size[i])
    }

初始化的過程相較于之前的版本苦蒿,已經(jīng)有了非常大的變化殴胧,不過核心也依然是作各種檢查斩郎,然后初始化mheap(主要是初始化各種allocator氏涩,方便以后allocator真正分配內(nèi)存。mcentral就是這個(gè)時(shí)候初始化的)枫虏,嘗試為當(dāng)前的M(M是什么报强,參見調(diào)度的文章)分配mcache以及根據(jù)操作系統(tǒng)設(shè)置正確的arenaHints灸姊。

  1. heapArena
    之前的Go版本里,arena是大小512G的(網(wǎng)上很多圖介紹秉溉,自行Google)力惯。但我在go1.12源碼里發(fā)現(xiàn)不是這樣了。mheap_.arenas是一個(gè)二維數(shù)組[L1][L2]heapArena(heapArena存的是對應(yīng)arena的元數(shù)據(jù))召嘶,維度以及arena本身的大小和尋址bit位數(shù)相關(guān)父晶,每個(gè)arena的起始地址按對應(yīng)大小對齊。heapArena這些元數(shù)據(jù)本身不存在heap里面弄跌。以我自己的機(jī)器(64位)為例甲喝,屬于下圖中的第一行。32位機(jī)器上是4M铛只。計(jì)算方法是:
1 << 48 = 2 ^ 48 = 64M * 1 * 4M (即只有1行埠胖,4M列糠溜,每個(gè)大小64M)

arena大小

mheap_.arena的這個(gè)二維數(shù)組中有些有對應(yīng)的堆,有些沒有(那么就是nil押袍,例如垃圾回收之后诵冒,把內(nèi)存還給了操作系統(tǒng))凯肋。go的內(nèi)存分配器總是嘗試分配連續(xù)的arena谊惭,這樣某些大的span可以跨越arena。

heapArena中bitmap用每2個(gè)bit記錄一個(gè)指針大形甓(8byte)的內(nèi)存信息圈盔,主要用于gc。spans是一個(gè)數(shù)組悄雅,長度等于一個(gè)heap中的頁數(shù)(每頁大小為8k驱敲,頁數(shù)可能為64M/8k,不同架構(gòu)會(huì)不同)宽闲,每個(gè)頁可能會(huì)指向一個(gè)span(即一個(gè)mspan指針)众眨。實(shí)際上,對于分配的容诬,空閑的和未分配的span娩梨,指針情況可能各不相同。pageInUse和pageMarks都是標(biāo)記頁的览徒,按位處理狈定。

  1. mache初始化和fixalloc
    再扯回來,第一次分配mcache习蓬,這里就涉及到真正的內(nèi)存分配了纽什。allocmcache中可以看到,之前已經(jīng)初始化了cachealloc躲叼,這里調(diào)用alloc函數(shù)芦缰,走到的是fixalloc的alloc函數(shù):
func (f *fixalloc) alloc() unsafe.Pointer {
    if f.size == 0 {
        print("runtime: use of FixAlloc_Alloc before FixAlloc_Init\n")
        throw("runtime: internal error")
    }

    if f.list != nil {
        v := unsafe.Pointer(f.list)
        f.list = f.list.next
        f.inuse += f.size
        if f.zero {
            memclrNoHeapPointers(v, f.size)
        }
        return v
    }
    if uintptr(f.nchunk) < f.size {
        f.chunk = uintptr(persistentalloc(_FixAllocChunk, 0, f.stat))
        f.nchunk = _FixAllocChunk
    }

    v := unsafe.Pointer(f.chunk)
    if f.first != nil {
        f.first(f.arg, v)
    }
    f.chunk = f.chunk + f.size
    f.nchunk -= uint32(f.size)
    f.inuse += f.size
    return v
}

nchunk代表目前剩余的大小,size是目標(biāo)大小枫慷。如果nchunk小于size让蕾,就從系統(tǒng)申請一塊(_FixAllocChunk這么大)內(nèi)存,然后按照size這個(gè)固定的大小一點(diǎn)一點(diǎn)用流礁。對于用完釋放的涕俗,又會(huì)存在list屬性中,供之后再次使用(使用的時(shí)候清零)神帅。實(shí)際分配內(nèi)存是通過persistentalloc一步步調(diào)用sysAlloc進(jìn)而調(diào)用mmap分配的再姑。
allocmcache接下來就會(huì)把mcache中各個(gè)spanclass對應(yīng)的mspan初始化為空mspan。

需要注意的是找御,上面這個(gè)特殊的M是這樣初始化mcache元镀。其實(shí)mcache是應(yīng)該跟著P的绍填,所以其他的mcache的初始化都是在procresize這個(gè)函數(shù)里,它在schedinit()中栖疑,位于mallocinit()之后讨永。

  1. newobject和mallocgc
    上面講了啟動(dòng)過程的各種初始化。初始化完畢遇革,程序執(zhí)行的時(shí)候卿闹,在堆上的對象是通過runtime.newobject函數(shù)來分配的。
    什么時(shí)候分配在堆萝快,什么時(shí)候分配在棧锻霎,這又是另外一個(gè)值得長篇探討的問題,稱為“逃逸分析”揪漩,這里暫不深入旋恼。
    newobject的代碼位于malloc.go中,它直接調(diào)用了mallocgc:
func newobject(typ *_type) unsafe.Pointer {
    return mallocgc(typ.size, typ, true)
}

而mallocgc里面就是包含了分配內(nèi)存時(shí)最核心的順序和步驟奄容,即小對象從mcache的freelist中開始分配冰更,大對象(大于32k,即maxSmallSize)直接從堆上分配昂勒。小對象的分配又分為是否是tiny對象(以maxTinySize=16為界)蜀细。

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
            size = maxTinySize
        } else {
            var sizeclass uint8
            if size <= smallSizeMax-8 {
                sizeclass = size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]
            } else {
                sizeclass = size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv]
            }
            size = uintptr(class_to_size[sizeclass])
            spc := makeSpanClass(sizeclass, noscan)
            span := c.alloc[spc]
            v := nextFreeFast(span)
            if v == 0 {
                v, span, shouldhelpgc = c.nextFree(spc)
            }
            x = unsafe.Pointer(v)
            if needzero && span.needzero != 0 {
                memclrNoHeapPointers(unsafe.Pointer(v), size)
            }
        }
    } else {
        var s *mspan
        shouldhelpgc = true
        systemstack(func() {
            s = largeAlloc(size, needzero, noscan)
        })
        s.freeindex = 1
        s.allocCount = 1
        x = unsafe.Pointer(s.base())
        size = s.elemsize
    }

    var scanSize uintptr
    if !noscan {
        // If allocating a defer+arg block, now that we've picked a malloc size
        // large enough to hold everything, cut the "asked for" size down to
        // just the defer header, so that the GC bitmap will record the arg block
        // as containing nothing at all (as if it were unused space at the end of
        // a malloc block caused by size rounding).
        // The defer arg areas are scanned as part of scanstack.
        if typ == deferType {
            dataSize = unsafe.Sizeof(_defer{})
        }
        heapBitsSetType(uintptr(x), size, dataSize, typ)
        if dataSize > typ.size {
            // Array allocation. If there are any
            // pointers, GC has to scan to the last
            // element.
            if typ.ptrdata != 0 {
                scanSize = dataSize - typ.size + typ.ptrdata
            }
        } else {
            scanSize = typ.ptrdata
        }
        c.local_scan += scanSize
    }
...

從核心分配代碼看,先根據(jù)待分配對象size算出實(shí)際使用的sizeClass(即mspan的不同分類)叁怪,然后算出spanClass审葬,這里spanClass(本身是一個(gè)uint8)包含了span的size信息和span是否需要scan(用于gc)的信息,相當(dāng)于mcache中alloc數(shù)組的index奕谭,數(shù)組是按一個(gè)noscan sizeClass一個(gè)scan sizeClass這樣交替排列下去的涣觉。

算好這些, 就調(diào)用nextFreeFast嘗試分配(mcache中)血柳,如果不成功官册,調(diào)用nextFree分配(還是mcache中),再不成功难捌,調(diào)用memclrNoHeapPointers分配(mcentral或mheap中)膝宁。

nextFreeFast相對簡單。mspan中allbits記錄著哪些元素是已分配的根吁,哪些未分配员淫。alloccache用數(shù)字按位代表freeindex開始的

func nextFreeFast(s *mspan) gclinkptr {
    theBit := sys.Ctz64(s.allocCache) // Is there a free object in the allocCache?
    if theBit < 64 {
        result := s.freeindex + uintptr(theBit)
        if result < s.nelems {
            freeidx := result + 1
            if freeidx%64 == 0 && freeidx != s.nelems {
                return 0
            }
            s.allocCache >>= uint(theBit + 1)
            s.freeindex = freeidx
            s.allocCount++
            return gclinkptr(result*s.elemsize + s.base())
        }
    }
    return 0
}

但為了講清這里的方式,需要簡單了解mspan的內(nèi)部結(jié)構(gòu)击敌,貼出一張網(wǎng)上盜的圖介返,簡單明了:


mspan內(nèi)部主要結(jié)構(gòu)

所以可以看到,mspan里面用位圖記錄了元素分配與否,直接查找即可圣蝎。
nextFree稍微復(fù)雜刃宵,因?yàn)橐幚矸峙洳怀晒Γ^續(xù)向mcentral申請內(nèi)存的邏輯:

func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
    s = c.alloc[spc]
    shouldhelpgc = false
    freeIndex := s.nextFreeIndex()
    if freeIndex == s.nelems {
        // The span is full.
        if uintptr(s.allocCount) != s.nelems {
            println("runtime: s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
            throw("s.allocCount != s.nelems && freeIndex == s.nelems")
        }
        c.refill(spc)
        shouldhelpgc = true
        s = c.alloc[spc]

        freeIndex = s.nextFreeIndex()
    }

    if freeIndex >= s.nelems {
        throw("freeIndex is not valid")
    }

    v = gclinkptr(freeIndex*s.elemsize + s.base())
    s.allocCount++
    if uintptr(s.allocCount) > s.nelems {
        println("s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
        throw("s.allocCount > s.nelems")
    }
    return
}

其中徘公,refill函數(shù)會(huì)從mcentral甚至mheap獲取mspan牲证。

這塊是最主要的分配邏輯。剩下的是tiny object(注意关面,需要不含指針且足夠刑古邸)和large object的分配。它們做特殊處理都是因?yàn)樘』蛱箸择桑贿m合適配到某些固定大小键闺。在許多場景中tiny object這種分配策略能顯著優(yōu)化性能。

參考資料

  1. https://yq.aliyun.com/articles/652551
  2. https://povilasv.me/go-memory-management/
  3. http://legendtkl.com/2017/04/02/golang-alloc/
  4. https://www.youtube.com/watch?v=3CR4UNMK_Is&t=1009s
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末澈驼,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子筛武,更是在濱河造成了極大的恐慌缝其,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,214評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件徘六,死亡現(xiàn)場離奇詭異内边,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)待锈,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,307評論 2 382
  • 文/潘曉璐 我一進(jìn)店門漠其,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人竿音,你說我怎么就攤上這事和屎。” “怎么了春瞬?”我有些...
    開封第一講書人閱讀 152,543評論 0 341
  • 文/不壞的土叔 我叫張陵柴信,是天一觀的道長。 經(jīng)常有香客問我宽气,道長随常,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,221評論 1 279
  • 正文 為了忘掉前任萄涯,我火速辦了婚禮绪氛,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘涝影。我一直安慰自己枣察,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,224評論 5 371
  • 文/花漫 我一把揭開白布袄琳。 她就那樣靜靜地躺著询件,像睡著了一般燃乍。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上宛琅,一...
    開封第一講書人閱讀 49,007評論 1 284
  • 那天刻蟹,我揣著相機(jī)與錄音,去河邊找鬼嘿辟。 笑死舆瘪,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的红伦。 我是一名探鬼主播英古,決...
    沈念sama閱讀 38,313評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼昙读!你這毒婦竟也來了召调?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 36,956評論 0 259
  • 序言:老撾萬榮一對情侶失蹤蛮浑,失蹤者是張志新(化名)和其女友劉穎唠叛,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體沮稚,經(jīng)...
    沈念sama閱讀 43,441評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡艺沼,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,925評論 2 323
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了蕴掏。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片障般。...
    茶點(diǎn)故事閱讀 38,018評論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖盛杰,靈堂內(nèi)的尸體忽然破棺而出挽荡,到底是詐尸還是另有隱情,我是刑警寧澤饶唤,帶...
    沈念sama閱讀 33,685評論 4 322
  • 正文 年R本政府宣布徐伐,位于F島的核電站,受9級特大地震影響募狂,放射性物質(zhì)發(fā)生泄漏办素。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,234評論 3 307
  • 文/蒙蒙 一祸穷、第九天 我趴在偏房一處隱蔽的房頂上張望性穿。 院中可真熱鬧,春花似錦雷滚、人聲如沸需曾。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,240評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽呆万。三九已至商源,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間谋减,已是汗流浹背牡彻。 一陣腳步聲響...
    開封第一講書人閱讀 31,464評論 1 261
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留出爹,地道東北人庄吼。 一個(gè)月前我還...
    沈念sama閱讀 45,467評論 2 352
  • 正文 我出身青樓,卻偏偏與公主長得像严就,于是被迫代替她去往敵國和親总寻。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,762評論 2 345

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