乐趣区

关于golang:Golang-中-defer-Close-的潜在风险

作为一名 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。能够预感的是,因为强制性刷盘,这种计划尽管能很好地保证数据安全性,然而在执行效率上却会大打折扣。

退出移动版