记一次使用golang连接池遇到的坑

前段时间需要利用fastdfs来实现文件的上传操作。但是fastdfs官方并不提供golang客户端。但是github大法好呀。于是言小白屁颠屁颠地在github上找到了一个fastdfs的第三方实现golang客户端:tRavAsty/fdfs_client

一切开发就绪,但是在测试阶段总会偶然的出现在对fastdfs发起上传文件请求的时候hang住的情况。

在加了无数次debug日志,以及最后祭出gdb的情况下,终于将问题范围缩小到connection.go中的相关实现上。

这个文件提供了连接池的相关操作。在此客户端中,一切与fastdfs的实际交互都会通过连接池中的连接进行。

问题定位

下面是调试定位过程。

第一次夯住时借助gdb看到程序一直在makeConn()方法中。此方法的代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (this *ConnectionPool) makeConn() (net.Conn, error) {
var n int
for {
n = rand.Intn(len(this.hosts))
if !this.busyConns[n] {
this.busyConns[n] = true
break
}
}
host := this.hosts[n]
addr := fmt.Sprintf("%s:%d", host, this.ports[n])

return net.DialTimeout("tcp", addr, time.Minute)
}

其中,busyConns是连接池的一个字段,用来记录连接是否有效。在该方法中,会随机挑选一个连接,只有在某个连接的有效性记录为false(无效)的情况下,才会将其标记为true(有效),然后创建一个新的连接。

这里的for是个死循环,只有在找到一个无效连接的情况下才会退出此循环。因此一开始怀疑这里存在问题导致无法退出。即存在初始化后所有连接之后(即busyConns里面所有项的值都为true),再次调用makeConns获取新连接时陷入死循环。将其改为遍历:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (this *ConnectionPool) makeConn() (net.Conn, error) {
var n int
var busy, has bool
for n, busy = range this.busyConns {
if !busy {
this.busyConns[n] = true
has = true
break
}
}
if !has {
logger.Errorf("all hosts are busy: %v", this.busyConns)
return nil, ErrAllConnBusy
}

host := this.hosts[n]
addr := fmt.Sprintf("%s:%d", host, this.ports[n])

return net.DialTimeout("tcp", addr, time.Minute)
}

OK。继续测试。该问题一直没再重现。然而,等到要上线的前一天,它它它,它又㕛叒叕出现了/(ㄒoㄒ)/~~

因为这是偶发情况,找不到触发条件,因此只能按住案发现场不重启,然后好好看看这个库的逻辑:

  1. 连接池库提供初始化连接池函数NewConnectionPool。在此函数中,调用makeConn方法创建指定数目的连接,然后扔到连接池中一个名为conns的channel中。
  2. 每次向连接池获取连接的时候,会调用Get()方法。这个方法中,使用for+select case模式。
    1. 如果能从conns这个channel中接收到一个连接,并且此连接不是nil,而且属于活跃连接(使用activeConn方法判定),那么返回该连接。否则退出此select case,进入下一次select case
    2. 如果接收不到连接,则会在default子块中尝试通过makeConn方法来获得一个新的连接。然后将此连接发送到conns这个channel中。这样,在下一次select case中,就能够接收到一个有效的连接了。

于是,这里又存在一个死循环。如果一直收不到连接,而在makeConn中又创建不了有效连接的话,那么select case块就会一直跑到default子块中,而唯一退出for循环的条件位于case子块呀大人~~~

通过上面我们可以知道,只要busyConns的值都为true,那么就不会返回有效连接。但是,搜遍整个代码,都没有把busyConns中的值设为false的操作呀摔!

于是,改改改。

根据busyConns的语义,当连接无效的时候,我们就应该把其在busyConns上对应的值置为false。而我们会在将连接放回连接池的时候检查连接的有效性。故而,可以在连接池的put方法里,当检查连接无效的时候,将其在busyConns上置为false。(为了避免此文像裹脚布,这里代码我就不贴了。)

好了,这次,我们知道是因为连接的问题触发程序卡住了。那么,改完测试一下。

运行程序,拿出命令tcpkill把连接灭掉。

然而,不幸的是,程序,再一次卡住了卡住了卡住了!!!

万念俱灰的言小白知道,这不是因为自己之前改得不对,事实上从调试日志来看,程序根本就没走到put方法。所以还是回到Get方法上。然后,在某小可怜的提示下,终于发现还有一处不对。

我们回到上面说到的Get方法。当从channel中收到一个连接的时候,是会检查连接有效性的。但是,问题来了,当连接无效的时候,直接退出当前select case,进入下一个select case,而没有把此无效连接通过put方法放回连接池。此时,会导致此连接对应的busyConns中的值还是一直保持着true不变。这样,我们在default子块中就再次陷入了makeConns一直获取不到有效连接的困境中。修改后的Get()关键部分代码如下:

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
for {
select {
case conn := <-this.conns:
if conn == nil {
logger.Errorf("[GET] connection from channel is null")
this.put(conn, false)
break
//return nil, ErrClosed
}
if err := this.activeConn(conn); err != nil {
logger.Errorf("[GET] active connection error: %s", err)
this.put(conn, true)
break
}
return this.wrapConn(conn), nil
default:
if this.Len() >= this.maxConns {
errmsg := fmt.Sprintf("Too many connctions %d", this.Len())
return nil, errors.New(errmsg)
}
conn, n, err := this.makeConn()
if err != nil {
return nil, err
}

this.conns <- conn
logger.Debugf("[GET] put connection for %s to channel, current channel size: %d", this.hosts[n], len(this.conns))
//put connection to pool and go next `for` loop
//return this.wrapConn(conn), nil
}
}

至此,问题解决。

总结

经过此次调试,有几点心得:

  1. 关键位置的日志一定要给足!
  2. 善用gdb
  3. 对可能造成死循环的情况一定要谨慎考虑*3。

人們往往根據內心已有的信念或情緒來對外部事物進行評判,以得出與內心一致的結論。這就是驗證性偏見。 —— 司馬懿心戰