一个 bad file descriptor 的问题
先来看一个 demo:
1 package main
2
3 import (
4 "fmt"
5 "net"
6 "os"
7 "runtime"
8 )
9
10 var rawFileList []*os.File
11
12 func main() {
13 l, err := net.Listen("tcp", ":12345")
14 if err != nil {
15 fmt.Println(err)
16 return
17 }
18
19 var connList []net.Conn
20 for {
21 conn, err := l.Accept()
22 connList = append(connList, conn)
23 if err != nil {
24 fmt.Println(err)
25 return
26 }
27
28 go func() {
29 f, err := conn.(*net.TCPConn).File()
30 if err != nil {
31 fmt.Println(err)
32 return
33 }
34
35 rawFile := os.NewFile(f.Fd(), "")
36 rawFileList = append(rawFileList, rawFile)
37 _ = rawFile
38 for {
39 var buf = make([]byte, 1024)
40 conn.Read(buf)
41 conn.Write([]byte(`HTTP/1.1 200 OK
42 Connection: Keep-Alive
43 Content-Length: 0
44 Content-Type: text/html
45 Server: Apache
46
47 `))
48 runtime.GC()
49 }
50 }()
51 }
52 }
可以认为是一个简单 read request,write response 的 http server,用 wrk 压的话,也能正常运行:
~ ❯❯❯ wrk http://localhost:12345
Running 10s test @ http://localhost:12345
2 threads and 10 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 589.84us 0.86ms 27.30ms 98.98%
Req/Sec 9.19k 1.00k 10.91k 68.50%
183093 requests in 10.02s, 16.94MB read
Requests/sec: 18278.93
Transfer/sec: 1.69MB
进程也没有什么错误日志,把上面的代码注释掉第 36 行再用 wrk 压测,这回结果就不一样了:
file tcp [::1]:12345->[::1]:58949: fcntl: bad file descriptor
这个结果还是有点令人意外的,我们又没有主动关闭连接,为什么会出现 bad file descriptor?
在代码中,我们使用连接的 rawFile 的 fd 新建了一个文件:
29 f, err := conn.(*net.TCPConn).File()
30 if err != nil {
31 fmt.Println(err)
32 return
33 }
34
35 rawFile := os.NewFile(f.Fd(), "") // 这里
36 rawFileList = append(rawFileList, rawFile)
37 _ = rawFile
注释掉 36 和没注释有什么区别呢?是谁把我们的连接给关了?
答案比较简单,rawFileList 是在堆上分配的全局对象,我们把 rawFile 追加进该数组后,GC 时便不会回收 rawFile。在 Go 语言中,文件类型在 GC 回收时会执行其 close 动作,这是通过 newFile 时的 SetFinalizer 完成的:
func newFile(fd uintptr, name string, kind newFileKind) *File {
... 省略
runtime.SetFinalizer(f.file, (*file).close)
return f
}
也就是说所有文件类型都会在 GC 时被 close,在本文开头的 demo 中,这个被 close 的文件是我们用 raw fd 创建出来的,而 raw fd 本身是 uintptr 类型。我们知道,带 GC 的语言,对象之间主要是通过指针引用的,当我们用 uintptr 来创建新文件时,其实已经把这个引用关系破坏掉了:
右边的 NewFile 如果被 GC 先回收了,那么左边还在用这个文件就会报 bad file descriptor:
这时候可能有读者会觉得奇怪了,按说 net.Conn 是有 File 方法的,为什么我们直接用 File 这个方法生成出来的文件就没有问题?
那是因为 File 的实现中,将原有的 fd 复制了一份:
func (c *conn) File() (f *os.File, err error) {
f, err = c.fd.dup() // 复制 fd
if err != nil {
err = &OpError{Op: "file", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err}
}
return
}
dup 操作会在 fd 上增加一个引用计数,当引用计数减为 0 时,才会执行 finalizer。
综上,看起来是个很简单的问题,生产环境查起来还是要费一些时间。因为类似的问题并不常见。祝你好运。