作為一名 Gopher,我們很容易形成一個編程慣例:每當有一個實現(xiàn)了 io.Closer
接口的對象 x
時,在得到對象并檢查錯誤之后,會立即使用 defer x.Close()
以保證函數(shù)返回時 x
對象的關閉 镶殷。以下給出兩個慣用寫法例子。
- HTTP 請求
resp, err := http.Get("https://golang.google.cn/")
if err != nil {
return err
}
defer resp.Body.Close()
// The following code: handle resp
- 訪問文件
f, err := os.Open("/home/golangshare/gopher.txt")
if err != nil {
return err
}
defer f.Close()
// The following code: handle f
存在問題
實際上微酬,這種寫法是存在潛在問題的绘趋。defer x.Close()
會忽略它的返回值,但在執(zhí)行 x.Close()
時颗管,我們并不能保證 x
一定能正常關閉陷遮,萬一它返回錯誤應該怎么辦?這種寫法垦江,會讓程序有可能出現(xiàn)非常難以排查的錯誤帽馋。
那么,Close()
方法會返回什么錯誤呢比吭?在 POSIX 操作系統(tǒng)中绽族,例如 Linux 或者 maxOS,關閉文件的 Close()
函數(shù)最終是調用了系統(tǒng)方法 close()
衩藤,我們可以通過 man close
手冊吧慢,查看 close()
可能會返回什么錯誤
ERRORS
The close() system call will fail if:
[EBADF] fildes is not a valid, active file descriptor.
[EINTR] Its execution was interrupted by a signal.
[EIO] A previously-uncommitted write(2) encountered an
input/output error.
錯誤 EBADF
表示無效文件描述符 fd,與本文中的情況無關赏表;EINTR
是指的 Unix 信號打斷检诗;那么本文中可能存在的錯誤是 EIO
匈仗。
EIO
的錯誤是指未提交讀,這是什么錯誤呢逢慌?
EIO
錯誤是指文件的 write()
的讀還未提交時就調用了 close()
方法悠轩。
上圖是一個經典的計算機存儲器層級結構,在這個層次結構中攻泼,從上至下火架,設備的訪問速度越來越慢,容量越來越大忙菠。存儲器層級結構的主要思想是上一層的存儲器作為低一層存儲器的高速緩存距潘。
CPU 訪問寄存器會非常之快,相比之下只搁,訪問 RAM 就會很慢,而訪問磁盤或者網絡俭尖,那意味著就是蹉跎光陰氢惋。如果每個 write()
調用都將數(shù)據(jù)同步地提交到磁盤,那么系統(tǒng)的整體性能將會極度降低稽犁,而我們的計算機是不會這樣工作的焰望。當我們調用 write()
時,數(shù)據(jù)并沒有立即被寫到目標載體上已亥,計算機存儲器每層載體都在緩存數(shù)據(jù)熊赖,在合適的時機下,將數(shù)據(jù)刷到下一層載體虑椎,這將寫入調用的同步震鹉、緩慢、阻塞的同步轉為了快速捆姜、異步的過程传趾。
這樣看來,EIO
錯誤的確是我們需要提防的錯誤泥技。這意味著如果我們嘗試將數(shù)據(jù)保存到磁盤浆兰,在 defer x.Close()
執(zhí)行時,操作系統(tǒng)還并未將數(shù)據(jù)刷到磁盤珊豹,這時我們應該獲取到該錯誤提示(只要數(shù)據(jù)還未落盤簸呈,那數(shù)據(jù)就沒有持久化成功,它就是有可能丟失的店茶,例如出現(xiàn)停電事故蜕便,這部分數(shù)據(jù)就永久消失了,且我們會毫不知情)忽妒。但是按照上文的慣例寫法玩裙,我們程序得到的是 nil
錯誤兼贸。
解決方案
我們針對關閉文件的情況,來探討幾種可行性改造方案
- 第一種方案吃溅,那就是不使用 defer
func solution01() error {
f, err := os.Create("/home/golangshare/gopher.txt")
if err != nil {
return err
}
if _, err = io.WriteString(f, "hello gopher"); err != nil {
f.Close()
return err
}
return f.Close()
}
這種寫法就需要我們在 io.WriteString
執(zhí)行失敗時溶诞,明確調用 f.Close()
進行關閉。但是這種方案决侈,需要在每個發(fā)生錯誤的地方都要加上關閉語句 f.Close()
螺垢,如果對 f
的寫操作 case 較多,容易存在遺漏關閉文件的風險赖歌。
- 第二種方案是枉圃,通過命名返回值 err 和閉包來處理
func solution02() (err error) {
f, err := os.Create("/home/golangshare/gopher.txt")
if err != nil {
return
}
defer func() {
closeErr := f.Close()
if err == nil {
err = closeErr
}
}()
_, err = io.WriteString(f, "hello gopher")
return
}
這種方案解決了方案一中忘記關閉文件的風險,如果有更多 if err !=nil
的條件分支庐冯,這種模式可以有效降低代碼行數(shù)孽亲。
- 第三種方案是,在函數(shù)最后 return 語句之前展父,顯示調用一次 f.Close()
func solution03() error {
f, err := os.Create("/home/golangshare/gopher.txt")
if err != nil {
return err
}
defer f.Close()
if _, err := io.WriteString(f, "hello gopher"); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
return nil
}
這種解決方案能在 io.WriteString
發(fā)生錯誤時返劲,由于 defer f.Close()
的存在能得到 close
調用。也能在 io.WriteString
未發(fā)生錯誤栖茉,但緩存未刷新到磁盤時预皇,得到 err := f.Close()
的錯誤乳讥,而且由于 defer f.Close()
并不會返回錯誤,所以并不擔心兩次 Close()
調用會將錯誤覆蓋。
- 最后一種方案是治唤,函數(shù) return 時執(zhí)行 f.Sync()
func solution04() error {
f, err := os.Create("/home/golangshare/gopher.txt")
if err != nil {
return err
}
defer f.Close()
if _, err = io.WriteString(f, "hello world"); err != nil {
return err
}
return f.Sync()
}
由于調用 close()
是最后一次獲取操作系統(tǒng)返回錯誤的機會泽裳,但是在我們關閉文件時笋额,緩存不一定被會刷到磁盤上冯袍。那么,我們可以調用 f.Sync()
(其內部調用系統(tǒng)函數(shù) fsync
)強制性讓內核將緩存持久到磁盤上去梨睁。
// Sync commits the current contents of the file to stable storage.
// Typically, this means flushing the file system's in-memory copy
// of recently written data to disk.
func (f *File) Sync() error {
if err := f.checkValid("sync"); err != nil {
return err
}
if e := f.pfd.Fsync(); e != nil {
return f.wrapErr("sync", e)
}
return nil
}
由于 fsync
的調用鲸睛,這種模式能很好地避免 close
出現(xiàn)的 EIO
∑潞兀可以預見的是官辈,由于強制性刷盤,這種方案雖然能很好地保證數(shù)據(jù)安全性遍坟,但是在執(zhí)行效率上卻會大打折扣拳亿。