前言
前段時(shí)間找工作搜索 golang 面試題時(shí),發(fā)現(xiàn)都是比較零散或是基礎(chǔ)的題目,覆蓋面較小。而自己也在邊面試時(shí)邊總結(jié)了一些知識(shí)點(diǎn),為了方便后續(xù)回顧,特此整理了一下。
1. 相比較于其他語言, Go 有什么優(yōu)勢(shì)或者特點(diǎn)?
- Go 允許跨平臺(tái)編譯,編譯出來的是二進(jìn)制的可執(zhí)行文件,直接部署在對(duì)應(yīng)系統(tǒng)上即可運(yùn)行。
- Go 在語言層次上天生支持高并發(fā),通過 goroutine 和 channel 實(shí)現(xiàn)。channel 的理論依據(jù)是 CSP 并發(fā)模型, 即所謂的
通過通信來共享內(nèi)存;Go 在 runtime 運(yùn)行時(shí)里實(shí)現(xiàn)了屬于自己的調(diào)度機(jī)制:GMP,降低了內(nèi)核態(tài)和用戶態(tài)的切換成本。 - Go 的代碼風(fēng)格是強(qiáng)制性的統(tǒng)一,如果沒有按照規(guī)定來,會(huì)編譯不通過。
2. Golang 里的 GMP 模型?
GMP 模型是 golang 自己的一個(gè)調(diào)度模型,它抽象出了下面三個(gè)結(jié)構(gòu):
-
G:也就是協(xié)程 goroutine,由 Go runtime 管理。我們可以認(rèn)為它是用戶級(jí)別的線程。 -
P:processor 處理器。每當(dāng)有 goroutine 要?jiǎng)?chuàng)建時(shí),會(huì)被添加到 P 上的 goroutine 本地隊(duì)列上,如果 P 的本地隊(duì)列已滿,則會(huì)維護(hù)到全局隊(duì)列里。 -
M:系統(tǒng)線程。在 M 上有調(diào)度函數(shù),它是真正的調(diào)度執(zhí)行者,M 需要跟 P 綁定,并且會(huì)讓 P 按下面的原則挑出個(gè) goroutine 來執(zhí)行:
優(yōu)先從 P 的本地隊(duì)列獲取 goroutine 來執(zhí)行;如果本地隊(duì)列沒有,從全局隊(duì)列獲取,如果全局隊(duì)列也沒有,會(huì)從其他的 P 上偷取 goroutine。
3. goroutine 的協(xié)程有什么特點(diǎn),和線程相比?
goroutine 非常的輕量,初始分配只有 2KB,當(dāng)??臻g不夠用時(shí),會(huì)自動(dòng)擴(kuò)容。同時(shí),自身存儲(chǔ)了執(zhí)行 stack 信息,用于在調(diào)度時(shí)能恢復(fù)上下文信息。
而線程比較重,一般初始大小有幾 MB(不同系統(tǒng)分配不同),線程是由操作系統(tǒng)調(diào)度,是操作系統(tǒng)的調(diào)度基本單位。而 golang 實(shí)現(xiàn)了自己的調(diào)度機(jī)制,goroutine 是它的調(diào)度基本單位。
4. Go 的垃圾回收機(jī)制?
Go 采用的是三色標(biāo)記法,將內(nèi)存里的對(duì)象分為了三種:
- 白色對(duì)象:未被使用的對(duì)象;
- 灰色對(duì)象:當(dāng)前對(duì)象有引用對(duì)象,但是還沒有對(duì)引用對(duì)象繼續(xù)掃描過;
- 黑色對(duì)象,對(duì)上面提到的灰色對(duì)象的引用對(duì)象已經(jīng)全部掃描過了,下次不用再掃描它了。
當(dāng)垃圾回收開始時(shí),Go 會(huì)把根對(duì)象標(biāo)記為灰色,其他對(duì)象標(biāo)記為白色,然后從根對(duì)象遍歷搜索,按照上面的定義去不斷的對(duì)灰色對(duì)象進(jìn)行掃描標(biāo)記。當(dāng)沒有灰色對(duì)象時(shí),表示所有對(duì)象已掃描過,然后就可以開始清除白色對(duì)象了。
5. go 的內(nèi)存分配是怎么樣的?
Go 的內(nèi)存分配借鑒了 Google 的 TCMalloc 分配算法,其核心思想是內(nèi)存池 + 多級(jí)對(duì)象管理。內(nèi)存池主要是預(yù)先分配內(nèi)存,減少向系統(tǒng)申請(qǐng)的頻率;多級(jí)對(duì)象有:mheap、mspan、arenas、mcentral、mcache。它們以 mspan 作為基本分配單位。具體的分配邏輯如下:
- 當(dāng)要分配大于 32K 的對(duì)象時(shí),從 mheap 分配。
- 當(dāng)要分配的對(duì)象小于等于 32K 大于 16B 時(shí),從 P 上的 mcache 分配,如果 mcache 沒有內(nèi)存,則從 mcentral 獲取,如果 mcentral 也沒有,則向 mheap 申請(qǐng),如果 mheap 也沒有,則從操作系統(tǒng)申請(qǐng)內(nèi)存。
- 當(dāng)要分配的對(duì)象小于等于 16B 時(shí),從 mcache 上的微型分配器上分配。
6. channel 的內(nèi)部實(shí)現(xiàn)是怎么樣的?
channel 內(nèi)部維護(hù)了兩個(gè) goroutine 隊(duì)列,一個(gè)是待發(fā)送數(shù)據(jù)的 goroutine 隊(duì)列,另一個(gè)是待讀取數(shù)據(jù)的 goroutine 隊(duì)列。
每當(dāng)對(duì) channel 的讀寫操作超過了可緩沖的 goroutine 數(shù)量,那么當(dāng)前的 goroutine 就會(huì)被掛到對(duì)應(yīng)的隊(duì)列上,直到有其他 goroutine 執(zhí)行了與之相反的讀寫操作,將它重新喚起。
7. 對(duì)已經(jīng)關(guān)閉的 channel 進(jìn)行讀寫,會(huì)怎么樣?
當(dāng) channel 被關(guān)閉后,如果繼續(xù)往里面寫數(shù)據(jù),程序會(huì)直接 panic 退出。如果是讀取關(guān)閉后的 channel,不會(huì)產(chǎn)生 pannic,還可以讀到數(shù)據(jù)。但關(guān)閉后的 channel 沒有數(shù)據(jù)可讀取時(shí),將得到零值,即對(duì)應(yīng)類型的默認(rèn)值。
為了能知道當(dāng)前 channel 是否被關(guān)閉,可以使用下面的寫法來判斷。
if v, ok := <-ch; !ok {
fmt.Println("channel 已關(guān)閉,讀取不到數(shù)據(jù)")
}
還可以使用下面的寫法不斷的獲取 channel 里的數(shù)據(jù):
for data := range ch {
// get data dosomething
}
這種用法會(huì)在讀取完 channel 里的數(shù)據(jù)后就結(jié)束 for 循環(huán),執(zhí)行后面的代碼。
8. map 為什么是不安全的?
map 在擴(kuò)縮容時(shí),需要進(jìn)行數(shù)據(jù)遷移,遷移的過程并沒有采用鎖機(jī)制防止并發(fā)操作,而是會(huì)對(duì)某個(gè)標(biāo)識(shí)位標(biāo)記為 1,表示此時(shí)正在遷移數(shù)據(jù)。如果有其他 goroutine 對(duì) map 也進(jìn)行寫操作,當(dāng)它檢測(cè)到標(biāo)識(shí)位為 1 時(shí),將會(huì)直接 panic。
如果我們想要并發(fā)安全的 map,則需要使用 sync.map。
9. map 的 key 為什么得是可比較類型的?
map 的 key、value 是存在 buckets 數(shù)組里的,每個(gè) bucket 又可以容納 8 個(gè) key 和 8 個(gè) value。當(dāng)要插入一個(gè)新的 key - value 時(shí),會(huì)對(duì) key 進(jìn)行 hash 運(yùn)算得到一個(gè) hash 值,然后根據(jù) hash 值 的低幾位(取幾位取決于桶的數(shù)量,比如一開始桶的數(shù)量是 5,則取低 5 位)來決定命中哪個(gè) bucket。
在命中某個(gè) bucket 后,又會(huì)根據(jù) hash 值的高 8 位來決定是 8 個(gè) key 里的哪個(gè)位置。如果不巧,發(fā)生了 hash 沖突,即該位置上已經(jīng)有其他 key 存在了,則會(huì)去其他空位置尋找插入。如果全都滿了,則使用 overflow 指針指向一個(gè)新的 bucket,重復(fù)剛剛的尋找步驟。
從上面的流程可以看出,在判斷 hash 沖突,即該位置是否已有其他 key 時(shí),肯定是要進(jìn)行比較的,所以 key 必須得是可比較類型的。像 slice、map、function 就不能作為 key。
10. mutex 的正常模式、饑餓模式、自旋?
正常模式
當(dāng) mutex 調(diào)用 Unlock() 方法釋放鎖資源時(shí),如果發(fā)現(xiàn)有正在阻塞并等待喚起的 Goroutine 隊(duì)列時(shí),則會(huì)將隊(duì)頭的 Goroutine 喚起。隊(duì)頭的 goroutine 被喚起后,會(huì)采用 CAS 這種樂觀鎖的方式去修改占有標(biāo)識(shí)位,如果修改成功,則表示占有鎖資源成功了,當(dāng)前占有成功的 goroutine 就可以繼續(xù)往下執(zhí)行了。
饑餓模式
由于上面的 Goroutine 喚起后并不是直接的占用資源,而是使用 CAS 方法去嘗試性占有鎖資源。如果此時(shí)有新來的 Goroutine,那么它也會(huì)調(diào)用 CAS 方法去嘗試性的占有資源。對(duì)于 Go 的并發(fā)調(diào)度機(jī)制來講,會(huì)比較偏向于 CPU 占有時(shí)間較短的 Goroutine 先運(yùn)行,即新來的 Goroutine 比較容易占有資源,而隊(duì)頭的 Goroutine 一直占用不到,導(dǎo)致餓死。
針對(duì)這種情況,Go 采用了饑餓模式。即通過判斷隊(duì)頭 Goroutine 在超過一定時(shí)間后還是得不到資源時(shí),會(huì)在 Unlock 釋放鎖資源時(shí),直接將鎖資源交給隊(duì)頭 Goroutine,并且將當(dāng)前狀態(tài)改為饑餓模式。
后面如果有新來的 Goroutine 發(fā)現(xiàn)是饑餓模式時(shí), 則會(huì)直接添加到等待隊(duì)列的隊(duì)尾。
自旋
如果 Goroutine 占用鎖資源的時(shí)間比較短,那么每次釋放資源后,都調(diào)用信號(hào)量來喚起正在阻塞等候的 goroutine,將會(huì)很浪費(fèi)資源。
因此在符合一定條件后,mutex 會(huì)讓等候的 Goroutine 去空轉(zhuǎn) CPU,在空轉(zhuǎn)完后再次調(diào)用 CAS 方法去嘗試性的占有鎖資源,直到不滿足自旋條件,則最終才加入到等待隊(duì)列里。
11. Go 的逃逸行為是指?
在傳統(tǒng)的編程語言里,會(huì)根據(jù)程序員指定的方式來決定變量?jī)?nèi)存分配是在棧還是堆上,比如聲明的變量是值類型,則會(huì)分配到棧上,或者 new 一個(gè)對(duì)象則會(huì)分配到堆上。
在 Go 里變量的內(nèi)存分配方式則是由編譯器來決定的。如果變量在作用域(比如函數(shù)范圍)之外,還會(huì)被引用的話,那么稱之為發(fā)生了逃逸行為,此時(shí)將會(huì)把對(duì)象放到堆上,即使聲明為值類型;如果沒有發(fā)生逃逸行為的話,則會(huì)被分配到棧上,即使 new 了一個(gè)對(duì)象。
12 context 使用場(chǎng)景及注意事項(xiàng)
Go 里的 context 有 cancelCtx 、timerCtx、valueCtx。它們分別是用來通知取消、通知超時(shí)、存儲(chǔ) key - value 值。context 的 注意事項(xiàng)如下:
- context 的 Done() 方法往往需要配合 select {} 使用,以監(jiān)聽退出。
- 盡量通過函數(shù)參數(shù)來暴露 context,不要在自定義結(jié)構(gòu)體里包含它。
- WithValue 類型的 context 應(yīng)該盡量存儲(chǔ)一些全局的 data,而不要存儲(chǔ)一些可有可無的局部 data。
- context 是并發(fā)安全的。
- 一旦 context 執(zhí)行取消動(dòng)作,所有派生的 context 都會(huì)觸發(fā)取消。
13. context 是如何一層一層通知子 context
當(dāng) ctx, cancel := context.WithCancel(父Context)時(shí),會(huì)將當(dāng)前的 ctx 掛到父 context 下,然后開個(gè) goroutine 協(xié)程去監(jiān)控父 context 的 channel 事件,一旦有 channel 通知,則自身也會(huì)觸發(fā)自己的 channel 去通知它的子 context, 關(guān)鍵代碼如下
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
14. waitgroup 原理
waitgroup 內(nèi)部維護(hù)了一個(gè)計(jì)數(shù)器,當(dāng)調(diào)用 wg.Add(1) 方法時(shí),就會(huì)增加對(duì)應(yīng)的數(shù)量;當(dāng)調(diào)用 wg.Done() 時(shí),計(jì)數(shù)器就會(huì)減一。直到計(jì)數(shù)器的數(shù)量減到 0 時(shí),就會(huì)調(diào)用
runtime_Semrelease 喚起之前因?yàn)?wg.Wait() 而阻塞住的 goroutine。
15. sync.Once 原理
內(nèi)部維護(hù)了一個(gè)標(biāo)識(shí)位,當(dāng)它 == 0 時(shí)表示還沒執(zhí)行過函數(shù),此時(shí)會(huì)加鎖修改標(biāo)識(shí)位,然后執(zhí)行對(duì)應(yīng)函數(shù)。后續(xù)再執(zhí)行時(shí)發(fā)現(xiàn)標(biāo)識(shí)位 != 0,則不會(huì)再執(zhí)行后續(xù)動(dòng)作了。關(guān)鍵代碼如下:
type Once struct {
done uint32
m Mutex
}
func (o *Once) Do(f func()) {
// 原子加載標(biāo)識(shí)值,判斷是否已被執(zhí)行過
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) { // 還沒執(zhí)行過函數(shù)
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 { // 再次判斷下是否已被執(zhí)行過函數(shù)
defer atomic.StoreUint32(&o.done, 1) // 原子操作:修改標(biāo)識(shí)值
f() // 執(zhí)行函數(shù)
}
}
16. 定時(shí)器原理
一開始,timer 會(huì)被分配到一個(gè)全局的 timersBucket 時(shí)間桶。每當(dāng)有 timer 被創(chuàng)建出來時(shí),就會(huì)被分配到對(duì)應(yīng)的時(shí)間桶里了。
為了不讓所有的 timer 都集中到一個(gè)時(shí)間桶里,Go 會(huì)創(chuàng)建 64 個(gè)這樣的時(shí)間桶,然后根據(jù) 當(dāng)前 timer 所在的 Goroutine 的 P 的 id 去哈希到某個(gè)桶上:
// assignBucket 將創(chuàng)建好的 timer 關(guān)聯(lián)到某個(gè)桶上
func (t *timer) assignBucket() *timersBucket {
id := uint8(getg().m.p.ptr().id) % timersLen
t.tb = &timers[id].timersBucket
return t.tb
}
接著 timersBucket 時(shí)間桶將會(huì)對(duì)這些 timer 進(jìn)行一個(gè)最小堆的維護(hù),每次會(huì)挑選出時(shí)間最快要達(dá)到的 timer。如果挑選出來的 timer 時(shí)間還沒到,那就會(huì)進(jìn)行 sleep 休眠;如果 timer 的時(shí)間到了,則執(zhí)行 timer 上的函數(shù),并且往 timer 的 channel 字段發(fā)送數(shù)據(jù),以此來通知 timer 所在的 goroutine。
17. gorouinte 泄漏有哪些場(chǎng)景
gorouinte 里有關(guān)于 channel 的操作,如果沒有正確處理 channel 的讀取,會(huì)導(dǎo)致 channel 一直阻塞住, goroutine 不能正常結(jié)束
18. Slice 注意點(diǎn)
Slice 的擴(kuò)容機(jī)制
如果 Slice 要擴(kuò)容的容量大于 2 倍當(dāng)前的容量,則直接按想要擴(kuò)容的容量來 new 一個(gè)新的 Slice,否則繼續(xù)判斷當(dāng)前的長(zhǎng)度 len,如果 len 小于 1024,則直接按 2 倍容量來擴(kuò)容,否則一直循環(huán)新增 1/4,直到大于想要擴(kuò)容的容量。主要代碼如下:
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.len < 1024 {
newcap = doublecap
} else {
for newcap < cap {
newcap += newcap / 4
}
}
}
除此之外,還會(huì)根據(jù) slice 的類型做一些內(nèi)存對(duì)齊的調(diào)整,以確定最終要擴(kuò)容的容量大小。
Slice 的一些注意寫法
// =========== 第一種
a := make([]string, 5)
fmt.Println(len(a), cap(a)) // 輸出5 5
a = append(a, "aaa")
fmt.Println(len(a), cap(a)) // 輸出6 10
// 總結(jié): 由于make([]string, 5) 則默認(rèn)會(huì)初始化5個(gè) 空的"", 因此后面 append 時(shí),則需要2倍了
// =========== 第二種
a:=[]string{}
fmt.Println(len(a), cap(a)) // 輸出0 0
a = append(a, "aaa")
fmt.Println(len(a), cap(a)) // 輸出1 1
// 總結(jié):由于[]string{}, 沒有其他元素, 所以append 按 需要擴(kuò)容的 cap 來
// =========== 第三種
a := make([]string, 0, 5)
fmt.Println(len(a), cap(a)) // 輸出0 5
a = append(a, "aaa")
fmt.Println(len(a), cap(a)) // 輸出1 5
// 總結(jié):注意和第一種的區(qū)別,這里不會(huì)默認(rèn)初始化5個(gè),所以后面的append容量是夠的,不用擴(kuò)容
// =========== 第四種
b := make([]int, 1, 3)
a := []int{1, 2, 3}
copy(b, a)
fmt.Println(len(b)) // 輸出1
// 總結(jié):copy 取決于較短 slice 的 len, 一旦最小的len結(jié)束了,也就不再復(fù)制了
range slice
以下代碼的執(zhí)行是不會(huì)一直循環(huán)下去的,原因在于 range 的時(shí)候會(huì) copy 這個(gè) slice 上的 len 屬性到一個(gè)新的變量上,然后根據(jù)這個(gè) copy 值去遍歷 slice,因此遍歷期間即使 slice 添加了元素,也不會(huì)改變這個(gè)變量的值了。
v := []int{1, 2, 3}
for i := range v {
v = append(v, i)
}
另外,range 一個(gè) slice 的時(shí)候是進(jìn)行一個(gè)值拷貝的,如果 slice 里存儲(chǔ)的是指針集合,那在 遍歷里修改是有效的,如果 slice 存儲(chǔ)的是值類型的集合,那么就是在 copy 它們的副本,期間的修改也只是在修改這個(gè)副本,跟原來的 slice 里的元素是沒有關(guān)系的。
slice 入?yún)⒆⒁恻c(diǎn)
如果 slice 作為函數(shù)的入?yún)?,通常希望?duì) slice 的操作可以影響到底層數(shù)據(jù),但是如果在函數(shù)內(nèi)部 append 數(shù)據(jù)超過了 cap,導(dǎo)致重新分配底層數(shù)組,這時(shí)修改的 slice 將不再是原來入?yún)⒌哪莻€(gè) slice 了。因此通常不建議在函數(shù)內(nèi)部對(duì) slice 有 append 操作,若有需要?jiǎng)t顯示的 return 這個(gè) slice。
19. make 和 new 的區(qū)別
new 是返回某個(gè)類型的指針,將會(huì)申請(qǐng)某個(gè)類型的內(nèi)存。而 make 只能用于 slice, map, channel 這種 golang 內(nèi)部的數(shù)據(jù)結(jié)構(gòu),它們可以只聲明不初始化,或者初始化時(shí)指定一些特定的參數(shù),比如 slice 的長(zhǎng)度、容量;map 的長(zhǎng)度;channel 的緩沖數(shù)量等。
20. defer、panic、recover 三者的用法
defer 函數(shù)調(diào)用的順序是后進(jìn)先出,當(dāng)產(chǎn)生 panic 的時(shí)候,會(huì)先執(zhí)行 panic 前面的 defer 函數(shù)后才真的拋出異常。一般的,recover 會(huì)在 defer 函數(shù)里執(zhí)行并捕獲異常,防止程序崩潰。
package main
import "fmt"
func main() {
defer func(){
fmt.Println("b")
}()
defer func() {
if err := recover(); err != nil {
fmt.Println("捕獲異常:", err)
}
}()
panic("a")
}
// 輸出
// 捕獲異常: a
// b
21 slice 和 array 的區(qū)別
array 是固定長(zhǎng)度的數(shù)組,并且是值類型的,也就是說是拷貝復(fù)制的, slice 是一個(gè)引用類型,指向了一個(gè)動(dòng)態(tài)數(shù)組的指針,會(huì)進(jìn)行動(dòng)態(tài)擴(kuò)容。
感興趣的朋友可以搜一搜公眾號(hào)「 閱新技術(shù) 」,關(guān)注更多的推送文章。
可以的話,就順便點(diǎn)個(gè)贊、留個(gè)言、分享下,感謝各位支持!
閱新技術(shù),閱讀更多的新知識(shí)。