看懂TCP协议

Posted 李嘉图杂文

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了看懂TCP协议相关的知识,希望对你有一定的参考价值。

预备知识

  1. TCP提供可靠连接的原理:

    • 每个TCP包都有一个序列号,接收方通过该序列号将响应数据包正确排序;也通过该序列号发现传输序列中丢失的数据包,并请求重传。
    • TCP并不使用顺序的整数作为数据包的序列号,而是通过一个计数器来记录发送的字节数。以字节数+上一个数据包的序列号的方式生成序列号。这样,需要重传的时候,只要把数据流用另一种方式分割成新的数据包,就可以让接收方重新接收正确的数据包流。
    • TCP不通过锁步(等待上一个接受被确认以后才发送下一个数据包)的方式进行通信。而是一次发送多个数据包。在某一时刻,发送方希望同时传输的数据量叫做 TCP窗口。
    • 接收方的TCP实现可以通过控制发送方的窗口大小来减缓或暂停连接。这叫做 流量控制。
    • 最后,如果TCP认为数据包被丢弃了,它会减少每秒发送的数据量,并假定网络正在变得拥堵。
  2. 大名鼎鼎的三次握手,四次挥手:

    (这个是网图,我不小心加了水印,不知道怎么去掉)下图是TCP建立连接的过程。

    SYN:“我想进行通信,这是数据包和初始序列号。”

    SYN-ACK:“好的,这是我向你发送数据包的初始序列号。”

    ACK:“好的。”

    结束过程,在图里看起来也很简单:

    FIN:“我们结束吧。”

    ACK:“好的。”

    FIN:“我们结束吧。”

    ACK:“好的。”

    (有些情况下,其实只需要三次挥手,即FIN,ACK-FIN,ACK)

    但是,我初学到这里的时候有个疑惑,以上图为例:

    服务端最后为什么接收客户端的ACK呢?这不是已经结束了吗,他怎么接受这个ACK的呢?

    解答在后面。


  3. TCP套接字

    和UDP的套接字的概念是一样的,前一篇文章讲过,这里就不赘述了。

    但是,TCP套接字有一个特殊的机制:

    这个机制可以解决上面的问题。(客户端的TCP套接字和UDP没什么差别)

    • 完整的服务端TCP套接字其实是两个套接字:

      被动套接字(监听套接字)+ 主动套接字(连接套接字)

    • 监听套接字(被动套接字):

    • 连接套接字(主动套接字):

    • 非常有助理解的一个细节:

      而连接套接字是可以多个套接字共享同一个本地套接字名的。

      一个监听套接字可以指挥操作系统生成多个同名的连接套接字,来和不同的客户端进行通信。

TCP通信的基本过程:

# Simple TCP client and server that send and receive 16 octets(字节)

import argparse, socket


# 如果接收的字节长度小于length,报错。
# recv必须在循环里实现recvall,否则可能会因为机器的缓存溢出问题导致数据传输不全的情况发生。
def recvall(sock, length):
    data = b''
    while len(data) < length:
        more = sock.recv(length - len(data))
        if not more:
            raise EOFError('was excepting %d bytes but only received '
                           '%d bytes before the socket closed'
                           % (length, len(data)))
        data += more
    return data


def server(interface, port):
    # 指定协议族,指定TCP协议
    # 这里创建的套接字就是被动套接字(监听套接字)
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    # 下面用setsockopt对sock进行额外的配置,这样的配置是对sock.sock的一种自定义写法,很多情况下是等价的。
    # SOL_SOCKET是指原始的协议的级别 ;
    # the SO_REUSEADDR flag tells the kernel to reuse a local socket in TIME_WAIT state,
    # without waiting for its natural timeout to expire.
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((interface, port))
    # listen(x)表示它程序希望套接字进行监听,x表示处于等待的连接的最大数目。
    sock.listen(1)
    print('Listing at', sock.getsockname())
    while True:
        # 套接字的监听调用实际上会返回一个新的套接字!就是下面的sc
        # 监听套接字调用accept方法,产生连接套接字(主动套接字),然后才能真正的和客户端进行通信。
        sc, sockname = sock.accept()
        print('We have accepted a connection from', sockname)
        print(sc)
        print(' Socket name:', sc.getsockname())
        print('Socket peer:', sc.getpeername())
        message = recvall(sc, 16)
        # repr的作用是,返回一个对象的 string 格式。
        print(' Incoming sixteen-octet message:', repr(message))
        sc.sendall(b'Farewell, client')
        sc.close()
        print(' Reply sent, socket closed')


def client(host, port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((host, port))
    print('Client has been assigned socket name', sock.getsockname())
    sock.sendall(b'Hi there, server')
    reply = recvall(sock, 16)
    print('The server said', repr(reply))
    sock.close()


if __name__ == '__main__':
    choices = {'client': client, 'server': server}
    parser = argparse.ArgumentParser(description='Send and receive over TCP')
    parser.add_argument('role', choices=choices, help='选择要运行的程序(server or client)')
    parser.add_argument('host', help='interface the server listens at;'
                        'host the client sends to')
    parser.add_argument('-p', metavar='PORT', type=int, default=1060,
                        help='TCP port (default 1060)')
    args = parser.parse_args()
    function = choices[args.role]
    function(args.host, args.p)

上面的例子中,有一个细节片段:

# the SO_REUSEADDR flag tells the kernel to reuse a local socket in TIME_WAIT state,
    # without waiting for its natural timeout to expire.
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

你也许会看不太懂第二个参数 _

这个参数设置涉及到TCP的关闭机制,不指定这个参数的话,在会话结束后,操作系统会保留已经不用了的连接套接字几分钟的时间(==监听套接字会被立刻关闭==)。

TCP显然是没有这样的逻辑问题的。

一旦应用程序认为某个TCP连接关闭了,操作系统实际上会在一个等待的状态中,把这个连接的记录最多保持4分钟。套接字处于这种状态时,任何最终的FIN数据包都是可以得到适当响应的。

但是很多程序中,这样的保留机制会造成端口的浪费,所以指定这个套接字选项, _ ,让处于这种半关闭状态的端口,不仅仅为刚结束的会话保留,其他程序也可以使用这个端口。

TCP的其他问题

你可能会好奇上面的程序中,为什么有 这样一个函数,没有它,为什么就会有缓存溢出问题?缓存溢出有什么影响吗?

  1. 缓存溢出问题是怎么产生的?

    以数据流的形式发送/接收数据时,数据可能处于以下的三种状态之一:

    下面以数据发送过程为例:

    由于第三种情况可能出现,我们调用send()函数的时候,要检查返回值,需要在一个循环内进行send()调用。

    bytes_sent = 0
    while bytes_sent < len(message):
     message_remaining = message[bytes_sent:]
     bytes_sent += s.send(message_remaining)

    解决发送数据时的这种问题很简单,python为我们封装了sendall()方法,用就完事了。

    recv没有这样的函数(这是因为recv应用场景更灵活多变),我们只能自己写一个recvall()函数。

    但是,真正的问题来了!

    我最多就是缓存溢出以后,等一会儿嘛,阻塞起来,等有资源之后再继续重传不就好了,看起来也没啥大问题吧?

    学过操作系统的同学,必然会突然想起来一个事情:

    有阻塞,就会有死锁!!!!!!

    缓存溢出问题的致命之处在于它可能造成的死锁问题!!!

    • 要发送的数据可能立即被本地接收,或者存储到缓存区。这可能是因为网卡或者系统正好空闲。
    • 网卡正忙,缓存区也满。那么本进程会被阻塞,直到本地系统可以接受并传输数据。
    • 最后一种情况介于两者之间,即一部分被接收了,但是接收这一部分数据之后,进程被阻塞了。这种情况下,我们发送数据调用的send()方法,会返回从数据串开始处已经被接收的字节数,剩余的数据则尚未被处理。
  2. 缓存溢出会导致死锁问题!

    死锁的概念:

    死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

    在我们的TCP通信问题中,当服务端和客户端的缓存区都被填满的时候,两个进程都被阻塞,就会陷入死锁的状态。

    下面写一对可能产生死锁问题的server-client

    # TCP client and server that leave too much data waiting
    import socket, sys

    # 服务端的功能是把客户端发送来的数据都转换成大写。
    def server(host, port):
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        sock.bind((host, port))
        sock.listen(1)
        print('Listing at', sock.getsockname())
        while True:
            sc, sockname = sock.accept()
            print('Processing up to 1024 bytes at a time from', sockname)
            n = 0
            while True:
                data = sc.recv(1024)
                if not data:
                    break
                output = data.decode('ascii').upper().encode('ascii')
                sc.sendall(output)
                n += len(data)
                print('\r %d bytes processed so far' % (n,), end=' ')
                # 刷新缓存区
                sys.stdout.flush()
            print()
            sc.close()
            print(' Socket closed')


    def client(host, port, bytecount):
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        bytecount = (bytecount + 15)
        message = b'capitalize this!'
        print('Sending', bytecount, 'bytes of data, in chunks of 16 bytes')
        sock.connect((host, port))
        sent = 0
        while sent < bytecount:
            sock.sendall(message)
            sent += len(message)
            print('\r %d bytes sent' % (sent,), end=' ')
            sys.stdout.flush()
        print()
        # 单向关闭套接字,我不再发送数据,只接收数据。
        sock.shutdown(socket.SHUT_WR)

        print('Receiving all the data the server sends back')

        received = 0
        while True:
            data = sock.recv(42)
            if not received:
                print(' The first data received says', repr(data))
            if not data:
                break
            received += len(data)
            print('\r %d bytes received' % (received,), end=' ')
        print()
        sock.close()

    这个示例里,死锁是这样产生的:

    客户端一次发送巨量的数据(比如1G),我们直接用sendall()发送数据块,会占满缓存区。客户端不再会调用recv()。于是客户端进入阻塞状态。

    由于客户端不再接收数据,所以服务端要发送的数据会在缓存区排队,积累,知道占满缓存区,此时服务端的recv()还是没有结束,于是服务端的进程也进入阻塞状态。

    死锁就这样产生了。

死锁的解决方案:

  1. 客户端和服务端通过套接字选项的设置,将阻塞关闭。像send(),recv()这样的方法在得知不能再发送数据的时候,会立刻返回。
  2. 多线程运行程序,比如把send(),和recv()分别运行在两个线程。

如果你耐心看到了这里,可以关注一下我的微信公众号“李嘉图杂文”。
李嘉图杂文 发起了一个读者讨论 欢迎讨论


以上是关于看懂TCP协议的主要内容,如果未能解决你的问题,请参考以下文章

一文看懂Modbus协议

一文看懂IPUDP和TCP三者的关系

一文看懂IPUDP和TCP三者的关系

30秒就能看懂的JavaScript 代码片段

HTTP协议极简教程,傻瓜都能看懂!

漫画:HTTP协议极简教程,傻瓜都能看懂!