前言
本文介绍TCP/IP的机制,帮助大家建立一个较为完整的tcp体系,而不会执着于分析协议帧结构。 下文如没有特地强调,均为ipv4协议。 如有不足,欢迎指正。
知识铺垫
网络结构分层
谈到网络,雷打不动的分层结构图。比较常用的为4层架构,和5层架构。网络结构层也叫mac层对应数据链路层和物理层。协议设计原则为:下层看不见上层的内容,仅将上层协议包作为payload(负载)直接包装成下层的数据包。但难免会有特例,如:arp就违反了分层原则,arp(网络层和数据链路层之间)需要知道ip层的ip。
协议帧封装的过程,就是不断加消息头的过程,从代码角度可以理解为一层一层的函数调用。所以高层协议可以看成对底层协议函数的封装。
传输层 : 抽象出了端口的概念,通过端口区分从ip层获取的数据流向 网络层 : 抽象了ip地址的概念,将数据包发送到目的主机上,抽象掉了网络种类。 链路层 : bit流。完成物理层的物理信号数字化,通过mac地址将数据交付。 物理层 : 真实的信号如电信号,光信号,为真实携带信息传输的介质。
ICMP
ICMP(internate control message protocol) : 该协议介于传输层和网络层之间,icmp通过ip层传输。ip协议没有直接提供发现发往目的地址失败的包的方法,icmp作为ip协议的辅助协议可以完成(1)差错信息的获取 (2)有关信息采集和配置的。没有icmp协议我们就无法实现可靠的tcp协议。从下图我们也可以发现,网络编程中很多错误码都是通过icmp发现的。
ip协议
概念
ip协议是TCP/IP协议族中的核心协议,所有tcp,udp,icmp,igmp数据都是通过ip数据报传输的。ip提供了一种尽力而为,无连接的数据交付服务。 (1)“尽力而为” :当某些错误发生,如一台路由器缓存满了,则会丢弃一些数据(通常是最后到达的数据) (2)“无连接”: 如先发数据报A后发数据报B,B可能在A前先到目的地址
ip分片和重组(iPv4)
ip帧格式
简单分析下ipv4消息头。ip消息头可分为20个字节的固定头部40字节可扩展头。 总长度:可携带数据大小(16位可表示范围0~65535),一个ip数据报中最大携带65535bit数据。
ip分片
(1) 虽然ipMTU(Maximum Transmission Unit, 最大传输单元)最大可携带65535bit,但链路层负载数往往没这么大,如以太网帧最大负载为1500字节。所以当一个ip数据报(负载大于1500字节)发送时,它会判断数据报从哪个接口发送,以及对应接口要求的MTU(Maximum Transmission Unit, 最大传输单元),将ip数据包进行分片然后通过交给链路层传输。既然分片了,那自然就需要重组来恢复ip数据报,分片重组的工作总是在到达目的后才进行(在路由过程中不重组),原因有两个(1)在路由过程中重组会损耗性能(2)分片可能经过不同的路由器最终抵达目的,所以每个路由器节点可能无法获得完整的分片组(路由器只能看到部分片,无法完整恢复出ip数据报),这是根本原因。 (2) 任何一个ip分片未收到,则该次传数据传输失败。分包提高了丢包的概率,因此应该避免ip分片。
ip分片例子
为了避免ip分片,传输层需要将数据切分到足够小(使ip包能够被一个链路帧所携带)。下面举个例子: slice1 :左边分片 slice2:右边分片
ip数据包想携带1473Byte字节传输,但ip总字节数1501超过了以太网MTU大小(1500字节),所以在udp层分片,如上图所示分为了slice1 和 slice2。在传输过程中,slice1或者slice2中只要一个分片丢失,则传输的udp包失败,这个特性使得它不太理想,所以为了避免这个问题,udp程序往往限制一个udp包的大小+ip头部不超过以太网帧的MTU负载大小。这里先做下小结:(1)udp头只有一份 (结合分层原则不难理解) (2)相比原本需要传输的数据,分片后总数据多了(n-1)* 20字节。 这里还要补充一下,ip数据报中的片偏移量记录了分片的顺序,如slice1中偏移量为0Byte, slice2中为1500Byte。 分片后投递顺序往往以偏移量大的优先,这里slice2先投递,目的是偏移量大的先到目的地址,接收端可以率先确定接收完该分组所需的缓存大小,分片拼成完整数据报后投递给传输层。
重组超时
当接收方收到任何一个分片时,ip层就会启动一个定时器,如果在定时器范围中未收全对应分片组,则向发送端回复一个icmpv4(本文均讨论ipv4)超时消息,告诉发送方数据报丢失。
UDP(User Datagram Protocol)
关于udp大家应该都知道它是一个传输层的,有消息边界的协议(客户端通过udp发送一个消息,服务端就会收到一个消息)
消息边界
什么是"消息边界"? 若定义一个函数UdpSend(data)用于发送udp数据包,UdpRecv用于接收udp包。当我们传入数据Data时,Udp不会将Data拆分成Udp小包交给ip层传输。(注意这里的分包不是ip层分片,如果该Udp包过大,ip层还是会分片的),所以你通过udp发送一个包,接收端最多就会收到一个包;对比tcp的"无消息边界",因为tcp在TcpSend(data)中将用户传入Data又分成小包了,而在TcpRecv时,却没有帮我们重组回来,所以会出现所谓粘包现象,本质上tcp将接收分包的任务交给了用户而已。 为了避免ip层分片,许多udp应用程序都将单个数据报大小设置在512字节以下。
udp数据报截断
如udp包可携带负载128字节,且确实写入例如128字节的有效内容,而接收方的接收缓冲区只有68字节,则多余的字节会被丢弃(类比c语言中的截断)。
TCP(Transmission Control Protocol)
好了啰嗦了这么久,终于到tcp了。 [注]: 以下提到的分组,皆为tcp分组。
tcp为什么会发生粘包
tcp定义为面向连接的,可靠的,无消息边界的传输协议。为什么是无消息边界呢?我之前在网上搜时,发现有不少人认为时nagle算法导致的,但并不是这样的,tcp是面向字节流的协议,也就是说传入的消息在tcp看来就是仅仅传入了一部分字节到tcp缓冲区而已,所以tcp发送时,也并不会将刚刚我们传入的数据当成一个整体来看,所以在tcp发送数据时,可能只发送了传入数据的一部分,也可能多发送了其他的数据。
对于ip和udp,没有实现差错纠正(有差错检测(通过icmp)),对于以太网和基于其上的其他协议(链路层协议),提供了一定次数的重试,不过还不成功则放弃。
tcp总述
叙述下上面这张图
tcp处理分组丢失和bit差错的方法是重发分组直到它被正确接收。 (1) 接收方是否收到分组? ack应答,收到分组后,回复ack应答包 (2)接收方收到的分组是否和之前发送方发送的包一样? 每个分组,都会有有且唯一的序列号,当收到序列号一样的分组时,会丢弃当前收到的分组 (3)ack应答丢失怎么办? 因为发送方不能简单地将该情况与分组为被接收端收到做区分,所以发送端就简单认为上一个分组没发送成功,则重新发送分组,接收方收到后发现,该分组已经收到过了,则直接丢弃该分组,并回复ack应答。 (4)一个包,一个应答的模式影响吞吐率,联想流水线,tcp允许多个分组,同时进入网络,可以提高网络吞吐率。
tcp一些重要概念如下
(1)tcp分包,将byte流打散为一组ip可携带的tcp分组,这叫做组包 (tcp组包是也是为了避免ip分包,详细过程参考上文ip分片)。序列号为每个分组在整个数据流中相对第一个字节的偏移,使用偏移作为序列号的优点是可以在路由过程中重新组包(分组大小在传输过程中可变)。 (2)tcp最大发送数据数量是由滑动窗口决定的,每发送一个滑动窗口的数据,就会设置一个定时器,每当收到一个分组的ack应答时,就会重置定时器,如果定时器超时了,就会重新发送对应的分组。滑动窗口一次可进入网络的数据为WS/R(单位bit/s) ,W为窗口大小,S为分组大小,R为发包到收到回报的时间。 (3) tcp使用的ack是累积的,延迟确认的。
累积的:比如tcp接收端收到一个序列号为N的tcp消息,则可以说明N-1字节之前的所有数据都接收到了 延迟确认的: tcp有时候会合并两个ack为一个发送给接收端 (4)tcp是有序的,接收端收到分组后会把序列号大的先缓存起来,直到小序列号分组填满缓冲区,才开始将数据交给上层应用。
TCP消息头
tcp消息头为如下格式20给固定头部,和40个字节可扩展头部。这里注意几个标记位:
ACK : 该tcp分组为ack应答 RST : 重置连接,一般在对端收到不符合tcp状态的包时会发送 SYN : 建立连接握手 FIN : 发送发结束向对端发送数据。 窗口大小为数据接收端(ack发送端)缓存大小,16位数,说明tcp缓冲区最大65535bit
tcp连接管理以及状态转移
连接管理可能会提前涉及到状态转移,对tcp状态转移不清楚,可以先看下面的状态转移部分。
tcp三次握手和四次挥手
ISN?:表示初始序列号为c,ISN(s)同理。注意将这里的序列号号tcp组包的中序列号,确认号为收到分组的序列号+1,也表示了对端下一段数据开始发送的位置。
三次握手
三次握手流程
客户端和服务端首先都为CLOSED状态。 客户端生成一个初始序列号c,向服务端发送SYN包,然后修改状态为SYN_SENT 服务端收到SYN包,合并SYN和ACK(将tcp协议头中的SYN和ACK位置为有效),确认号为c+1,序列号为s,并修改状态为SYN_RECV 客户端收到SYN+ACK修改状态为ESTABLISHED,表示客户端可发送数据,并且向服务端发送ACK,序列号为ACK中的值c+1,确认号为s+1 服务端收到ACK后,修改状态为ESTABLISHED,此时tcp建立连接成功可以正常收发数据。
下面为三次握手结合状态转移的时序图
四次挥手
四次挥手流程
客户端发送SYN+ACK,序列号为K,ACK确认号为L,(FIN结束包需要发送ACK是为了确认关闭前的最后一个包),修改状态为FIN_WAIT_1 服务端收到SYN+ACK,回复ACK确认FIN已经收到,修改状态为CLOSE_WAIT 服务端主动发送SYN+ACK,修改状态为LAST_FIN等待客户端的FIN确认包,。注意这里序列号和确认号和上一步中一样,原因是服务端和客户端没有交换序列号,序列号和确认号维持不变。 客户端收到FIN+ACK的后 修改状态为FIN_WAIT_2 客户端发送FIN的ACK应答,修改状态为TIME_WAIT,一段时间后关闭。注意这里的序列号为k不是服务端确认号ACK期望的k+1,表示这为最后一个应答包。 服务端收到最后一个ACK应答后,关闭
下面为四次挥手结合状态转移的时序图
tcp 半关闭(half close)
对比建立连接过程的三次握手,断开连接需要4个报文,这是为了满足tcp的half close。half close是什么呢?举个例子,客户端调用close可以完全关闭tcp连接,调用shutdown可以关闭客户端发数据功能,但仍可以接收数据,服务端同理。 半连接时,客户端为FIN_WAIT_1,服务端为CLOSE_WAIT状态,这其实就是正常关闭的一个中间状态。
两个连接同时打开或关闭(linux中不允许)
同时打开
同时打开,双方都为客户端。客户端发送SYN后状态变为SYN_SENT,在该状态下接收到SYN+ACK后状态变为ESTABLISHED状态(SYN包,状态变为SYN_RECV ==> ACK包,状态变为ESTABLISHED)。
同时关闭
客户端主动断开连接发送完FIN+ACK后会修改状态问FIN_WAIT_1,如果在该状态下收到ACK会变为FIN_WAIT_2,收到FIN+ACK会直接变为TIME_WIAT状态。在同时关闭中显然收到的为FIN+ACK,所以也需要四个报文段。
补充
(1)tcp为2对ip+端口组成的四元组,如果ip端口都一致,而且序列号,碰巧也能满足的话,由于网络问题 导致的旧连接的包可能会发送到新连接上。 (2)每个tcp连接的初始序列号都不一样
tcp状态转换
下面为tcp状态转移的详细过程TCP/IP卷一 第二版原图 上面那张图可能不方便查看,下面将客户端可服务端的状态转移图分开了。 里面有部分可能与上图有些不同
ESTABLISHED 到 FIN_WAIT_1,原图中为FIN (没有数据传输时,关闭时不需要确认最后一个数据包),而下面会有FIN+ACK(则是在ACK完最后一个数据包后close的)
客户端状态转移图
这里要提一下FIN_WAIT_1到TIME_WAIT的过程为什么会不同,目的是什么? (2) FIN_WAIT_1收到FIN的ACK应答后,只能确认接收端知道发起端要请求断开连接了,所以发起端留出FIN_WAIT_2状态来等待对端的FIN,发起段收到FIN说明对端也请求断开,两边都断开说明tcp通信结束,发起段进入TIME_WAIT状态,一段时间后关闭。 (2) 如果在FIN_WAIT_1,收到FIN+ACK,发起端就已经知道对端也在请求断开连接了,所以就无需FIN_WAIT_2了 (3) 如果在FIN_WAIT_1,收到FIN,说明客户端知道对端请求关闭了,所以还需要收到发起端FIN的ACK确认包才能断开连接,所以这里多了给CLOSING状态用于和FIN_WAIT_2做区分。
服务端状态转移图
TIME_WAIT(2MSL)作用
在本地与外地的ip和端口号相同的情况下,2MSL能够避免新的连接将前一个连接的延迟报文解释为自身数据的情况。
重置报文段
在网络编程中我们常常能遇到 error:对端以重置。这说明我们收到了RST包。 一般来说,在接收端收不对的包就会向发送端立刻发送一个RST,同时丢弃所有未发送的数据。如接收端为ESTABLISHED 状态却收到了FIN或SYN包, 或者接收端不为ESTABLISED,却收到数据包,接收端就会发送一个RST给接收端 ctrl+c杀死服务端,也会发送RST包给客户端。
keep alive作用
如果客户端和服务端之间没有数据传输,而服务端崩溃(可以用拔网线,重启服务来避免客户端收到服务端的FIN包来模拟),客户端状态还是ESTABLISHED,客户端就无法发现对端已经消失,我们可以使用tcp的keep alive来帮助侦测。 补充:电脑上会有大量tcp处于半连接状态(服务端可以从上面读数据),若现在重启服务端,让客户但发送数据,服务端就会向客户端发送RST包(原因:服务端状态此时不为ESTABLISHED了)。
tcp连接队列
在被应用程序使用前的新连接可能会处于以下两种状态 (1)收到了SYN处于SYN_RECV (2)处于ESTABLISHED,但由于操作系统在 执行其他优先级更高的任务,没有及时交给应用程序。 在操作系统内部往往用两个数组进行管理。linux会采用以下规则 (1)当一个新连接到达(第一个SYN到达),将会检查系统参数netipv4.tcp_max_syn_backlog(默认1000),如果处在SYN_RECV状态的连接超过netipv4.tcp_max_syn_backlog,则会拒绝新连接。 (2)完成握手的连接(即ESTABLISHED状态)数如果超过net.cofre.somaxconn,则操作系统tcp模块会忽略掉新的SYN包(这将会导致客户端tcp重传SYN)
tcp滑动窗口
发送方滑动窗口
tcp以字节为单位维持窗口(而非包),所以上述分组的序号即为tcp的序列号。 SND.WND : 滑动窗口大小 关闭 : 滑动窗口左边界向右移动 打开 : 滑动窗口右边界向右移动 收缩:: 滑动窗口右边界向左移动
下面举几个例子例子帮助理解(所有例子均为描述图15-9)
例一
当收到ACK(滑动窗口大小不变) 滑动窗口关闭3bit,打开3bit,即滑动窗口右移3bit
例二
当收到ACK(滑动窗口大小缩小n bit) 滑动窗口关闭3bit,打开3bit,滑动窗口收缩n bit
例三
当收到ACK(滑动窗口大小增大n bit) 滑动窗口关闭3bit,打开 (3 + n) bit,
接收方滑动窗口
接收方相对于发送方简单,序列号在窗口之外的数据都会被丢弃;只有当收到的包序列号等于左边界时,滑动窗口才会向右滑动,否则丢弃。
超时重传与快速重传
下层ip层可能出现丢包,重复,失序。 重复:接收端直接丢弃 丢包: 超时重传 失序:快速重传,接收端收到序列号不对的包时,会将该包丢弃,同时立刻生成确认信息(ACK = 接收端滑动窗口左边界)
阻塞控制
这里先介绍下什么时阻塞控制,上文中我们谈到了用于流量控制的滑动窗口,目的是为了让收发保持一致来避免重传,但该机制只能保证发送端和接收端的速率合理,网络传输路径中如果有某个路由器处理不过来,一样会导致重传,所以我们还需要机制来避免中间网络负担过重,这就是阻塞控制。 阻塞控制也用了窗口的设计,若堵塞控制的窗口为w,流量控制的窗口为W,网络中一次可进入的bit数为 w > W ? w : W ,即为较小者。
下面的算法我只简单叙述下,感兴趣的同学可以自行了解下
nagle 算法
nagle算法为将小包合成大包一次发送,通过减少额外头部的数量来减少网络中的流量。若定义400字节为一个大包,则必须大包收到应答后才能发送下一个,即同一时刻tcp通道中只会有一个tcp包。所以nagel算法不适合实时性要求高的应用,比如即使战略游戏。
慢启动
如果tcp一开始就以最大速率发送数据的话,很容易冲垮网络,所以tcp并不是立刻以最大速率发送的,而是以一个较低速率以指数次的速度逼近最大速率的。
阻塞避免
堵塞避免和慢启动类似,相比更加温和了而已,速度为线性增长。
|