tcp连接关闭总结

我们知道在常见的web服务中,一般客户端都会在需要的时候通过http的get,post方法直接请求一个服务器接口,获取到相应的数据,一个http请求本质上都是客户端通过tcp协议和服务端建立连接,然后构造一个http协议的请求req发给服务器,服务器返回相应的resp数据。

 

最简单的http请求可能就是每次请求都会重新建立一个tcp连接,然后请求结束后主动关闭这个tcp连接,这种方式处理比较简单,但是问题也比较明显,就是每次请求都要进行3次握手4次挥手,增加了额外的消耗,所以大多数http的客户端sdk会使用一个连接池管理一定数量的tcp连接,省去每次请求重新建立和关闭tcp的开销,提高性能。

 

我们通常听得最多的tcp知识点可能就是3次握手和4次挥手,tcp3次握手的触发时机会容易理解,就是在需要发起http请求的时候,但是对于一个tcp连接什么情况下触发4次挥手,关闭连接的问题可能思考的并不多,比如一个tcp连接如果双方长时间没有通信,这个tcp连接自动断开吗?还有如果客户端或者服务器有一方进程出现问题,或者网络出现异常,客户端和服务器无法正常完成4次挥手,那这个tcp连接是不是就永远不会被释放呢?

 

这里面其实又分为好几种情况:

  1. 首先说下一个已建立的tcp连接如果双方都没有主动断开的话,是会一直存在的,不会自动断开,即使这个tcp连接之间网络断开过,然后又重新联通,实际对这个连接不会造成影响。
  2. 如果客户端或服务器进程有一方崩溃或正常重启,导致文件描述符(fd)已经失效,tcp连接的另一方是否能够感知到呢,这里需要区分一下,如果只是进程本身不在了导致资源释放,这时候系统内核可以感知,会给该进程所有的tcp对端发送一个rst报文,通知对方该连接已经异常,可以关闭连接了,如果这个报文发送失败了,对方也是无法感知的;如果是进程所在的机器挂掉了,导致进程不在了,这个时候系统重启也不会给tcp对端发送rst报文,所以对方也是无法感知的。

 

关于rst报文发送的时机包含以下几种情况:

  • 当尝试和未开放的服务器端口建立tcp连接时,服务器tcp将会直接向客户端发送reset报文
  • 双方之前已经正常建立了通信通道,也可能进行过了交互,当某一方在交互的过程中发生了异常,如崩溃等,异常的一方会向对端发送reset报文,通知对方将连接关闭
  • 当收到TCP报文,但是发现该报文不是已建立的TCP连接列表可处理的,则其直接向对端发送reset报文
  • ack报文丢失,并且超出一定的重传次数或时间后,会主动向对端发送reset报文释放该TCP连接

 

通过上面的分析我们知道,实际上有些时候我们是不能及时感知到tcp连接的另一方是否已经异常,导致一些异常tcp连接不能被及时释放,特别对于服务器来说是肯定需要处理的,不然很容易就会导致系统文件描述符资源的耗尽。

 

在TCP协议提供了心跳保活(KeepAlive)机制,即定时发送一个心跳包来探测对方是否正常,默认探测间隔是2h,这个时间间隔对于大多数应用来说太长了。KeepAlive并不是TCP协议规范的一部分,但在几乎所有的TCP/IP协议栈(不管是Linux还是Windows)中,都实现了KeepAlive功能。KeepAlive默认不是开启的,如果想使用KeepAlive,需要在你的应用中设置SO_KEEPALIVE才可以生效。

 

tcp连接默认的KeepAlive间隔太长,我们可以设置地短一点,比如golang里面服务器端对于所有的tcp连接都自动设置了一个15s的KeepAlive时间间隔;通过KeepAlive这种方式来检测tcp连接的有效性有个缺点就是比较浪费资源,我们也可以通过设置socket读写超时时间的方式来及时感知到某个tcp连接是否存在异常,不过这个也有个缺点就是可能会受网络环境的影响而关闭掉本来正常的tcp连接。

 

下面是自己在测试golang的net库时使用的的例子,可以作为参考:

server:

package main

import (
    "net"
    "log"
    "time"
)


func main() {

    addr := "0.0.0.0:8080"

    tcpAddr, err := net.ResolveTCPAddr("tcp",addr)

    if err != nil {
        log.Fatalf("net.ResovleTCPAddr fail:%s", addr)
    }

    listener, err := net.ListenTCP("tcp", tcpAddr)
    if err != nil {
        log.Fatalf("listen %s fail: %s", addr, err)
    } else {

        log.Println("rpc listening", addr)
    }


    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Println("listener.Accept error:", err)
            continue
        }

        go handleConnection(conn)

    }

}


func handleConnection(conn net.Conn) {

    defer conn.Close()

    var buffer []byte = []byte("You are welcome. I'm server.")


    for {
        //conn.SetWriteDeadline(time.Now().Add(1 * time.Second))
        n, err := conn.Write(buffer)
        if err != nil {
            log.Println("Write error:", err)
            break
        }
        log.Println("send:", n)

        time.Sleep(3*time.Second)
    }

    log.Println("connetion end")

}

client:

package main


import (
    "log"
    "net"
    "os"
    "time"
)

func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:8080")
    if err != nil {
        log.Println("dial failed:", err)
        os.Exit(1)
    }
    defer conn.Close()


    buffer := make([]byte, 512)

    for {
        conn.SetReadDeadline(time.Now().Add(2 * time.Second))
        n, err := conn.Read(buffer)
        if err != nil {
            log.Println("Read failed:", err)
            return
        }

        log.Println("count:", n, "msg:", string(buffer))
    }

}

执行结果抓包如下:

tcp连接关闭总结

最上面三行是tcp的3次握手,然后第4行协商滑动窗口大小,第5,6行是发送和回复的第一个msg,然后server端sleep了3秒,导致客户端读超时,主动退出,发送了断开连接的请求,最后一行是服务器再次发送数据时,客户端返回的rst报文,表示该连接已经被关闭。

 

 

如果服务器和客户端之间不发送数据,会看到每隔15s,服务端会发送一个keepalive的包进行探测:

tcp连接关闭总结

这个15s的探测代码是在/usr/lib/go/src/net/tcpsock_posix.go:150中的accept()设置的:

func (ln *TCPListener) accept() (*TCPConn, error) {                             
        fd, err := ln.fd.accept()                                               
        if err != nil {                                                         
                return nil, err                                                 
        }                                                                       
        tc := newTCPConn(fd)                                                    
        if ln.lc.KeepAlive >= 0 {                                               
                setKeepAlive(fd, true)                                          
                ka := ln.lc.KeepAlive                                           
                if ln.lc.KeepAlive == 0 {                                       
                        ka = defaultTCPKeepAlive                                
                }                                                               
                setKeepAlivePeriod(fd, ka)                                                                                           
        }                                                                       
        return tc, nil                                                          
}

 

 

 

关于broken pipe和connection reset by peer的区别可以参考如下:

broken pipe:

broken pipe只出现在调用write的时候。broken pipe的意思是对端的管道已经断开,往往发生在远端把这个读/写管道关闭了,你无法在对这个管道进行读写操作。从tcp的四次挥手来讲,远端已经发送了FIN序号,告诉你我这个管道已经关闭,这时候,如果你继续往管道里写数据,第一次,你会收到一个远端发送的RST信号,如果你继续往管道里write数据,操作系统就会给你发送SIGPIPE的信号,并且将errno置为Broken pipe(32),如果你的程序默认没有对SIGPIPE进行处理,那么程序会中断退出。一般情况下,可以用signal(SIGPIPE,SIG_IGN)忽略这个信号,这样的话程序不会退出,但是write会返回-1并且将errno置为Broken pipe(32)。broken pipe只会出现在往对端已经关闭的管道里写数据的情况下(在收到对端的RST序号后第一次写不会出现broke pipe,而是write返回-1,这时候正确的做法应该是本端也close这个管道,如果继续write,那么就会出现这个错误)。

 

connection reset by peer:

connection reset by peer在调用write或者read的时候都会出现。按照glibc的说法,是such as by the remote machine rebooting or an unrecoverable protocol violation。从字面意义上来看,是表示远端机器重启或者发生不可恢复的错误。从我的测试来看,目前只出现在对端直接kill掉进程的情况。这两种情况有什么不同呢?对比tcpdump的截包图来看,直接kill掉远端进程的话,远端并没有发送FIN序号,来告诉对方,我已经关闭管道,而是直接发送了RST序号,而远端如果调用close或者shutdown的话,是会发送FIN序号的。按照TCP的四次挥手来看,是需要FIN这个序号的。个人猜测,如果在本端没有收到对方的FIN序号而直接收到了RST序号的话,表明对端出现了machine rebooting or an unrecoverable protocol violation,这时候对这个管道的IO操作,就会出现connection reset by peer错误。

 

 

 

参考:

http://lovestblog.cn/blog/2014/05/20/tcp-broken-pipe/

https://www.cnblogs.com/fgokey/p/5949004.html