简单地 defer file.Close() 可能是一种错误用法

在 Go 语言中使用 defer 语句已经可以说成是一种语言风格或习惯了。 但是方便归方便,能不错误地使用 defer 语句来延迟关闭/清理文件却并不是那么容易保证的。

写文件并关闭

比如在 Go 语言文件操作的相关代码中,随处可见类似下面这样的代码:

f, err := os.Create(`path`)
if err != nil {
	panic(err)
}
defer f.Close()

if _, err := f.Write([]byte(`content`)); err != nil {
	panic(err)
}

这段代码咋一看可能没什么问题?问题其一在于:没有检查 Close() 方法的返回值

Close() 函数实际上是返回一个 error。 在平常写文件的过程中,我们会非常习惯地检查 Write() 的返回值,而通常忽略 Close() 的返回值。 并且我们通常会认为出问题多数会在 Write() 的时候,而不是在 Close()。这更加促成我们对 Close() 返回值的不重视行为。

那么问题来了,Write() 成功就一定代表文件内容被成功持久化到存储设备中了吗?并不是

既然不是,那就意味着我们很可能在冒着文件数据丢失的风险。

Write 有多“成功”?

对于计算机架构稍微有点了解的都知道,当你离 CPU 越远时,CPU 对所需数据的操作速度就会越慢。 最快的是 CPU 内部的寄存器,相对慢一点就是 RAM,最慢的当属 网络 I/O 和 磁盘 I/O 了。 因此,如果每一次的 Write() 操作都将待写入的数据同步地提交到磁盘的话,操作系统的响应速度将可能会是极其慢的。

另有一种比较变态的行为就是,一个字节一个字节地写文件。对于机械硬盘比较熟悉的人都知道,硬盘写数据的时候会移动机械臂和碰头到指定的扇区。 你想像一下写一个字节就可能移动一次是多么恐怖的事情。

但是幸运的是,上述事情不会真正地发生。在操作系统和硬盘自身驱动层面,它们都会做自己的缓存(Cache)和缓冲(Buffer)。 缓存可以把热数据放在内存中,这样一来读同样的数据时候就不用每次都真正地访问硬盘了。缓冲用来积压数据,可以把多次写入的少量数据合并在一起,然后一次性提供给硬盘写入。 缓存和缓冲都可以大大地提高数据的读写性能。

所以,当我们在调用 Write() 并“成功”返回后,只能说明的是:数据被成功缓冲起来了。有没有真正落盘(指数据成功持久化到存储设备中)是不知道的。落盘的最佳时机操作系统和磁盘会自己决定。 如果还未落盘(但 Write 成功了)就遭遇突然地断电,那么,数据大概率是丢了(文件系统本身有相关恢复能力,本文不讨论此范畴)。

更好的报错时机

操作系统,不出意外的话,会认为我们在 Close() 一个文件以后,就不会再有操作了。这时候是它向我们报告错误的时候。 于是,前面说讲的“幸运”给我们带来了一种“不幸”,就是我们可能遇到 Write() 的时候很少报错,而在最后一步 Close() 的时候把前面的所有错误集中抛出来的情况。

通过查看 close 的说明也可以验证这一点(man 2 close #darwin ):

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.

其中的 EIO 就如实向我们陈述了这一点:你终于关闭文件了,但是我要告诉你,关闭文件之前的那些写入操作失败了!

这是多么的不幸!

重视 Close() 的错误

经过上面的讨论,我想我们可以达成一个共识:Close() 的返回值非常重要!

所以我们可能要改一下代码了:

f, err := os.Create(`path`)
if err != nil {
	panic(err)
}
defer func() {
	if err := f.Close(); err != nil {
		panic(err) // 或设置到函数返回值中
	}
}()
不要不小心改变了 defer 的逻辑(点击展开)

这样写要注意 Go 语言中的 defer 的用法:被 defer 的函数的参数(包括 receiver)是在 defer 语句执行的时候求值的。

因此,假设后面复用了 f,要注意它们的区别:

// 写法一:同一个变量被复用,defer 两次,是正确的用法
f, err := os.Create(`path1`)
if err != nil {
	panic(err)
}
defer f.Close()

f, err = os.Create(`path2`)
if err != nil {
	panic(err)
}
defer f.Close()

// 写法二:会导致前一个 f 没有 Close(),后面的 f 被 Close() 两次

f, err := os.Create(`path1`)
if err != nil {
	panic(err)
}
defer func() {
	if err := f.Close(); err != nil {
		panic(err)
	}
}()

f, err = os.Create(`path2`)
if err != nil {
	panic(err)
}
defer func() {
	if err := f.Close(); err != nil {
		panic(err)
	}
}()

但本文的重点不在 defer 的本身用法上,因此不再讨论这个了,只是想提醒一下:如果看了本文的朋友想重构一下相关代码,不要不小心改变了代码中原有的 defer 逻辑。

或者,不要把 Close() 放在 defer 中:

f, err := os.Create(`path`)
if err != nil {
	return err
}

// 写入操作...

return f.Close()

但是这样写就体验不到 defer 的好处了,不建议这样写。

Close() 也没报错,那就万事大吉了吗?

也不是。

如果把操作系统的 close() 帮助说明再读读,还会新的发现:

Note:

A successful close does not guarantee that the data has been successfully saved to disk, as the kernel uses the buffer cache to defer writes. Typically, filesystems do not flush buffers when a file is closed. If you need to be sure that the data is physically stored on the underlying disk, use fsync(2). (It will depend on the disk hardware at this point.)

简单地说,close() 成功了并不保证数据也成功落盘了,因为操作系统内核会利用缓冲延迟写操作。 并且通常来说,文件系统也不会在文件关闭的时候刷盘(flush)。 如果你要确保数据真正成功落盘,你需要调用 fsync()(对应 Go 中的 f.Sync() 方法,后文不区分)。

上面有一点没有提到,fsync() 会使数据立即落盘。当然,可以想像的是,这必然会对性能造成影响:

  • 原来是:不调用 fsync(),数据可能存在落盘失败问题,但是程序不知道,整个函数会快速返回;
  • 现在是:调用了 fsync(),数据仍然存在落盘失败问题,但是程序能够知道,这会增大函数等待时间。

结语

  • 本文没有结出最佳实践,用户应该自己确定要不要 Sync(),及如何折中的问题;
  • Go 语言的 Close()Sync() 文档并不是很详细,需要参考操作系统的文档;

参考

如果文章有帮助到你,请我喝杯冰可乐吧~

发表于:2021年4月10日 ,阅读量:293 ,标签:Go查看源内容