zl程序教程

您现在的位置是:首页 >  其他

当前栏目

释放有限的资源以避免泄露

2023-02-26 09:48:26 时间

本文是对 《100 Go Mistackes:How to Avoid Them》 一书的翻译。因翻译水平有限,难免存在翻译准确性问题,敬请谅解

众所周知,计算机的资源(内存、磁盘)都是有限的,在编程时,这些资源必须在代码的中的某个地方被关闭释放,以避免造成资源不足而泄露。但开发人员在编写代码时往往会忽略关闭已打开的资源,从而因资源不足导致程序出现异常。

本文主要介绍在Go中,凡是实现了io.Closer接口的结构体,最终都必须要被关闭以释放资源。

下面这个例子是一个getBody函数,该函数会构建一个HTTP GET请求并处理得到的HTTP响应。下面是第一版本的实现:

func getBody(url string) (string, error) {
   resp, err := http.Get(url)
   if err != nil {
       return "", err
  }
   body, err := ioutil.ReadAll(resp.Body) ①
   if err != nil {
       return "", err
  }
   return string(body), nil
}

① 读取resp.Body并将其转换成一个字节数组[]byte

我们使用了http.Get方法,然后我们使用ioutil.ReadAll读取响应值。这个函数的功能看起来算是正常的。至少,它正确返回了HTTP响应。

然而,这里存在一个资源泄露的问题让我们看看是在哪里

resp是一个*http.Response指针类型。它包含一个io.ReaderCloser字段(io.ReadCloser同时包含io.Reader接口和io.Closer接口)。如果http.Get没有返回错误,那该字段必须被关闭。否则,就会造成资源泄露。它会占用一些内存,这些内存在函数执行后就不再需要了,但因没有主动释放资源所以不能被GC回收,同时在资源匮乏的时候客户端还不能重用TCP连接

处理该主体关闭的最方便的方法就是使用defer语句:

func getBody(url string) (string, error) {
   resp, err := http.Get(url)
   if err != nil {
       return "", err
  }
   defer resp.Body.Close() ①
   body, err := ioutil.ReadAll(resp.Body)
   if err != nil {
       return "", err
  }
   return string(body), nil
}

① 如果http.Get没有返回错误,我们会使用defer来关闭响应值。

在该实现中,我们使用延迟函数(defer)正确处理了返回资源需要关闭的操作,这样一旦getBody函数返回,该延迟关闭语句就会被执行。

注意:我们应该注意resp.Body.Close()返回的错误场景。我们在错误管理一章将会看到在延迟函数中如何处理错误。在这个例子以及后续的例子中,我们将暂时忽略错误。

我们应该注意的是 无论我们是否从response.Body中读取到内容,我们都需要把响应资源关闭。例如,在下面的函数中我们仅返回了HTTP状态码。然而,响应体也必须被关闭:

func getStatusCode(url string) (int, error) {
   resp, err := http.Get(url)
   if err != nil {
       return 0, err
  }
   defer resp.Body.Close() ①
   return resp.StatusCode, nil
}

① 即使没读取内容,响应体也需要被关闭。

我们应该确保在正确的时刻释放掉资源。例如,如果不考虑err的值,在err判断之前就调用延迟函数 resp.Body.Close():

func getStatusCode(url string) (int, error) {
  resp, err := http.Get(url)
  defer resp.Body.Close() ①
  if err != nil {
    return 0, err
  }

  return resp.StatusCode, nil
}

① 在该阶段,resp可能是nil

因为resp可能是nil,所以这段代码可能会导致程序panic:

panic: runtime error: invalid memory address or nil pointer dereference

最后一件关于HTTP请求体关闭需要注意的事情。也是一个非常少见的情况,就是如果响应是空,而非nil时关闭响应:

resp, err := http.Get(url)
if resp != nil { ①
   defer resp.Body.Close() ②
}
if err != nil {
   return "", err
}

① 如果response不是nil

② 作为延迟函数关闭响应体

该实现是错误的。该实现依赖一些条件(例如,重定向失败),resp和err都不是nil。

依据Go官方文档所说:出现错误时,任何都可以被忽略掉一个返回非nil错误的非nil响应只有当检查Redirect失败时才会出现,然而,这时返回的Response.Body已经被关闭了

因此,if resp != nil {}的检查语句是没必要的。我们应该坚持最初的解决方案,只有在没有错误的情况下才在延迟函数中关闭主体

注意:在服务端,当实现一个HTTP handler时,不必关闭请求,因为它会被服务器自动关闭。

关闭资源以避免泄露不仅仅和HTTP的响应体有关。通常来说,所有实现了io.Closer接口的结构体都需要在某个时刻被关闭。该接口包含唯一的一个Close方法:

type Closer interface {
  Close() error
}
让我们看一些其他关于资源需要被关闭而避免泄露的例子。

sql.Rows

sql.Rows是用于sql查询结果的结构体。因为该结构体实现了io.Closer接口,所以它必须被关闭。我们也可以像下面这样使用延迟函数来处理关闭逻辑:

db, err := sql.Open("postgres", dataSourceName) ①
if err != nil {
   return err
}
rows, err := db.Query("SELECT * FROM MYTABLE") ②
if err != nil {
   return err
}
defer rows.Close() ③

// Use rows

① 创建一个SQL连接

② 执行一个SQL查询

③ 关闭 rows

如果Query的调用没有返回错误,那我们就需要及时的关闭rows。

os.File

os.File代表一个打开的文件标识符。和sql.Rows一样,最终也应该的被关闭:

f, err := os.Open("events.log") ①
if err != nil {
   return err
}
defer f.Close() ②

// Use file descriptor

① 打开文件

② 关闭文件标识符

当所在的函数块返回时我们又一次使用defer来调度Close方法。

注意:正在关闭的文件不会保证文件内容已经被写到磁盘上。事实上,写入的内容可能留在了文件系统的缓冲区上,还没有被刷新到磁盘上。如果持久化是一个关键因素,我们应该使用Sync()方法来把缓冲区上的内容刷到磁盘上。

压缩实现

压缩的写入和读取实现也需要被关闭的。事实上,他们创建的内部缓冲区也是需要被手动释放的。例如:gzip.Writer.

var b bytes.Buffer ①
w := gzip.NewWriter(&b) ②

defer w.Close() ③

① 创建一个缓冲区

② 创建一个新的gzip writer

③ 关闭 gzip.Writer

gzip.Reader具有同样的逻辑:

var b bytes.Buffer ①
r, err := gzip.NewReader(&b) ②
if err != nil {
   return nil, err
}

defer r.Close() ③

① 创建一个缓冲区

② 创建一个新的gzip writer

③ 关闭gzip.Writer

小结

我们已经看到,关闭有限的资源以避免泄漏是多么重要。有限的资源必须在正确的时间和特定的场景下被关闭。有时,是否需要资源不是很明确。我们只能通过阅读相关的API文档或实际实践来决定。然而,我们必须要谨慎,如果一个结构体实现了io.Closer接口,我们就必须要在最后调用Close方法。