Golang 中 defer Close() 的潛在風險

機器鈴砍菜刀發表於2021-11-22

作為一名 Gopher,我們很容易形成一個程式設計慣例:每當有一個實現了 io.Closer 介面的物件 x 時,在得到物件並檢查錯誤之後,會立即使用 defer x.Close() 以保證函式返回時 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() 會忽略它的返回值,但在執行 x.Close() 時,我們並不能保證 x 一定能正常關閉,萬一它返回錯誤應該怎麼辦?這種寫法,會讓程式有可能出現非常難以排查的錯誤。

那麼,Close() 方法會返回什麼錯誤呢?在 POSIX 作業系統中,例如 Linux 或者 maxOS,關閉檔案的 Close() 函式最終是呼叫了系統方法 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() 呼叫都將資料同步地提交到磁碟,那麼系統的整體效能將會極度降低,而我們的計算機是不會這樣工作的。當我們呼叫 write() 時,資料並沒有立即被寫到目標載體上,計算機儲存器每層載體都在快取資料,在合適的時機下,將資料刷到下一層載體,這將寫入呼叫的同步、緩慢、阻塞的同步轉為了快速、非同步的過程。

這樣看來,EIO 錯誤的確是我們需要提防的錯誤。這意味著如果我們嘗試將資料儲存到磁碟,在 defer x.Close() 執行時,作業系統還並未將資料刷到磁碟,這時我們應該獲取到該錯誤提示(只要資料還未落盤,那資料就沒有持久化成功,它就是有可能丟失的,例如出現停電事故,這部分資料就永久消失了,且我們會毫不知情)。但是按照上文的慣例寫法,我們程式得到的是 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 執行失敗時,明確呼叫 f.Close() 進行關閉。但是這種方案,需要在每個發生錯誤的地方都要加上關閉語句 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 的條件分支,這種模式可以有效降低程式碼行數。

  • 第三種方案是,在函式最後 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 發生錯誤時,由於 defer f.Close() 的存在能得到 close 呼叫。也能在 io.WriteString 未發生錯誤,但快取未重新整理到磁碟時,得到 err := f.Close() 的錯誤,而且由於 defer f.Close() 並不會返回錯誤,所以並不擔心兩次 Close() 呼叫會將錯誤覆蓋。

  • 最後一種方案是,函式 return 時執行 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() 是最後一次獲取作業系統返回錯誤的機會,但是在我們關閉檔案時,快取不一定被會刷到磁碟上。那麼,我們可以呼叫 f.Sync() (其內部呼叫系統函式 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 出現的 EIO。可以預見的是,由於強制性刷盤,這種方案雖然能很好地保證資料安全性,但是在執行效率上卻會大打折扣。

相關文章