网络编程TCP/UDP协议(传输层特性)
Posted 西科陈冠希
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了网络编程TCP/UDP协议(传输层特性)相关的知识,希望对你有一定的参考价值。
TCP/UDP协议(传输层特性)
背景知识
端口号
在TCP/IP协议中, 用 “源IP”, “源端口号”, “目的IP”, “目的端口号”, “协议号” 这样一个五元组来标识一个通信(可以通过netstat -n查看)
并且端口号是有范围划分的:
0 - 1023: 知名端口号, HTTP, FTP, SSH等这些广为使用的应用层协议, 他们的端口号都是固定的
1024 - 65535: 操作系统动态分配的端口号. 客户端程序的端口号, 就是由操作系统从这个范围分配的
知名端口号
UDP
分析报头
16位UDP长度, 表示整个数据报(UDP首部+UDP数据)的最大长度;如果校验和出错, 就会直接丢弃。
所以想要拿到UDP的数据只需要截取一定的长度后就知道有效载荷是多少,通过目的端口号决定传送给谁,并且源端口号标注我是谁,如何判断是UDP呢就是通过16位的UDP长度来判断(需要上层解析)
UDP特点
无连接: 知道对端的IP和端口号就直接进行传输, 不需要建立连接;
不可靠: 没有确认机制, 没有重传机制; 如果因为网络故障该段无法发到对方, UDP协议层也不会给应用层返回任何错误信息;
面向数据报: 不能够灵活的控制读写数据的次数和数量;
面向数据报
因为TCP是流式传输,而UDP是数据报传输,应用层交给UDP多长的报文, UDP原样发送, 既不会拆分, 也不会合并。
UDP也有缓冲区,直接利用sendto和recvfrom不是直接真正的接受和发送,而是塞给缓冲区,通过内核来决定传输。
注意
我们注意到, UDP协议首部中有一个16位的最大长度. 也就是说一个UDP能传输的数据最大长度是64K(包含UDP首部).然而64K在当今的互联网环境下, 是一个非常小的数字.如果我们需要传输的数据超过64K, 就需要在应用层手动的分包, 多次发送, 并在接收端手动拼装
基于UDP的应用层协议
TCP
TCP报头分析
源/目的端口号: 表示数据是从哪个进程来, 到哪个进程去;
32位序号/32位确认号: 这里就是解决了乱序的问题(与后边传输特性相关)
4位TCP报头长度: 表示该TCP头部有多少个32位bit(有多少个4字节); 所以TCP头部最大长度是15 * 4 = 60(一般是20)
标志位
URG: 紧急指针是否有效
ACK: 确认号是否有效
PSH: 提示接收端应用程序立刻从TCP缓冲区把数据读走
RST: 对方要求重新建立连接; 我们把携带RST标识的称为复位报文段SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段
FIN: 通知对方, 本端要关闭了, 我们称携带FIN标识的为结束报文段
16位校验和: 发送端填充, CRC校验. 接收端校验不通过, 则认为数据有问题. 此处的检验和不光包含TCP首部, 也包含TCP数据部分.
16位紧急指针: 标识哪部分数据是紧急数据;(也就是拥有优先插队的优先权)
40字节头部选项: 可以后期选择选项,并且通过四位报头长度改变来识别;
确认应答机制
前一个数据是通过后边的确认应答ACK来确保前一个数据被已接受的。
并且这里就会有序列号和确认序列号,例如我发的数据的序列号为1,则确认应答的确认序号就是2,让客户端从2开始发,也就是2之前的我已经接收到。
就相当于一个线性结构。
超时重传机制
丢包有两张情况如下:
其本质都是数据没有发送成功,所以如果间隔一定的时间内没有收到ack,就会对数据进行重发,因为有序列号所以不会出现数据冗余,有去重的作用
超时的时间:
最理想的情况下, 找到一个最小的时间, 保证 “确认应答一定能在这个时间内返回”.
但是这个时间的长短, 随着网络环境的不同, 是有差异的.
如果超时时间设的太长, 会影响整体的重传效率;如果超时时间
设的太短, 有可能会频繁发送重复的包;
此时TCP就引入了动态增加时间的过程,超时以500ms为一个单位进行控制,每次乘性增加,累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接
连接管理机制
正常情况下,三次握手,四次挥手。
客户端发起SYN请求,进入同步发送状态,服务器接受到后,进入同步接受状态,并且发送SYN+ACK,当客户端接收到ACK表明C->S的通道建立成功,状态变为ESTAB,并且有服务器发的SYN客户端也会给服务器发送ACK,表示建立成功。(只要其中有一项丢包则建立链接失败)
为什么是三次而不是其他次数呢?
①通过三次握手可以有效的进行信道的验证,也就是全双工的验证。
②当只有两次握手的时候最后一次ACK是服务器发的,如果ACK丢失则服务器认为建立成功,而客户端不知道,则浪费服务器资源,所以优先保证服务器资源不被浪费,第三次让客户端发ACK。(这里就会引入超时重传)
四次挥手的含义及其状态变化
首先四次挥手是CS双方都需要断开,fin是单向的请求,发送过去后另一方不一定当时就要断开,所以不能和ACK一起发送。
服务器状态变换:
[CLOSED -> LISTEN] 服务器端调用listen后进入LISTEN状态, 等待客户端连接;
[LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段), 就将该连接放入内核等待队列中, 并向客户端发送SYN确认报文.
[SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文, 就进入ESTABLISHED状态, 可以进行读写数据了.
[ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调用close), 服务器会收到结束报文段, 服务器返回确认报文段并进入CLOSE_WAIT;
[CLOSE_WAIT -> LAST_ACK] 进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据); 当服务器真正调用close关闭连接时, 会向客户端发送FIN, 此时服务器进入LAST_ACK状态, 等待最后一个ACK到来(这个ACK是客户端确认收到了FIN)
[LAST_ACK -> CLOSED] 服务器收到了对FIN的ACK, 彻底关闭连接
客户端状态转化
[CLOSED -> SYN_SENT] 客户端调用connect, 发送同步报文段;
[SYN_SENT -> ESTABLISHED] connect调用成功, 则进入ESTABLISHED状态, 开始读写数据;
[ESTABLISHED -> FIN_WAIT_1] 客户端主动调用close时, 向服务器发送结束报文段, 同时进入FIN_WAIT_1;
[FIN_WAIT_1 -> FIN_WAIT_2] 客户端收到服务器对结束报文段的确认, 则进入FIN_WAIT_2, 开始等待服务器的结束报文段;
[FIN_WAIT_2 -> TIME_WAIT] 客户端收到服务器发来的结束报文段, 进入TIME_WAIT, 并发出LAST_ACK;
[TIME_WAIT -> CLOSED] 客户端要等待一个2MSL(Max Segment Life, 报文最大生存时间)的时间, 才会进入CLOSED状态
TIME_WAIT
谁主动断开连接谁就会进入TIMEWAIT因为,TIMEWAIT要去等最后一个ack抵达,如果发送了fin后就断开,如果最后一个ack丢失此时就无法进行完整的连接断开,当设置了这个状态回去等待一段时间,如果丢失则会重传FIN,此时就会重新发送ACK,一般TIME——WAIT的时间设置成2MSL也就是最大的传输时间(来回),其次就是有可能对方还有信息没有传送到,等待对方的消息,也就是网络上遗存的消息消散。
特殊场景:
bind error
如果服务器是主动断开的一方,那么服务器就会进入此状态,就会去等待ack发送给客户端,
此时如果重启服务器连接之前的端口就会发现失败,是因为当时的连接还没有断开需要等待TIME——WAIT状态消失才能释放资源。
使用setsockopt()设置socket描述符的 选项SO_REUSEADDR为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符
CLOSE_WAIT
正常情况下客户端发送fin时候,服务器会close掉,但如果代码没有close此时四次挥手就不会完成,服务器也就进入了close——wait。
滑动窗口
刚才我们讨论了确认应答策略, 对每一个发送的数据段, 都要给一个ACK确认应答. 收到ACK后再发送下一个数据段.这样做有一个比较大的缺点, 就是性能较差. 尤其是数据往返的时间较长的时候
窗口大小指的是无需等待确认应答而可以继续发送数据的最大值. 上图的窗口大小就是4000个字节(四个段).
发送前四个段的时候, 不需要等待任何ACK, 直接发送;
收到第一个ACK后, 滑动窗口向后移动, 继续发送第五个段的数据; 依次类推;
操作系统内核为了维护这个滑动窗口, 需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答; 只有确认应答过的数据, 才能从缓冲区删掉;
窗口越大, 则网络的吞吐率就越高;
left和right两个指针控制着窗口大小(取决于对方所能接受的大小)
也是两种丢包情况和之前一样。丢失ack和丢失数据包
快重传:
当某一段报文段丢失之后, 发送端会一直收到 1001 这样的ACK, 就像是在提醒发送端 "我想要的是 1001"一样;
如果发送端主机连续三次收到了同样一个 “1001” 这样的应答, 就会将对应的数据 1001 - 2000 重新发送;
这个时候接收端收到了 1001 之后, 再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已经收到了, 被放到了接收端操作系统内核的接收缓冲区中;
有可能会有窗口大小小于三的场景就用不到快重传,此时还需要超时重传(两者互补)
流量控制
接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应.因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制(FlowControl)
接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段, 通过ACK端通知发送端;窗口大小字段越大, 说明网络的吞吐量越高;接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;发送端接受到这个窗口之后, 就会减慢自己的发送速度;如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端
拥塞控制(网络问题)
TCP引入 ==慢启动 ==机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据
此处引入一个概念程为拥塞窗口发送开始的时候, 定义拥塞窗口大小为1;每次收到一个ACK应答, 拥塞窗口加1;每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口;也就是 min(拥塞窗口,接受窗口)
为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍.
此处引入一个叫做慢启动的阈值
当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长
当TCP开始启动的时候, 慢启动阈值等于窗口最大值;
在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回1;
少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;当TCP通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降;拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案.
延迟应答
如果接收到主机立刻返回ACK应答,此时有可能刚应答的窗口大小为100,而应用层很快又拿走了此时窗口就更大了,于是就有大神做出了一个操作就是延迟应答。
假设接收端缓冲区为1M. 一次收到了500K的数据; 如果立刻应答, 返回的窗口就是500K;
但实际上可能处理端处理的速度很快, 10ms之内就500K数据从缓冲区消费掉了;
在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;
如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M;
所有的操作都是保证效率变高,在网络不拥塞的情况下追求高效率。
也不是所有的包都可以延迟应答,总得有一个限制:
数量限制: 每隔N个包就应答一次
时间限制: 超过最大延迟时间就应答一次
(N一般取200ms)
捎带应答
而在真实情况下,我们在传递数据的时候不是你说一句话,我给你发个收到,然后再给你发另外一句话。
而是通过收到这个信息,变相的去发送我想要传达的信息。也就是我可以搭载着ACK的顺风车一起发送给你一条信息,这样就不需要发送两个信息(节省了成本)
面向字节流
UDP是数据包,TCP是字节流,数据包是一次性发送我所有的数据,要么接受,要么选择不接受。这样就保证了,数据可以一次性的被对方收到。而字节流不是这样。
TCP不是直接通过send和recv进行传输的上边也提到过,而是底层建立的两个一个发送缓冲区,和接受缓冲区,每次收发信息都是通过取出塞入缓冲区里,而什么时候发就是由内核决定。
- 调用write时, 数据会先写入发送缓冲区中;
- 如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
- 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去
- 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
- 然后应用程序可以调用read从接收缓冲区拿数据;
- 另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做 全双工
也就是说我们可以任意读取我想要的字节数,和任意写入我想要写入的长度,都是可以通过程序猿自己设置的。
粘包问题
- 首先要明确, 粘包问题中的 “包” , 是指的应用层的数据包.
- 在TCP的协议头中, 没有如同UDP一样的 “报文长度” 这样的字段, 但是有一个序号这样的字段.
- 站在传输层的角度, TCP是一个一个报文过来的. 按照序号排好序放在缓冲区中.
- 站在应用层的角度, 看到的只是一串连续的字节数据.
- 那么应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分, 是一个完整的应用层数据包
解决方案:
对于定长的包, 保证每次都按固定大小读取即可; 例如上面的Request结构, 是固定大小的, 那么就从缓冲区从头开始按sizeof(Request)依次读取即可;
对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包的结束位置;
对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议, 是程序猿自己来定的, 只要保证分隔符不和正文冲突即可)
UDP协议来说,就不存在粘包问题,因为UDP协议中有明确的数据边界,也就是有数据的长度,并且在应用层的角度,UDP数据包要么一次收要么不收,所有不会出现粘包问题。
TCP异常
- 进程终止: 进程终止会释放文件描述符, 仍然可以发送FIN. 和正常关闭没有什么区别.
- 机器重启: 和进程终止的情况相同.
- 机器掉电/网线断开: 接收端认为连接还在, 一旦接收端有写入操作, 接收端发现连接已经不在了, 就会进行reset. 即使没有写入操作, TCP自己也内置了一个保活定时器, 会定期询问对方是否还在. 如果对方不在, 也会把连接释放.
- 另外, 应用层的某些协议, 也有一些这样的检测机制. 例如HTTP长连接中, 也会定期检测对方的状态. 例如QQ, 在QQ断线之后, 也会定期尝试重新连接.
定时器:是操作系统的一种调度时候使用的方式。就操作系统是一个大的进程,当打开操作系统的时候,不断地进行时间中断来通知那个进程需要启动了,那些需要多长时间后启动,滴答滴答不断地提醒。
TCP小结
定时器(超时重传定时器, 保活定时器, TIME_WAIT定时器等)
TCP应用层协议
以上是关于网络编程TCP/UDP协议(传输层特性)的主要内容,如果未能解决你的问题,请参考以下文章