go runtime

go運(yùn)行時(shí)

零 前置知識(shí)

操作系統(tǒng)的每個(gè)進(jìn)程都認(rèn)為自己可以訪問計(jì)算機(jī)的所有物理內(nèi)存,但由于計(jì)算機(jī)必定運(yùn)行著多個(gè)程序,每個(gè)進(jìn)程都不能擁有全部內(nèi)存。

為了避免了進(jìn)程直接訪問實(shí)際的物理地址,操作系統(tǒng)會(huì)將物理內(nèi)存虛擬為一個(gè)數(shù)組,每個(gè)元素都有一個(gè)唯一的物理地址(PA)。
物理存儲(chǔ)其器中存儲(chǔ)著一個(gè)頁表(page table),該表即虛擬地址與物理地址的映射表,讀取該表,即可完成地址翻譯。

假設(shè)一個(gè)程序訪問地址為0x1001的內(nèi)存,實(shí)際上,該數(shù)據(jù)并不一定是存儲(chǔ)在0x1001的物理地址中,甚至也不在物理內(nèi)存中(如果物理內(nèi)存滿了,則可以轉(zhuǎn)移到磁盤上)。這些地址不必反映真實(shí)的物理地址,可以稱為“虛擬內(nèi)存”。

一 內(nèi)存分區(qū)

1.0 程序的內(nèi)存使用

現(xiàn)在使用命令來查看Go程序的內(nèi)存使用:

go build main.go
size main

此時(shí)會(huì)顯示Go程序在未啟動(dòng)時(shí),內(nèi)存的使用情況:

runtime-01.png

此時(shí)可執(zhí)行程序內(nèi)部已經(jīng)分好了三段信息,分別為:

  • text 代碼區(qū)
  • data 數(shù)據(jù)區(qū)
  • bss 未初始化數(shù)據(jù)區(qū)

貼士:
data和bss區(qū)域可以一起稱呼為靜態(tài)區(qū)/全局區(qū)

  • 上述三個(gè)區(qū)域大小都是固定的

程序在執(zhí)行后,會(huì)額外增加棧區(qū)、堆區(qū)。

1.1 text 代碼區(qū)

代碼區(qū)用于存放CPU執(zhí)行的機(jī)器指令,一般情況下,代碼區(qū)具備以下特性:

  • 共享:即可以提供給其他程序調(diào)用,這樣就可以讓代碼區(qū)的數(shù)據(jù)在內(nèi)存中只存放一份即可,有效節(jié)省空間。
  • 只讀:用于放置程序修改其指令
  • 規(guī)劃局部變量信息

1.2 data 數(shù)據(jù)區(qū)

數(shù)據(jù)區(qū)用于存儲(chǔ)數(shù)據(jù):

  • 被初始化后的全局變量
  • 被初始化后的靜態(tài)變量(包含全局靜態(tài)變量、局部靜態(tài)變量)
  • 常量數(shù)據(jù)(如字符串常量)

1.3 bss 未初始化數(shù)據(jù)區(qū)

未初始化數(shù)據(jù)區(qū)用于存儲(chǔ):

  • 全局未初始化變量
  • 未初始化靜態(tài)變量

如果是C語言,未初始化,卻被使用了,這會(huì)產(chǎn)生一個(gè)隨機(jī)的值。Go語言中,為了防止C的這種現(xiàn)象,該區(qū)域的數(shù)據(jù)會(huì)在程序執(zhí)行之前被初始化為零值(0或者空)。

1.4 stack 棧區(qū)

棧是一種先進(jìn)后出(FILO)的內(nèi)存結(jié)構(gòu),由編譯器自動(dòng)進(jìn)行分配和釋放。一般用于存儲(chǔ):函數(shù)的參數(shù)值、返回值、局部變量等。棧區(qū)大小一般只有1M,也可以實(shí)現(xiàn)擴(kuò)充:

  • Windows最大可以擴(kuò)充為10M
  • Linux最大可以擴(kuò)充為16M

1.5 heap 堆區(qū)

棧的內(nèi)存空間非常小,當(dāng)我們遇到一些大文件讀取時(shí),棧區(qū)是不夠存儲(chǔ)的,這時(shí)候就會(huì)用到堆區(qū)。堆區(qū)空間比較大,其大小與計(jì)算機(jī)硬件的內(nèi)存大小有關(guān)。
堆區(qū)沒有棧的先進(jìn)后出的規(guī)則,位于BSS區(qū)域棧區(qū)之間,用于內(nèi)存的動(dòng)態(tài)分配。
在C、C++等語言中,該部分內(nèi)存由程序員手動(dòng)分配(c中的malloc函數(shù),c++中的new函數(shù))和釋放(C中的free函數(shù),C++的delete函數(shù)),如果不釋放,可能會(huì)造成內(nèi)存泄露,但是程序結(jié)束時(shí),操作系統(tǒng)會(huì)進(jìn)行回收。
在Java、Go、JavaScript中,都有垃圾回收機(jī)制(GC),可以實(shí)現(xiàn)內(nèi)存的自動(dòng)釋放!

注意:Go語言與其他語言不同,對(duì)棧區(qū)、堆區(qū)進(jìn)行虛擬管理。

1.6 操作系統(tǒng)內(nèi)存分配圖

操作系統(tǒng)會(huì)為每個(gè)進(jìn)程分配一定的內(nèi)存地址空間,如圖所示:

image.png

上圖所示的是32位系統(tǒng)中虛擬內(nèi)存的分配方式,不同系統(tǒng)分配的虛擬內(nèi)存是不同的,但是其數(shù)據(jù)所占區(qū)域的比例是相同的:

  • 32位:最大內(nèi)存地址為232,這么多的字節(jié)數(shù)換算為G單位,即為4G。(換算為1G=1024MB=10241024KB=10241024*1024B)
  • 64位:最大內(nèi)存地址為264,這么多的字節(jié)數(shù)換算為G單位,數(shù)值過大,不便圖示

注意:棧區(qū)是從高地址往低地址存儲(chǔ)的

二 變量逃逸

由于棧的性能相對(duì)較高,變量是分配到了棧,還是堆中,對(duì)程序的性能和安全有較大影響。
逃逸分析是一種確定指針動(dòng)態(tài)范圍的方法,用來分析程序的哪些地方可以訪問到指針。當(dāng)一個(gè)變量或?qū)ο笤谧映绦蛑蟹峙鋬?nèi)存時(shí),一個(gè)指向變量或?qū)ο蟮闹羔樋赡芴右莸狡渌麍?zhí)行線程中,甚至去調(diào)用子程序。

指針逃逸:一個(gè)對(duì)象的指針在任何一個(gè)地方都可以訪問到。
逃逸分析的結(jié)果可以用來保證指針的生命周期只在當(dāng)前進(jìn)程或線程中。

func toHeap() *int {
    var x int
    return &x
}

func toStack() int {
    x := new(int)
    *x = 1
    return *x
}

func main() {

}

上述兩個(gè)函數(shù)分別創(chuàng)建了2個(gè)變量,但是申請(qǐng)的位置是不一樣的。打開逃逸分析日志:

go run -gcflags '-m -l' main.go
# command-line-arguments
./main.go:4:6: moved to heap: x
./main.go:9:10: toStack new(int) does not escape

如上所示,toHeap()中的x分配到了堆上,toStack()中的x最后分配到了棧上。does not escape 表示未逃逸。同樣是變量內(nèi)存的申請(qǐng),兩個(gè)函數(shù)獲得的位置卻是不一樣的!!

這是因?yàn)間o在一定程序上消除了堆和棧的區(qū)別,在編譯的時(shí)候會(huì)自動(dòng)進(jìn)行變量逃逸分析,不逃逸的對(duì)象就放到棧上,可能逃逸的對(duì)象就放到堆上。

  • 一般情況下,函數(shù)的局部變量會(huì)分配到函數(shù)棧上
  • 變量在函數(shù)return之后還被引用,會(huì)被分配到堆上,比如上述的案例toHeap()

Go的GC判斷變量是否回收的實(shí)現(xiàn)思路:從每個(gè)包級(jí)的變量、每個(gè)當(dāng)前運(yùn)行的函數(shù)的局部變量開始,通過指針和引用的訪問路徑遍歷,是否可以找到該變量,如果不存在這樣的訪問路徑,那么說明該變量是不可達(dá)的,也就是說它是否存在并不會(huì)影響后續(xù)計(jì)算結(jié)果。

示例:

var global *int
func f() {            
    var x int    
    x = 1
    global = &x
}
func g() {
    y := new(int)
    *y = 1
}

上述的函數(shù)調(diào)用結(jié)果說明:

  • 雖然x變量定義在f函數(shù)內(nèi)部,但是其必定在堆上分配,因?yàn)楹瘮?shù)退出后仍然能通過包一級(jí)變量global找到,這樣的變量,我們稱之為從函數(shù)f中逃逸了
  • g函數(shù)返回時(shí),變量*y不可達(dá),因此沒有從函數(shù)g中逃逸,其內(nèi)存分配在棧上,會(huì)馬上被回收。(當(dāng)然也可以選擇在堆上分配,然后由Go語言的GC回收這個(gè)變量的內(nèi)存空間)

二 變量逃逸分析案例

2.1 案例一

在C++中,開發(fā)者需要自己手動(dòng)分配內(nèi)存來適應(yīng)不同的算法需求。比如,函數(shù)局部變量盡量使用棧(函數(shù)退出,內(nèi)部變量也依次退出),全局變量、結(jié)構(gòu)體使用堆。

Go語言將這個(gè)過程整合到了編譯器中,命名為“變量逃逸分析”,這個(gè)技術(shù)由編譯器分析代碼的特征和代碼生命期,決定是堆還是棧進(jìn)行內(nèi)存分配。

func test(num int) int {
    var t int
    t = num
    return t 
}

//空函數(shù),什么也不做
func void() {

}

func main() {

    var a int                    //聲明變量并打印
    void()                        //調(diào)用空函數(shù)
    fmt.Println(a, test(0))        //打印a,并調(diào)用test

}

運(yùn)行上述代碼:

# -gcflags參數(shù)是編譯參數(shù),-m表示進(jìn)行內(nèi)存分析,-l表示避免程序內(nèi)聯(lián)(優(yōu)化)
go run -gcflags "-m -l" test.go    

得到結(jié)果:

# command-line-arguments
./test.go:22:13: a escapes to heap                      # 29行的變量a逃逸到堆
./test.go:22:21: test(0) escapes to heap                # test(0)調(diào)用逃逸到堆
./test.go:22:13: main ... argument does not escape      # 默認(rèn)提示
0 0

test(0)調(diào)用逃逸到堆,但是test()函數(shù)會(huì)返回一個(gè)整數(shù)值,這個(gè)值被fmt.Println()使用后還是會(huì)在其聲明后繼續(xù)在main函數(shù)中存在。

test函數(shù)中的聲明的變量t是整型,該值通過test函數(shù)返回值逃出了函數(shù),t變量的值被復(fù)制并作為test函數(shù)的返回值返回,即使t在test函數(shù)中分配的內(nèi)存被釋放,也不會(huì)影響main函數(shù)中使用test返回的值,t變量使用棧分配不會(huì)影響結(jié)果。

2.2 案例2

type Data struct {

}

func test() *Data {
    var d Data
    return &d                 // 返回局部變量地址
}

func main() {
    fmt.Println(test())        //輸出 &{}
}

繼續(xù)使用命令:go run -gcflags "-m -l" test.go

# command-line-arguments
./test.go:11:9: &d escapes to heap
./test.go:10:6: moved to heap: d                    # 新增提示:將d移到堆中
./test.go:15:18: test() escapes to heap
./test.go:15:13: main ... argument does not escape
&{}

moved to heap: d 表示go編譯器已經(jīng)確認(rèn)如果d變量被分配在棧上是無法保證程序最終結(jié)果的,如果堅(jiān)持這樣做,test()的返回值是僵尸Data結(jié)構(gòu)的一個(gè)不可預(yù)知的內(nèi)存地址。這種情況一般是C/C++語言中容易犯錯(cuò)的地方:引用了一個(gè)函數(shù)局部變量的地址。Go最終選擇將d的Data結(jié)構(gòu)分配到堆上,然后由垃圾回收期去回收d的內(nèi)存。

三 原則總結(jié)

在使用Go語言進(jìn)行編程時(shí),Go語言設(shè)計(jì)者不希望開發(fā)者將精力放在內(nèi)存應(yīng)該分配在棧還是堆上,編譯器會(huì)自動(dòng)幫助開發(fā)者完成這個(gè)糾結(jié)的選擇。

編譯器覺得變量應(yīng)該分配在堆還是棧上的原則是:

  • 變量是否被取地址
  • 變量是否發(fā)生逃逸

四 內(nèi)存分配器

4.0 Golang的內(nèi)存分配器TCMalloc

Go的內(nèi)存分配基于TCMalloc(Thread-Cacing Malloc,Google開發(fā)的一款高性能內(nèi)存分配器),源碼位于runtime/malloc.go。但是經(jīng)過多年發(fā)展,Go的內(nèi)存分配算法已經(jīng)大幅進(jìn)化,但是學(xué)習(xí)TCMalloc仍然能看到一些Go內(nèi)存分配的基礎(chǔ)。

Go采用離散式空閑列表算法(Segregated Free List)分配內(nèi)存,主要核心算法思想是:

  • 線程私有性
  • 內(nèi)存分配粒度

4.1 線程私有性

TCMalloc內(nèi)存管理體系分為三個(gè)層次:

  • ThreadCache:一般與負(fù)責(zé)小內(nèi)存分配,每個(gè)線程都擁有一份ThreadCache,理想情況下,每個(gè)線程的內(nèi)存申請(qǐng)都可以在自己的ThreadCache內(nèi)完成,線程之間無競(jìng)爭,所以TCMalloc非常高效,這既是TCMalloc的線程私有性
  • CentralCache:內(nèi)部含有多個(gè)CentralFreelist
  • PageHeap:與負(fù)責(zé)大內(nèi)存分配,是中央堆分配器,被所有線程共享,可以與操作系統(tǒng)直接交互(申請(qǐng)、釋放內(nèi)存),大尺寸內(nèi)存分配會(huì)直接通過PageHeap分配

TCMalloc具備線程私有性質(zhì),然而現(xiàn)實(shí)往往是骨感的!ThreadCache中內(nèi)存不足時(shí),還需要其他2個(gè)組件幫助,內(nèi)存的分配、釋放從上述三個(gè)層級(jí)中依次遞進(jìn):當(dāng)最小的Thread層分配內(nèi)存失敗,則從下一層的CentralCache中分配一批補(bǔ)充上來。

CentralFreeList是TheadCache和PageHeap之間協(xié)調(diào)者。

  • 分配內(nèi)存:CentralFreeList會(huì)將PageHeap中的內(nèi)存切分為小塊,分配給ThreadCache。
  • 釋放內(nèi)存:CentralFreeList會(huì)獲取從ThreadCache中回收的內(nèi)存,歸還給PageHeap。

如圖所示:

image.png

4.2 內(nèi)存分配粒度

內(nèi)存分配調(diào)度的最小單位也稱為粒度,TCMalloc有兩種分配粒度:

  • span:用于內(nèi)部管理。span是由連續(xù)的page內(nèi)存組成的一個(gè)大塊內(nèi)存,負(fù)責(zé)分配超過256KB的大內(nèi)存
  • object:用于面向?qū)ο蠓峙?。object是由span切割成的小塊,其尺寸被預(yù)設(shè)了一些規(guī)格(class),如16B,32B(88種),不會(huì)大于256KB(交給了span)。同一個(gè)span切出來的都是相同的object。

貼士:ThreadCache和CentralCache是管理object的,PageHeap管理的是span。

如圖所示(每個(gè)class對(duì)應(yīng)一個(gè)鏈表):

image.png

在申請(qǐng)小內(nèi)存(小于256KB時(shí)),TCMalloc會(huì)根據(jù)申請(qǐng)內(nèi)存的大小,匹配到與之大小最接近的class中,如:

  • 申請(qǐng)O~8B大小時(shí),會(huì)被匹配到 class1 中,分配 8B 大小
  • 申請(qǐng)9~16B大小時(shí),會(huì)被匹配到 class2 中,分配 16 B大小

上述的分配方式可以既非常靈活,又能極大避免內(nèi)存浪費(fèi)!

五 內(nèi)存分配

5.0 分配的第一步

分配器以page為單位,向操作系統(tǒng)申請(qǐng)“大塊內(nèi)存”,這些大塊內(nèi)存由n個(gè)地址連續(xù)的page組成,并用名為span的對(duì)象進(jìn)行管理。

示例:現(xiàn)在擁有128page的span,如果要申請(qǐng)1page的span,則該span會(huì)被劃分為2個(gè):1+127,再把127page的span記錄下來。

5.1 小內(nèi)存分配

小內(nèi)存分配對(duì)應(yīng)的ThreadCache是TCMalloc三級(jí)分配的第一層,是一個(gè)TSL線程本地存儲(chǔ)對(duì)象,負(fù)責(zé)小于256KB的內(nèi)存申請(qǐng)。每個(gè)線程都獨(dú)立擁有各自的離散式空閑列表,所以分配過程不需要鎖,分配速度很高。

ThreadCache在分配小內(nèi)存時(shí),首先會(huì)通過SizeMap查找要分配的內(nèi)存所對(duì)應(yīng)的class以及object大小,然后檢查空閑列表(free list)是否為空:

  • 如果非空,表示線程還有空閑的內(nèi)存,那么直接從列表中移除第一個(gè)object并返回,這個(gè)過程不需要任何鎖!
  • 如果未空,表示線程沒有空閑的內(nèi)存,那么從哪個(gè)CentralFreeList中獲取若干object,因?yàn)镃entralCache是被所有線程共享的,能夠獲取多少object是由慢啟動(dòng)算法決定的。獲取的object會(huì)被分配到ThreadCache對(duì)應(yīng)的class列表中,最終取出其中一個(gè)object返回

如果CentralFreeList中的object也不夠用,則會(huì)向PageHeap申請(qǐng)一連串頁面,這些頁面被切割為一系列object,再將部分object轉(zhuǎn)移給ThreadCache。

如果PageHeap也不夠用了,則會(huì)向操作系統(tǒng)申請(qǐng)內(nèi)存(page為單位),Go中此處使用mmap方法申請(qǐng),或者通過在/dev/mem中映射。申請(qǐng)完畢后繼續(xù)上面的操作,將內(nèi)存逐級(jí)遞送給線程。

5.2 CentralCache

CentralCache內(nèi)部含有多個(gè)CentralFreelist,即針對(duì)每一種class的object。ThreadCache維護(hù)的是object鏈表,CentralFreelist維護(hù)的是span鏈表。

CentralFreelist示意圖:

image.png

在 span 內(nèi)的 object 都已經(jīng)空閑(free)的情況下,將 span 整體回收給 PageHeap。( span.refcount_記錄了被分配出去的 object 個(gè)數(shù)〉。但是如果每個(gè)回收的 object 都需要尋找自己所屬的 span,然后才能掛進(jìn)freelist,這樣就比較耗時(shí)了。所以 CentralFreeList 里面還
維護(hù)了一個(gè)緩存 (tc_slots_),回收的若干 object 先往緩存里塞,不管 object 大小如何,緩存滿了再分類放進(jìn)相應(yīng) span 的 object 鏈。相反,如果 ThreadCache 申請(qǐng) object,也是先嘗試在緩存里面給,沒了再去 span 鏈那里申請(qǐng)。

那么這個(gè)若干具體是多少個(gè) object 呢?其實(shí)這是預(yù)定義的,稱作 batch size,不同的class 可能有不同的值。 ThreadCache 向 Central Cache 分配或回收 object 時(shí),都盡量以batch_size 為一個(gè)批次。而為了使得緩存簡單高效,如果某次分配或者回收的 object 個(gè)數(shù)小于 batch size,則會(huì)繞過緩存,直接處理。

為了避免在分配 object 時(shí)判斷 span 是否為空,CentralFreeList 里的 span 鏈表被分為兩個(gè),分別是 nonempty_ 和 empty_,根據(jù) span 的 objects 鏈?zhǔn)欠裼锌臻e,放入對(duì)應(yīng)鏈表。當(dāng)?shù)搅诵枰峙鋾r(shí),只需要在由空變非空、或者由非空變空時(shí)移動(dòng) span 就可以了。

CentralFreeList 作為整個(gè)體系的中間人,它從 PageHeap 中獲得 span ,并按照預(yù)定大小( SizeMap 中的定義)將其分割成大小固定的 object ,然后 ThreadCache 可以共享 CentralFreeList 列表。

當(dāng) ThreadCache 需要從 CentralFreeList 中獲取 object 時(shí),會(huì)從 nonempty 鏈表中獲取第一個(gè) span,并從這個(gè) span 的 object 鏈表中獲取可用 object 返回 。 當(dāng)該 span 無可用 object時(shí),將此span 從 nonempty_鏈表移除,并掛到 empty一鏈表上。

當(dāng) ThreadCache 把 object 歸還給 CentralFreeList 時(shí),object 會(huì)找到它所屬的 span,并掛載到 object 鏈表表頭,如果 span 處在 empty_鏈表, CentralFreeList 會(huì)重新將其掛載到nonempty_鏈表。

span 里還有一個(gè)值用于計(jì)算 object 是否己滿,每次分配出 去一個(gè) object, refcount 值就會(huì)加 1 ,每回收一個(gè) object 就會(huì)減 1 ,如果 refcount 等于 0 就表示此 span 所有 object 都
回家了,然后 span 會(huì)從 CentralFreeList 的鏈表中釋放,并將其退還給上一層 的 PageHeap 。

5.3 大內(nèi)存分配

如果遇到要分配的內(nèi)存大于page這個(gè)單位,就需要多個(gè)page來分配,即將多個(gè)page組成一個(gè)span來分配。

TCMalloc中定義的page大小為8KB(Linux中為4KB),其每次向操作系統(tǒng)申請(qǐng)內(nèi)存的大小至少為1page。

PageHeap雖然按page申請(qǐng)內(nèi)存,但是其內(nèi)存基本單位是span(一個(gè)地址連續(xù)的page)。PageHeap內(nèi)部維護(hù)了一個(gè)核心關(guān)系:page與span的映射關(guān)系。 當(dāng)釋放回收一個(gè) object 時(shí) ,把 object 放回原來的位置需要 CentralFreeList 來處理( object 放回原來的 span,然后才還給 PageHeap ),但是之所以能夠放回對(duì)應(yīng)的 span 里是因?yàn)橛?page 到 span 的映射
關(guān)系,地址值經(jīng)過地址對(duì)齊,很容易知道它屬于哪一個(gè) page。再通過 page 到 span 的映射關(guān)系就能知道 object 應(yīng)該放到哪里。

span.sizeclass 記錄了 span 切分的 object 屬于哪個(gè) class ,那么屬于這個(gè) span 的 object在釋放時(shí)就可以放到 ThreadCache 對(duì)應(yīng) class 的 FreeList 上面,接下來 object 如果要回收還給 CentralFreeList,就可以直接把它掛到對(duì)應(yīng) span 的 objects 鏈表上。

page 到 span 的映射關(guān)系是基于 radix tree 實(shí)現(xiàn)的,你可以把它理解為一種很大的數(shù)組,用 page 值作為偏移可以訪問到 page 所對(duì)應(yīng)的 span (也有多個(gè) page 指向 同一個(gè) span 的情況,因?yàn)?span 有時(shí)可不止一個(gè) page ) 。查詢 radix tree 需要消耗一定時(shí)間,所以為了避免這些開銷, PageHeap 和 CentralFreeList 類似,維護(hù)了 一個(gè)最近活躍的 page 到 class 對(duì)應(yīng)關(guān)系的緩存。為了保持緩存的效率,緩存只有 64KB,舊的對(duì)應(yīng)關(guān)系會(huì)被新來的對(duì)應(yīng)關(guān)系替換掉。

當(dāng)需要某個(gè)尺寸的 span 沒有空閑時(shí),可以把更大尺寸的 span 拆分,如果大的 span也沒有了,就是向操作系統(tǒng)要的時(shí)候了;回收時(shí)-,也需要判斷相鄰的 span 是否空閑,以便將它們組合。 判斷相鄰 span 還是使用 radix tree 查詢,這種數(shù)據(jù)結(jié)構(gòu)就像一個(gè)大數(shù)組,可以獲取當(dāng)前 span 前后相鄰的 span 地址。span 的尺寸有從 1 page 到 255 page 的所有規(guī)格,所以 span 可以以 page 為單位,用任意尺寸進(jìn)行拆分和組合。

六 Go運(yùn)行時(shí)簡述

6.1 Go Runtime簡介

Go語言的內(nèi)存分配是自主管理的,所以內(nèi)置了運(yùn)行時(shí)(Runtime),這樣能自主實(shí)現(xiàn)內(nèi)存使用模式,如內(nèi)存池、預(yù)分配等。這樣的好處是不會(huì)讓每次內(nèi)存分配都進(jìn)行系統(tǒng)調(diào)用(會(huì)從用戶態(tài)切換到內(nèi)核態(tài))。

Golang的運(yùn)行時(shí)內(nèi)存分配算法基于TCMalloc算法,即Thread-Caching Malloc,其核心思想是把內(nèi)存分為多級(jí)管理,降低了鎖的粒度。在Go中,可用的堆內(nèi)存采用二級(jí)分配的方式進(jìn)行管理。

Go中的每個(gè)線程都會(huì)自行維護(hù)一個(gè)獨(dú)立的內(nèi)存池,進(jìn)行內(nèi)存分配是會(huì)優(yōu)先從該內(nèi)存池中分配,當(dāng)內(nèi)存池不足時(shí)才會(huì)向全局內(nèi)存池申請(qǐng),以避免不同線程對(duì)全局內(nèi)存池的頻繁競(jìng)爭。

6.2 內(nèi)存分配過程

Go程序在啟動(dòng)時(shí)會(huì)從操作系統(tǒng)申請(qǐng)一大塊內(nèi)存(可以減少系統(tǒng)調(diào)用,所以Go在剛啟動(dòng)時(shí)占用很大)。實(shí)際中,申請(qǐng)到的大塊內(nèi)存并不一定是連續(xù)的,Go會(huì)將這些零散的內(nèi)存構(gòu)建為一個(gè)鏈表,如圖所示:

image.png

mspan結(jié)構(gòu)體即鏈表中的節(jié)點(diǎn)對(duì)象,位于 src/sruntime/mheap.go:

 type mspan struct {
    next            *mspan          // 雙向鏈表下一個(gè)節(jié)點(diǎn)
    prev            *mspan          // 雙向鏈表前一個(gè)節(jié)點(diǎn)
    startAddr       uintptr         // 起始序號(hào)
    npages          uintptr         // 當(dāng)前管理的頁數(shù)
    manualFreeList  gclinkptr       // 待分配的 object 鏈表
    nelems          uintptr         // 剩余可分配塊個(gè)數(shù)
    allocCount      uint16          // 已分配塊個(gè)數(shù)
 }

啟動(dòng)后申請(qǐng)到的內(nèi)存在Go中會(huì)被重新分配虛擬地址空間,在X64上分別是 512MB、16GB、512GB,如圖所示:

image.png

圖中的三塊區(qū)域:

  • arena:即堆區(qū),Go在這里進(jìn)行動(dòng)態(tài)內(nèi)存分配,該區(qū)域被分割成了每塊8KB大小的頁P(yáng)age,這些頁組合成為 mspan
  • bitmap:表示頁中具體的信息,即arena區(qū)哪些地址保存了對(duì)象,bitmap使用4bit標(biāo)志位表示對(duì)象是否包含指針、GC標(biāo)記信息
  • spans區(qū)域:表示具體頁,即mspan指針,每個(gè)指針對(duì)應(yīng)一頁,spans區(qū)域的大小即為:
    • 512GB/8KB:得到arena區(qū)域的頁數(shù)
    • 上述結(jié)構(gòu)*8B:得到spans區(qū)域所有指針大小,其值為512MB

源碼位于:src/runtime/malloc.go

 _PageShift         = 13
 _PageSize = 1 << _PageShift          // 1左移13 (1后面有13個(gè)0) 8KB

注意:內(nèi)存分配器只負(fù)責(zé)內(nèi)存塊的創(chuàng)建、提取等,其回收動(dòng)作是由GC清理后觸發(fā)的,不會(huì)主動(dòng)回收!

內(nèi)存分配器會(huì)將管理的內(nèi)存分為兩種:

  • span:由多個(gè)連續(xù)的頁組成
  • object:span會(huì)被按照特定大小切分成多個(gè)小塊,每個(gè)小塊都可以用于存儲(chǔ)對(duì)象

具體的分配過程:

  • 為對(duì)象分配內(nèi)存時(shí),只需要從鏈表中取出一個(gè)大小合適的節(jié)點(diǎn)即可
  • 為對(duì)象回收內(nèi)存時(shí),會(huì)將對(duì)象使用的內(nèi)存重新插回到鏈表中
  • 如果閑置內(nèi)存過多,也會(huì)嘗試歸還部分內(nèi)存給操作系統(tǒng),降低整體開銷

6.3 內(nèi)存分配器的組件

內(nèi)存分配器包括3個(gè)組件:cache、central、heap。

cache
每個(gè)運(yùn)行期工作線程都會(huì)綁定一個(gè)cache,用于無鎖obeject的分配,在本地緩存可用的mspan資源,這樣就可以直接給運(yùn)行時(shí)分配,因?yàn)椴淮嬖诙鄠€(gè)go協(xié)程競(jìng)爭的情況,所以不會(huì)消耗資源。

macache結(jié)構(gòu)體位于 src/runtime/mcache.go:

type mcache struct {
    alloc   [numSpanClasses]*mspan      // mspan結(jié)構(gòu)體指針數(shù)組,以該值為索引管理多個(gè)用于分配的span
}

central
為所有mcache提供切分好的后備span資源,每個(gè)central保存一種特定大小的全局mspan列表,包括已經(jīng)分配出去的和未分配出去的。每個(gè)mcentral都會(huì)對(duì)應(yīng)一種mspan,根據(jù)mspan的種類不同,分割的object大小不同。

mcentral結(jié)構(gòu)體位于 src/runtime/mcentral.go

type mcentral struct {
    lock        mutex
    sizeclass   int32           // 規(guī)格
    nonempty    mSpanList       // 尚有空閑object的mspan鏈表
    empty       mSpanList       // 無空閑object的mspan鏈表,或者是已被mcache取走的mspan鏈表
    nmalloc     uint64          // 已累計(jì)分配的對(duì)象個(gè)數(shù)
}

sizeclass 規(guī)格即內(nèi)存分配大小的規(guī)格,依據(jù)不同的規(guī)格描述不同mspan。

heap

管理閑置span,需要時(shí)想操作系統(tǒng)申請(qǐng)內(nèi)存

Go要求盡量復(fù)用內(nèi)存,其復(fù)用機(jī)制總結(jié)如下:

  • Go程序啟動(dòng)時(shí),向操作系統(tǒng)申請(qǐng)一大塊內(nèi)存,之后自行管理
  • Go內(nèi)存管理的基本單元是mspan,由若干頁組成,每種mspan都可以分配特定大小的object
  • mcache、mcentral、mheap是go內(nèi)存管理的是哪個(gè)組件,其關(guān)系依次推進(jìn)
    • mcache:管理線程在本地緩存的mspan
    • mcentral:管理全局的mspan供所有線程使用
    • mheap:管理go所有動(dòng)態(tài)分配的內(nèi)存
  • 一般小對(duì)象通過mspan分配內(nèi)存,大對(duì)象直接由mheap分配內(nèi)存

七 Mspan 內(nèi)存管理器詳解

在Go語言中,內(nèi)存被劃分為兩部分:

  • 堆:供內(nèi)存分配
  • bitmap:管理堆

這兩部分的內(nèi)存都是從同一個(gè)地址開始申請(qǐng)的,向高地址的方向增長的就是內(nèi)存池,向低地址方向增長的就是 bitmap 。

Go語言的內(nèi)存管理緩存結(jié)構(gòu):

Go 語言為每個(gè)系統(tǒng)線程分配了 一個(gè)本地 MCache (類似 TCMalloc 中的 ThreadCache,不過 Go 語言改了名稱),少量的地址分配就是從 MCache 分配的,并且定期進(jìn)行垃圾回收,所以 Go 語言的分配器包含了顯式與隱式的調(diào)用。 Go 語言定義的小塊內(nèi)存與 TCMalloc 基本一致, Go 語言底層會(huì)把這些小塊內(nèi)存按照指定規(guī)格(和 TCMalloc 的 class 類似)進(jìn)行切割,整個(gè)過程結(jié)構(gòu)都與 TCMalloc 相似。

Go語言內(nèi)存分配的主要組件:

  • MCache:每個(gè)尺寸的 class 都有一個(gè)空 閑鏈表 。 每個(gè) goroutine C 線程〉都有自己的局部 MCache (小對(duì)象從它取,無須加鎖,沒有競(jìng)爭,因此十分高效)
  • MCentral:與 TCMalloc 的 CentralCache 類似, MCache 可以從這里獲取更多 內(nèi)存,當(dāng)自身無空閑內(nèi)存時(shí),可以向 MHeap 申請(qǐng)一個(gè) span (只能一個(gè)〉,申請(qǐng)的 span 包含多少個(gè) page 由 central 的 sizeclass 確定
  • MHeap:負(fù)責(zé)將 MSpan 組織和管理起來。分配過程和 TCMalloc 類似,從 free 數(shù)組中分配,如果發(fā)生切割則將剩余的部分放回 free 數(shù)組中?;厥者^程也類似,回
    收一個(gè) Mspan 時(shí),先查找它的相鄰地址,再通過 map 映射得到對(duì)應(yīng)的 Mspan,如果 Mspan 的狀態(tài)是未使用,則可以將兩者合井 。 最后將這個(gè) page 或者合并后的 page歸還到臺(tái)ee 數(shù)組分配池或者 large 中。

Go的alloc示意圖:

image.png

struct Mcache alloc from 'cachealloc' by FixAlloc意思是newobject是從arena區(qū)域分配的,runtime層自身管理的結(jié)構(gòu)如mache等是專門設(shè)計(jì)了fixAlloc來分配的,這里與TCMalloc不一樣!

八 內(nèi)存分配代碼詳解

在 Go 語言中,內(nèi)存分配器只管理內(nèi)存塊,并不關(guān)心對(duì)象的狀態(tài),而且不會(huì)主動(dòng)回收內(nèi)存,需要由垃圾回收器完成清理操作后,再觸發(fā)內(nèi)存管理器回收內(nèi)存 。

8.1 初始化

初始化過程大體是通過 sysReserve 向系統(tǒng)申請(qǐng)一塊連續(xù)的內(nèi)存(由 spans+bitmap+arena 組成)。其中 arena 為各級(jí)別緩存結(jié)構(gòu)提供的內(nèi)存塊, spans 是一個(gè)指針數(shù)組,用來按照 page 尋址 arena 區(qū)域。 sysReserve 最終調(diào)用的是系統(tǒng)函數(shù) mmap,會(huì)申請(qǐng) 512GB 的虛擬地址空間 ( 64 位機(jī)器上為 spans 512MB,bitmap 16GB 、 arena 512GB ) ,當(dāng)然真正的物理內(nèi)存則是用到的時(shí)候發(fā)生缺頁才真實(shí)占用。

MHeap 在 mallocinit()中 初始化,而 mallocinit 被 schedinit()調(diào)用,代碼詳見/src/runtime/proc.go mallocinit()

MCentral 的初始化比較簡單,設(shè)置自身級(jí)別并將兩個(gè) mspanList 初始化 。 而 MCache 在 procresize(nprocs int32) * p 中初始化(代碼如下) , procresize 也在 schedinit()中調(diào)用,順序在 mallocinit()之后,也就是說發(fā)生在 MHeap 與 MCentral 的初始化后面 。代碼見 func p 「oc 「esize(np 「ocs int32) *p。

所有的 P 都存放在一個(gè)全局?jǐn)?shù)組 allp 中, procresizeO的目的就是將 allp 用到的 P 進(jìn)行初始化,同時(shí)對(duì)多余的 P 的資源隔離 。至此, 管理結(jié)構(gòu) h征leap 、 MCentral 及每個(gè) P 的 MCache 都初始化完畢,接下來進(jìn)入分配階段。

8.2 分配

分配的整個(gè)流程是:將小對(duì)象所需 內(nèi)存大小向上取整到最近的尺寸類別或者稱為規(guī)格( class ),查找相應(yīng) 的 MCache 的 空 閑 鏈表, 如 果鏈表不空,直接從上面分配一個(gè)對(duì)象,這個(gè)過程不加鎖;如果 MCache 空閑鏈表是空的,通過 MCentral 的空閑鏈表取一些對(duì)象進(jìn)行補(bǔ)充:如果 MCentral 的空閑鏈表也是空的, 則在 h啞leap 中取用一些 page 對(duì) MCentral 進(jìn)行補(bǔ)充,然后將這些內(nèi)存分割成特定規(guī)格:如果 MHeap 沒有足夠大的 page 時(shí),從操作系統(tǒng)分配一組新的 page 。

為了避免逃逸的情況,假設(shè)關(guān)閉了內(nèi)聯(lián)優(yōu)化,現(xiàn)在來看源碼,當(dāng) 時(shí)W 一個(gè)對(duì)象時(shí),調(diào)用的是 newobject(), 但實(shí)際上調(diào)用的是 mallocgc()

對(duì)于小于 16B 的內(nèi)存塊, Mcache 有一個(gè)專門的內(nèi)存區(qū)域“ tiny”用來分配, tiny 指針指向 tiny 內(nèi)存塊的起始地址 。 如上所示, tinyoffset 表示 tiny 當(dāng)前分配的地址位置,之后的分配根據(jù) tinyoffset 尋址 。 先根據(jù)要分配的對(duì)象大小進(jìn)行地址對(duì)齊, 比如 size 是 8 的倍數(shù), tinyoffset 就和 8 對(duì)齊 ,然后進(jìn)行分配 。 如果 tiny 剩余 的 空 間不夠用,則重新 申 請(qǐng)一個(gè) 16B 的內(nèi)存塊, 并分配給 object 。 如果有余,則記錄在 tiny 上。

對(duì)于大于 32阻的內(nèi)存分配,直接跳過 mcache 和 mcentral,通過 mheap 分配。大于 32KB 的內(nèi)存分配都是分配整數(shù)頁,先右移然后低位與計(jì)算需要的頁數(shù)。詳見func largeAlloc(size uintptr, needzero bool) *mspan

最后是對(duì)于大小介于 16KB~ 32KB的小對(duì) 象內(nèi)存分配,首先計(jì)算應(yīng)該分配的sizeclass ,然后去 mcache 里面申請(qǐng),如果不夠,就讓 mcache 向 mcentral 申請(qǐng)?jiān)俜峙?。 Mcentral為 mcache 分配完之后會(huì)判斷自己需不需要擴(kuò)充,如果需要就向 mheap 申請(qǐng) 。源碼位于 sizeclass部分。

8.3 回收釋放

這里的回收并非是垃圾回收,而是更簡單的內(nèi)存回收。MSpan 里有 sweepgen 回收標(biāo)記,回收的內(nèi)存會(huì)先全部回到 MCentral,如果己經(jīng)回收所有的 MSpan,就還給 MHeap 的空閑列表 。 回收內(nèi)存的一個(gè)很重要的原因是為了復(fù)用,所以很多時(shí)候并不會(huì)直接釋放內(nèi)存。

對(duì)于MCache,使用內(nèi)存時(shí)有兩種情況:第一種是用完了閑置,第二種是用了但沒用完,前者直接標(biāo)記等待回收就可以,至于多出來沒用到的部分就需要另外想辦法還給
MCentral,代碼見 func freemcache(c *mcache)func (c * mcache )releaseAll(),以及標(biāo)記回收func (s *mspan) sweep(preserve bool) bool。

源碼中sysmon 是監(jiān)控線程,它會(huì)遍歷 MHeap 中 large 列表里 的所有空閑的 MSpan , 發(fā)現(xiàn)空閑時(shí)間超過闊值就調(diào)用 madvise,讓系統(tǒng)內(nèi)核釋放這個(gè)線程相關(guān)的物理內(nèi)存 。

經(jīng)過上面的步驟 , MCache 的空閑 MSpan 己經(jīng)還給 MCentral 了, 接下來就是 MCentral還給 MHeap 了,這個(gè)過程簡單來說是當(dāng) MSpan 的 object 全部收回時(shí),將 MSpan 歸還給Mheap,代碼見func (c * mcentral)freeSpan(s *mspan, preserve bool, wasempty bool) bool。

到了最后, MHeap 那里并不會(huì)定時(shí)向操作系統(tǒng)歸還內(nèi)存,而是先把相鄰的 span 合井,使之成為一塊更大的內(nèi)存,以 page 為單位調(diào)度回收。

九 運(yùn)行時(shí)追蹤

9.0 使用gdb追蹤

使用gdb可以直觀的追蹤到程序運(yùn)行信息,使用步驟如下:

# 隨便編譯一個(gè)有main函數(shù)的go文件
go build -o main                    

# 在gdb模式下開始追蹤
info files                      # 會(huì)輸出 Entry point: 0x44ae10
b *0x44ae10                     # 斷點(diǎn) 這個(gè)文件,此時(shí)會(huì)輸入如下信息
    Breakpoint 1 at 0x44ae10: file /usr/local/go/src/runtime/rt0_linux_amd64.s, line 8.
info symbol 0x44ae10
    _rt0_amd64_linux in section .text
b _rt0_amd64
    Breakpoint 2 at 0x44ae10: file /usr/local/go/src/runtime/asm_amd64.s, line 15.
b runtime.rt0_go
    Breakpoint 3 at 0x44ae10: file /usr/local/go/src/runtime/asm_amd64.s, line 89.

9.1 運(yùn)行時(shí)入口

Go編譯出來的程序包含2個(gè)部分:

  • 運(yùn)行時(shí):入口為runtime包下的asm_amd64.s文件,完成命令行參數(shù)、操作系統(tǒng)、調(diào)度器初始化工作,然后創(chuàng)建 main goroutine 運(yùn)行 runtime.main函數(shù)。
  • 用戶邏輯:以main函數(shù)為入口

貼士:asm_amd64.s文件只針對(duì)linux64平臺(tái),入口文件會(huì)依據(jù)平臺(tái)不同而不同,該文件核心代碼如下:

CALL    runtime·args(SB)
CALL    runtime·osinit(SB)
CALL    runtime·schedinit(SB)

// create a new goroutine to start program
MOVQ    $runtime·mainPC(SB), AX                 // entry
PUSHQ   AX
PUSHQ   $0                                      // arg size
CALL    runtime·newproc(SB)                     // 用于創(chuàng)建 goroutine任務(wù)
POPQ    AX
POPQ    AX

// start this M
CALL    runtime·mstart(SB)                      // 讓線程進(jìn)入任務(wù)調(diào)度模式

MOVL    $0xf1, 0xf1                             // crash
RET

從運(yùn)行時(shí)創(chuàng)建main goroutine來看,go程序的整個(gè)進(jìn)程從一開始就以并發(fā)模式運(yùn)行了。

9.2 運(yùn)行時(shí)大致流程

image.png

十 運(yùn)行時(shí)初始化

運(yùn)行時(shí)需要對(duì)命令行參數(shù)、操作系統(tǒng)、調(diào)度器初始化等進(jìn)行初始化。其中最重要的是操作系統(tǒng)和調(diào)度器。

本章節(jié)只記錄系統(tǒng)相關(guān)初始化操作,調(diào)度器相關(guān)位于后文中。

10.1 CPU數(shù)量

CPU處理器數(shù)量在并發(fā)編程中是最重要的指標(biāo)之一,決定了并行策略、架構(gòu)設(shè)計(jì)。

當(dāng)然現(xiàn)在也有超線程技術(shù)(Hyper-Threading),在單個(gè)物理核心內(nèi)虛擬出多個(gè)邏輯處理器,類似多線程,將等待時(shí)間挖掘出來執(zhí)行其他任務(wù),以提升整體性能。但是相應(yīng)的,邏輯處理器之間需要共享一些資源(如緩存刷新),可能也因此拖慢執(zhí)行效率。

那么Go中的runtime.NumCPU返回的是物理核數(shù)量還是包含超線程的結(jié)果呢? (答案是后者)

// runtime2.go中的源碼
var ncpu int32

// os_linux.go中的源碼:
func osinit() {
    ncpu = getproccount()       // 返回邏輯處理器數(shù)量
}

// debug.go中的源碼:
func NumCPU() int {
    return int(ncpu)
}

10.2 schedinit

我們看看初始化時(shí)候做了哪些操作:

// proc.go
func schedinit() {
    sched.maxmcount = 10000             // M最大數(shù)量限制
    stackinit()                         // 內(nèi)存相關(guān)初始化
    malloclinit()                       // 內(nèi)存相關(guān)初始化
    mcommoninit(_g_.m)                  // M相關(guān)初始化
    goargs()                            // 存儲(chǔ)命令行參數(shù)
    goenvs()                            // 存儲(chǔ)環(huán)境變量
    parsedebugvars()                    // 解析GODEBUF參數(shù)
    gcinit()                            // 初始化gc
    sched.lastpoll = uint64(nanotime()) // 初始化poll時(shí)間

    // 設(shè)置 GOMAXPROCS,新版golang默認(rèn)設(shè)置為cpu核心數(shù)
    procs := ncpu                       
    if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
        procs = n 
    }
    if procresize(procs) != nil {
        throw("unknown runnable goroutine during bootstrap")
    }
}

完成上述初始化操作后執(zhí)行:runtime.main函數(shù)。

十一 邏輯層初始化

上述初始化是針對(duì)運(yùn)行時(shí)內(nèi)核而進(jìn)行的,并非邏輯層里的init函數(shù),如runtime包里的init函數(shù),標(biāo)準(zhǔn)庫、第三方庫中的init函數(shù)。

邏輯層初始化位于proc.go:

// The main goroutine.
func main() {

    // 棧最大值:64位位1G
    if sys.PtrSize == 8 {
        maxstacksize = 1000000000
    } else {
        maxstacksize = 250000000
    }

    // 啟動(dòng)后臺(tái)監(jiān)控
    systemstack(func() {
        newm(sysmon, nil)
    })

    // 執(zhí)行runtime包內(nèi)初始化函數(shù)
    runtime_init()

    // 啟動(dòng)時(shí)間、啟動(dòng)垃圾回收期
    runtimeInitTime = nanotime()
    gcenable()

    // 執(zhí)行用戶、標(biāo)準(zhǔn)庫、第三方庫初始化函數(shù)
    fn := main_init
    fn()

    // 如果是庫,不還行用戶入口函數(shù)
    if isarchive || islibrary {
        return
    }

    // 執(zhí)行用戶入口函數(shù)
    fn = main_main
    fn()

    // 退出
    exit(0)
}

由上看出,執(zhí)行了runtime.init,main.init,main.main三個(gè)函數(shù)。

其中前二者是初始化函數(shù),由編譯器動(dòng)態(tài)生成,職能分別為:

  • runtime.init:只負(fù)責(zé)runtime包的初始化
  • main.init:標(biāo)準(zhǔn)庫、第三方庫、用戶自定義函數(shù)在這里初始化

如圖所示:


image.png
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 轉(zhuǎn)載:Go內(nèi)存分配那些事,就這么簡單! 這是Go語言充電站的第27期分享。 新老朋友好久不見,我是大彬,這篇文章準(zhǔn)...
    meng_philip123閱讀 1,416評(píng)論 0 1
  • 介紹 了解操作系統(tǒng)對(duì)內(nèi)存的管理機(jī)制后,現(xiàn)在可以去看下 Go 語言是如何利用底層的這些特性來優(yōu)化內(nèi)存的。Go 的內(nèi)存...
    達(dá)菲格閱讀 9,024評(píng)論 2 47
  • 雖然這不是一次正經(jīng)的面試僅僅是和大佬聊了聊但作為第一次go的面試我覺得還是有必要記錄一下,問到的問題不會(huì)全寫只會(huì)羅...
    小王ovo閱讀 876評(píng)論 0 4
  • Go語言內(nèi)置運(yùn)行時(shí)(就是runtime),拋棄了傳統(tǒng)的內(nèi)存分配方式,改為自主管理。這樣可以自主地實(shí)現(xiàn)更好的內(nèi)存使用...
    ddu_sw閱讀 1,555評(píng)論 0 5
  • 零 前置知識(shí) 操作系統(tǒng)的每個(gè)進(jìn)程都認(rèn)為自己可以訪問計(jì)算機(jī)的所有物理內(nèi)存,但由于計(jì)算機(jī)必定運(yùn)行著多個(gè)程序,每個(gè)進(jìn)程都...
    voidFan閱讀 1,496評(píng)論 0 1

友情鏈接更多精彩內(nèi)容