為什么要避免在 Go 中使用 ioutil.ReadAll?
ioutil.ReadAll 主要的作用是從一個 io.Reader 中讀取所有數(shù)據(jù),直到結尾。

在 GitHub 上搜索 ioutil.ReadAll,類型選擇 Code,語言選擇 Go,一共得到了 637307 條結果。
這說明 ioutil.ReadAll 還是挺受歡迎的,主要也是用起來確實方便。
但是當遇到大文件時,這個函數(shù)就會暴露出兩個明顯的缺點:
性能問題,文件越大,性能越差。
文件過大的話,可能直接撐爆內(nèi)存,導致程序崩潰。
為什么會這樣呢?這篇文章就通過源碼來分析背后的原因,并試圖給出更好的解決方案。
下面我們正式開始。
ioutil.ReadAll
首先,我們通過一個例子看一下 ioutil.ReadAll 的使用場景。比如說,使用 http.Client 發(fā)送 GET 請求,然后再讀取返回內(nèi)容:
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() 返回的數(shù)據(jù),存儲在 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 版本開始,直接調(diào)用 io.ReadAll() 函數(shù),下面再看看 io.ReadAll() 的實現(xiàn):
//?src/io/io.go
func?ReadAll(r?Reader)?([]byte,?error)?{
????//?創(chuàng)建一個?512?字節(jié)的?buf
????b?:=?make([]byte,?0,?512)
????for?{
????????if?len(b)?==?cap(b)?{
????????????//?如果?buf?滿了,則追加一個元素,使其重新分配內(nèi)存
????????????b?=?append(b,?0)[:len(b)]
????????}
????????//?讀取內(nèi)容到?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
????????}
????}
}
我給代碼加上了必要的注釋,這段代碼的執(zhí)行主要分三個步驟:
創(chuàng)建一個 512 字節(jié)的
buf;不斷讀取內(nèi)容到
buf,當buf滿的時候,會追加一個元素,促使其重新分配內(nèi)存;直到結尾或報錯,則返回;
知道了執(zhí)行步驟,但想要分析其性能問題,還需要了解 Go 切片的擴容策略,如下:
如果期望容量大于當前容量的兩倍就會使用期望容量;
如果當前切片的長度小于 1024 就會將容量翻倍;
如果當前切片的長度大于 1024 就會每次增加 25% 的容量,直到新容量大于期望容量;
也就是說,如果待拷貝數(shù)據(jù)的容量小于 512 字節(jié)的話,性能不受影響。但如果超過 512 字節(jié),就會開始切片擴容。數(shù)據(jù)量越大,擴容越頻繁,性能受影響越大。
如果數(shù)據(jù)量足夠大的話,內(nèi)存可能就直接撐爆了,這樣的話影響就大了。
那有更好的替換方案嗎?當然是有的,我們接著往下看。
io.Copy
可以使用 io.Copy 函數(shù)來代替,源碼定義如下:
src/io/io.go
func?Copy(dst?Writer,?src?Reader)?(written?int64,?err?error)?{
????return?copyBuffer(dst,?src,?nil)
}
其功能是直接從 src 讀取數(shù)據(jù),并寫入到 dst。
和 ioutil.ReadAll 最大的不同就是沒有把所有數(shù)據(jù)一次性都取出來,而是不斷讀取,不斷寫入。
具體實現(xiàn) Copy 的邏輯在 copyBuffer 函數(shù)中實現(xiàn):
//?src/io/io.go
func?copyBuffer(dst?Writer,?src?Reader,?buf?[]byte)?(written?int64,?err?error)?{
????//?如果源實現(xiàn)了?WriteTo?方法,則直接調(diào)用?WriteTo
????if?wt,?ok?:=?src.(WriterTo);?ok?{
????????return?wt.WriteTo(dst)
????}
????//?同樣的,如果目標實現(xiàn)了?ReaderFrom?方法,則直接調(diào)用?ReaderFrom
????if?rt,?ok?:=?dst.(ReaderFrom);?ok?{
????????return?rt.ReadFrom(src)
????}
????//?如果?buf?為空,則創(chuàng)建?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)
????}
????//?循環(huán)讀取數(shù)據(jù)并寫入
????for?{
????????nr,?er?:=?src.Read(buf)
????????if?nr?>?0?{
????????????nw,?ew?:=?dst.Write(buf[0:nr])
????????????if?nw?0?||?nr?????????????????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
}
此函數(shù)執(zhí)行步驟如下:
如果源實現(xiàn)了
WriteTo方法,則直接調(diào)用WriteTo方法;同樣的,如果目標實現(xiàn)了 ReaderFrom 方法,則直接調(diào)用 ReaderFrom 方法;
如果
buf為空,則創(chuàng)建 32KB 的buf;最后就是循環(huán)
Read和Write;
對比之后就會發(fā)現(xiàn),io.Copy 函數(shù)不會一次性讀取全部數(shù)據(jù),也不會頻繁進行切片擴容,顯然在數(shù)據(jù)量大時是更好的選擇。
ioutil 其他函數(shù)
再看看 ioutil 包的其他函數(shù):
func ReadDir(dirname string) ([]os.FileInfo, error)func ReadFile(filename string) ([]byte, error)func WriteFile(filename string, data []byte, perm os.FileMode) errorfunc 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 讀取指定目錄中的所有目錄和文件(不包括子目錄)。
//?返回讀取到的文件信息列表和遇到的錯誤,列表是經(jīng)過排序的。
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?讀取文件中的所有數(shù)據(jù),返回讀取的數(shù)據(jù)和遇到的錯誤
//?如果讀取成功,則?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 向文件中寫入數(shù)據(jù),寫入前會清空文件。
//?如果文件不存在,則會以指定的權限創(chuàng)建該文件。
//?返回遇到的錯誤。
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?目錄中創(chuàng)建一個以?prefix?為前綴的臨時文件,并將其以讀
//?寫模式打開。返回創(chuàng)建的文件對象和遇到的錯誤。
//?如果?dir?為空,則在默認的臨時目錄中創(chuàng)建文件(參見?os.TempDir),多次
//?調(diào)用會創(chuàng)建不同的臨時文件,調(diào)用者可以通過 f.Name()?獲取文件的完整路徑。
//?調(diào)用本函數(shù)所創(chuàng)建的臨時文件,應該由調(diào)用者自己刪除。
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,只不過創(chuàng)建的是目錄,返回目錄的完整路徑。
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
這個函數(shù)的使用場景是這樣的:
有時候我們需要傳遞一個 io.ReadCloser 的實例,而我們現(xiàn)在有一個 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 提供了幾個很實用的工具函數(shù),背后實現(xiàn)邏輯也并不復雜。
本篇文章從一個問題入手,重點研究了 ioutil.ReadAll 函數(shù)。主要原因是在小數(shù)據(jù)量的情況下,這個函數(shù)并沒有什么問題,但當數(shù)據(jù)量大時,它就變成了一顆定時炸彈。有可能會影響程序的性能,甚至會導致程序崩潰。
接下來給出對應的解決方案,在數(shù)據(jù)量大的情況下,最好使用 io.Copy 函數(shù)。
文章最后繼續(xù)介紹了 ioutil 的其他幾個函數(shù),并給出了程序示例。相關代碼都會上傳到 GitHub,需要的同學可以自行下載。
好了,本文就到這里吧。關注我,帶你通過問題讀 Go 源碼。
源碼地址:
https://github.com/yongxinz/gopher
推薦閱讀:
參考文章:
https://haisum.github.io/2017/09/11/golang-ioutil-readall/
https://juejin.cn/post/6977640348679929886
https://zhuanlan.zhihu.com/p/76231663
