golang 面試總結

前言

前段時間找工作搜索 golang 面試題時,發現都是比較零散或是基礎的題目,覆蓋面較小。而自己也在邊面試時邊總結了一些知識點,爲了方便後續回顧,特此整理了一下。

1. 相比較於其他語言, Go 有什麼優勢或者特點?

2. Golang 裏的 GMP 模型?

GMP 模型是 golang 自己的一個調度模型,它抽象出了下面三個結構:

優先從 P 的本地隊列獲取 goroutine 來執行;如果本地隊列沒有,會從其他的 P 上偷取 goroutine;如果其他 P 上也沒有,則會從全局隊列上獲取 goroutine。

3. goroutine 的協程有什麼特點,和線程相比?

goroutine 非常的輕量,初始分配只有 2KB,當棧空間不夠用時,會自動擴容。同時,自身存儲了執行 stack 信息,用於在調度時能恢復上下文信息。

而線程比較重,一般初始大小有幾 MB(不同系統分配不同),線程是由操作系統調度,是操作系統的調度基本單位。而 golang 實現了自己的調度機制,goroutine 是它的調度基本單位。

4. Go 的垃圾回收機制?

Go 採用的是三色標記法,將內存裏的對象分爲了三種:

當垃圾回收開始時,Go 會把根對象標記爲灰色,其他對象標記爲白色,然後從根對象遍歷搜索,按照上面的定義去不斷的對灰色對象進行掃描標記。當沒有灰色對象時,表示所有對象已掃描過,然後就可以開始清除白色對象了。

5. go 的內存分配是怎麼樣的?

Go 的內存分配借鑑了 Google 的 TCMalloc 分配算法,其核心思想是內存池 + 多級對象管理。內存池主要是預先分配內存,減少向系統申請的頻率;多級對象有:mheap、mspan、arenas、mcentral、mcache。它們以 mspan 作爲基本分配單位。具體的分配邏輯如下:

6. channel 的內部實現是怎麼樣的?

channel 內部維護了兩個 goroutine 隊列,一個是待發送數據的 goroutine 隊列,另一個是待讀取數據的 goroutine 隊列。

每當對 channel 的讀寫操作超過了可緩衝的 goroutine 數量,那麼當前的 goroutine 就會被掛到對應的隊列上,直到有其他 goroutine 執行了與之相反的讀寫操作,將它重新喚起。

7. 對已經關閉的 channel 進行讀寫,會怎麼樣?

當 channel 被關閉後,如果繼續往裏面寫數據,程序會直接 panic 退出。如果是讀取關閉後的 channel,不會產生 pannic,還可以讀到數據。但關閉後的 channel 沒有數據可讀取時,將得到零值,即對應類型的默認值。

爲了能知道當前 channel 是否被關閉,可以使用下面的寫法來判斷。

 if v, ok := <-ch; !ok {
  fmt.Println("channel 已關閉,讀取不到數據")
 }

還可以使用下面的寫法不斷的獲取 channel 裏的數據:

 for data := range ch {
  // get data dosomething
 }

這種用法會在讀取完 channel 裏的數據後就結束 for 循環,執行後面的代碼。

8. map 爲什麼是不安全的?

map 在擴縮容時,需要進行數據遷移,遷移的過程並沒有採用鎖機制防止併發操作,而是會對某個標識位標記爲 1,表示此時正在遷移數據。如果有其他 goroutine 對 map 也進行寫操作,當它檢測到標識位爲 1 時,將會直接 panic。

如果我們想要併發安全的 map,則需要使用 sync.map。

9. map 的 key 爲什麼得是可比較類型的?

map 的 key、value 是存在 buckets 數組裏的,每個 bucket 又可以容納 8 個 key 和 8 個 value。當要插入一個新的 key - value 時,會對 key 進行 hash 運算得到一個 hash 值,然後根據 hash 值 的低幾位 (取幾位取決於桶的數量,比如一開始桶的數量是 5,則取低 5 位) 來決定命中哪個 bucket。

在命中某個 bucket 後,又會根據 hash 值的高 8 位來決定是 8 個 key 裏的哪個位置。如果不巧,發生了 hash 衝突,即該位置上已經有其他 key 存在了,則會去其他空位置尋找插入。如果全都滿了,則使用 overflow 指針指向一個新的 bucket,重複剛剛的尋找步驟。

從上面的流程可以看出,在判斷 hash 衝突,即該位置是否已有其他 key 時,肯定是要進行比較的,所以 key 必須得是可比較類型的。像 slice、map、function 就不能作爲 key。

10. mutex 的正常模式、飢餓模式、自旋?

正常模式

當 mutex 調用 Unlock() 方法釋放鎖資源時,如果發現有正在阻塞並等待喚起的 Goroutine 隊列時,則會將隊頭的 Goroutine 喚起。隊頭的 goroutine 被喚起後,會採用 CAS 這種樂觀鎖的方式去修改佔有標識位,如果修改成功,則表示佔有鎖資源成功了,當前佔有成功的 goroutine 就可以繼續往下執行了。

飢餓模式

由於上面的 Goroutine 喚起後並不是直接的佔用資源,而是使用 CAS 方法去嘗試性佔有鎖資源。如果此時有新來的 Goroutine,那麼它也會調用 CAS 方法去嘗試性的佔有資源。對於 Go 的併發調度機制來講,會比較偏向於 CPU 佔有時間較短的 Goroutine 先運行,即新來的 Goroutine 比較容易佔有資源,而隊頭的 Goroutine 一直佔用不到,導致餓死。

針對這種情況,Go 採用了飢餓模式。即通過判斷隊頭 Goroutine 在超過一定時間後還是得不到資源時,會在 Unlock 釋放鎖資源時,直接將鎖資源交給隊頭 Goroutine,並且將當前狀態改爲飢餓模式。

後面如果有新來的 Goroutine 發現是飢餓模式時, 則會直接添加到等待隊列的隊尾。

自旋

如果 Goroutine 佔用鎖資源的時間比較短,那麼每次釋放資源後,都調用信號量來喚起正在阻塞等候的 goroutine,將會很浪費資源。

因此在符合一定條件後,mutex 會讓等候的 Goroutine 去空轉 CPU,在空轉完後再次調用 CAS 方法去嘗試性的佔有鎖資源,直到不滿足自旋條件,則最終才加入到等待隊列裏。

11. Go 的逃逸行爲是指?

在傳統的編程語言裏,會根據程序員指定的方式來決定變量內存分配是在棧還是堆上,比如聲明的變量是值類型,則會分配到棧上,或者 new 一個對象則會分配到堆上。

在 Go 裏變量的內存分配方式則是由編譯器來決定的。如果變量在作用域(比如函數範圍)之外,還會被引用的話,那麼稱之爲發生了逃逸行爲,此時將會把對象放到堆上,即使聲明爲值類型;如果沒有發生逃逸行爲的話,則會被分配到棧上,即使 new 了一個對象。

12 context 使用場景及注意事項

Go 裏的 context 有 cancelCtx 、timerCtx、valueCtx。它們分別是用來通知取消、通知超時、存儲 key - value 值。context 的 注意事項如下:

13. context 是如何一層一層通知子 context

ctx, cancel := context.WithCancel(父Context)時,會將當前的 ctx 掛到父 context 下,然後開個 goroutine 協程去監控父 context 的 channel 事件,一旦有 channel 通知,則自身也會觸發自己的 channel 去通知它的子 context, 關鍵代碼如下

go func() {
   select {
   case <-parent.Done():
    child.cancel(false, parent.Err())
   case <-child.Done():
   }
 }()

14. waitgroup 原理

waitgroup 內部維護了一個計數器,當調用 wg.Add(1) 方法時,就會增加對應的數量;當調用 wg.Done() 時,計數器就會減一。直到計數器的數量減到 0 時,就會調用 runtime_Semrelease 喚起之前因爲 wg.Wait() 而阻塞住的 goroutine。

15. sync.Once 原理

內部維護了一個標識位,當它 == 0 時表示還沒執行過函數,此時會加鎖修改標識位,然後執行對應函數。後續再執行時發現標識位 != 0,則不會再執行後續動作了。關鍵代碼如下:

type Once struct {
 done uint32
 m    Mutex
}

func (o *Once) Do(f func()) {
    // 原子加載標識值,判斷是否已被執行過
 if atomic.LoadUint32(&o.done) == 0 {
  o.doSlow(f)
 }
}

func (o *Once) doSlow(f func()) { // 還沒執行過函數
 o.m.Lock()
 defer o.m.Unlock()
 if o.done == 0 { // 再次判斷下是否已被執行過函數
  defer atomic.StoreUint32(&o.done, 1) // 原子操作:修改標識值
  f() // 執行函數
 }
}

16. 定時器原理

一開始,timer 會被分配到一個全局的 timersBucket 時間桶。每當有 timer 被創建出來時,就會被分配到對應的時間桶裏了。

爲了不讓所有的 timer 都集中到一個時間桶裏,Go 會創建 64 個這樣的時間桶,然後根據 當前 timer 所在的 Goroutine 的 P 的 id 去哈希到某個桶上:

// assignBucket 將創建好的 timer 關聯到某個桶上
func (t *timer) assignBucket() *timersBucket {
 id := uint8(getg().m.p.ptr().id) % timersLen
 t.tb = &timers[id].timersBucket
 return t.tb
}

接着 timersBucket 時間桶將會對這些 timer 進行一個最小堆的維護,每次會挑選出時間最快要達到的 timer。如果挑選出來的 timer 時間還沒到,那就會進行 sleep 休眠;如果 timer 的時間到了,則執行 timer 上的函數,並且往 timer 的 channel 字段發送數據,以此來通知 timer 所在的 goroutine。

17. gorouinte 泄漏有哪些場景

gorouinte 裏有關於 channel 的操作,如果沒有正確處理 channel 的讀取,會導致 channel 一直阻塞住, goroutine 不能正常結束

18. Slice 注意點

Slice 的擴容機制

如果 Slice 要擴容的容量大於 2 倍當前的容量,則直接按想要擴容的容量來 new 一個新的 Slice,否則繼續判斷當前的長度 len,如果 len 小於 1024,則直接按 2 倍容量來擴容,否則一直循環新增 1/4,直到大於想要擴容的容量。主要代碼如下:

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
        }
    }
}

除此之外,還會根據 slice 的類型做一些內存對齊的調整,以確定最終要擴容的容量大小。

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


// 總結: 由於make([]string, 5) 則默認會初始化5個 空的"", 因此後面 append 時,則需要2倍了


// =========== 第二種
a:=[]string{}
fmt.Println(len(a), cap(a))   //  輸出0   0

a = append(a, "aaa")
fmt.Println(len(a), cap(a))   // 輸出1  1

// 總結:由於[]string{}, 沒有其他元素, 所以append 按 需要擴容的 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

// 總結:注意和第一種的區別,這裏不會默認初始化5個,所以後面的append容量是夠的,不用擴容

// =========== 第四種
b := make([]int, 1, 3)
a := []int{1, 2, 3}
copy(b, a)

fmt.Println(len(b))  // 輸出1

// 總結:copy 取決於較短 slice 的 len, 一旦最小的len結束了,也就不再複製了

range slice

以下代碼的執行是不會一直循環下去的,原因在於 range 的時候會 copy 這個 slice 上的 len 屬性到一個新的變量上,然後根據這個 copy 值去遍歷 slice,因此遍歷期間即使 slice 添加了元素,也不會改變這個變量的值了。

v := []int{1, 2, 3}
for i := range v {
 v = append(v, i)
}

另外,range 一個 slice 的時候是進行一個值拷貝的,如果 slice 裏存儲的是指針集合,那在 遍歷裏修改是有效的,如果 slice 存儲的是值類型的集合,那麼就是在 copy 它們的副本,期間的修改也只是在修改這個副本,跟原來的 slice 裏的元素是沒有關係的。

slice 入參注意點

如果 slice 作爲函數的入參,通常希望對 slice 的操作可以影響到底層數據,但是如果在函數內部 append 數據超過了 cap,導致重新分配底層數組,這時修改的 slice 將不再是原來入參的那個 slice 了。因此通常不建議在函數內部對 slice 有 append 操作,若有需要則顯示的 return 這個 slice。

19. make 和 new 的區別

new 是返回某個類型的指針,將會申請某個類型的內存。而 make 只能用於 slice, map, channel 這種 golang 內部的數據結構,它們可以只聲明不初始化,或者初始化時指定一些特定的參數,比如 slice 的長度、容量;map 的長度;channel 的緩衝數量等。

20. defer、panic、recover 三者的用法

defer 函數調用的順序是後進先出,當產生 panic 的時候,會先執行 panic 前面的 defer 函數後才真的拋出異常。一般的,recover 會在 defer 函數里執行並捕獲異常,防止程序崩潰。

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 的區別

array 是固定長度的數組,並且是值類型的,也就是說是拷貝複製的, slice 是一個引用類型,指向了一個動態數組的指針,會進行動態擴容。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/-aggggkokYS8fCnt066RxA