iT邦幫忙

2024 iThome 鐵人賽

DAY 4
0
Software Development

那些年,我們一起走過的Go錯系列 第 4

DAY04-Go-我說在座的各位都是垃圾--回收要注意的事

  • 分享至 

  • xImage
  •  

前言


剛開始進入這個行業時,常聽到Senior Engineer和Junior Engineer的區別之一,就是資深工程師懂得多,擁有更多解決問題的方式。然而,隨著時間的推移,我逐漸了解這背後的意思——所有技術決策其實都是一種取捨(trade-off),因此我們必須從全局來考量。而要能做到這點的基礎,就是你懂得足夠多。

工欲善其事,必先利其器。

上一章提到 Golang 的設計,雖然它不像其他語言有那麼多花哨的功能,但擁有自動垃圾回收(GC)機制。然而,這並不是每個人都喜歡的功能。Discord 就因為 Go 的垃圾回收問題,轉而選擇 Rust
這不是說 Go 的垃圾回收設計不好,而是它不再符合他們的使用需求。

第一步:了解使用需求,來決定使用哪種語言
在選擇語言時,首要的工作是明確專案的需求,根據這些需求來決定最適合的語言。

第二步:了解各語言的限制,避免踩雷
接下來的重點,就是理解每種語言的限制,找出哪些情況會導致語言無法滿足需求,進而做出最佳選擇。

本文

  • slice的問題
  • map的問題
  • defer的問題

slice 和 map 沒有初始化或指定大小,是初學 Go 時很容易犯的錯誤。

Slice 的問題
我們來看看,當 slice 沒有指定大小時,持續 append 新元素會有什麼影響。

package main

import "fmt"

func main() {
    var mySlice []int // 未初始化,初始值nil
    fmt.Printf("初始化:長度 = %d, 容量 = %d, 資料 = %v\n", len(mySlice), cap(mySlice), mySlice)

    // 持續 append 新元素
    for i := 1; i <= 10; i++ {
        mySlice = append(mySlice, i)
        fmt.Printf("第 %d 次 append:長度 = %d, 容量 = %d, 資料 = %v\n", i, len(mySlice), cap(mySlice), mySlice)
    }
}

輸出結果:

初始化:長度 = 0, 容量 = 0, 資料 = []
第 1 次 append:長度 = 1, 容量 = 1, 資料 = [1]
第 2 次 append:長度 = 2, 容量 = 2, 資料 = [1, 2]
第 3 次 append:長度 = 3, 容量 = 4, 資料 = [1, 2, 3]
第 4 次 append:長度 = 4, 容量 = 4, 資料 = [1, 2, 3, 4]
第 5 次 append:長度 = 5, 容量 = 8, 資料 = [1, 2, 3, 4, 5]
第 6 次 append:長度 = 6, 容量 = 8, 資料 = [1, 2, 3, 4, 5, 6]
第 7 次 append:長度 = 7, 容量 = 8, 資料 = [1, 2, 3, 4, 5, 6, 7]
第 8 次 append:長度 = 8, 容量 = 8, 資料 = [1, 2, 3, 4, 5, 6, 7, 8]
第 9 次 append:長度 = 9, 容量 = 16, 資料 = [1, 2, 3, 4, 5, 6, 7, 8, 9]
第 10 次 append:長度 = 10, 容量 = 16, 資料 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

append 的作用機制:

  1. 檢查容量:當你使用 append 時,Go 會先檢查 slice 的容量是否足夠。如果容量足夠,就直接將新元素加入現有內存中,slice 的長度增加,容量保持不變。
  2. 記憶體擴展:如果容量不足,Go 會分配一個更大的內存區塊,通常是目前容量的兩倍(容量超過 1024 後,擴展變得保守)。
  3. 數據複製:當記憶體擴展後,Go 會將原本 slice 中的數據複製到新的記憶體區塊,然後將新元素添加進去。
  4. 舊記憶體等待 GC 回收:舊的記憶體區塊不再使用,會被標記為垃圾,等待垃圾回收器(GC)在下一次回收週期中釋放。

重新分配內存的影響

  • 記憶體碎片化:舊的記憶體區塊雖然被標記為回收,但在 GC 之前這些區塊仍然佔用空間,可能導致記憶體碎片化。
  • 增加 GC 負擔:頻繁的記憶體分配會增加 GC 的負擔,特別是當應用程式持續進行大量的 slice 或 map 擴展操作時,會導致性能下降。

因此,千萬別犯這種錯誤,否則很容易暴露自己還不是 Senior。


map的問題

map沒有指定初始大小時,可能會造成更大的問題
尤其是當 map 不斷擴展時。原因在於 map 的底層結構是由多個 "bucket" 組成,這些桶用來存放鍵值對。

當你向 map 添加越來越多的鍵值對時,Go 會動態分配更多的桶來容納新數據,但這些桶一旦被分配,是不會縮減的,即使你刪除了一些鍵值對,map 的大小不會因此縮減。

為什麼這樣會更糟?

  • 內存浪費:即使你刪除了大部分鍵值對,map 的桶數量依然保持不變,這意味著那些空的桶仍然佔用記憶體,導致內存無法高效利用。
  • 動態擴展開銷大:map 沒有指定大小時,Go 會根據鍵值對數量動態擴展記憶體。每次擴展會重新分配更大的內存區塊,並將原來的數據重新哈希,然後放入新的桶中。這個過程不僅耗費計算資源,還會影響性能。
  • 不縮減的特性:即使你刪掉 map 中的很多元素,Go 也不會縮減記憶體,這意味著如果你沒有合理初始化 map 的大小,最終會浪費大量內存。

最佳解法:
當下能做的,千萬別拖到最後才做。

  • 一開始就指定大小:對於 slice 和 map,能夠預估的初始大小最好一開始就設定好,這樣可以避免頻繁的記憶體擴展,提升性能。

  • 定期清理和重建 slice 和 map:如果經常處理大量數據或刪除操作,定期重建 slice 或 map 可以幫助釋放多餘的內存。特別是 slice,如果涉及到創建子 slice,需要特別注意,因為底層記憶體可能依然被占用。最好的方法是使用 copy 函數將數據複製到一個新的 slice 中。 slice細節瞭解可以到看看

千萬別等到系統變慢、性能下降,才開始懷疑人生,懷疑code


有沒有例外的時候,不要一開始就分配大小的時候

有的

這樣做,nil slice就不會被分配記憶體

func getData(condition bool) []int {
    if !condition {
        return nil //return nil slice
    }
    return []int{1, 2, 3}
}

defer的問題

func readFile() {
    f, _ := os.Open("file.txt")
    // 忘記使用 defer f.Close()
}

這個真的就是超級低級錯誤
但我還真的看過資深工程師做過

不管是哪種程式語言,正確地關閉和釋放資源都是良好的編碼習慣。這包括但不限於:

  1. 檔案:使用完檔案後應該關閉,以避免資源洩漏。
  2. 網路連線:無論是 TCP、HTTP 還是其他類型的連線,都應在完成後關閉。
  3. 資料庫連線:與資料庫的連線應該在不再需要時釋放。
  4. 記憶體分配:在需要手動管理記憶體的語言中(如 C 或 C++),應確保分配的記憶體在不再使用時被釋放。
  5. 鎖和通道:在並發程式中,應確保鎖和通道的正確釋放和關閉,以避免死鎖和資源洩漏。

結語

魔鬼都在細節裡
小事做好了,大事自然成


引用資料來源:https://www.manning.com/books/100-go-mistakes-and-how-to-avoid-them


上一篇
DAY03-GO的簡單至上
下一篇
Day05-Go-傳值、傳指標傻傻分不清楚?
系列文
那些年,我們一起走過的Go錯6
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言