目錄標題
- slice
- slice和array的區別
- slice擴容機制
- slice是否線程安全
- slice分配到棧上還是堆上
- 擴容過程中是否重新寫入
- go深拷貝發生在什么情況下?切片的深拷貝是怎么做的
- copy和左值進行初始化區別
- slice和map的區別
- map
- map介紹
- map的key的類型
- map對象如何比較
- map的底層原理
- map 負載因子
- map哈希沖突解決
- map擴容機制
- 擴容條件
- 增量擴容
- 等量擴容
- 實現線程安全的map
- sync.map的實現
- 場景
- map和sync.map的區別
- map查找過程
- map插入過程
- map沒申請空間,取值,會發生什么情況
- set的原理,Java 的HashMap和 go 的map底層原理
- channel
- channel介紹
- channel底層實現
- 背景
- 底層結構
- 緩沖區—環形隊列
- 等待隊列
- channel 讀寫
- 寫數據
- 讀數據
- 出現panic的場景
- 出現阻塞的場景
- channel和鎖對比
- channel應用場景
- 有無緩沖在使用上的區別
- channel是否線程安全
- 用channel實現分布式鎖
- go channel實現歸并排序
- 判斷channel已關閉
- chan和共享內存的優劣勢
- 使用chan不占內存空間實現傳遞信息
- go中的syncLock和channel的性能區別
- 同一個協程里面,對無緩沖channel同時發送和接收數據有什么問題
- defer
- defer規則
- defer的執行順序
- 延遲函數的參數在defer語句出現時就已經確定
- 延遲函數可能操作主函數的具名返回值
- 函數返回過程
- 主函數擁有匿名返回值,返回字面值
- 主函數擁有匿名返回值,返回變量
- 主函數擁有具名返回值
- defer與return誰先誰后
- defer遇見panic
- defer遇見panic,但是并不捕獲異常的情況
- defer遇見panic,并捕獲異常
- defer中包含panic
- 調度模型
- golang操作內核線程
- 講一講GMP模型
- 能開多少個M由什么決定
- 能開多少個M由什么決定
- golang調度能不能不要P
- 第一版
- 第二版
- 為什么GMP這么快
- GMP調度過程
- 兩種類型的隊列
- g阻塞,g,m,p發生什么
- 為什么P的local queue可無鎖訪問,任務竊取的時候要加鎖嗎?
- go調度中阻塞的方式
- 具體的調度策略
- 同時啟動一萬個G如何調度
- 搶占式調度及goroutine泄漏
- go的搶占式調度
- Goroutine 泄露
- P和M的數量一定是1:1嗎?如果一個G阻塞了會怎么樣?
- 一個協程掛起換入另外一個協程是什么過程?
- golang gmp模型,全局隊列中的G會不會饑餓,為什么?P的數量是多少?能修改嗎?M的數量是多少?
- 第一版
- 第二版
- 內存管理
- make和new的異同點
- 內存模型
- span
- 三級對象管理
- 四級內存塊管理
- 內存分配的實現
- 簡單介紹一下go的內存分配機制?有mcentral為啥要mcache?
- 第一版
- 第二版
- GC觸發時機
- go垃圾回收介紹
- 第一版
- 第二版
- Java垃圾回收
- golang逃逸分析
- 介紹棧堆
- 逃逸策略
- 逃逸分析好處
- 常見的逃逸現象
- 避免逃逸方法
- 寫代碼時如何減少對象分配
- 內存分配和tcmalloc的區別
- Go 語言內存分配,什么分配在堆上,什么分配在棧上
- go性能調優的方法
- 內存優化
- 并發優化
- 其它優化
- 虛擬內存有什么作用 (無效,屬于操作系統)
- 并發編程
- 說一下reflect
- runtime提供常見的方法
- sync.once 如何實現并發安全
- context數據結構
- go 怎么控制查詢timeout (context)
- go并發優秀在哪里
- 高并發特點
- golang并發控制
- 數據安全控制
- 并發行為控制
- golang支持哪些并發機制
- golang中Context的使用場景
- 用共享內存的方式實現并發如何保證安全
- 從運行速度來講,go的并發模型channel和goroutine
- 怎么理解“不要用共享內存來通信,而是用通信來共享內存”
slice
slice和array的區別
- 大小固定 vs. 大小可變:
- 數組是大小固定的,定義時需要指定數組的長度,無法動態增加或減少長度。
- 切片是基于數組的動態長度的抽象,可以根據需要動態調整長度。
- 值傳遞 vs. 引用傳遞:
- 數組在賦值或傳遞時,會進行值拷貝,即創建一個新的數組副本。
- 切片在賦值或傳遞時,只是傳遞了一個指向底層數組的引用,不會進行拷貝。
- 定義方式:
- 數組的長度是固定的,定義時需要指定長度,例如
var arr [5]int
。 - 切片的長度是可變的,可以通過
make
函數或使用切片字面量定義,例如s := make([]int, 5)
或s := []int{1, 2, 3}
。
- 數組的長度是固定的,定義時需要指定長度,例如
- 內存分配:
- 數組在定義時會直接分配連續的內存空間,長度固定。
- 切片在底層依賴數組,會根據實際需要動態分配內存空間。
- 操作和功能:
- 數組具有一些內置的操作和功能,如遍歷、排序等。
- 切片提供了更多的操作和功能,如追加、拼接、截取等。
slice擴容機制
擴容是為切片分配新的內存空間并復制原切片中元素的過程。在 go 語言的切片中,擴容的過程是:估計大致容量 -> 確定容量 -> 覆蓋原切片 -> 完成擴容。
- 首先判斷,如果新申請容量大于 2 倍的舊容量,最終容量就是新申請的容 量
- 否則判斷,如果舊切片的長度小于 1024,則最終容量就是舊容量的兩倍
- 否則判斷,如果舊切片長度大于等于 1024,則最終容量從舊容量開始循環 增加原來的 1/4, 直到最終容量大于等于新申請的容量
- 如果最終容量計算值溢出,則最終容量就是新申請容量
slice是否線程安全
Go 的切片(slice)類型本身并不是線程安全的。多個 goroutine 并發地對同一個切片進行讀寫操作可能會導致數據競爭和不確定的結果。如果需要在并發環境下安全地使用切片,可以采取以下幾種方式:
- 使用互斥鎖(Mutex)或讀寫鎖(RWMutex)來保護對切片的并發訪問。在訪問切片前獲取鎖,操作完成后釋放鎖,以確保同一時間只有一個 goroutine 可以訪問切片。
- 使用通道(Channel)來進行同步和通信。將切片操作封裝為一個獨立的 goroutine,通過通道接收和發送操作來保證對切片的順序訪問。
- 使用原子操作(Atomic Operations)來進行原子性的讀寫操作。Go 提供了一些原子操作的函數,如
atomic.AddInt32
、atomic.LoadPointer
等,可以確保在并發環境下對切片的操作是原子的。
slice分配到棧上還是堆上
有可能分配到棧上,也有可能分配到棧上。當開辟切片空間較大時,會逃逸到堆上。
擴容過程中是否重新寫入
切片的擴容, 當在尾部擴容時,追加元素,不需要重新寫入;
var a []int
a = append(a, 1)
在頭部插入時;會引起內存的重分配,導致已有的元素全部重新寫入;
a = append([]int{0}, a...);
在中間插入時,會局部重新寫入,如下: 使用鏈式操作在插入元素,在內層append函數中會創建一個臨式切片,然后將a[i:]內容復制到新創建的臨式切片中,再將臨式切片追加至a[:i]中。
a = append(a[:i], append([]int{x}, a[i:]...)...)
a = append(a[:i], append([]int{1, 2, 3}, a[i:]...)...)//在第i個位置上插入切片
go深拷貝發生在什么情況下?切片的深拷貝是怎么做的
- 深拷貝(Deep Copy):
拷貝的是數據本身,創造一個樣的新對象,新創建的對象與原對象不共享內存,新創建的對象在內存中開辟一個新的內存地址,新對象值修改時不會影響原對象值。既然內存地址不同,釋放內存地址時,可分別釋放。
- 淺拷貝(Shallow Copy):
拷貝的是數據地址,只復制指向的對象的指針,此時新對象和老對象指向的內存地址是一樣的,新對象值修改時老對象也會變化。釋放內存地址時,同時釋放內存地址。參考來源 (opens new window)在go語言中值類型賦值都是深拷貝,引用類型一般都是淺拷貝:
- 值類型的數據,默認全部都是深拷貝:Array、Int、String、Struct、Float,Bool
- 引用類型的數據,默認全部都是淺拷貝:Slice,Map
對于引用類型,想實現深拷貝,不能直接 := ,而是要先開辟地址空間(new) ,再進行賦值。可以使用 copy() 函數對slice進行深拷貝,copy 不會進行擴容,當要復制的 slice 比原 slice 要大的時候,只會移除多余的。使用 append() 函數來進行深拷貝,append 會進行擴容
copy和左值進行初始化區別
- copy(slice2, slice1)實現的是深拷貝。拷貝的是數據本身,創造一個新對象,新創建的對象與原對象不共享內存,新創建的對象在內存中開辟一個新的內存地址,新對象值修改時不會影響原對象值。 同樣的還有:遍歷slice進行append賦值
- 如slice2 := slice1實現的是淺拷貝。拷貝的是數據地址,只復制指向的對象的指針,此時新對象和老對象指向的內存地址是一樣的,新對象值修改時老對象也會變化。默認賦值操作就是淺拷貝。
slice和map的區別
Map 是一種無序的鍵值對的集合。Map 可以通過 key 來快速檢索數據,key 類似于索引,指向數據的值。 而 Slice 是切片,可以改變長度,動態擴容,切片有三個屬性,指針,長度,容量。 二者都可以用 make 進行初始化。
map
map介紹
Go中Map是一個KV對集合。底層使用hash table,用鏈表來解決沖突 ,出現沖突時,不是每一個Key都申請一個結構通過鏈表串起來,而是以bmap為最小粒度掛載,一個bmap可以放8個kv。每個map的底層結構是hmap,是有若干個結構為bmap的bucket組成的數組。每個bucket底層都采用鏈表結構。bmap 就是我們常說的“桶”,桶里面會最多裝 8 個 key,這些 key之所以會落入同一個桶,是因為它們經過哈希計算后,哈希結果是“一類”的,關于key的定位我們在map的查詢和賦值中詳細說明。在桶內,又會根據key計算出來的hash值的高8位來決定 key到底落入桶內的哪個位置(一個桶內最多有8個位置)。
map的key的類型
map[key]value
,其中key必須是可比較的,也就是可以通過==
和!=
進行比較,所以可以比較的類型才能作為key,其實就是等價問go語言中哪些類型是可以比較的:
什么可以比較:bool、array、numeric(浮點數、整數等)、pointer、string、interface、channel
什么不能比較:function、slice、map
golang中的map,的 key 可以是很多種類型,比如 bool, 數字,string, 指針, channel , 還有 只包含前面幾個類型的 interface types, structs, arrays; map是可以進行嵌套的。
map對象如何比較
使用reflect.DeepEqual 這個函數進行比較。使用 reflect.DeepEqual 有一點注意:由于使用了反射,所以有性能的損失。
map的底層原理
- map的實現原理
- go map是基于hash table(哈希表)來實現的,沖突的解決采用拉鏈法
- map的底層結構
- hmap(哈希表):每個hmap內含有多個bmap(buckets(桶)、oldbuckets(舊桶)、overflow(溢出桶))可以這樣理解,每個哈希表都是由多個桶組成的
type hmap struct {count int //元素的個數flags uint8 //狀態標志B uint8 //可以最多容納 6.5 * 2 ^ B 個元素,6.5為裝載因子noverflow uint16 //溢出的個數hash0 uint32 //哈希種子buckets unsafe.Pointer //指向一個桶數組oldbuckets unsafe.Pointer //指向一個舊桶數組,用于擴容nevacuate uintptr //搬遷進度,小于nevacuate的已經搬遷overflow *[2]*[]*bmap //指向溢出桶的指針
}
-
- buckets:一個指針,指向一個bmap數組、存儲多個桶。
- oldbuckets: 是一個指針,指向一個bmap數組,存儲多個舊桶,用于擴容。
- overflow:overflow是一個指針,指向一個元素個數為2的數組,數組的類型是一個指針,指向一個slice,slice的元素是桶(bmap)的地址,這些桶都是溢出桶。為什么有兩個?因為Go map在哈希沖突過多時,會發生擴容操作。[0]表示當前使用的溢出桶集合,[1]是在發生擴容時,保存了舊的溢出桶集合。overflow存在的意義在于防止溢出桶被gc。
- bmap(哈希桶): bmap是一個隸屬于hmap的結構體,一個桶(bmap)可以存儲8個鍵值對。如果有第9個鍵值對被分配到該桶,那就需要再創建一個桶,通過overflow指針將兩個桶連接起來。在hmap中,多個bmap桶通過overflow指針相連,組成一個鏈表。
type bmap struct {//元素hash值的高8位代表它在桶中的位置,如果tophash[0] < minTopHash,表示這個桶的搬遷狀態tophash [bucketCnt]uint8//接下來是8個key、8個value,但是我們不能直接看到;為了優化對齊,go采用了key放在一起,value放在一起的存儲方式,keys [8]keytype //key單獨存儲values [8]valuetype //value單獨存儲pad uintptroverflow uintptr //指向溢出桶的指針
}
map 負載因子
負載因子用于衡量一個哈希表沖突情況,公式為:
負載因子 = 鍵數量/bucket數量
例如,對于一個bucket數量為4,包含4個鍵值對的哈希表來說,這個哈希表的負載因子為1.哈希表需要將負載因子控制在合適的大小,超過其閥值需要進行rehash,也即鍵值對重新組織:
- 哈希因子過小,說明空間利用率低
- 哈希因子過大,說明沖突嚴重,存取效率低
每個哈希表的實現對負載因子容忍程度不同,比如Redis實現中負載因子大于1時就會觸發rehash,而Go則在在負載因子達到6.5時才會觸發rehash,因為Redis的每個bucket只能存1個鍵值對,而Go的bucket可能存8個鍵值對,所以Go可以容忍更高的負載因子。
map哈希沖突解決
在Go語言中,普通的map
類型在哈希沖突的情況下采用了開鏈法(鏈地址法)來解決。當不同的鍵經過哈希計算后映射到了同一個桶(bucket)時,就會產生哈希沖突。為了解決這些沖突,每個桶會維護一個鏈表,將哈希值相同的鍵值對鏈接在一起。以下是哈希沖突如何在Go中的普通map
中解決的簡要過程:
- 哈希計算:當插入或查找一個鍵值對時,首先會對鍵進行哈希計算,得到一個哈希值。
- 映射到桶:哈希值會被映射到一個特定的桶。Go中的
map
底層使用了一個哈希表,這個哈希表由多個桶組成。 - 處理沖突:如果兩個不同的鍵的哈希值映射到了同一個桶,就會發生哈希沖突。此時,系統會將新的鍵值對添加到該桶對應的鏈表中。
- 鏈表操作:鏈表中的每個節點代表一個鍵值對,相同哈希值的鍵值對會鏈接在同一個桶的鏈表上。插入時會在鏈表頭部插入節點,這使得查找和刪除操作的時間復雜度相對較低。
- 查找和刪除:對于查找操作,系統會先計算哈希值并找到對應的桶,然后遍歷該桶的鏈表以找到目標鍵值對。對于刪除操作,會在鏈表中找到目標鍵值對并將其從鏈表中移除
map擴容機制
擴容條件
- 負載因子大于6.5時,負載因子 = 鍵數量/bucket數量
- overflow的數量達到2^min(B,15)時
增量擴容
新建一個bucket數組,新的bucket數組的長度是原來的兩倍,然后舊bucket數組中的數據搬遷到新的bucket數組中。考慮到如果map存儲了數以億計的key-value,一次性搬遷將會造成比較大的延時,Go采用逐步搬遷策略,即每次訪問map時都會觸發一次搬遷,每次搬遷2個鍵值對。
等量擴容
所謂等量擴容,實際上并不是擴大容量,buckets數量不變,重新做一遍類似增量擴容的搬遷動作,把松散的鍵值對重新排列一次,以使bucket的使用率更高,進而保證更快的存取。
實現線程安全的map
Map默認不是并發安全的,并發讀寫時程序會panic。map為什么不支持線程安全?和場景有關,官方認為大部分場景不需要多個協程進行并發訪問,如果為小部分場景加鎖實現并發訪問,大部分場景將付出加鎖代價(性能降低)。
- 加讀寫鎖
- 分片加鎖
- sync.Map
加讀寫鎖、分片加鎖,這兩種方案都比較常用,后者的性能更好,因為它可以降低鎖的粒度,提高訪問此 map 對象的吞吐。前者并發性能雖然不如后者, 但是加鎖的方式更加簡單。sync.Map 是 Go 1.9 增加的一個線程安全的 map ,雖然是官方標準,但反而是不常用的,原因是 map 要解決的場景很難 描述,很多時候程序員在做抉擇是否該用它,不過在一些特殊場景會使用 sync.Map,場景一:只會增長的緩存系統,一個 key 值寫入一次而被讀很多次; 場景二:多個 goroutine 為不相交的鍵讀、寫和重寫鍵值對。對它的使用場景介紹,來自官方文檔 (opens new window),這里就不展開了。 加讀寫鎖,擴展 map 來實現線程安全,支持并發讀寫。使用讀寫鎖 RWMutex,是為了讀寫性能的考慮。 對 map 對象的操作,無非就是常見的增刪改查和遍歷。我們可以將查詢和遍歷看作讀操作,增加、修改和 刪除看作寫操作。示例代碼鏈接:https://github.com/guowei-gong/go-demo/blob/main/mutex/demo.go 。通過讀寫鎖提供線程安全的 map,但是大量并發讀寫的情況下,鎖的競爭會很激烈,導致性能降低。如何解決這個問題? 盡量減少鎖的粒度和鎖的持有時間,減少鎖的粒度,常用方法就是分片 Shard,將一把鎖分成幾把鎖,每個鎖控制一個分片。
sync.map的實現
sync.map采用讀寫分離和用空間換時間的策略保證map的讀寫安全。
- 散列桶和片段劃分:
sync.Map
的底層使用了一個散列桶數組來存儲鍵值對。這個數組被劃分成多個小的片段,每個片段有自己的鎖,這樣不同的片段可以獨立地進行操作,從而減少了競爭。 - 讀寫分離:為了允許高并發讀取,
sync.Map
實現了一種讀寫分離的機制。在讀取時,不需要鎖定,多個goroutine可以并發讀取。寫操作涉及到寫入數據,會獲取特定散列桶的寫鎖。 - 散列算法和沖突解決:
sync.Map
使用散列算法將鍵映射到散列桶。每個散列桶中都可能包含多個鍵值對,因此可能會出現散列沖突。沖突的解決方式通常是通過鏈表來存儲具有相同散列的鍵值對。 - 版本控制:
sync.Map
引入了版本控制的概念。每個散列桶中都包含了一個版本號,用于跟蹤對散列桶的修改。這使得在讀取時可以檢測到同時進行的寫入,從而確保讀取的數據的一致性。 - 內存管理和垃圾回收:
sync.Map
還包含了一些內存管理機制,以避免不再使用的內存積累。當某個散列桶不再被使用時,相應的內存可能會被釋放。
基本結構:
type Map struct {mu Mutexread atomic.Value //包含對并發訪問安全的map內容的部分(無論是否持有mu)dirty map[ant]*entry //包含map內容中需要保存mu的部分misses int //計算自從上次讀取map更新后,需要鎖定mu來確定key是否存在的加載次數
}
read:read 使用 map[any]*entry
存儲數據,本身支持無鎖的并發讀read 可以在無鎖的狀態下支持 CAS 更新,但如果更新的值是之前已經刪除過的 entry 則需要加鎖操作由于 read 只負責讀取,dirty 負責寫入,因此使用 amended
來標記 dirty 中是否包含 read 沒有的字段
**dirty:**dirty 本身就是一個原生 map,需要加鎖保證并發寫入
**entry:**read 和 dirty 都是用到 entry 結構entry 內部只有一個 unsafe.Pointer 指針 p 指向 entry 實際存儲的值指針 p 有三種狀態
-
p == nil
在此狀態下對應: entry 已經被刪除 或 map.dirty == nil 或 map.dirty 中有 key 指向 e 此處不明
-
p == expunged
在此狀態下對應:entry 已經被刪除 或 map.dirty != nil 同時該 entry 無法在 dirty 中找到
-
其他情況
entry 都是有效狀態并被記錄在 read 中,如果 dirty 不為空則也可以在 dirty 中找到
場景
- 只會增長的緩存系統,一個key只寫一次而被讀很多次
- 多個goroutine為不相交的鍵集讀、寫和重寫鍵值對
map和sync.map的區別
- 線程安全性:
map
是非線程安全的,多個 goroutine 并發地讀寫map
可能會導致數據競爭和不確定的結果。而sync.Map
是線程安全的,可以在多個 goroutine 并發地讀寫sync.Map
,而不需要額外的同步操作。 - 擴容機制:
map
的擴容是在插入新元素時自動進行的,按需增加內部哈希表的大小。而sync.Map
不會自動擴容,它始終使用固定大小的內部哈希表。 - 功能和方法:
map
提供了常見的讀取、插入、更新和刪除等操作,如m[key]
、m[key] = value
、delete(m, key)
等。而sync.Map
提供了一組特定的方法,如Load
、Store
、Delete
和Range
,用于讀取、存儲、刪除和遍歷鍵值對。 - 性能:由于
sync.Map
是線程安全的,它需要進行額外的同步操作,因此在并發性能方面可能會比普通的map
稍慢。而普通的map
在單個 goroutine 下的讀取和寫入操作性能較高
map查找過程
查找過程如下:
- 根據key值算出哈希值
- 取哈希值低位與hmap.B取模確定bucket位置
- 取哈希值高位在tophash數組中查詢
- 如果tophash[i]中存儲值也哈希值相等,則去找到該bucket中的key值進行比較
- 當前bucket沒有找到,則繼續從下個overflow的bucket中查找。
- 如果當前處于搬遷過程,則優先從oldbuckets查找
注:如果查找不到,也不會返回空值,而是返回相應類型的0值。
map插入過程
新元素插入過程如下:
- 根據key值算出哈希值
- 取哈希值低位與hmap.B取模確定bucket位置
- 查找該key是否已經存在,如果存在則直接更新值
- 如果沒找到將key,將key插入
map沒申請空間,取值,會發生什么情況
在map查詢操作中,最多可以給兩個變量賦值,第一個為值,第二個為bool類型的變量,用于指示是否存在指定的鍵,如果鍵不存在,那么第一個值為相應類型的零值。如果只指定一個變量,那么該變量僅表示改鍵對應的值,如果鍵不存在,那么該值同樣為相應類型的零值。
set的原理,Java 的HashMap和 go 的map底層原理
1. Set原理: Set特性: 1. 不包含重復key. 2.無序. 如何去重: 通過查看源碼add(E e)方法,底層實現有一個map,map是HashMap,Hash類型是散列,所以是無序的. 如果key值相同,將會覆蓋,這就是set為什么能去重的原因(key相同會覆蓋). 注意: 如果new出兩個對象add到set中,因為兩個對象的地址不相同,所以map在計算key的hash值時,將它當成了兩個不同的元素。這時要重寫equals和hashcode兩個方法。 這樣才能保證set集合的元素不重復.
2. Java HashMap:
線程不安全 安全的map(CurrentHashMap) HashMap由數組+鏈表組成,數組是HashMap的主體, 鏈表則是為了解決哈希沖突而存在的,如果定位到的數組位置不含鏈表(當前entry的next指向null),那么查找,添加等操作很快,僅需一次尋址即可; 如果定位到的數組包含鏈表,對于添加操作,其時間復雜度為O(n),首先遍歷鏈表,存在即覆蓋,否則新增; 對于查找操作來講,仍需遍歷鏈表,然后通過key對象的equals方法逐一比對查找。 所以,性能考慮,HashMap中的鏈表出現越少,性能才會越好。 假如一個數組槽位上鏈上數據過多(即鏈表過長的情況)導致性能下降該怎么辦? JDK1.8在JDK1.7的基礎上針對增加了紅黑樹來進行優化。 即當鏈表超過8時,鏈表就轉換為紅黑樹,利用紅黑樹快速增刪改查的特點提高HashMap的性能,其中會用到紅黑樹的插入、刪除、查找等算法。
3. go map:
線程不安全 安全的map(sync.map) 特性: 1. 無序. 2. 長度不固定. 3. 引用類型. 底層實現: 1.hmap 2.bmap(bucket) hmap中含有n個bmap,是一個數組. 每個bucket又以鏈表的形式向下連接新的bucket. bucket關注三個字段: 1. 高位哈希值 2. 存儲key和value的數組 3. 指向擴容bucket的指針 高位哈希值: 用于尋找bucket中的哪個key. 低位哈希值: 用于尋找當前key屬于hmap中的哪個bucket. map的擴容: 當map中的元素增長的時候,Go語言會將bucket數組的數量擴充一倍,產生一個新的bucket數組,并將舊數組的數據遷移至新數組。 加載因子 判斷擴充的條件,就是哈希表中的加載因子(即loadFactor)。 加載因子是一個閾值,一般表示為:散列包含的元素數 除以 位置總數。是一種“產生沖突機會”和“空間使用”的平衡與折中:加載因子越小,說明空間空置率高,空間使用率小,但是加載因子越大,說明空間利用率上去了,但是“產生沖突機會”高了。 每種哈希表的都會有一個加載因子,數值超過加載因子就會為哈希表擴容。 Golang的map的加載因子的公式是:map長度 / 2^B(這是代表bmap數組的長度,B是取的低位的位數)閾值是6.5。其中B可以理解為已擴容的次數。 當Go的map長度增長到大于加載因子所需的map長度時,Go語言就會將產生一個新的bucket數組,然后把舊的bucket數組移到一個屬性字段oldbucket中。注意:并不是立刻把舊的數組中的元素轉義到新的bucket當中,而是,只有當訪問到具體的某個bucket的時候,會把bucket中的數據轉移到新的bucket中。 map刪除: 并不會直接刪除舊的bucket,而是把原來的引用去掉,利用GC清除內存。
channel
channel介紹
channel是Golang在語言層面提供的goroutine間的通信方式,channel主要用于進程內各goroutine間的通信。channel分為無緩沖channel和有緩沖channel。
Channel 在 gouroutine 間架起了一條管道,在管道里傳輸數據,實現 gouroutine 間的通信;在并發編程中它線程安全的,所以用起來非常方便;channel 還提供“先進先出”的特性;它還能影響 goroutine 的阻塞和喚醒。
channel底層實現
背景
- Go語言提供了一種不同的并發模型–通信順序進程(communicating sequential processes,CSP)。
- 設計模式:通過通信的方式共享內存
- channel收發操作遵循先進先出(FIFO)的設計
底層結構
type hchan struct {qcount uint // 當前隊列中剩余元素個數dataqsiz uint // 環形隊列長度,即可以存放的元素個數buf unsafe.Pointer // 環形隊列指針elemsize uint16 // 每個元素的大小closed uint32 // 標識關閉狀態elemtype *_type // 元素類型sendx uint // 隊列下標,指示元素寫入時存放到隊列中的位置recvx uint // 隊列下標,指示元素從隊列的該位置讀出recvq waitq // 等待讀消息的goroutine隊列sendq waitq // 等待寫消息的goroutine隊列lock mutex // 互斥鎖,chan不允許并發讀寫
}
從數據結構可以看出channel由隊列、類型信息、goroutine等待隊列組成,channel內部數據結構主要包含:
- 環形隊列
- 等待隊列(讀隊列和寫隊列)
- mutex
緩沖區—環形隊列
chan內部實現了一個環形隊列作為其緩沖區,隊列的長度是創建chan時指定的。
- dataqsiz指示了隊列長度為6,即可緩存6個元素;
- buf指向隊列的內存,隊列中還剩余兩個元素;
- qcount表示隊列中還有兩個元素;
- sendx指示后續寫入的數據存儲的位置,取值[0, 6);
- recvx指示從該位置讀取數據, 取值[0, 6);
等待隊列
從channel讀數據,如果channel緩沖區為空或者沒有緩沖區,當前goroutine會被阻塞。
向channel寫數據,如果channel緩沖區已滿或者沒有緩沖區,當前goroutine會被阻塞。
被阻塞的goroutine將會掛在channel的等待隊列中:
- 因讀阻塞的goroutine會被向channel寫入數據的goroutine喚醒;
- 因寫阻塞的goroutine會被從channel讀數據的goroutine喚醒;
channel 讀寫
寫數據
向一個channel中寫數據簡單過程如下:
- 如果等待接收隊列recvq不為空,說明緩沖區中沒有數據或者沒有緩沖區,此時直接從recvq取出G,并把數據寫入,最后把該G喚醒,結束發送過程;
- 如果緩沖區中有空余位置,將數據寫入緩沖區,結束發送過程;
- 如果緩沖區中沒有空余位置,將待發送數據寫入G,將當前G加入sendq,進入睡眠,等待被讀goroutine喚醒;
讀數據
- 如果等待發送隊列sendq不為空,且沒有緩沖區,直接從sendq中取出G,把G中數據讀出,最后把G喚醒,結束讀取過程;
- 如果等待發送隊列sendq不為空,此時說明緩沖區已滿,從緩沖區中首部讀出數據,把G中數據寫入緩沖區尾部,把G喚醒,結束讀取過程;
- 如果緩沖區中有數據,則從緩沖區取出數據,結束讀取過程;
- 將當前goroutine加入recvq,進入睡眠,等待被寫goroutine喚醒;
出現panic的場景
關閉channel時會把recvq中的G全部喚醒,本該寫入G的數據位置為nil。把sendq中的G全部喚醒,但這些G會panic。
除此之外,panic出現的常見場景還有:
- 關閉值為nil的channel
- 關閉已經被關閉的channel
- 向已經關閉的channel寫數據
出現阻塞的場景
- 無緩沖區讀寫數據會阻塞
- 緩沖區已滿,寫入會阻塞;緩沖區為空,讀取數據會阻塞
- 值為nil讀寫數據會阻塞
channel和鎖對比
并發問題可以用channel解決也可以用Mutex解決,但是它們的擅長解決的問題有一些不同。channel關注的是并發問題的數據流動,適用于數據在多個協程中流動的場景。而mutex關注的是是數據不動,某段時間只給一個協程訪問數據的權限,適用于數據位置固定的場景。
channel應用場景
channel適用于數據在多個協程中流動的場景,有很多實際應用:
- 定時任務:超時處理
- 解耦生產者和消費者,可以將生產者和消費者解耦出來,生產者只需要往channel發送數據,而消費者只管從channel中獲取數據。
- 控制并發數:以爬蟲為例,比如需要爬取1w條數據,需要并發爬取以提高效率,但并發量又不過過大,可以通過channel來控制并發規模,比如同時支持5個并發任務
有無緩沖在使用上的區別
無緩沖:發送和接收需要同步。 有緩沖:不要求發送和接收同步,緩沖滿時發送阻塞。 因此 channel 無緩沖時,發送阻塞直到數據被接收,接收阻塞直到讀到數據;channel有緩沖時,當緩沖滿時發送阻塞,當緩沖空時接收阻塞。
channel是否線程安全
- channel為什么設計成線程安全? 不同協程通過channel進行通信,本身的使用場景就是多線程,為了保證數據的一致性,必須實現線程安全。
- channel如何實現線程安全的? channel的底層實現中, hchan結構體中采用Mutex鎖來保證數據讀寫安全。在對循環數組buf中的數據進行入隊和出隊操作時,必須先獲取互斥鎖,才能操作channel數據。
用channel實現分布式鎖
分布式鎖定義-控制分布式系統有序的去對共享資源進行操作,通過互斥來保持一致性。 通過數據庫,redis,zookeeper都可以實現分布式鎖。其中,最常見的是用redis的setnx實現。
通過channel作為媒介,利用struct{}{}作為信號,判斷struct{}{}是否存在進行加鎖、解鎖操作。
go channel實現歸并排序
func Merge(ch1 <-chan int, ch2 <-chan int) <-chan int {out := make(chan int)go func() {// 等上游的數據 (這里有阻塞,和常規的阻塞隊列并無不同)v1, ok1 := <-ch1v2, ok2 := <-ch2// 取數據for ok1 || ok2 {if !ok2 || (ok1 && v1 <= v2) {// 取到最小值, 就推到 out 中out <- v1v1, ok1 = <-ch1} else {out <- v2v2, ok2 = <-ch2}}// 顯式關閉close(out)}()// 開完goroutine后, 主線程繼續執行, 不會阻塞return out
判斷channel已關閉
方式1:通過讀chennel實現
用 select 和 <-ch 來結合判斷,ok的結果和含義: true:讀到數據,并且通道 (opens new window)沒有關閉。 false:通道關閉,無數據讀到。需要注意: 1.case 的代碼必須是 _, ok:= <- ch 的形式,如果僅僅是 <- ch 來判斷,是錯的邏輯,因為主要通過 ok的值來判斷; 2.select 必須要有 default 分支,否則會阻塞函數,我們要保證一定能正常返回;
方式2:通過context
通過一個 ctx 變量來指明 close 事件,而不是直接去判斷 channel 的一個狀態. 當ctx.Done()中有值時,則判斷channel已經退出。注意: select 的 case 一定要先判斷 ctx.Done() 事件,否則很有可能先執行了 chan 的操作從而導致 panic 問題;
chan和共享內存的優劣勢
Go的設計思想就是, 不要通過共享內存來通信,而是通過通信來共享內存,前者就是傳統的加鎖,后者就是Channel。 共享內存是在操作內存的同時,通過互斥鎖、CAS等保證并發安全,而channel雖然底層維護了一個互斥鎖,來保證線程安全,但其可以理解為先進先出的隊列,通過管道進行通信。 共享內存優勢是資源利用率高、系統吞吐量大,劣勢是平均周轉時間長、無交互能力。 channel優勢是降低了并發中的耦合,劣勢是會出現死鎖。
使用chan不占內存空間實現傳遞信息
// 空結構體的寬度是0,占用了0字節的內存空間。
// 所以空結構體組成的組合數據類型也不會占用內存空間。
channel := make(chan struct{})
go func() {// do somethingchannel <- struct{}{}
}()
fmt.Println(<-channel)
go中的syncLock和channel的性能區別
hannel的底層也是用了syns.Mutex,算是對鎖的封裝,性能應該是有損耗的。根據壓測結果來說Mutex 比 channel的性能快了兩倍左右
同一個協程里面,對無緩沖channel同時發送和接收數據有什么問題
同一個協程里,不能對無緩沖channel同時發送和接收數據,如果這么做會直接報錯死鎖。對于一個無緩沖的channel而言,只有不同的協程之間一方發送數據一方接受數據才不會阻塞。channel無緩沖時,發送阻塞直到數據被接收,接收阻塞直到讀到數據。
defer
defer規則
defer的執行順序
多個defer出現的時候,它是一個“棧”的關系,也就是先進后出。一個函數中,寫在前面的defer會比寫在后面的defer調用的晚。
延遲函數的參數在defer語句出現時就已經確定
func a() {i := 0defer fmt.Println(i)i++return
}
defer語句中的fmt.Println()參數i值在defer出現時就已經確定下來,實際上是拷貝了一份。后面對變量i的修改不會影響fmt.Println()函數的執行,仍然打印”0”。注意:對于指針類型參數,規則仍然適用,只不過延遲函數的參數是一個地址值,這種情況下,defer后面的語句對變量的修改可能會影響延遲函數。
延遲函數可能操作主函數的具名返回值
定義defer的函數,即主函數可能有返回值,返回值有沒有名字沒有關系,defer所作用的函數,即延遲函數可能會影響到返回值。若要理解延遲函數是如何影響主函數返回值的,只要明白函數是如何返回的就足夠了。
函數返回過程
關鍵字return不是一個原子操作,實際上return只代理匯編指令ret,即將跳轉程序執行。比如語句return i
,實際上分兩步進行,即將i值存入棧中作為返回值,然后執行跳轉,而defer的執行時機正是跳轉前,所以說defer執行時還是有機會操作返回值的。
主函數擁有匿名返回值,返回字面值
一個主函數擁有一個匿名的返回值,返回時使用字面值,比如返回”1”、”2”、”Hello”這樣的值,這種情況下defer語句是無法操作返回值的
func f() int {var i intdefer func() {i++}()return 2
}
// 上面的return語句,直接把1寫入棧中作為返回值,延遲函數無法操作該返回值,所以就無法影響返回值。
主函數擁有匿名返回值,返回變量
一個主函數擁有一個匿名的返回值,返回使用本地或全局變量,這種情況下defer語句可以引用到返回值,但不會改變返回值。
func f() int {var i intdefer func() {i++}()return i
}
// 上面的函數,返回一個局部變量,同時defer函數也會操作這個局部變量。對于匿名返回值來說,可以假定仍然有一個變量存儲返回值,假定返回值變量為”anony”,上面的返回語句可以拆分成以下過程:
anony = i
i++
return
// 由于i是整型,會將值拷貝給anony,所以defer語句中修改i值,對函數返回值不造成影響。
主函數擁有具名返回值
主函聲明語句中帶名字的返回值,會被初始化成一個局部變量,函數內部可以像使用局部變量一樣使用該返回值。如果defer語句操作該返回值,可能會改變返回結果。一個影響函返回值的例子:
func foo() (ret int) {defer func() {ret++}()return 0
}
// 上面的函數拆解出來,如下所示:
ret = 0
ret++
return
// 函數真正返回前,在defer中對返回值做了+1操作,所以函數最終返回1。
defer與return誰先誰后
return之后的語句先執行,defer后的語句后執行
defer遇見panic
能夠觸發defer的是遇見return(或函數體到末尾)和遇見panic。defer遇見return情況如下:
遇到panic時,遍歷本協程的defer鏈表,并執行defer。在執行defer過程中:遇到recover則停止panic,返回recover處繼續往下執行。如果沒有遇到recover,遍歷完本協程的defer鏈表后,向stderr拋出panic信息。
defer遇見panic,但是并不捕獲異常的情況
package mainimport ("fmt"
)
func main() {deferTest()fmt.Println("main 正常結束")
}
func deferTest() {defer func() { fmt.Println("defer: panic 之前1") }()defer func() { fmt.Println("defer: panic 之前2") }()panic("異常內容") //觸發defer出棧defer func() { fmt.Println("defer: panic 之后,永遠執行不到") }()
}
defer遇見panic,并捕獲異常
package mainimport ("fmt"
)func main() {deferTest()fmt.Println("main 正常結束")
}func deferTest() {defer func() {fmt.Println("defer: panic 之前1, 捕獲異常")if err := recover(); err != nil {fmt.Println(err)}}()defer func() { fmt.Println("defer: panic 之前2, 不捕獲") }()panic("異常內容") //觸發defer出棧defer func() { fmt.Println("defer: panic 之后, 永遠執行不到") }()
}
defer 最大的功能是 panic 后依然有效所以defer可以保證你的一些資源一定會被關閉,從而避免一些異常出現的問題。
defer中包含panic
package mainimport ("fmt"
)func main() {defer func() {if err := recover(); err != nil{fmt.Println(err)}else {fmt.Println("fatal")}}()defer func() {panic("defer panic")}()panic("panic")
}
// 輸出 defer panic
panic僅有最后一個可以被revover捕獲。觸發panic("panic")
后defer順序出棧執行,第一個被執行的defer中 會有panic("defer panic")
異常語句,這個異常將會覆蓋掉main中的異常panic("panic")
,最后這個異常被第二個執行的defer捕獲到。
調度模型
golang操作內核線程
在此模型下的用戶線程與內核線程一一對應,也就是說完全接管了用戶線程,它也屬于內核的一部分,統一由調度器來創建、終止和切換。這樣就能完全發揮出多核的優勢,多個線程可以跑在不同的CPU上,實現真正的并行。但也正由于一切都由內核來調度,這樣大大增加了工作量,線程的切換是非常耗時的,而且創建也很用到更多的資源,所以也大大減少能創建線程的數量。由于是一對一的關系所以也叫(1:1)線程實現。
講一講GMP模型
G(Goroutine)
:G 就是我們所說的 Go 語言中的協程 Goroutine 的縮寫,相當于操作系統中的進程控制塊。其中存著 goroutine 的運行時棧信息,CPU 的一些寄存器的值以及執行的函數指令等。M(Machine)
:代表一個操作系統的主線程,對內核級線程的封裝,數量對應真實的 CPU 數。一個 M 直接關聯一個 os 內核線程,用于執行 G。M 會優先從關聯的 P 的本地隊列中直接獲取待執行的 G。M 保存了 M 自身使用的棧信息、當前正在 M上執行的 G 信息、與之綁定的 P 信息。P(Processor)
:Processor 代表了 M 所需的上下文環境,代表 M 運行 G 所需要的資源。是處理用戶級代碼邏輯的處理器,可以將其看作一個局部調度器使 go 代碼在一個線程上跑。當 P 有任務時,就需要創建或者喚醒一個系統線程來執行它隊列里的任務,所以 P 和 M 是相互綁定的。總的來說,P 可以根據實際情況開啟協程去工作,它包含了運行 goroutine 的資源,如果線程想運行 goroutine,必須先獲取 P,P 中還包含了可運行的 G 隊列。
能開多少個M由什么決定
- 由于M必須持有一個P才可以運行Go代碼,所以同時運行的M個數,也即線程數一般等同于CPU的個數,以達到盡可能的使用CPU而又不至于產生過多的線程切換開銷。
- P的個數默認等于CPU核數,每個M必須持有一個P才可以執行G,一般情況下M的個數會略大于P的個數,這多出來的M將會在G產生系統調用時發揮作用。
- Go語?本身是限定M的最?量是10000,可以在runtime/debug包中的SetMaxThreads函數來修改設置
能開多少個M由什么決定
- P的個數在程序啟動時決定,默認情況下等同于CPU的核數
- 程序中可以使用 runtime.GOMAXPROCS() 設置P的個數,在某些IO密集型的場景下可以在一定程度上提高性能。
- 一般來講,程序運行時就將GOMAXPROCS大小設置為CPU核數,可讓Go程序充分利用CPU。在某些IO密集型的應用里,這個值可能并不意味著性能最好。理論上當某個Goroutine進入系統調用時,會有一個新的M被啟用或創建,繼續占滿CPU。但由于Go調度器檢測到M被阻塞是有一定延遲的,也即舊的M被阻塞和新的M得到運行之間是有一定間隔的,所以在IO密集型應用中不妨把GOMAXPROCS設置的大一些,或許會有好的效果。
golang調度能不能不要P
第一版
1.介紹golang調度器中P是什么?
Processor的簡稱,處理器,上下文。
2.簡述p的功能與為什么必須要P
它的主要用途就是用來執行goroutine的,它維護了一個goroutine隊列。Processor是讓咱們從N:1調度到M:N調度的重要部分
第二版
在 Go 語言中,P(Processor)是調度器的一部分,用于管理和執行 goroutine。每個 P 都有一個固定的系統線程(OS thread)關聯,用于在該線程上執行 goroutine。P 的存在是為了協調調度器和系統線程之間的關系,它充當了調度器和操作系統之間的中間層。P 的作用包括:
- 調度:P 負責將 goroutine 分配給系統線程執行,并在系統線程空閑時重新分配。
- Goroutine 棧管理:P 管理 goroutine 的棧空間,包括分配和回收。
- 垃圾回收:P 參與垃圾回收過程,協助標記和清理不再使用的內存。
由于 Go 語言的調度器是基于 M:N 模型實現的,即將 M 個 goroutine 關聯到 N 個系統線程上執行,因此不能直接在沒有 P 的情況下運行 goroutine。
為什么GMP這么快
談到 Go 語言調度器,繞不開操作系統,進程與線程這些概念。線程是操作系統調度的最小單元,而 Linux 調度器并不區分進程和線程的調度,它們在不同操作系統上的實現也不同,但是在大多數實現中線程屬于進程。多個線程可以屬于同一個進程并共享內存空間。因為多線程不需要創建新的虛擬內存空間,所以它們也不需要內存管理單元處理上下文的切換,線程之間的通信也正是基于共享內存進行的,與重量級進程相比,線程顯得比較輕量。雖然線程比較輕量,但是在調度時也有比較大的額外開銷。每個線程會都占用 1MB 以上的內存空間,在切換線程時不止會消耗較多內存,恢復寄存器中的內存還需要向操作系統申請或者銷毀資源。每一個線程上下文的切換都需要消耗 1 us 的時間,而 Go 調度器對 Goroutine 的上下文切換越為 0.2us,減少了 80% 的額外開銷。Go 語言的調度器使用與 CPU 數量相等的線程來減少線程頻繁切換帶來的內存開銷,同時在每一個線程上執行額外開銷更低的 Goroutine 來降低操作系統和硬件的負載。
GMP調度過程
- 我們通過 go func()來創建一個goroutine;
- 有兩個存儲G的隊列,一個是局部調度器P的本地隊列、一個是全局G隊列。新創建的G會先保存在P的本地隊列中,如果P的本地隊列已經滿了就會保存在全局的隊列中;
- G只能運行在M中,一個M必須持有一個P,M與P是1:1的關系。M會從P的本地隊列彈出一個可執行狀態的G來執行,如果P的本地隊列為空,會從全局隊列拿P,如果全局隊列也為空,就會向其他的MP組合偷取一個可執行的G來執行;
- 一個M調度G執行的過程是一個循環機制;
- 當M執行某一個G時候如果發生了syscall或則其余阻塞操作,M會阻塞,如果當前有一些G在執行,runtime會把這個線程M從P中摘除(detach),然后再創建一個新的操作系統的線程(如果有空閑的線程可用就復用空閑線程)來服務于這個P;
- 當M系統調用結束時候,這個G會嘗試獲取一個空閑的P執行,并放入到這個P的本地隊列。如果獲取不到P,那么這個線程M變成休眠狀態, 加入到空閑線程中,然后這個G會被放入全局隊列中。
兩種類型的隊列
- 本地隊列:本地的隊列是無鎖的,沒有數據競爭問題,處理速度比較高。
- 全局隊列:是用來平衡不同的P的任務數量,所有的M共享P的全局隊列。
- 全局G隊列(Global Queue):存放等待運?的G。
- P的本地G隊列:同全局隊列類似,存放的也是等待運?的G,存的數量有限,不超過256個。 新建G時,G優先加入到P的本地隊列,如果隊列滿了,則會把本地隊列中?半的G移動到全局隊列
- P列表:所有的P都在程序啟動時創建,并保存在數組中,最多有 GOMAXPROCS(可配置)個。可通過 runtime.GOMAXPROCS() 來進?設置,1.5版本之前默認為1,使?單核?執?,之后默認為最?邏輯cpu數量,即默認有最?邏輯cpu數量個P。、
- M列表:當前操作系統分配給golang程序的內核線程數。線程想運?任務就得獲取P,從P的本地隊列獲取G,P隊列為空時,M會優先嘗試從全局隊列拿?批G放到P的本地隊列,或從其他P的本地隊列偷?半放到??P的本地隊列。M運?G,G執?之后,M會從P獲取下?個G,不斷重復下去。 Goroutine調度器和OS調度器是通過M結合起來的,每個M都代表了1個內核線程,OS調度器負責把內核線程分配到CPU的
g阻塞,g,m,p發生什么
當g阻塞時,p會和m解綁,去尋找下一個可用的m。 g&m在阻塞結束之后會優先尋找之前的p,如果此時p已綁定其他m,當前m會進入休眠,g以可運行的狀態進入全局隊列
為什么P的local queue可無鎖訪問,任務竊取的時候要加鎖嗎?
綁定在P上的local queue是順序執行的,不存在執行狀態的G協程搶占,所以可以無鎖訪問。任務竊取也是竊取其他P上等待狀態的G協程,所以也可以不用加鎖。
go調度中阻塞的方式
- 由于原子、互斥量或通道操作調用導致 Goroutine 阻塞,調度器將把當前阻塞的 Goroutine 切換出去,重新調度 LRQ 上的其他 Goroutine;
- 由于網絡請求和 IO 操作導致 Goroutine 阻塞。Go 程序提供了網絡輪詢器(NetPoller)來處理網絡請求和 IO 操作的問題,其后臺通過 kqueue(MacOS),epoll(Linux)或 iocp(Windows)來實現 IO 多路復用。通過使用 NetPoller 進行網絡系統調用,調度器可以防止 Goroutine 在進行這些系統調用時阻塞 M。這可以讓 M 執行 P 的 LRQ 中其他的 Goroutines,而不需要創建新的 M。執行網絡系統調用不需要額外的 M,網絡輪詢器使用系統線程,它時刻處理一個有效的事件循環,有助于減少操作系統上的調度負載。用戶層眼中看到的 Goroutine 中的“block socket”,實現了 goroutine-per-connection 簡單的網絡編程模式。實際上是通過 Go runtime 中的 netpoller 通過 Non-block socket + I/O 多路復用機制“模擬”出來的。
- 當調用一些系統方法的時候(如文件 I/O),如果系統方法調用的時候發生阻塞,這種情況下,網絡輪詢器(NetPoller)無法使用,而進行系統調用的 G1 將阻塞當前 M1。調度器引入 其它M 來服務 M1 的P。
- 如果在 Goroutine 去執行一個 sleep 操作,導致 M 被阻塞了。Go 程序后臺有一個監控線程 sysmon,它監控那些長時間運行的 G 任務然后設置可以強占的標識符,別的 Goroutine 就可以搶先進來執行。
具體的調度策略
Go的調度器內部有三個重要的結構,G(代表一個goroutine,它有自己的棧),M(Machine,代表內核級線程),P(Processor([prɑ?ses?r]),上下文處理器,它的主要用途就是用來連接執行的goroutine和內核線程的,定義在源碼的src/runtime/runtime.h文件中) -G代表一個goroutine對象,每次go調用的時候,都會創建一個G對象 -M代表一個線程,每次創建一個M的時候,都會有一個底層線程創建;所有的G任務,最終還是在M上執行 -P代表一個處理器,每一個運行的M都必須綁定一個P,就像線程必須在每一個CPU核上執行一樣 一個M對應一個P,一個P下面掛多個G,但同一時間只有一個G在跑,其余都是放入等待隊列(runqueue([kju?]))。 當一個P的隊列消費完了就去全局隊列里取,如果全局隊列里也消費完了會去其他P的隊列里搶任務(所以需要單獨存儲下一個 g 的地址,而不是從隊列里獲取)。
同時啟動一萬個G如何調度
首先一萬個G會按照P的設定個數,盡量平均地分配到每個P的本地隊列中。如果所有本地隊列都滿了,那么剩余的G則會分配到GMP的全局隊列上。接下來便開始執行GMP模型的調度策略:
- 本地隊列輪轉:每個P維護著一個包含G的隊列,不考慮G進入系統調用或IO操作的情況下,P周期性的將G調度到M中執行,執行一小段時間,將上下文保存下來,然后將G放到隊列尾部,然后從隊首中重新取出一個G進行調度。
- 系統調用:上面說到P的個數默認等于CPU核數,每個M必須持有一個P才可以執行G,一般情況下M的個數會略大于P的個數,這多出來的M將會在G產生系統調用時發揮作用。當該G即將進入系統調用時,對應的M由于陷入系統調用而進被阻塞,將釋放P,進而某個空閑的M1獲取P,繼續執行P隊列中剩下的G。
- 工作量竊取:多個P中維護的G隊列有可能是不均衡的,當某個P已經將G全部執行完,然后去查詢全局隊列,全局隊列中也沒有新的G,而另一個M中隊列中還有3很多G待運行。此時,空閑的P會將其他P中的G偷取一部分過來,一般每次偷取一半。
搶占式調度及goroutine泄漏
go的搶占式調度
在1.1 版本中的調度器是不支持搶占式調度的,程序只能依靠 Goroutine 主動讓出 CPU 資源才能觸發調度。Go 語言的調度器在 1.2 版本中引入基于協作的搶占式調度,解決了以下的問題:
- 某些 Goroutine 可以長時間占用線程,造成其它 Goroutine 的饑餓;
- 垃圾回收需要暫停整個程序(Stop-the-world,STW),最長可能需要幾分鐘的時間,導致整個程序無法工作;
1.2 版本的搶占式調度雖然能夠緩解這個問題,但是它實現的搶占式調度是基于協作的,在之后很長的一段時間里 Go 語言的調度器都有一些無法被搶占的邊緣情況,例如:for 循環或者垃圾回收長時間占用線程,這些問題中的一部分直到 1.14 才被基于信號的搶占式調度解決。 搶占式分為兩種:
- 協作式的搶占式調度
- 基于信號的搶占式調度
Goroutine 泄露
Goroutine 作為一種邏輯上理解的輕量級線程,需要維護執行用戶代碼的上下文信息。在運行過程中也需要消耗一定的內存來保存這類信息,而這些內存在目前版本的 Go 中是不會被釋放的。因此,如果一個程序持續不斷地產生新的 goroutine、且不結束已經創建的 goroutine 并復用這部分內存,就會造成內存泄漏的現象。造成泄露的大多數原因有以下三種:
- Goroutine 內正在進行 channel/mutex 等讀寫操作,但由于邏輯問題,某些情況下會被一直阻塞。
- Goroutine 內的業務邏輯進入死循環,資源一直無法釋放。
- Goroutine 內的業務邏輯進入長時間等待,有不斷新增的 Goroutine 進入等待。
P和M的數量一定是1:1嗎?如果一個G阻塞了會怎么樣?
不一定,M必須持有P才可以執行代碼,跟系統中的其他線程一樣,M也會被系統調用阻塞。P的個數在啟動程序時決定,默認情況下等于CPU的核數,可以使用環境變量GOMAXPROCS或在程序中使用runtime.GOMAXPROCS()方法指定P的個數。 M的個數通常稍大于P的個數,因為除了運行Go代碼,runtime包還有其他內置任務需要處理。
一個協程掛起換入另外一個協程是什么過程?
對于進程、線程,都是有內核進行調度,有CPU時間片的概念,進行搶占式調度。協程,又稱微線程,纖程。英文名Coroutine。協程的調用有點類似子程序,如程序A調用了子程序B,子程序B調用了子程序C,當子程序C結束了返回子程序B繼續執行之后的邏輯,當子程序B運行結束了返回程序A,直到程序A運行結束。但是和子程序相比,協程有掛起的概念,協程可以掛起跳轉執行其他協程,合適的時機再跳轉回來。 本質上goroutine就是協程,但是完全運行在用戶態,采用了MPG模型:
M:內核級線程
G:代表一個goroutine
P:Processor,處理器,用來管理和執行goroutine的。
G-M-P三者的關系與特點:
- P的個數取決于設置的GOMAXPROCS,go新版本默認使用最大內核數,比如你有8核處理器,那么P的數量就是8
- M的數量和P不一定匹配,可以設置很多M,M和P綁定后才可運行,多余的M處于休眠狀態。
- P包含一個LRQ(Local Run Queue)本地運行隊列,這里面保存著P需要執行的協程G的隊列
- 除了每個P自身保存的G的隊列外,調度器還擁有一個全局的G隊列GRQ(Global Run Queue),這個隊列存儲的是所有未分配的協程G。
golang gmp模型,全局隊列中的G會不會饑餓,為什么?P的數量是多少?能修改嗎?M的數量是多少?
第一版
- 全局隊列中的G不會饑餓。 因為線程想運行任務就得獲取P,從P的本地隊列獲取G,P隊列為空時,M也會嘗試從全局隊列拿一批G放到P的本地隊列,或從其他P的本地隊列偷一半放到自己P的本地隊列。 M運行G,G執行之后,M會從P獲取下一個G,不斷重復下去。所以全局隊列中的G總是能被消費掉.
- P的數量可以理解為最大為本機可執行的cpu的最大數量。 通過runtime.GOMAXPROCS(runtime.NumCPU())設置。 runtime.NumCPU()方法返回當前進程可用的邏輯cpu數量。
第二版
全局隊列中的G不會饑餓,P中每執行61次調度,就需要優先從全局隊列中獲取一個G到當前P中,并執行下一個要執行的G。
P數量問題可以通過 runtime.GOMAXPROCS() 設置數量,默認為當前CPU可執行的最大數量。M數量問題 Go語?本身是限定M的最?量是10000。 runtime/debug包中的SetMaxThreads函數來設置。 有?個M阻塞,會創建?個新的M。 如果有M空閑,那么就會回收或者睡眠。
內存管理
make和new的異同點
- 用途不同:
make
用于創建和初始化引用類型(如 slice、map 和 channel),而new
用于創建指針類型的值。 - 返回類型不同:
make
返回的是所創建類型的引用,而new
返回的是對應類型的指針。 - 參數不同:
make
接收的參數是類型和一些可選的長度或容量等參數,具體取決于所創建的類型。而new
只接收一個參數,即所要創建類型的指針。 - 初始化不同:
make
創建的引用類型會進行初始化,并返回一個可用的、已分配內存的對象。而new
創建的指針類型只是返回一個對應類型的指針,并不會進行初始化。
內存模型
Go語言運行時依靠細微的對象切割、極致的多級緩存、精準的位圖管理實現了對內存的精細化管理。 將對象分為微小對象、小對象、大對象,使用三級管理結構mcache、mcentral、mheap用于管理、緩存加速span對象的訪問和分配,使用精準的位圖管理已分配的和未分配的對象及對象的大小。 Go語言運行時依靠細微的對象切割、極致的多級緩存、精準的位圖管理實現了對內存的精細化管理以及快速的內存訪問,同時減少了內存的碎片。
span
Go 將內存分成了67個級別的span,特殊的0級特殊大對象大小是不固定的。
當具體的對象需要分配內存時,并不是直接分配span,而是分配不同級別的span中的元素。因此span的級別不是以每個span的大小為依據,而是以span中元素的大小為依據的。
Span等級 | 元素大小(字節) | Span大小(字節) | 元素個數 |
---|---|---|---|
1 | 8 | 8192 | 1024 |
2 | 16 | 8192 | 512 |
3 | 32 | 8192 | 256 |
4 | 48 | 8192 | 170 |
65 | 64 | 8192 | 128 |
… | … | … | … |
65 | 28672 | 57344 | 2 |
66 | 32768 | 32768 | 1 |
第1級span擁有的元素個數為8192/8=1024。每個span的大小和span中元素的個數都不是固定的,例如第65級span的大小為57344字節,每個元素的大小為28672字節,元素個數為2。span的大小雖然不固定,但其是8KB或更大的連續內存區域。 每個具體的對象在分配時都需要對齊到指定的大小,假如我們分配17字節的對象,會對應分配到比17字節大并最接近它的元素級別,即第3級,這導致最終分配了32字節。因此,這種分配方式會不可避免地帶來內存的浪費。
三級對象管理
為了方便對Span進行管理,加速Span對象訪問、分配。分別為mcache、mcentral、mheap。 TCMalloc內存分配算法的思想: 每個邏輯處理器P都存儲了一個本地span緩存,稱作mcache。如果協程需要內存可以直接從mcache中獲取,由于在同一時間只有一個協程運行在邏輯處理器P上,所以中間不需要加鎖。mcache包含所有大小規格的mspan,但是每種規格大小只包含一個。除class0外,mcache的span都來自mcentral。
mcentral 所有邏輯處理器P共享的。
-
對象收集所有給定規格大小的span。每個mcentral都包含兩個mspan的鏈表:empty mspanList表示沒有空閑對象或span已經被mcache緩存的span鏈表,nonempty mspanList表示有空閑對象的span鏈表。(為了的分配Mspan到Mcache中)
mheap 每個級別的span都會有一個mcentral用于管理span鏈表(0級除外),其實 都是一個數組,由Mheap管理 作用: 不只是管理central,大對象也會直接通過mheap進行分配。
-
mheap實現了對虛擬內存線性地址空間的精準管理,建立了span與具體線性地址空間的聯系,保存了分配的位圖信息,是管理內存的最核心單元。堆區的內存被分成了HeapArea大小進行管理。對Heap進行的操作必須全局加鎖,而mcache、mcentral可以被看作某種形式的緩存。
四級內存塊管理
Go 根據對象大小,將堆內存分成了 HeapArea->chunk->span->page 4種內存塊進行管理。不同的內存塊用于不同的場景,便于高效地對內存進行管理。
- HeapArea 內存塊最大,其大小與平臺相關,在UNIX 64位操作系統中占據64MB。
- chunk占據了512KB
- span根據級別大小的不同而不同,但必須是page的倍數
- 而1個page占據8KB
內存分配的實現
Golang內存分配和TCMalloc差不多,都是把內存提前劃分成不同大小的塊,其核心思想是把內存分為多級管理,從而降低鎖的粒度。先了解下內存管理每一級的概念: mspan mspan跟tcmalloc中的span相似,它是golang內存管理中的基本單位,也是由頁組成的,每個頁大小為8KB,與tcmalloc中span組成的默認基本內存單位頁大小相同。mspan里面按照8*2n大小(8b,16b,32b … ),每一個mspan又分為多個object。
mcache mcache跟tcmalloc中的ThreadCache相似,ThreadCache為每個線程的cache,同理,mcache可以為golang中每個Processor提供內存cache使用,每一個mcache的組成單位也是mspan。
mcentral mcentral跟tcmalloc中的CentralCache相似,當mcache中空間不夠用,可以向mcentral申請內存。可以理解為mcentral為mcache的一個“緩存庫”,供mcaceh使用。它的內存組成單位也是mspan。mcentral里有兩個雙向鏈表,一個鏈表表示還有空閑的mspan待分配,一個表示鏈表里的mspan都被分配了。
mheap mheap跟tcmalloc中的PageHeap相似,負責大內存的分配。當mcentral內存不夠時,可以向mheap申請。那mheap沒有內存資源呢?跟tcmalloc一樣,向OS操作系統申請。還有,大于32KB的內存,也是直接向mheap申請。
golang 分配內存具體過程如下:
- 程序啟動時申請一大塊內存,并劃分成spans、bitmap、arena區域
- arena區域按頁劃分成一個個小塊
- span管理一個或多個頁
- mcentral管理多個span供線程申請使用
- mcache作為線程私有資源,資源來源于mcentral
簡單介紹一下go的內存分配機制?有mcentral為啥要mcache?
第一版
1.介紹內存分配機制
GO語言內存管理子系統主要由兩部分組成:內存分配器和垃圾回收器(gc)。內存分配器主要解決小對象的分配管理和多線程的內存分配問題。什么是小對象呢?小于等于32k的對象就是小對象,其它都是大對象。小對象的內存分配是通過一級一級的緩存來實現的,目的就是為了提升內存分配釋放的速度以及避免內存碎片等問題
2.介紹MCentral
所有線程共享的組件,不是獨占的,因此需要加鎖操作。它其實也是一個緩存,cache的一個上游用戶,但緩存的不是小對象內存塊,而是一組一組的內存page(一個page4K)。從圖2可以看出,在heap結構里,使用了一個0到n的數組來存儲了一批central,并不是只有一個central對象。從上面結構定義可以知道這個數組長度位61個元素,也就是說heap里其實是維護了61個central,這61個central對應了cache中的list數組,也就是每一個sizeclass就有一個central。所以,在cache中申請內存時,如果在某個sizeclass的內存鏈表上找不到空閑內存,那么cache就會向對應的sizeclass的central獲取一批內存塊。注意,這里central數組的定義里面使用填充字節,這是因為多線程會并發訪問不同central避免false sharing。
3.介紹mcache
每個線程都有一個cache,用來存放小對象。由于每個線程都有cache,所以獲取空閑內存是不用加鎖的。cache層的主要目的就是提高小內存的頻繁分配釋放速度。 我們在寫程序的時候,其實絕大多數的內存申請都是小于32k的,屬于小對象,因此這樣的內存分配全部走本地cache,不用向操作系統申請顯然是非常高效的
4.闡述二者區別
mcentral與mcache有一個明顯區別,就是有鎖存在,由于mcentral是公共資源,會有多個mcache向它申請mspan,因此必須加鎖,另外,mcentral與mcache不同,由于P綁定了很多Goroutine,在P上會處理不同大小的對象,mcache就需要包含各種規格的mspan,但mcentral不同,同一個mcentral只負責一種規格的mspan就夠了。
第二版
Go 的內存分配借鑒了 Google 的 TCMalloc 分配算法,其核心思想是內存池 + 多級對象管理。內存池主要是預先分配內存,減少向系統申請的頻率;多級對象有:mheap、mspan、arenas、mcentral、mcache。它們以 mspan 作為基本分配單位。具體的分配邏輯如下: 當要分配大于 32K 的對象時,從 mheap 分配。 當要分配的對象小于等于 32K 大于 16B 時,從 P 上的 mcache 分配,如果 mcache 沒有內存,則從 mcentral 獲取,如果 mcentral 也沒有,則向 mheap 申請,如果 mheap 也沒有,則從操作系統申請內存。 當要分配的對象小于等于 16B 時,從 mcache 上的微型分配器上分配。
GC觸發時機
- 內存分配量達到閥值觸發GC
每次內存分配時都會檢查當前內存分配量是否已達到閥值,如果達到閥值則立即啟動GC。
閥值 = 上次GC內存分配量 * 內存增長率
內存增長率由環境變量GOGC
控制,默認為100,即每當內存擴大一倍時啟動GC
- 定期觸發GC
默認情況下,最長2分鐘觸發一次GC,這個間隔在src/runtime/proc.go:forcegcperiod
變量中被聲明:
var forcegcperiod int64 = 2 * 60 * 1e9
- 手動觸發
程序代碼中也可以使用runtime.GC()
來手動觸發GC。這主要用于GC性能測試和統計。
go垃圾回收介紹
第一版
三色標記法+混合寫屏障
- 初始狀態下所有對象都是白色的。
- 從根節點開始遍歷所有對象,把遍歷到的對象變成灰色對象
- 遍歷灰色對象,將灰色對象引用的對象也變成灰色對象,然后將遍歷過的灰色對象變成黑色對象。
- 循環步驟3,直到灰色對象全部變黑色。
- 通過寫屏障(write-barrier)檢測對象有變化,重復以上操作
- 收集所有白色對象(垃圾)。
-
標記清除: 此算法主要有兩個主要的步驟:
標記(Mark phase)
清除(Sweep phase)
第一步,找出不可達的對象,然后做上標記。 第二步,回收標記好的對象。
操作非常簡單,但是有一點需要額外注意:mark and sweep算法在執行的時候,需要程序暫停!即 stop the world。 也就是說,這段時間程序會卡在哪兒。故中文翻譯成 卡頓.
標記-清掃(Mark And Sweep)算法存在什么問題? 標記-清掃(Mark And Sweep)算法這種算法雖然非常的簡單,但是還存在一些問題:
STW,stop the world;讓程序暫停,程序出現卡頓。
標記需要掃描整個heap
清除數據會產生heap碎片 這里面最重要的問題就是:mark-and-sweep 算法會暫停整個程序。
-
三色并發標記法: 首先:程序創建的對象都標記為白色。 gc開始:掃描所有可到達的對象,標記為灰色 從灰色對象中找到其引用對象標記為灰色,把灰色對象本身標記為黑色 監視對象中的內存修改,并持續上一步的操作,直到灰色標記的對象不存在 此時,gc回收白色對象 最后,將所有黑色對象變為白色,并重復以上所有過程。
-
混合寫屏障: 注意: 當gc進行中時,新創建一個對象. 按照三色標記法的步驟,對象會被標記為白色,這樣新生成的對象最后會被清除掉,這樣會影響程序邏輯. golang引入寫屏障機制.可以監控對象的內存修改,并對對象進行重新標記. gc一旦開始,無論是創建對象還是對象的引用改變,都會先變為灰色。
第二版
goalng1.8的GC采用三色標記法+混合寫屏障
三色標記法:將所有對象分為三類,白色、黑色與灰色。
白色:暫無對象引用的潛在垃圾,其內存可能會被垃圾回收器回收
黑色:表示活躍的對象
灰色:黑色與白色的中間狀態
三色標記算法分五步進行。
- 將所有的對象標記為白色
- 從根節點出發,將第一次遍歷到的節點標記為灰色
- 遍歷節點,將灰色節點遍歷到的白色節點標記為灰色,把遍歷到的灰色節點標記為黑色
- 循環執行該過程
- 直到沒有灰色節點,回收所有白色節點
屏障機制分為插入屏障和刪除屏障,插入屏障實現的是強三色不變式,刪除屏障則實現了弱三色不變式。值得注意的是為了保證棧的運行效率,屏障只對堆上的內存對象啟用,棧上的內存會在GC結束后啟用STW重新掃描。
插入屏障:對象被引用時觸發的機制,當白色對象被黑色對象引用時,白色對象被標記為灰色(棧上對象無插入屏障)。
C
語言這種較為傳統的語言通過malloc
和free
手動向操作系統申請和釋放內存,這種自由管理內存的方式給予程序員極大的自由度,但是也相應地提高了對程序員的要求。C
語言的內存分配和回收方式主要包括三種:
- 函數體內的局部變量:在棧上創建,函數作用域結束后自動釋放內存
- 靜態變量:在靜態存儲區域上分配內存,整個程序運行結束后釋放(全局生命周期)
- 動態分配內存的變量:在堆上分配,通過
malloc
申請,free
釋放
C
、C++
和Rust
等較早的語言采用的是手動垃圾回收,需要程序員通過向操作系統申請和釋放內存來手動管理內存,程序員極容易忘記釋放自己申請的內存,對于一個長期運行的程序往往是一個致命的缺點。
Java垃圾回收
就是將 對象的內存周期劃分為幾塊,按照每塊的情況采取不同的垃圾回收算法。一般是把Java堆分為新生代和老年代。年輕代:年輕代用來存放新近創建的對象,年輕代中存在的對象是死亡非常快的。存在朝生夕死的情況。 老年代:老年代中存放的對象是存活了很久的對象。 垃圾回收算法分為三種,分別為標記-清除算法,復制算法,標記-整理算法。
標記-清除算法:標記無用對象,然后對其進行清除回收。 復制算法:將內存區域劃分為大小相等的兩部分,每次只使用一部分,當該部分用完后將其存活的對象移至另一部分,并把該部分內存全部清除。 標記-整理算法:標記無用對象,讓所有存活的對象都向內存一端移動,然后清除掉存活對象邊界外的內存區域。
golang逃逸分析
Golang 的逃逸分析,是指編譯器根據代碼的特征和生命周期,自動的把變量分配到堆或者是棧上面。Go 在編譯階段確立逃逸,并不是在運行時。可以使用 -gcflags="-m"
參數來查看逃逸分析的詳細信息,包括哪些變量逃逸到堆上。
介紹棧堆
棧( stack)是系統自動分配空間的,例如我們定義一個 char a;系統會自動在棧上為其開辟空間。而堆(heap)則是程序員根據需要自己申請的空間,例如 malloc(10);開辟十個字節的空間。棧在內存中是從高地址向下分配的,并且連續的,遵循先進后出原則。系統在分配的時候已經確定好了棧的大小空間。棧上面的空間是自動回收的,所以棧上面的數據的生命周期在函數結束后,就被釋放掉了。堆分配是從低地址向高地址分配的,每次分配的內存大小可能不一致,導致了空間是不連續的,這也產生內存碎片的原因。由于是程序分配,所以效率相對慢些。而堆上的數據只要程序員不釋放空間,就一直可以訪問到,不過缺點是一旦忘記釋放會造成內存泄露。
逃逸策略
每當函數中申請新的對象,編譯器會根據該對象是否被函數外部引用來決定是否逃逸:
- 如果函數外部沒有引用,則優先放到棧中;
- 如果函數外部存在引用,則必定放到堆中;
注意,對于函數外部沒有引用的對象,也有可能放到堆中,比如內存過大超過棧的存儲能力。
逃逸分析好處
- 內存分配優化:逃逸分析可以幫助編譯器確定哪些變量可以在棧上分配,而不是在堆上分配。棧上分配的變量生命周期受限于函數或棧幀的范圍,分配和釋放內存的開銷較小,可以提高程序的性能。
- 減少內存壓力:通過將變量分配在棧上,可以減少對堆的內存壓力。這對于大量臨時對象的創建和銷毀非常有用,可以減少垃圾回收的頻率,提高程序的吞吐量。
- 減少垃圾回收壓力:逃逸分析可以減少不必要的堆分配,從而減少垃圾回收器的負擔。這對于大型和長時間運行的應用程序尤為重要,可以降低垃圾回收的停頓時間。
常見的逃逸現象
func(函數類型)數據類型;interface{} 數據類型 ;指針類型
[]interface{}
數據類型,通過[]
賦值必定會出現逃逸。map[string]interface{}
類型嘗試通過賦值,必定會出現逃逸。map[interface{}]interface{}
類型嘗試通過賦值,會導致key和value的賦值,出現逃逸。map[string][]string
數據類型,賦值會發生[]string
發生逃逸。[]*int
數據類型,賦值的右值會發生逃逸現象。func(*int)
函數類型,進行函數賦值,會使傳遞的形參出現逃逸現象。func([]string)
: 函數類型,進行[]string{"value"}
賦值,會使傳遞的參數出現逃逸現象。chan []string
數據類型,想當前channel中傳輸[]string{"value"}
會發生逃逸現象。- 發送指針或帶有指針的值到channel,因為編譯時候無法知道那個goroutine會在channel接受數據,編譯器無法知道什么時候釋放。
- 在一個切片上存儲指針或帶指針的值。比如[]*string,導致切片內容逃逸,其引用值一直在堆上。
- 切片的append導致超出容量,切片重新分配地址,切片背后的存儲基于運行時的數據進行擴充,就會在堆上分配。
- 調用接口類型時,接口類型的方法調用是動態調度,實際使用的具體實現只能在運行時確定,如一個接口類型為io.Reader的變量r,對r.Read(b)的調用將導致r的值和字節片b的后續轉義并因此分配到堆上。
- 在方法內把局部變量指針返回,被外部引用,其生命周期大于棧,導致內存溢出。
避免逃逸方法
- 不要盲目使用變量指針作為參數,雖然減少了復制,但變量逃逸的開銷更大。
- 預先設定好slice長度,避免頻繁超出容量,重新分配。
- 一個經驗是,指針指向的數據大部分在堆上分配的。
寫代碼時如何減少對象分配
例如如果需要把數字轉換成字符串,使用 strconv.Itoa() 比 fmt.Sprintf() 要快一倍左右。如果需要把數字轉換成字符串,使用 strconv.Itoa() 比 fmt.Sprintf() 要快一倍左右。
內存分配和tcmalloc的區別
go 內存分配核心思想就是把內存分為多級管理,從而降低鎖的粒度。它將可用的堆內存采用二級分配的方式進行管理:每個線程都會自行維護一個獨立的內存池,進行內存分配時優先從該內存池中分配,當內存池不足時才會向全局內存池申請,以避免不同線程對全局內存池的頻繁競爭。
- Go在程序啟動時,會向操作系統申請一大塊內存,之后自行管理。
- Go內存管理的基本單元是mspan,它由若干個頁組成,每種mspan可以分配特定大小的object。
- mcache, mcentral, mheap是Go內存管理的三大組件,層層遞進。mcache管理線程在本地緩存的mspan;mcentral管理全局的mspan供所有線程使用;mheap管理Go的所有動態分配內存。
- 極小的對象(<=16B)會分配在一個object中,以節省資源,使用tiny分配器分配內存;一般對象(16B-32KB)通過mspan分配內存;大對象(>32KB)則直接由mheap分配內存。
tcmalloc tcmalloc 是google開發的內存分配算法庫,最開始它是作為google的一個性能工具庫 perftools 的一部分。TCMalloc是用來替代傳統的malloc內存分配函數。它有減少內存碎片,適用于多核,更好的并行性支持等特性。 TC就是Thread Cache兩英文的簡寫。它提供了很多優化,如: 1.TCMalloc用固定大小的page(頁)來執行內存獲取、分配等操作。這個特性跟Linux物理內存頁的劃分是不是有同樣的道理。 2.TCMalloc用固定大小的對象,比如8KB,16KB 等用于特定大小對象的內存分配,這對于內存獲取或釋放等操作都帶來了簡化的作用。 3.TCMalloc還利用緩存常用對象來提高獲取內存的速度。 4.TCMalloc還可以基于每個線程或者每個CPU來設置緩存大小,這是默認設置。 5.TCMalloc基于每個線程獨立設置緩存分配策略,減少了多線程之間鎖的競爭。
Go中的內存分類并不像TCMalloc那樣分成小、中、大對象,但是它的小對象里又細分了一個Tiny對象,Tiny對象指大小在1Byte到16Byte之間并且不包含指針的對象。小對象和大對象只用大小劃定,無其他區分。 Go內存管理與tcmalloc最大的不同在于,它提供了逃逸分析和垃圾回收機制。
Go 語言內存分配,什么分配在堆上,什么分配在棧上
Go 語言有兩部分內存空間:棧內存和堆內存。棧內存由編譯器自動分配和釋放,函數調用的參數、返回值以及局部變量大都會被分配到棧上。堆內存的生命周期比棧內存要長,如果函數返回的值還會在其他地方使用,那么這個值就會被編譯器自動分配到堆上。堆內存相比棧內存來說,不能自動被編譯器釋放,只能通過垃圾回收器才能釋放,所以棧內存效率會很高。
go性能調優的方法
內存優化
- 將小對象合并成結構體一次分配,減少內存分配次數 Go runtime底層采用內存池機制,每個span大小為4k,同時維護一個cache。cache有一個0到n的list數組,list數組的每個單元掛載的是一個鏈表,鏈表的每個節點就是一塊可用的內存塊,同一鏈表中的所有節點內存塊都是大小相等的;但是不同鏈表的內存大小是不等的,即list數組的一個單元存儲的是一類固定大小的內存塊,不同單元里存儲的內存塊大小是不等的。cache緩存的是不同類大小的內存對象,申請的內存大小最接近于哪類緩存內存塊時,就分配哪類內存塊。當cache不夠時再向spanalloc中分配。
- 緩存區內容一次分配足夠大小空間,并適當復用 在協議編解碼時,需要頻繁地操作[]byte,可以使用bytes.Buffer或其它byte緩存區對象。 bytes.Buffer等通過預先分配足夠大的內存,避免當增長時動態申請內存,減少內存分配次數。對于byte緩存區對象需要考慮適當地復用。
- slice和map采make創建時,預估大小指定容量 slice和map與數組不一樣,不存在固定空間大小,可以根據增加元素來動態擴容。 slice初始會指定一個數組,當對slice進行append等操作時,當容量不夠時,會自動擴容: 如果新的大小是當前大小2倍以上,則容量增漲為新的大小; 否則循環以下操作:如果當前容量小于1024,按2倍增加;否則每次按當前容量1/4增漲,直到增漲的容量超過或等新大小。 map的擴容比較復雜,每次擴容會增加到上次容量的2倍。map的結構體中有一個buckets和oldbuckets,用于實現增量擴容: 正常情況下,直接使用buckets,oldbuckets為空; 如果正在擴容,則oldbuckets不為空,buckets是oldbuckets的2倍, 因此,建議初始化時預估大小指定容量
- 長調用棧避免申請較多的臨時對象 Goroutine的調用棧默認大小是4K(1.7修改為2K),采用連續棧機制,當棧空間不夠時,Go runtime會自動擴容: 當棧空間不夠時,按2倍增加,原有棧的變量會直接copy到新的棧空間,變量指針指向新的空間地址; 退棧會釋放棧空間的占用,GC時發現棧空間占用不到1/4時,則棧空間減少一半。 比如棧的最終大小2M,則極端情況下,就會有10次的擴棧操作,會帶來性能下降。 因此,建議控制調用棧和函數的復雜度,不要在一個goroutine做完所有邏輯;如的確需要長調用棧,而考慮goroutine池化,避免頻繁創建goroutine帶來棧空間的變化。
- 避免頻繁創建臨時對象 Go在GC時會引發stop the world,即整個情況暫停。Go1.8最壞情況下GC為100us。但暫停時間還是取決于臨時對象的個數,臨時對象數量越多,暫停時間可能越長,并消耗CPU。 因此,建議GC優化方式是盡可能地減少臨時對象的個數:盡量使用局部變量;所多個局部變量合并一個大的結構體或數組,減少掃描對象的次數,一次回盡可能多的內存。
并發優化
- 高并發的任務處理使用goroutine池 Goroutine雖然輕量,但對于高并發的輕量任務處理,頻繁來創建goroutine來執行,執行效率并不會太高,因為:過多的goroutine創建,會影響go runtime對goroutine調度,以及GC消耗;高并發時若出現調用異常阻塞積壓,大量的goroutine短時間積壓可能導致程序崩潰。
- 避免高并發調用同步系統接口 goroutine的實現,是通過同步來模擬異步操作。 網絡IO、鎖、channel、Time.sleep、基于底層系統異步調用的Syscall操作并不會阻塞go runtime的線程調度。 本地IO調用、基于底層系統同步調用的Syscall、CGo方式調用C語言動態庫中的調用IO或其它阻塞會創建新的調度線程。 網絡IO可以基于epoll的異步機制(或kqueue等異步機制),但對于一些系統函數并沒有提供異步機制。例如常見的posix api中,對文件的操作就是同步操作。雖有開源的fileepoll來模擬異步文件操作。但Go的Syscall還是依賴底層的操作系統的API。系統API沒有異步,Go也做不了異步化處理。 因此,建議:把涉及到同步調用的goroutine,隔離到可控的goroutine中,而不是直接高并的goroutine調用。
- 高并發時避免共享對象互斥 傳統多線程編程時,當并發沖突在4~8線程時,性能可能會出現拐點。Go推薦不通過共享內存來通信,Go創建goroutine非常容易,當大量goroutine共享同一互斥對象時,也會在某一數量的goroutine出在拐點。 因此,建議:goroutine盡量獨立,無沖突地執行;若goroutine間存在沖突,則可以采分區來控制goroutine的并發個數,減少同一互斥對象沖突并發數。
其它優化
- 避免使用CGO或者減少CGO調用次數 GO可以調用C庫函數,但Go帶有垃圾收集器且Go的棧動態增漲,無法與C無縫地對接。Go的環境轉入C代碼執行前,必須為C創建一個新的調用棧,把棧變量賦值給C調用棧,調用結束現拷貝回來。調用開銷較大,需要維護Go與C的調用上下文,兩者調用棧的映射。相比直接的GO調用棧,單純的調用棧可能有2個甚至3個數量級以上。 因此,建議:盡量避免使用CGO,無法避免時,要減少跨CGO的調用次數。
- 減少[]byte與string之間轉換,盡量采用[]byte來字符串處理 GO里面的string類型是一個不可變類型,GO中[]byte與string底層是兩個不同的結構,轉換存在實實在在的值對象拷貝,所以盡量減少不必要的轉化。 因此,建議:存在字符串拼接等處理,盡量采用[]byte。
- 字符串的拼接優先考慮bytes.Buffer string類型是一個不可變類型,但拼接會創建新的string。GO中字符串拼接常見有如下幾種方式: string + 操作 :導致多次對象的分配與值拷貝 fmt.Sprintf :會動態解析參數,效率好不哪去 strings.Join :內部是[]byte的append bytes.Buffer :可以預先分配大小,減少對象分配與拷貝 因此,建議:對于高性能要求,優先考慮bytes.Buffer,預先分配大小。
虛擬內存有什么作用 (無效,屬于操作系統)
虛擬內存就是說,讓物理內存擴充成更?的邏輯內存,從?讓程序獲得更多的可?內存。虛擬內存使?部分加載的
技術,讓?個進程或者資源的某些??加載進內存,從?能夠加載更多的進程,甚?能加載?內存?的進程,這樣
看起來好像內存變?了,這部分內存其實包含了磁盤或者硬盤,并且就叫做虛擬內存。
并發編程
說一下reflect
recflect是golang用來檢測存儲在接口變量內部(值value;類型concrete type) pair對的一種機制。它提供了兩種類型(或者說兩個方法)讓我們可以很容易的訪問接口變量內容,分別是reflect.ValueOf() 和 reflect.TypeOf()。
- ValueOf用來獲取輸入參數接口中的數據的值,如果接口為空則返回0
- TypeOf用來動態獲取輸入參數接口中的值的類型,如果接口為空則返回nil
runtime提供常見的方法
- Gosched():讓當前線程讓出 cpu 以讓其它線程運行,它不會掛起當前線程,因此當前線程未來會繼續執行。
- NumCPU():返回當前系統的 CPU 核數量。
- GOMAXPROCS():設置最大的可同時使用的 CPU 核數。 通過runtime.GOMAXPROCS函數,應用程序可以設置運行時系統中的 P 最大數量。注意,如果在運行期間設置該值的話,會引起“Stop the World”。所以,應在應用程序最早期調用,并且最好是在運行Go程序之前設置好操作程序的環境變量GOMAXPROCS,而不是在程序中調用runtime.GOMAXPROCS函數。無論我們傳遞給函數的整數值是什么值,運行時系統的P最大值總會在1~256之間。go1.8 后,默認讓程序運行在多個核上,可以不用設置了。go1.8 前,還是要設置一下,可以更高效的利用 cpu。
- Goexit():退出當前 goroutine(但是defer語句會照常執行)。
- NumGoroutine:返回正在執行和排隊的任務總數。 runtime.NumGoroutine函數在被調用后,會返回系統中的處于特定狀態的 Goroutine 的數量。這里的特定狀態是指Grunnable\Gruning\Gsyscall\Gwaition。處于這些狀態的Groutine即被看做是活躍的或者說正在被調度。注意:垃圾回收所在Groutine的狀態也處于這個范圍內的話,也會被納入該計數器。
- GOOS:查看目標操作系統。很多時候,我們會根據平臺的不同實現不同的操作,就可以用GOOS來查看自己所在的操作系統。
- runtime.GC:會讓運行時系統進行一次強制性的垃圾收集。 強制的垃圾回收:不管怎樣,都要進行的垃圾回收。非強制的垃圾回收:只會在一定條件下進行的垃圾回收(即運行時,系統自上次垃圾回收之后新申請的堆內存的單元(也成為單元增量)達到指定的數值)。
- GOROOT():獲取 goroot 目錄。
- runtime.LockOSThread 和 runtime.UnlockOSThread 函數:前者調用會使調用他的 Goroutine 與當前運行它的M鎖定到一起,后者調用會解除這樣的鎖定。
sync.once 如何實現并發安全
type Once struct {done unit32m Mutex
}
他們分別為標記是否已經執行過的標志(done),以及執行時所用的互斥鎖(m) 除了結構體外,sync.Once還包括了一個公開的方法Do:
func (o *Once) Do(f func()) {if atomic.LoadUint32(&o.done) == 0 {o.doSlow(f)}
}
Once.Do方法的實現非常簡單,通過atomic.LoadUint32獲取Once實例的done屬性值。 若done值為0時,表示函數f未被調用過或正運行中且未結束,則將調用doSlow方法; 若done值為1時,表示函數f已經調用且完成,則直接返回。 這里使用了原子操作方法atomic.LoadUint32而不是直接將o.done進行比較,也是為了避免并發狀態下錯誤地判斷執行狀態,產生不必要的鎖操作帶來的時間開銷。
func (o *Once) doSlow(f func()) {o.m.Lock()defer o.m.Unlock()if o.done == 0 {defer atomic.StoreUint32(&o.done, 1)f()}
}
Once.doSlow方法的實現使用了傳統的互斥鎖Mutex操作,在執行時即調用o.m.Lock方法獲得鎖,然后再繼續判斷是否已經完成并調用f函數。 可以看到,在獲得鎖后還需要對o.done的值進行一次判斷,避免了f函數被重復調用。 最后,在退出doSlow方法時還需要對獲取的鎖進行釋放,若進入到f函數的調用則需要更改o.done屬性值。
context數據結構
Context 是一個接口,定義了 4 個方法,它們都是冪等的。也就是說連續多次調用同一個方法,得到的結果都是相同的。
- Done() 返回一個 channel,可以表示 context 被取消的信號:當這個 channel 被關閉時,說明 context 被取消了。注意,這是一個只讀的channel。 我們又知道,讀一個關閉的 channel 會讀出相應類型的零值。并且源碼里沒有地方會向這個 channel 里面塞入值。換句話說,這是一個 receive-only 的 channel。因此在子協程里讀這個 channel,除非被關閉,否則讀不出來任何東西。也正是利用了這一點,子協程從 channel 里讀出了值(零值)后,就可以做一些收尾工作,盡快退出。
- Err() 返回一個錯誤,表示 channel 被關閉的原因。例如是被取消,還是超時。
- Deadline() 返回 context 的截止時間,通過此時間,函數就可以決定是否進行接下來的操作,如果時間太短,就可以不往下做了,否則浪費系統資源。當然,也可以用這個 deadline 來設置一個 I/O 操作的超時時間。
- Value() 獲取之前設置的 key 對應的 value。
go 怎么控制查詢timeout (context)
context 監聽是否有 IO 操作,開始從當前連接中讀取網絡請求,每當讀取到一個請求則會將該cancelCtx傳入,用以傳遞取消信號,可發送取消信號,取消所有進行中的網絡請求。
- Deadline — 返回 context.Context 被取消的時間,也就是完成工作的截止日期;
- Done — 返回一個 Channel,這個 Channel 會在當前工作完成或者上下文被取消之后關閉,多次調用 Done 方法會返回同一個 Channel;
- Err — 返回 context.Context 結束的原因,它只會在 Done 返回的 Channel 被關閉時才會返回非空的值;
- 如果 context.Context 被取消,會返回 Canceled 錯誤;
- 如果 context.Context 超時,會返回 DeadlineExceeded 錯誤;
- Value — 從 context.Context 中獲取鍵對應的值,對于同一個上下文來說,多次調用 Value 并傳入相同的 Key 會返回相同的結果,該方法可以用來傳遞請求特定的數據;
go并發優秀在哪里
Go中天然的支持并發,Go允許使用go語句開啟一個新的運行期線程,即 goroutine,以一個不同的、新創建的goroutine來執行一個函數。同一個程序中的所有goroutine共享同一個地址空間。 Goroutine非常輕量,除了為之分配的棧空間,其所占用的內存空間微乎其微。并且其棧空間在開始時非常小,之后隨著堆存儲空間的按需分配或釋放而變化。內部實現上,goroutine會在多個操作系統線程上多路復用。如果一個goroutine阻塞了一個操作系統線程,例如:等待輸入,這個線程上的其他goroutine就會遷移到其他線程,這樣能繼續運行。開發者并不需要關心/擔心這些細節。 Go語言的并發機制運用起來非常簡便,在啟動并發的方式上直接添加了語言級的關鍵字就可以實現,和其他編程語言相比更加輕量。
高并發特點
- 用戶空間:避免了內核態和用戶態的切換導致的成本
- 可以由語言和框架層進行調度
- 更小的棧空間允許創建大量的實例 2)
channel:
被單獨創建并且可以在進程之間傳遞,它的通信模式類似于 boss-worker 模式的,一個實體通過將消息發送到 channel 中,然后又監聽這個 channel 的實體處理,兩個實體之間是匿名的,這個就實現實體中間的解耦,在實現原理上其實是一個阻塞的消息隊列。 3)調度器:
goroutine 中提供了調度器,在調度器加入了steal working 算法 ,goroutine 是可以被異步搶占,因此沒有函數調用的進程不再對調度器造成死鎖或造成垃圾回收的大幅變慢。并且 go 對網絡IO庫進行了封裝,屏蔽了復雜的細節,對外提供統一的語法關鍵字支持,簡化了并發程序編寫的成本。
golang并發控制
數據安全控制
- 互斥鎖 sync.Mutex
- 讀寫鎖 sync.RWMutex
- 原子操作 sync/atomic
并發行為控制
golang控制并發有三種經典的方式,一種是通過channel通知實現并發控制 一種是WaitGroup,另外一種就是Context。
- 使用最基本通過channel通知實現并發控制 無緩沖通道: 無緩沖的通道指的是通道的大小為0,也就是說,這種類型的通道在接收前沒有能力保存任何值,它要求發送 goroutine 和接收 goroutine 同時準備好,才可以完成發送和接收操作。 從上面無緩沖的通道定義來看,發送 goroutine 和接收 gouroutine 必須是同步的,同時準備后,如果沒有同時準備好的話,先執行的操作就會阻塞等待,直到另一個相對應的操作準備好為止。這種無緩沖的通道我們也稱之為同步通道。
- 通過sync包中的WaitGroup實現并發控制 在 sync 包中,提供了 WaitGroup ,它會等待它收集的所有 goroutine 任務全部完成,在主 goroutine 中 Add(delta int) 索要等待goroutine 的數量。 在每一個 goroutine 完成后 Done() 表示這一個goroutine 已經完成,當所有的 goroutine 都完成后,在主 goroutine 中 WaitGroup 返回返回。
- 在Go 1.7 以后引進的強大的Context上下文,實現并發控制 在一些簡單場景下使用 channel 和 WaitGroup 已經足夠了,但是當面臨一些復雜多變的網絡并發場景下 channel 和 WaitGroup 顯得有些力不從心了。 比如一個網絡請求 Request,每個 Request 都需要開啟一個 goroutine 做一些事情,這些 goroutine 又可能會開啟其他的 goroutine,比如數據庫和RPC服務。 所以我們需要一種可以跟蹤 goroutine 的方案,才可以達到控制他們的目的,這就是Go語言為我們提供的 Context,稱之為上下文非常貼切,它就是goroutine 的上下文。 它是包括一個程序的運行環境、現場和快照等。每個程序要運行時,都需要知道當前程序的運行狀態,通常Go 將這些封裝在一個 Context 里,再將它傳給要執行的 goroutine 。 context 包主要是用來處理多個 goroutine 之間共享數據,及多個 goroutine 的管理。 context包方法: Done() 返回一個只能接受數據的channel類型,當該context關閉或者超時時間到了的時候,該channel就會有一個取消信號 Err() 在Done() 之后,返回context 取消的原因。 Deadline() 設置該context cancel的時間點 Value() 方法允許 Context 對象攜帶request作用域的數據,該數據必須是線程安全的。
golang支持哪些并發機制
Go語言中實現了兩種并發模型,一種是我們熟悉的線程與鎖的并發模型,它主要依賴于共享內存實現的。程序的正確運行很大程度依賴于開發人員的能力和技巧,程序在出錯時不易排查。另一種就是CSP并發模型,它使用通信的手段來共享內存。CSP中的并發實體是獨立的,它們之間沒有共享的內存空間,它們之間的數據交換通過通道實現的
CSP并發模型:
Go實現了兩種并發模式。第一種:多線程共享內存。第二種:通過通信來共享內存(CSP)
CSP并發模型是Go語言特有的并發模型,也是Go語言官方所推薦的并發模型。
Go的CSP并發模型,是由Go語言中的goroutine
與channel
共同來實現的。
- goroutine:Go語言中使用關鍵字
go
來創建goroutine。將關鍵字go
放到需要調用的函數前,在相同地址空間調用運行這個函數,該函數在執行的時候會創建一個獨立的線程去執行,這個線程就是Go語言中的goroutine。 - channel:Go語言中goroutine之間的通信機制
線程模型:
-
一對一模型(1:1)
將一個用戶級線程映射到一個內核線程,每一個線程由內核調度器獨立調度,線程之間互不影響
優點:在多核處理器的條件下,實現了真正的并行。
缺點:為每一個用戶級線程建立一個內核線程,開銷大,浪費資源。
-
多對一模型(M:1)
將多個用戶級線程映射到一個內核線程。
優點:線程上下文切換發生在用戶空間。
缺點:只有一個處理器被應用,在多處理環境下是不可以被接受的,實現了并發,不能解決并行問題。
-
多對多模型(M:N)
多個用戶級線程運行在多個內核線程上,這使得大部分的線程上下文切換都發生在用戶空間,而多個內核線程又能充分利用處理器資源
golang中Context的使用場景
Go1.7加入到標準庫,在于控制goroutine的生命周期。當一個計算任務被goroutine承接了之后,由于某種原因,我們希望中止這個goroutine的計算任務,那么就用得到這個Context了。 包含CancelContext,TimeoutContext,DeadLineContext,ValueContext
場景一:RPC調用 在主goroutine上有4個RPC,RPC2/3/4是并行請求的,我們這里希望在RPC2請求失敗之后,直接返回錯誤,并且讓RPC3/4停止繼續計算。這個時候,就使用的到Context。
場景二:PipeLine runSimplePipeline的流水線工人有三個,lineListSource負責將參數一個個分割進行傳輸,lineParser負責將字符串處理成int64,sink根據具體的值判斷這個數據是否可用。他們所有的返回值基本上都有兩個chan,一個用于傳遞數據,一個用于傳遞錯誤。(<-chan string, <-chan error)輸入基本上也都有兩個值,一個是Context,用于傳聲控制的,一個是(in <-chan)輸入產品的。
場景三:超時請求 我們發送RPC請求的時候,往往希望對這個請求進行一個超時的限制。當一個RPC請求超過10s的請求,自動斷開。當然我們使用CancelContext,也能實現這個功能(開啟一個新的goroutine,這個goroutine拿著cancel函數,當時間到了,就調用cancel函數)。鑒于這個需求是非常常見的,context包也實現了這個需求:timerCtx。具體實例化的方法是 WithDeadline 和 WithTimeout。具體的timerCtx里面的邏輯也就是通過time.AfterFunc來調用ctx.cancel的。
場景四:HTTP服務器的request互相傳遞數據 context還提供了valueCtx的數據結構。這個valueCtx最經常使用的場景就是在一個http服務器中,在request中傳遞一個特定值,比如有一個中間件,做cookie驗證,然后把驗證后的用戶名存放在request中。我們可以看到,官方的request里面是包含了Context的,并且提供了WithContext的方法進行context的替換。
用共享內存的方式實現并發如何保證安全
Go的設計思想就是, 不要通過共享內存來通信,而是通過通信來共享內存,前者就是傳統的加鎖,后者就是Channel。也就是說,設計Channel的主要目 的就是在多任務間傳遞數據的,本身就是安全的。 看源碼就知道channel內部維護了一個互斥鎖,來保證線程安全,channel底層實現出隊入隊時也加鎖。
從運行速度來講,go的并發模型channel和goroutine
- goroutine 是一種非常輕量級的實現,可在單個進程里執行成千上萬的并發任務,它是Go語言并發設計的核心。 說到底 goroutine 其實就是線程,但是它比線程更小,十幾個 goroutine 可能體現在底層就是五六個線程,而且Go語言內部也實現了 goroutine 之間的內存共享。 使用 go 關鍵字就可以創建 goroutine,將 go 聲明放到一個需調用的函數之前,在相同地址空間調用運行這個函數,這樣該函數執行時便會作為一個獨立的并發線程,這種線程在Go語言中則被稱為 goroutine。
- channel 是Go語言在語言級別提供的 goroutine 間的通信方式。可以使用 channel 在兩個或多個 goroutine 之間傳遞消息
怎么理解“不要用共享內存來通信,而是用通信來共享內存”
共享內存會涉及到多個線程同時訪問修改數據的情況,為了保證數據的安全性,那就會加鎖,加鎖會讓并行變為串行,cpu此時也會忙于線程搶鎖。另外使用過多的鎖,容易使得程序的代碼邏輯堅澀難懂,并且容易使程序死鎖,死鎖了以后排查問題相當困難,特別是很多鎖同時存在的時候。
在這種情況下,不如換一種方式,把數據復制一份,每個線程有自己的,只要一個線程干完一件事其他線程不用去搶鎖了,這就是一種通信方式,把共享的以通知方式交給線程,實現并發。go語言的channel就保證同一個時間只有一個goroutine能夠訪問里面的數據,為開發者提供了一種優雅簡單的工具,所以go原生的做法就是使用channle來通信,而不是使用共享內存來通信。