golang 面試總結

lincoln發表於2022-02-09

前言

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

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

  • Go 允許跨平臺編譯,編譯出來的是二進位制的可執行檔案,直接部署在對應系統上即可執行。
  • Go 在語言層次上天生支援高併發,通過 goroutine 和 channel 實現。channel 的理論依據是 CSP 併發模型, 即所謂的通過通訊來共享記憶體;Go 在 runtime 執行時裡實現了屬於自己的排程機制:GMP,降低了核心態和使用者態的切換成本。
  • Go 的程式碼風格是強制性的統一,如果沒有按照規定來,會編譯不通過。

2. Golang 裡的 GMP 模型?

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

  • G: 也就是協程 goroutine,由 Go runtime 管理。我們可以認為它是使用者級別的執行緒。
  • P: processor 處理器。每當有 goroutine 要建立時,會被新增到 P 上的 goroutine 本地佇列上,如果 P 的本地佇列已滿,則會維護到全域性佇列裡。
  • M: 系統執行緒。在 M 上有排程函式,它是真正的排程執行者,M 需要跟 P 繫結,並且會讓 P 按下面的原則挑出個 goroutine 來執行:

優先從 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 作為基本分配單位。具體的分配邏輯如下:

  • 當要分配大於 32K 的物件時,從 mheap 分配。
  • 當要分配的物件小於等於 32K 大於 16B 時,從 P 上的 mcache 分配,如果 mcache 沒有記憶體,則從 mcentral 獲取,如果 mcentral 也沒有,則向 mheap 申請,如果 mheap 也沒有,則從作業系統申請記憶體。
  • 當要分配的物件小於等於 16B 時,從 mcache 上的微型分配器上分配。

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 的 注意事項如下:

  • context 的 Done() 方法往往需要配合 select {} 使用,以監聽退出。
  • 儘量通過函式引數來暴露 context,不要在自定義結構體裡包含它。
  • WithValue 型別的 context 應該儘量儲存一些全域性的 data,而不要儲存一些可有可無的區域性 data。
  • context 是併發安全的。
  • 一旦 context 執行取消動作,所有派生的 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 是一個引用型別,指向了一個動態陣列的指標,會進行動態擴容。


感興趣的朋友可以搜一搜公眾號「 閱新技術 」,關注更多的推送文章。
可以的話,就順便點個贊、留個言、分享下,感謝各位支援!
閱新技術,閱讀更多的新知識。
閱新技術

相關文章