Conn.File方式导致无法close对应旧fd,线程数泄漏

使用go编写server时,有时需要获取当前连接对应的connection属性。而Linux系统中,Connection本质为socket类型的file descriptor。go源码中多种网络类型(比如tcp/udp/unix)的conn均提供了File方法,用于获取连接对应的socket file descriptor。但使用该方法后,会导致close连接时阻塞,进而造成线程增加,引发系统panic。

问题复现

问题的复现过程也比较简单,逻辑代码如下:

...
conn, err := ln.Accept()
// 获取conn对应fd
connT, ok := conn.(*net.TCPConn)
file, _ := connT.File()
// do some operation using file
...

当http server承载较多流量后,调用Shutdown方法关闭server,会发现服务无法关闭。

问题分析

  1. pprof分析

分析go问题,必然先通过pprof神器,尝试能否看出端倪。出现问题时goroutine profile显示如下:

grprofile.png

可看出大量goroutine在进行SemacquireMutex系统调用,即阻塞在锁竞争过程,但无法根据调用栈明确导致锁竞争的调用方。

  1. dlv调试

为了明确具体的调用方,使用dlv对http.Shutdown方法进行调试。

dlv1.png

dlv2.png

dlv3.png

dlv4.png

dlv4.png

通过断点调试,发送goroutine最后阻塞在runtime_Semacquire函数中,且与线上pprof内容吻合。

  1. 阻塞分析

搜索相关资料,发现use RawConn.Control to get fd instead of Fd()net: UnixListener blocks forever in Close() if File() is used to get the file descriptor中提到过相关问题,该问题为go升级1.11后的已知问题。

调用TCPConn.File方法时,会复制底层fd,返回结果称为duplacated fd,如下:

connfile.png

调用duplacated fdfile.Fd()方法时,会通过系统调用fcntl将duplacated fd和底层fd内容设置为blocking状态。但对于original fd,仍然为nonblocking状态,所以产生了死锁。示意如下:

invoke.png

如何解决

将代码中出现的file.Fd()方法调用改为tcpConn.SyscallConn(),从而避免fd关闭时死锁。

...
conn, err := ln.Accept()
// 获取conn对应fd
connT, ok := conn.(*net.TCPConn)
rawConn, err := tcpConn.SyscallConn()
rawConn.Control(func(fd uintptr) {
	// do some operation using file
})
...

Note

验证过程中也发现,在连接可正常关闭后,server占用的线程数并没有随之降低,此为go语言本身的问题(或许不一定能定义成问题),可参考极端情况下收缩 Go 的线程数

参考

  1. use RawConn.Control to get fd instead of Fd()

  2. net: UnixListener blocks forever in Close() if File() is used to get the file descriptor

  3. 极端情况下收缩 Go 的线程数

  4. runtime: let idle OS threads exit