多工具实践下解读TCP协议的三次握手

标签: 计算机网络  网络  tcpip  python  socket

通过Python编程和Wirehark抓包,直观地理解TCP/IP协议中的“三次握手”还有“四次挥手”。

计算机网络协议应该说看起来是比较抽象和无趣的,由于现在软件开发,各类网络库已经帮我们处理了大多数网络问题,我们不再需要过分关注协议细节。然而,很多优化场景下,了解这些协议的细节显然是必要的。

计算机网络

计算机网络最重要的特征就是通用性 。计算机网络主要由通用可编程硬件构建,并且不需要为诸如打电话或者传输电视信号那样的特定应用做任何优化。相反,计算机网络能够传输多种不同类型的数据,并且支持各种各样不断增加的应用。在因特网的构建中,我们得到了如何设计网络的丰富经验,这些经验就体现在网络体系结构(network architecture)中。网络体系结构指明可用的软硬件构件,并且说明如何将它们组织起来构成一个完整的系统。

特点:分层和协议

抽象——隐藏定义良好的接口背后的细节——是系统设计者用于处理复杂性的基本工具。抽象的思想是定义一个能捕获系统某些特征的模型,并将这个模型封装为一个对象,为系统的其他组件提供一个可操作的接口,同时向对象使用者隐藏对象的实现细节

分层是抽象的自然结果。总体的思想是从底层硬件提供的服务开始,向上增加一系列的层,每一层都提供更高级(更抽象)的服务。高层提供的服务由低层提供的服务来实现。

分层具有两个优点:

  • 第一,它将建造网络这个问题分解为多个可处理的部分。不是把想要的所有功能都集中在一个软件中,而是可以实现多个层,每一层解决一部分问题。
  • 它提供一种更为模块化的设计。如果想增加一些新的服务,只需要修改某一层的功能,同时可以继续使用其他各层提供的功能。

系统的层次不是简单的线性序列。通常,在系统的任意一层上都提供多种抽象,每种抽象都建立在同样的低层抽象上。

网络系统分层的抽象对象称为协议(protocol)。就是说,一个协议提供一种通信服务,供高层对象(如一个应用进程或更高层的协议)交换消息。每个协议定义两种不同的接口。首先,它为同一计算机上想使用其他通信服务的其他对象定义了一个服务接口(service interface)。这个服务接口定义了本地对象可以在该协议上执行的操作。

其次,协议为另一台机器上的对等实体定义了一个对等接口(peer interface)。第二种接口定义了对等实体之间为实现通信服务而交换的消息的格式和含义。这将决定一台机器上的请求/应答协议以什么方式与另一台机器上的对等实体进行通信。例如,HTTP协议详细规范了“GET”命令的格式,包括该命令可以使用哪些参数,以及当接收到此命令时Web服务器该如何响应。

总之,协议定义了一个本地输出的通信服务(服务接口),以及一组规则。这些规则用于管理协议以及对等实体为实现该服务而交换的消息(对等接口)。

TCP/IP体系结构

因特网体系结构有时也称为TCP/IP体系结构,因为TCP和IP是它的两个主要协议。因特网体系结构是从早期的分组交换网ARPANET发展而来的。在OSI体系结构出现之前,因特网和ARPANET就已经存在了,并且在创建它们时所积累的经验对OSI参考模型产生了重大影响。

尽管七层OSI模型 理论上可以应用于因特网,但是实际上通常采用四层模型

四层模型

在最底层是各种网络协议,这些协议由硬件(如网络适配器)和软件(如网络设备驱动程序)共同实现。例如,以太网或无线协议(如802.11 Wi-Fi标准)就会出现在这一层。其实,这些协议实际上还可以包含几个子层,但是因特网体系结构并没有对此做任何假设。

第二层(从下往上)只有一个网际协议(Internet Protocol, IP),这个协议**支持多种网络技术互联成为一个逻辑网络。**主要的任务就是给传输层的数据加上目标地址和源地址。

第三层包括两个主要的协议,传输控制协议(Transmission Control Protocol, TCP)和用户数据报协议(User Datagram Protocol, UDP)。

TCP提供可靠的字节流信道,UDP则提供不可靠的数据报传输信道(数据报(datagram)可认为是消息的同义词)。TCP和UDP有时也称作端到端(end-to-end)协议,但称作传输协议同样正确。

在传输层上运行着大量应用协议,如HTTP、FTP、Telnet(远程登录)和简单邮件传输协议(Simple Mail Transfer Protocol, SMTP),使得常用的应用可以互相操作。

大部分人熟悉四层因特网体系结构也熟悉七层OSI体系结构,并且就两个体系结构之间层次的对应关系达成了共识。因特网的应用层被看作第七层,传输层是第四层,IP层(网络互联层或网络层)是第三层。IP层下面的链路或子网层是第二层。

OSI模型与TCP/IP模型对照

因特网体系结构有三个特点值得注意:

  • 因特网体系结构没有严格划分层。应用可以自由地跨越已定义的传输层,而直接使用IP层或低层网络。事实上程序员可以自由定义新的信道抽象或在任何已有协议上运行的应用程序。
  • 因特网体系结构呈现一个沙漏形状————顶部宽,中间窄,底部宽。这种形状实际上反映了因特网体系结构的中心哲学。就是说,**IP层作为体系结构的焦点,为各种网络中的交换分组定义了一种通用的方法。**IP层之上可以有多个传输协议,每个协议为应用程序提供一种不同的信道抽象。这样,从主机到主机传输消息的问题就从提供有用的进程到进程通信服务的问题中完全分离出来。IP层之下,这个体系结构允许很多不同的网络技术,从以太网到无线再到单个的点到点链路。(物理介质无关)
  • 要将一个新的协议包含在网络体系结构中,必须有一个协议规范并至少有一个(最好两个)该规范的典型实现。这种要求有助于保证体系结构的协议能够有效实现。

沙漏体系

小结

在因特网体系结构的三个属性中,值得重申的是“沙漏”这一重要设计理念。沙漏的细腰部代表最小的、经过精心挑选的通用功能集,它允许高层应用和低层通信技术并存,共享各种功能并快速发展,**沙漏模型十分重要,它使得因特网能够快速适应用户的新要求和技术的变革。**试想一下,无论移动通信还是互联网技术如何发展,这套网络体系的设计始终没有过时,就可以明白为什么这套体系堪称架构设计的典范。

TCP协议的连接

TCP协议,很多人都听过一句话,叫“三次握手,四次挥手 ”,它描述的就是 TCP 协议建立通信和断开通信的时的交互次数。

TCP 在 RFC793 中有详细的说明,之后更新过几次(参考RFC 1323、2581、2988、3390)。

TCP 要求客户端先给某个服务器建立一个连接,再通过这个连接与服务器交换数据,最后终止这个连接。TCP 要求向另外一端发送数据的时候,对端要返回一个确认(ACK,acknowledge)。如果没有收到确认,TCP 就自动重传数据并等待更长的时间。经过几次重传,还不能成功,TCP才选择放弃。

注意,TCP 不能保证数据一定会被对方接收,这个谁都保证不了。试想一下,如果客户端或者服务器突然就断掉了,例如:遭遇停电、导弹袭击等等。准确地讲,应该说 TCP 提供可靠的传送或者故障的可靠通知

TCP连接的建立:三次握手

使用 Wireshark 抓取到的一组TCP三次握手的摘要:

146	20.135050	192.168.124.10	120.55.138.92	TCP	78	51103 → 443 [SYN] Seq=0 Win=65535 Len=0 MSS=1460 WS=64 TSval=1052844824 TSecr=0 SACK_PERM=1
147	20.178232	120.55.138.92	192.168.124.10	TCP	66	443 → 51103 [SYN, ACK] Seq=0 Ack=1 Win=14600 Len=0 MSS=1444 SACK_PERM=1 WS=128
148	20.178330	192.168.124.10	120.55.138.92	TCP	54	51103 → 443 [ACK] Seq=1 Ack=1 Win=262144 Len=0

在建立连接之前,服务器必须准备好接受外来的连接,一般通过 socketbindlisten 这三个函数来完成。直白一点,就是绑定并监听某个端口,等待用户连接。我们连接的时候还需要确认防火墙没有关闭该端口。
三次握手的过程大致如下:
(1)客户端通过 connect 主动发起一个连接,客户端会发送一个 SYN(同步)分节。通常这个 SYN 分节不携带数据,但是会告诉服务器将在连接中发送的数据的初始***。
(2)服务器必须确认(ACK)客户的 SYN,同时也发送一个 SYN 分节,包含服务器发送的数据的初始***。
(3)客户端必须确认服务器的 SYN。

大体过程如下图:

TCP三次握手

Python 实现

Python 语言在标准库里已经提供良好的封装,非常方便。我们可以很容易地编写最基本的客户端和服务器。

首先引入标准库,

import socket
import sys

创建 socket,设定并绑定监听端口:

# 创建一个TCP/IP socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

server_address = ('localhost', port)
print(f'=> starting up on {server_address[0]} port {server_address[1]}', file=sys.stderr)
# 绑定地址端口
sock.bind(server_address)

socket.AF_INET 用于 IPv4 网络寻址。当然还有目前仍然有待普及的 IPv6 的socket.INET6。一个地址 localhost 和端口号 port 构成一个服务器地址。bind 用于绑定地址和端口。

然后,设置为服务器模式(开启监听):

# 调用listen将套接字设置为服务器模式
sock.listen()

下面就是等待用户连接,注意,accept 会等到有客户连接才会继续往下执行(也就是阻塞进行等待,不会出现无限刷屏的情况,不知道你有没有想起和 yield 很类似)。

# 调用 accept 等待客户连接
while True:
    print(f'=> waiting for a connection...', file=sys.stderr)
    connection, client_address = sock.accept()

执行到这里,服务器也获得了用户的连接和地址。“三次握手”就已经完成了。那么在结束数据传送之后,我们还需要关闭连接,产生“四次挥手”这一礼貌而不失优雅的活动。因此我们使用一个 try ... finally 来保证最后连接的关闭,无论是否出现岔子。

try:
    print(f'=> connection from {client_address}', file=sys.stderr)

    # 使用 recv() 读取数据
    while True:
        data = connection.recv(16)
        print(f'=> received {data}')
        if data:
            print('=> sending data back to client', file=sys.stderr)
            connection.sendall(data)
        else:
            print(f'=> no data from {client_address}', file=sys.stderr)
            break
finally:
    # 关闭连接
    connection.close()

recv(16) 意味着每次读取16字节的数据,这个当然可以调整。但是,最好是2的幂次,诸如16、32、64、1024等等。这样,就算完成了一个可以把用户发送回来的数据拆分并返回给用户的服务器。
接下来,我们编写客户端代码。新建一个 Python 文件,客户端建立 socket 之后,只需要用 connect 连接到服务器就行了,不需要去绑定什么。

import socket
import sys


def client(port=8000):
    # 创建一个TCP/IP socket
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    server_address = ('localhost', port)
    print(f'-> connecting to {server_address[0]} port {server_address[1]}', file=sys.stderr)
    # 连接到服务器
    sock.connect(server_address)

建立连接之后,就可以向服务器发送数据**(总共41个字节)**。等到一切结束之后,同样要关闭连接。

try:
    message = b'This is the message. It will be repeated.'
    print(f'-> sending {message}', file=sys.stderr)
    sock.sendall(message)

    # 输出回应
    amount_received = 0
    amount_expected = len(message)

    while amount_received < amount_expected:
        data = sock.recv(16)
        amount_received += len(data)
        print(f'recevied {data}', file=sys.stderr)


finally:
    # 关闭连接
    print('closing socket', file=sys.stderr)
    sock.close()

有几点需要注意的,socket 是底层接口,所以字符串应该是字节字符串,用 b 标识。如果要从控制台输入,应该用 bytes 对输入的 str 进行 encoding 处理成字节字符串。服务器拆分成16字节,这里也用这个逻辑获取数据。

执行效果:
先启动服务器

➜ python3 server.py
=> starting up on localhost port 8000
=> waiting for a connection...
=> connection from ('127.0.0.1', 55231)
=> received b'This is the mess'
=> sending data back to client
=> received b'age. It will be '
=> sending data back to client
=> received b'repeated.'
=> sending data back to client
=> received b''
=> no data from ('127.0.0.1', 55231)
=> waiting for a connection...

再启动客户端:

➜ python3 client.py
-> connecting to localhost port 8000
-> sending b'This is the message. It will be repeated.'
recevied b'This is the mess'
recevied b'age. It will be '
recevied b'repeated.'
closing socket

在运行客户端时候顺便开启 Wireshark 进行抓包,发现了16次交互记录。可以发现,前三次(101 - 103) 就是建立连接的“三次握手”(three-way handshake)。最后4次(113 - 116),就是“四次挥手”。每次请求,都会收到回应(ACK),可以想到,客户端发送一次数据,服务器返回一次响应,后来服务器分4次发送数据给客户端,因此总共8次。那么3+8+4=15,剩下那一次,就是编号为104的 TCP Windows Update,也就是TCP提供的流量控制(flow control)功能。

Wireshark抓包结果

TCP过程解读

三次握手之谜

TCP 建立连接的三次握手的第二次把服务器针对客户端上一次的请求作了回复(ACK),并同时发送了相关的数据(SYN)。由于服务器发出的 SYN,客户端需要再次发送 ACK 作为回复,这就是第三次握手。

第二步为什么要合并,而不是分成 ACK 和 SYN 两步。一个很容易想到的原因当然就是网络通信的成本很高,拆分效率很低。当然,能够合并的前提是因为 ACK 和 SYN 都是服务器发给客户端的,方向一致。还有另外一个原因,如果拆分成两轮,割裂了联系。如果两轮之间相隔了很久,就不像建立连接的逻辑了。

除此之外,三次握手的主要目的是为了建立 ISN(Initial Sequence Number,初始***),通过它把客户端和服务器端的计数对齐。**这个数是随机的,不能选择0。**如果选择0,很容易被推测出来然后伪造 ISN 进行攻击。

Wireshark 为了数字容易理解,默认是相对 SEQ,可以通过点击右键,在 Protocal Preferences 中把相对***(Relative sequence numbers)去掉,就可以看到随机生成的 Seq。当然,字节看数据包的详情也可以。

取消相对Seq

这个过程的简要示意如下,TCP 提供有序的传输,避免乱序正是由 SEQ 保证的,三次握手主要就就是对 SEQ 进行初始化。

当然还有很多参数。比如,通信的时候是 SYN 还是 ACK,还是 SYN + ACK 是通过一个标示位(1或0)区分的。

Flags

数据传送

连接建立以后,双方就可以开始通信了,此时出现了 [104],也就是服务器向客户端发送了窗口更新。(这个问题后面再谈,我们先看一下数据传送的过程)

初始 SEQ 有了,了解后面交互时 SEQ 的增长方式是很有必要的。
服务器按16字节发送给客户端,第一次的时候[107],相对 SEQ 为1,数据长度为16。那么第二次 [109] ([108] 是客户端的回复),发送数据给客户端的 SEQ = 1 + 16 = 17。第三次[111],SEQ=17+16=33。第四次,余下的9个字节(客户端总共发送了41个字节),那么第4次 [113] 的 SEQ = 33 + 9 = 42。另外,传递数据的时候,Flags 设定在 Push 上。

还需要注意一点,TCP 是双工通信,双方都维护了各自的 SEQ。如果站在客户端角度,[105]时,客户端的 SEQ=1,向服务器发送了41个字节的数据,下一次[108],SEQ = 1+41=42

至于ACK,在传送数据时它表示确认收到了哪些字节。比如客户端发送了,Seq = x, Len= y,那么服务器回复的ACK就是 x+y理论上,这个数就等于发送方(此处即客户端)下一个SEQ号。

到了这里,可以发现TCP 通过 SEQ 保证有序性,加上ACK,就可以判断是否丢包,一同保证了TCP的可靠性。

四次挥手的告别

四次挥手的过程比较简单,就是两轮SYN/ACK的过程,只不过信号是FIN。这时候,可能就有疑问了,为什么挥手要四次不能三次?不能像三次握手那样合并吗?
四次握手

客户端向服务器发送了FIN,表示“我不会继续发送数据了(喂,你传完数据就可以挂了)”,此时,服务器可能向客户端发送数据的操作还没有完成。一旦合并,则可能会出现客户端的数据还没有接收完,忽然收到“后续没有数据,终止连接”的问题。所以,服务器还是按照TCP的要求发了ACK,等到服务器同意结束(例如数据传完了),再发一个FIN,告诉客户端“我不会继续发送数据给你了,挂了~”,客户端再回复一个ACK“好”,通话就结束了。

四次挥手过程也不一定是可靠的,这个问题意义重大,就是会遇到著名的拜占庭将军问题。这个问题由著名的计算机科学家,分布式理论泰斗 Leslie Lamport 提出。简单地讲,由于信道的不可靠,可能会造成信息的遗失、监听和篡改,从而造成两方无法达成共识。

在数据传送中,我们看到了 TCP 通过SEQ+ACK,虽然不能完全解决拜占庭解决问题,但是还是保证了大多数情况下的正确性(极端情况不予考虑)。今天,分布式成为一个热门话题,区块链或者分布式系统里也遇到了类似问题。

那些更深入一点的细节

每一次的 SYN 包含了很多 TCP 选项,下面稍微解释一下一些常用的。

  • MSS(最大分节大小):发送 SYN 的一端使用这个参数告诉对端它的最大分节大小(MSS,maximum segement size)。换句话说,就是它在每次 TCP 交互过程中愿意接受的最大数据量。发送端 TCP 使用接收端的 MSS 值作为发送分节的最大大小。
  • Win(窗口规模选项):TCP 连接的任何一端能够通告对端的最大窗口大小是 65535,因为在 TCP 首部中这个参数占16位(21612^{16}-1)。随着网速的提高,加上一些特殊链路需求,比如卫星,要求有更大的窗口获得尽可能大的吞吐量。因此,这种情况在 RFC1323 中有所讨论,要求窗口大小必须左移(0-14位),可以提供的窗口大小接近 1G。这个偏移的选项在三次握手中进行约定。

三次握手中的第二次,在 Options 中,服务器告诉客户端这个偏移的大小。此处即乘以64。

偏移

[104]中可以看到,Window size value 为 6379,乘以 64 后为 408256。

Window窗口规模

  • Timestamps(时间戳选项):对于高速网络连接而言,这个选项是非常必要的。网络通信中,有可能出现路由问题导致的延迟或重复(注意,不是因为超时重传)。路由稳定后,这些数据包又会正常达到目的地。高速网络中,32位的***短时间内就可能循环一轮,这些迷途知返的数据包可能会因为使用了相同的***而产生混淆,因此需要加入时间戳。

最后

TCP 就这样?当然不是。TCP 的细节非常复杂,状态机定义了11个状态。当时设计 TCP 的时候,主要是用于军事通信的要求,尤其是考虑了在不可靠的通信环境下和拥塞情况下可用,在实际的工程设计里还考虑很多因素,每个参数都有它的设计意义。有很多细节问题没有讨论,比如动态估算RTT、数据重传,甚至TCP头部等等。不过,如果仅仅只是做软件开发的话,我觉得时间有限的话就没必要去再往下理解具体设计细节,毕竟这对绝大多数的程序没有多大的意义,还是放心地交给库函数去做吧。

版权声明:本文为zhaozigu123原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/zhaozigu123/article/details/104993990

智能推荐

Flutter:Scaffold.of() called with a context that does not contain a Scaffold.

Flutter:Scaffold.of() called with a context that does not contain a Scaffold. 当我第一次点击按钮想要弹出底部消息时出现了如下错误 当BuildContext在Scaffold之前时,调用Scaffold.of(context)会报错。这时可以通过Builder Widget来解决,代码如下:...

【机器学习基础】线性回归

                                                        &nbs...

08-Vue实现书籍购物车案例

书籍购物车案例 index.html main.js style.css 1.内容讲解 写一个table和thead,tbody中每一个tr都用来遍历data变量中的books列表。 结果如下: 在thead中加上购买数量和操作,并在对应的tbody中加入对应的按钮。结果如下: 为每个+和-按钮添加事件,将index作为参数传入,并判断当数量为1时,按钮-不可点击。 结果如下: 为每个移除按钮添加...

堆排序

堆排序就是利用堆进行排序的方法,基本思想是,将代排序列构造成一个大根堆,此时整个序列的最大值就是堆顶的根节点。将它与堆数组的末尾元素交换,此时末尾元素就是最大值,移除末尾元素,然后将剩余n-1个元素重新构造成一个大根堆,堆顶元素为次大元素,再次与末尾元素交换,再移除,如此反复进行,便得到一个有序序列。 (大根堆为每一个父节点都大于两个子节点的堆) 上面思想的实现还要解决两个问题: 1.如何由一个无...

基础知识(变量类型和计算)

一、值类型 常见的有:number、string、Boolean、undefined、Symbol 二、引用类型 常用的有:object、Array、null(指针指向为空)、function 两者的区别: 值类型暂用空间小,所以存放在栈中,赋值时互不干扰,所以b还是100 引用类型暂用空间大,所以存放在堆中,赋值的时候b是引用了和a一样的内存地址,所以a改变了b也跟着改变,b和a相等 如图: 值...

猜你喜欢

Codeforces 1342 C. Yet Another Counting Problem(找规律)

题意: [l,r][l,r][l,r] 范围内多少个数满足 (x%b)%a!=(x%a)%b(x \% b) \% a != (x \% a) \% b(x%b)%a!=(x%a)%b。 一般这种题没什么思路就打表找一下规律。 7 8 9 10 11 12 13 14 15 16 17 18 19 20 28 29 30 31 32 33 34 35 36 37 38 39 40 41 49 50...

[笔记]飞浆PaddlePaddle-百度架构师手把手带你零基础实践深度学习-21日学习打卡(Day 3)

[笔记]飞浆PaddlePaddle-百度架构师手把手带你零基础实践深度学习-21日学习打卡(Day 3) (Credit: https://gitee.com/paddlepaddle/Paddle/raw/develop/doc/imgs/logo.png) MNIST数据集 MNIST数据集可以认为是学习机器学习的“hello world”。最早出现在1998年LeC...

哈希数据结构和代码实现

主要结构体: 实现插入、删除、查找、扩容、冲突解决等接口,用于理解哈希这种数据结构 完整代码参见github: https://github.com/jinxiang1224/cpp/tree/master/DataStruct_Algorithm/hash...

解决Ubuntu中解压zip文件(提取到此处)中文乱码问题

在Ubuntu系统下,解压zip文件时,使用右键--提取到此处,得到的文件内部文件名中文出现乱码。 导致此问题出现的原因一般为未下载相应的字体。 解决方案: 在终端中使用unar命令。 需要注意的是系统需要包含unar命令,如果没有,采用如下的方式解决: 实例效果展示: 直接提取到此处: 使用 unar filename.zip得到的文件...