前段时间需要利用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
14func (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)
}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
21func (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)
}
因为这是偶发情况,找不到触发条件,因此只能按住案发现场不重启,然后好好看看这个库的逻辑: 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
32for {
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。
人們往往根據內心已有的信念或情緒來對外部事物進行評判,以得出與內心一致的結論。這就是驗證性偏見。 —— 司馬懿心戰