為什麼要避免在 Go 中使用 ioutil.ReadAll?

yongxinz發表於2022-01-06

原文連結: 為什麼要避免在 Go 中使用 ioutil.ReadAll?

ioutil.ReadAll 主要的作用是從一個 io.Reader 中讀取所有資料,直到結尾。

在 GitHub 上搜尋 ioutil.ReadAll,型別選擇 Code,語言選擇 Go,一共得到了 637307 條結果。

這說明 ioutil.ReadAll 還是挺受歡迎的,主要也是用起來確實方便。

但是當遇到大檔案時,這個函式就會暴露出兩個明顯的缺點:

  1. 效能問題,檔案越大,效能越差。
  2. 檔案過大的話,可能直接撐爆記憶體,導致程式崩潰。

為什麼會這樣呢?這篇文章就通過原始碼來分析背後的原因,並試圖給出更好的解決方案。

下面我們正式開始。

ioutil.ReadAll

首先,我們通過一個例子看一下 ioutil.ReadAll 的使用場景。比如說,使用 http.Client 傳送 GET 請求,然後再讀取返回內容:

func main() {
	res, err := http.Get("http://www.google.com/robots.txt")
	if err != nil {
		log.Fatal(err)
	}
	
	robots, err := io.ReadAll(res.Body)
	res.Body.Close()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s", robots)
}

http.Get() 返回的資料,儲存在 res.Body 中,通過 ioutil.ReadAll 將其讀取出來。

表面上看這段程式碼沒有什麼問題,但仔細分析卻並非如此。想要探究其背後的原因,就只能靠原始碼說話。

ioutil.ReadAll 的原始碼如下:

// src/io/ioutil/ioutil.go

func ReadAll(r io.Reader) ([]byte, error) {
	return io.ReadAll(r)
}

Go 1.16 版本開始,直接呼叫 io.ReadAll() 函式,下面再看看 io.ReadAll() 的實現:

// src/io/io.go

func ReadAll(r Reader) ([]byte, error) {
    // 建立一個 512 位元組的 buf
	b := make([]byte, 0, 512)
	for {
		if len(b) == cap(b) {
			// 如果 buf 滿了,則追加一個元素,使其重新分配記憶體
			b = append(b, 0)[:len(b)]
		}
		// 讀取內容到 buf
		n, err := r.Read(b[len(b):cap(b)])
		b = b[:len(b)+n]
		// 遇到結尾或者報錯則返回
		if err != nil {
			if err == EOF {
				err = nil
			}
			return b, err
		}
	}
}

我給程式碼加上了必要的註釋,這段程式碼的執行主要分三個步驟:

  1. 建立一個 512 位元組的 buf
  2. 不斷讀取內容到 buf,當 buf 滿的時候,會追加一個元素,促使其重新分配記憶體;
  3. 直到結尾或報錯,則返回;

知道了執行步驟,但想要分析其效能問題,還需要了解 Go 切片的擴容策略,如下:

  1. 如果期望容量大於當前容量的兩倍就會使用期望容量;
  2. 如果當前切片的長度小於 1024 就會將容量翻倍;
  3. 如果當前切片的長度大於 1024 就會每次增加 25% 的容量,直到新容量大於期望容量;

也就是說,如果待拷貝資料的容量小於 512 位元組的話,效能不受影響。但如果超過 512 位元組,就會開始切片擴容。資料量越大,擴容越頻繁,效能受影響越大。

如果資料量足夠大的話,記憶體可能就直接撐爆了,這樣的話影響就大了。

那有更好的替換方案嗎?當然是有的,我們接著往下看。

io.Copy

可以使用 io.Copy 函式來代替,原始碼定義如下:

src/io/io.go

func Copy(dst Writer, src Reader) (written int64, err error) {
	return copyBuffer(dst, src, nil)
}

其功能是直接從 src 讀取資料,並寫入到 dst

ioutil.ReadAll 最大的不同就是沒有把所有資料一次性都取出來,而是不斷讀取,不斷寫入。

具體實現 Copy 的邏輯在 copyBuffer 函式中實現:

// src/io/io.go

func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
	// 如果源實現了 WriteTo 方法,則直接呼叫 WriteTo
	if wt, ok := src.(WriterTo); ok {
		return wt.WriteTo(dst)
	}
	// 同樣的,如果目標實現了 ReaderFrom 方法,則直接呼叫 ReaderFrom
	if rt, ok := dst.(ReaderFrom); ok {
		return rt.ReadFrom(src)
	}
	// 如果 buf 為空,則建立 32KB 的 buf
	if buf == nil {
		size := 32 * 1024
		if l, ok := src.(*LimitedReader); ok && int64(size) > l.N {
			if l.N < 1 {
				size = 1
			} else {
				size = int(l.N)
			}
		}
		buf = make([]byte, size)
	}
	// 迴圈讀取資料並寫入
	for {
		nr, er := src.Read(buf)
		if nr > 0 {
			nw, ew := dst.Write(buf[0:nr])
			if nw < 0 || nr < nw {
				nw = 0
				if ew == nil {
					ew = errInvalidWrite
				}
			}
			written += int64(nw)
			if ew != nil {
				err = ew
				break
			}
			if nr != nw {
				err = ErrShortWrite
				break
			}
		}
		if er != nil {
			if er != EOF {
				err = er
			}
			break
		}
	}
	return written, err
}

此函式執行步驟如下:

  1. 如果源實現了 WriteTo 方法,則直接呼叫 WriteTo 方法;
  2. 同樣的,如果目標實現了 ReaderFrom 方法,則直接呼叫 ReaderFrom 方法;
  3. 如果 buf 為空,則建立 32KB 的 buf
  4. 最後就是迴圈 ReadWrite

對比之後就會發現,io.Copy 函式不會一次性讀取全部資料,也不會頻繁進行切片擴容,顯然在資料量大時是更好的選擇。

ioutil 其他函式

再看看 ioutil 包的其他函式:

  • func ReadDir(dirname string) ([]os.FileInfo, error)
  • func ReadFile(filename string) ([]byte, error)
  • func WriteFile(filename string, data []byte, perm os.FileMode) error
  • func TempFile(dir, prefix string) (f *os.File, err error)
  • func TempDir(dir, prefix string) (name string, err error)
  • func NopCloser(r io.Reader) io.ReadCloser

下面舉例詳細說明:

ReadDir

// ReadDir 讀取指定目錄中的所有目錄和檔案(不包括子目錄)。
// 返回讀取到的檔案資訊列表和遇到的錯誤,列表是經過排序的。
func ReadDir(dirname string) ([]os.FileInfo, error)

舉例:

package main

import (
	"fmt"
	"io/ioutil"
)

func main() {
	dirName := "../"
	fileInfos, _ := ioutil.ReadDir(dirName)
	fmt.Println(len(fileInfos))
	for i := 0; i < len(fileInfos); i++ {
		fmt.Printf("%T\n", fileInfos[i])
		fmt.Println(i, fileInfos[i].Name(), fileInfos[i].IsDir())

	}
}

ReadFile

// ReadFile 讀取檔案中的所有資料,返回讀取的資料和遇到的錯誤
// 如果讀取成功,則 err 返回 nil,而不是 EOF
func ReadFile(filename string) ([]byte, error)

舉例:

package main

import (
	"fmt"
	"io/ioutil"
	"os"
)

func main() {
	data, err := ioutil.ReadFile("./test.txt")
	if err != nil {
		fmt.Println("read error")
		os.Exit(1)
	}
	fmt.Println(string(data))
}

WriteFile

// WriteFile 向檔案中寫入資料,寫入前會清空檔案。
// 如果檔案不存在,則會以指定的許可權建立該檔案。
// 返回遇到的錯誤。
func WriteFile(filename string, data []byte, perm os.FileMode) error

舉例:

package main

import (
	"fmt"
	"io/ioutil"
)

func main() {
	fileName := "./text.txt"
	s := "Hello AlwaysBeta"
	err := ioutil.WriteFile(fileName, []byte(s), 0777)
	fmt.Println(err)
}

TempFile

// TempFile 在 dir 目錄中建立一個以 prefix 為字首的臨時檔案,並將其以讀
// 寫模式開啟。返回建立的檔案物件和遇到的錯誤。
// 如果 dir 為空,則在預設的臨時目錄中建立檔案(參見 os.TempDir),多次
// 呼叫會建立不同的臨時檔案,呼叫者可以通過 f.Name() 獲取檔案的完整路徑。
// 呼叫本函式所建立的臨時檔案,應該由呼叫者自己刪除。
func TempFile(dir, prefix string) (f *os.File, err error)

舉例:

package main

import (
	"fmt"
	"io/ioutil"
	"os"
)

func main() {
	f, err := ioutil.TempFile("./", "Test")
	if err != nil {
		fmt.Println(err)
	}
	defer os.Remove(f.Name()) // 用完刪除
	fmt.Printf("%s\n", f.Name())
}

TempDir

// TempDir 功能同 TempFile,只不過建立的是目錄,返回目錄的完整路徑。
func TempDir(dir, prefix string) (name string, err error)

舉例:

package main

import (
	"fmt"
	"io/ioutil"
	"os"
)

func main() {
	dir, err := ioutil.TempDir("./", "Test")
	if err != nil {
		fmt.Println(err)
	}
	defer os.Remove(dir) // 用完刪除
	fmt.Printf("%s\n", dir)
}

NopCloser

// NopCloser 將 r 包裝為一個 ReadCloser 型別,但 Close 方法不做任何事情。
func NopCloser(r io.Reader) io.ReadCloser

這個函式的使用場景是這樣的:

有時候我們需要傳遞一個 io.ReadCloser 的例項,而我們現在有一個 io.Reader 的例項,比如:strings.Reader

這個時候 NopCloser 就派上用場了。它包裝一個 io.Reader,返回一個 io.ReadCloser,相應的 Close 方法啥也不做,只是返回 nil

舉例:

package main

import (
	"fmt"
	"io/ioutil"
	"reflect"
	"strings"
)

func main() {
	//返回 *strings.Reader
	reader := strings.NewReader("Hello AlwaysBeta")
	r := ioutil.NopCloser(reader)
	defer r.Close()

	fmt.Println(reflect.TypeOf(reader))
	data, _ := ioutil.ReadAll(reader)
	fmt.Println(string(data))
}

總結

ioutil 提供了幾個很實用的工具函式,背後實現邏輯也並不複雜。

本篇文章從一個問題入手,重點研究了 ioutil.ReadAll 函式。主要原因是在小資料量的情況下,這個函式並沒有什麼問題,但當資料量大時,它就變成了一顆定時炸彈。有可能會影響程式的效能,甚至會導致程式崩潰。

接下來給出對應的解決方案,在資料量大的情況下,最好使用 io.Copy 函式。

文章最後繼續介紹了 ioutil 的其他幾個函式,並給出了程式示例。相關程式碼都會上傳到 GitHub,需要的同學可以自行下載。

好了,本文就到這裡吧。關注我,帶你通過問題讀 Go 原始碼。


原始碼地址:

推薦閱讀:

參考文章:

相關文章