0%

tcp监听端口异常回收——golang gc踩坑实录

背景

最近在开发一个golang reactor模型的网络库,底层采用epoll多路复用网络IO。我编写了一个测试脚本用于对程序进行压测,压测脚本的内容是运行一个tcp client,client会大批量的向server发送数据包,但是当我进行实测的时候,奇怪的现象发生了,一旦我运行一个压测脚本几秒后,其他后续的测试脚本就无法再与服务端建立连接,报错如下:

1
Error connecting: dial tcp 127.0.0.1:10101: connect: connection refused

使用netstat命令查看,程序监听的10101端口已经被释放,说明socket已经被回收

1
2
3
4
5
6
root@0cf669f12840:/var/www/go/src/sophonn# netstat -ntlp
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 127.0.0.11:39339 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:2380 0.0.0.0:* LISTEN 8/etcd
tcp6 0 0 :::2379 :::* LISTEN 8/etcd

涉及到的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
func main() {
w := &util.WaitGroupWrapper{}
w.Wrap(func() {
//创建tcp server 监听10101端口
listener, err := net.Listen("tcp", ":10101")
tcpListenr := listener.(*net.TCPListener)
if err != nil {
return
}
//创建epoll poller
e, _ := sophonn.CreatePoller(func(inframe []byte) []byte {
str := "recive success" + string(inframe)
return []byte(str)
})
if err := e.Run(tcpListenr); err != nil {
fmt.Println(err.Error())
return
}
})
w.Wait()
}

func (e *Eventpoller) Run(listener *net.TCPListener) error {
file, err := listener.File()
if err != nil {
return err
}
//向epoll poller中添加fd
err = e.p.AddRead(int(file.Fd()))
if err !=nil {
return err
}
//poller wait
err = e.p.Wait(func(fd int, event uint32) error {
conn, ok := e.clients[fd]
if !ok {
return e.Accept(fd)
}
if event&poll.WriteEvents > 0 {
return e.Write(conn)
} else if event&poll.ReadEvents > 0 {
return e.Read(conn)
}
return nil
})
if err != nil {
return err
}
return nil
}

排错过程

是否主动close?

首先排查,是不是程序是否有主动的close操作,对监听的文件描述符进行了释放。在对程序进行debug之后排除了这种可能

gc?

既然不是主动对端口进行的释放,那么大概率是gc对资源的回收导致的。
打开gctrace对程序的gc进行监控,命令如下

1
GODEBUG=gctrace=1 go run ./example/server/server.go

同时使用netstat对端口监听状况进行持续监控

1
netstat -ntlpc

监控发现,端口的回收和程序运行后的第一次gc刚好是同时发生的,所以可以断定是gc引发的问题。

问题解决

既然发现是gc将listener回收,那么解决办法就是防止listener被gc回收,所以把listener注入到Eventpoller结构体中,重新编译运行server,发现问题被成功解决了

继续深入

其实排错过程中我依然有几个疑问:

why gc ?

listener在函数体内分配,理论上应该分配在栈空间,那么为什么还会被gc回收呢?我们都知道gc并不会涉及栈空间的内存,那么这个问题唯一的可能就是变量发生了内存逃逸,listener被编译器分配到了堆中,才发生了后续的gc问题。
golang内存逃逸是在编译的时候决定的,内存逃逸查看的指令如下

1
go build -gcflags '-m' xxx.go

gc是如何实现对资源的回收?

我们知道gc是对堆内存的回收,那么golang的gc是如何实现在回收内存的同时对系统资源进行释放的呢?
答案是finalizer,我们顺着net.Listen的源码探索,最后可以在setAddr方法中找到如下代码

1
2
3
4
5
func (fd *netFD) setAddr(laddr, raddr Addr) {
fd.laddr = laddr
fd.raddr = raddr
runtime.SetFinalizer(fd, (*netFD).Close)
}

gc回收资源的关键就在于runtime.SetFinalizer(fd, (*netFD).Close)这一行。

那么这个函数的作用是什么呢,finalizer是与对象关联的一个函数,通过runtime.SetFinalizer 来设置,它在对象被GC的时候,这个finalizer会被调用,golang通过Finalizer完成一些类似于资源释放的操作。而listener fd的资源回收就是通过finalizer设置了对应的Close方法实现了资源的回收。
关于finalizer的详情可以阅读文章深入理解Go-runtime.SetFinalizer原理剖析

欢迎关注我的其它发布渠道