在程序开发中会经常使用临时资源,这些资源必须在代码中的某个位置进行关闭以防止泄露。例如,对于操作磁盘或内存的结构体,通常可以实现io.Closer接口来表达必须关闭临时资源。本文将深入分析三个常见的示例代码,说明资源如果没有正确关闭会产生什么问题以及如何处理它们。
首先,我们讨论一个与HTTP相关的问题,下面程序编写了一个getBody方法,该方法会发出一个HTTP GET请求并返回HTTP正文响应。实现代码如下:
type handler struct {
client http.Client
url string
}
func (h handler) getBody() (string, error) {
resp, err := h.client.Get(h.url)
if err != nil {
return "", err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}
上述程序通过http.Get请求数据并使用io.ReadAll解析响应。看起来没有问题,但是实际上存在资源泄露。resp是一个*http.Response类型,它包含一个io.ReadCloser类型字段Body。io.ReadCloser实现了io.Reader和io.Closer接口。如果http.Get请求正常返回没有出现错误,则必须要关闭resp.Body资源,否则会导致资源泄露。会造成已分配但是不再需要的内存不能被GC回收,甚至在最坏的情况下会导致客户端无法重用TCP连接。可以通过defer语句关闭resp.Body,处理起来非常方便,代码如下。
defer func() {
err := resp.Body.Close()
if err != nil {
log.Printf("failed to close response: %v\n", err)
}
}()
将body资源关闭操作放在defer语句中,保证了当getBody返回时,一定执行Close操作。注意上面的程序使用了闭包,在defer函数内部引用了外部变量resp.
「NOTE: 在服务端,实现HTTP处理程序时不需要关闭请求正文,因为服务器会自动关闭。」
此外,我们还需要知道一点,无论是否读取了body中的数据,最后body也是要必需关闭。例如,如果我们只对HTTP状态码感兴趣,而不关心正文内容,也是要对body进行关闭,否则也会导致内存泄露。
func (h handler) getStatusCode(body io.Reader) (int, error) {
resp, err := h.client.Post(h.url, "application/json", body)
if err != nil {
return 0, err
}
defer func() {
err := resp.Body.Close()
if err != nil {
log.Printf("failed to close response: %v\n", err)
}
}()
return resp.StatusCode, nil
}
像上面的程序,即使我们没有读取body中的内容,在函数返回时也是需要对其进行关闭。
还有一点需要注意的是,根据对body是否进行过数据读取,在关闭body时会产生不同的行为:
因此,如果getStatusCode被重复调用并且我们想要利用keep-alive连接,即使我们对body内容不关心,仍然应该读取它的内容。
func (h handler) getStatusCode(body io.Reader) (int, error) {
resp, err := h.client.Post(h.url, "application/json", body)
if err != nil {
return 0, err
}
// Close response body
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode, nil
}
在上面的代码中,我们通过读取body中的内容以保持连接处于活动状态。需要注意的是,读取内容时没有使用io.ReadAll,而是使用io.Copy将body中的内容读到io.Discard中,io.Discard实现了io.Writer接口。此代码读取body中的内容,但直接丢弃不保存,效率比io.ReadAll更高。
「NOTE: 像下面这样,不是通过判断错误err为nil, 而是通过判断响应resp不为nil, 对body进行关闭的实现并不少见。这样的写法不是必需的,写成这样是考虑到在某些情况下(例如,重定向失败),resp和err都不是nil。然而,根据官方文档(https://github.com/golang/go/blob/master/src/net/http/client.go#L565-L567),出错时,可以忽略任何响应。仅当CheckRedirect失败时才会产生同时有非nil错误和非nil resp情况,即使这样,返回的Response.Body也已经关闭。因此,不需要 if resp!=nil{}
检查。当没有错误的时候通过defer关闭body即可。」
resp, err := http.Get(url)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return "", err
}
关闭资源避免泄露不仅仅是HTTP body才有,一般来说,所有实现了io.Closer接口的结构都应该在某个时候关闭。io.Closer接口包含一个Closer方法,它的定义如下:
type Closer interface {
Close() error
}
sql.Rows是执行SQL查询结果的结构类型,由于该结构实现了io.Closer接口,所以在使用完之后需要关闭。下面的程序省略了关闭操作,导致连接无法放回到连接池中,造成连接泄露。
db, err := sql.Open("postgres", dataSourceName)
if err != nil {
return err
}
rows, err := db.Query("SELECT * FROM CUSTOMERS")
if err != nil {
return err
}
// Use rows
return nil
我们可以在if err!=nil
语句之后,调用defer函数,在函数内部通过闭包执行rows.Close操作。实现代码如下. 这样在执行Query操作后,如果没有返回错误时,可以确保rows被关闭,防止内存泄露。
// Open connection
rows, err := db.Query("SELECT * FROM CUSTOMERS")
if err != nil {
return err
}
defer func() {
if err := rows.Close(); err != nil {
log.Printf("failed to close rows: %v\n", err)
}
}()
// Use rows
「NOTE: 上面程序中的变量db类型为*sqlDB, 它代表一个连接池,该类型也实现了io.Closer接口,然而,关闭sql.DB是很少见的,因为db一般是长期存在的并且在多个goroutine之间可以共享它。」
os.File代表一个打开的文件描述符,和sql.Rows一样,在使用完成之后需要关闭。下面的程序同样使用defer来推迟调用Close方法。如果我们最后不关闭os.File.它本身不会导致泄露。因为当os.File被垃圾回收时,文件会自动关闭。但是,最好显示调用Close,因为我们不知道何时触发下一次GC(除非我们手动执行GC).
f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, os.ModeAppend)
if err != nil {
return err
}
defer func() {
if err := f.Close(); err != nil {
log.Printf("failed to close file: %v\n", err)
}
}()
显示调用Close还有另一个好处:当我们想要主动监控返回的错误时,通过Close执行时返回的错误可以了解程序运行情况。例如,关闭可写文件描述符时,可以知道数据是否写入成功。因为向文件描述符中写入数据不是一个同步操作,出于性能考虑,数据先被写入到内存中的缓存中。BSD手册中的close(2)提到,关闭操作可能导致以前未写入的数据(仍在缓存中)遇到I/O错误时返回错误。所以,当我们向文件写入数据时,通过关闭os.File, 将关闭时可能产生的错误返回给调用方。
func writeToFile(filename string, content []byte) (err error) {
// Open file
defer func() {
closeErr := f.Close()
if err == nil {
err = closeErr
}
}()
_, err = f.Write(content)
return
}
上述程序对返回值进行了命名,写入操作没有返回错误时并将错误值设置为f.Close返回的结果。如果写入成功但关闭失败,即执行f.Close返回的错误非nil, 调用方可以知道writeToFile执行出现了问题,能够进行合理的处理。
此外,关闭os.File成功并不能保证文件会写入磁盘。实际中,写入仍然可能存在于文件系统上的缓冲区中,而还未刷新到磁盘上。如果内容持久化到磁盘非常重要,我们可以使用Sync()方法提交更改,在这种情况下,可以忽略Close产生的错误。下面的程序实现的一个同步写入版本,它能够保证在返回之前将内容写入磁盘,坏处是相比上面的非同步实现对性能有一定的影响。
func writeToFile(filename string, content []byte) error {
// Open file
defer func() {
_ = f.Close()
}()
_, err = f.Write(content)
if err != nil {
return err
}
return f.Sync()
}
总结:通过上面3个案例说明关闭临时资源非常重要,否则会导致泄露。临时资源必须在恰当的时间和特定的情况下关闭。有时候,对于资源是否必须要关闭可能不是非常清楚,我们可以仔细阅读API文档或通过已有的经验来学习了解。有一点需要记住,如果一个结构体实现了io.Closer接口,最后必须要调用Close方法进行关闭。还有一点,如果闭包执行失败,我们要考虑是记录足够的日志信息还是对外抛出错误,具体怎么处理更好取决于实现。