目录
再谈端口号
端口号范围划分
认识知名端口号
netstat
pidof
UDP协议
UDP协议端格式
UDP的特点
面向数据报
UDP的缓冲区
UDP使用注意事项
基于UDP的应用层协议
TCP协议
TCP协议段格式
几个问题:
确认应答(ACK)机制
6个标记位
超时重传机制
连接管理机制
理解TIME_WAIT状态
解决TIME_WAIT状态引起的bind失败的方法
理解 CLOSE_WAIT 状态
流量控制
16位窗口大小:接收缓冲区剩余空间的大小
滑动窗口
拥塞控制
延迟应答
捎带应答
面向字节流
粘包问题
TCP异常问题
TCP小结
基于TCP应用层协议
TCP/UDP对比
理解 listen 的第二个参数
传输层负责数据能够从发送端传输接收端。
端口号(Port)标识了一个主机上进行通信的不同的应用程序;
在TCP/IP协议中, 用 "源IP", "源端口号", "目的IP", "目的端口号", "协议号" 这样一个五元组来标识一个通信(可以通过netstat -n查看);
- 0 - 1023: 知名端口号, HTTP, FTP, SSH等这些广为使用的应用层协议, 他们的端口号都是固定的。
- 1024 - 65535: 操作系统动态分配的端口号, 客户端程序的端口号, 就是由操作系统从这个范围分配的。
有些服务器是非常常用的, 为了使用方便, 人们约定一些常用的服务器, 都是用以下这些固定的端口号:
- ssh服务器, 使用22端口
- ftp服务器, 使用21端口
- telnet服务器, 使用23端口
- http服务器, 使用80端口
- https服务器, 使用443
执行下面的命令, 可以看到知名端口号:
cat /etc/services
我们自己写一个程序使用端口号时, 要避开这些知名端口号。
那么这里有两个问题需要思考:
1. 一个端口号是否可以被多个进程bind?
我们知道端口号可以用于表示进程唯一性,如果一个端口号被多个进程同时bind,那么接收数据时,就不能完全确定该由哪个进程去接收。
2. 一个进程是否可以bind多个端口号?
相反一个进程是可以bind多个端口号的,接收数据时无论从那个端口号都可以到达该进程。
netstat是一个用来查看网络状态的重要工具。
语法: netstat [选项]
功能:查看网络状态
常用选项:
- n 拒绝显示别名,能显示数字的全部转化成数字
- l 仅列出有在 Listen (监听) 的服務状态
- p 显示建立相关链接的程序名
- t (tcp)仅显示tcp相关选项
- u (udp)仅显示udp相关选项
- a (all)显示所有选项,默认不显示LISTEN相关
在查看服务器的进程id时非常方便.
语法: pidof [进程名]
功能:通过进程名, 查看进程id
也可以组合使用:xargs可以帮助我们把管道里读取的 pid 值输入到kill -9 后面
16位UDP长度, 表示整个数据报(UDP首部+UDP数据)的最大长度;如果校验和出错, 就会直接丢弃;
那么在UDP这里它是如何封装/解包,又是如何分用的呢?
通过上面的图片我们知道UDP采用的方式是固定报头,也就是说UDP报头的长度是固定的。收到请求时,直接把固定长度的报头分离出来。解析报头之后,再把有效载荷通过目的端口传输给目标进程,进程再去做后续的处理。
所谓的报头其实就是一种结构化数据对象,它里面定义了上图的参数,通过一些方式对这些参数进行赋值,而后添加在有效载荷的前面。
我们在了解了所有的协议之后,知道一个请求/响应是有报头和有效载荷的。
UDP传输的过程类似于寄信。
- 无连接: 知道对端的IP和端口号就直接进行传输, 不需要建立连接;
- 不可靠: 没有确认机制, 没有重传机制; 如果因为网络故障该段无法发到对方, UDP协议层也不会给应用层返回任何错误信息;
- 面向数据报: 不能够灵活的控制读写数据的次数和数量;
应用层交给UDP多长的报文, UDP原样发送, 既不会拆分, 也不会合并;
用UDP传输100个字节的数据:
如果发送端调用一次sendto,发送100个字节,那么接收端也必须调用对应的一次recvfrom,接收100个字节;而不能循环调用10次recvfrom, 每次接收10个字节。
UDP没有真正意义上的 发送缓冲区. 调用sendto会直接交给内核, 由内核将数据传给网络层协议进行后续的传输动作;
UDP具有接收缓冲区. 但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致; 如果缓冲区满了, 再到达的UDP数据就会被丢弃;
UDP的socket既能读, 也能写, 这个概念叫做 全双工。
我们注意到, UDP协议首部中有一个16位的最大长度. 也就是说一个UDP能传输的数据最大长度是64K(包含UDP首部)。然而64K在当今的互联网环境下, 是一个非常小的数字.
如果我们需要传输的数据超过64K, 就需要在应用层手动的分包, 多次发送, 并在接收端手动拼装;
- NFS: 网络文件系统
- TFTP: 简单文件传输协议
- DHCP: 动态主机配置协议
- BOOTP: 启动协议(用于无盘设备启动)
- DNS: 域名解析协议
当然, 也包括你自己写UDP程序时自定义的应用层协议;
TCP全称为 "传输控制协议(Transmission Control Protocol"). 人如其名, 要对数据的传输进行一个详细的控制;
- 源/目的端口号: 表示数据是从哪个进程来, 到哪个进程去;
- 32位序号/32位确认号: 后面详细讲;
- 4位TCP报头长度: 表示该TCP头部有多少个32位bit(有多少个4字节); 所以TCP头部最大长度是15 * 4 = 60
- 6位标志位:
URG: 紧急指针是否有效
ACK: 确认号是否有效
PSH: 提示接收端应用程序立刻从TCP缓冲区把数据读走
RST: 对方要求重新建立连接; 我们把携带RST标识的称为复位报文段
SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段
FIN: 通知对方, 本端要关闭了, 我们称携带FIN标识的为结束报文段
- 16位窗口大小: 后面再说
- 16位校验和: 发送端填充, CRC校验. 接收端校验不通过, 则认为数据有问题. 此处的检验和不光包含TCP首部, 也包含TCP数据部分.
- 16位紧急指针: 标识哪部分数据是紧急数据;
- 40字节头部选项: 暂时忽略;
1.在TCP这里是如何封装/解包的,又是如何分用的?
首先tcp协议是有标准长度的,也就是说没有选项的话,长度就为20字节。先读取20个字节的内容,将它转为一个结构化的数据,提取标准报头中的 4位首部长度。通过这个参数就可以计算剩余报头的大小了。把TCP报头处理完毕后,剩下的就是有效载荷了。那么问题来了,TCP报头里没有有效载荷的长度。我们怎么去辨别有效载荷的结尾呢?这个在粘包问题那里会讲
封装的话,TCP和UDP一样,报头其实就是一种结构化数据对象,定义对象,给对象里的参数赋值,再将其放到应用层传下来的有效载荷的前面。
分用:报头里是有目的端口号这个参数的,通过这个的端口号就可以找到应用层的进程,然后数据就可以交付给进程了。
2.我们收到一个报文,是如何找到曾经bind特定port的进程的?网络协议栈和文件是什么关系?
因为系统中有很多场景需要我们快速定位进程,所以就有了进程PCB。操作系统内会有一个hash表,我们调用的bind函数就相当于把端口号插入hash内部,通过hash绑定该进程。有报文时就可以通过端口号在hash里映射找到对应进程PCB。他们两的关系是Linux下一切皆文件。
通过上面说的可以找到对应进程,那数据又是如何交给对应进程的呢?
进程PCB里要维护一个文件描述符表,网络套接字socket也是文件描述符,当然也会被描述符表管理起来。文件描述符表里存放的都是指针,这个指针指向一个file结构体,结构体里有自己的读写方法,也有自己的读写缓冲区。传输层把TCP报头和有效载荷分离开后,就把有效载荷放到该文件的读写缓冲区里,上层就可以通过对应的socket文件描述符,从读写缓冲区里把数据读走了。
3.TCP的可靠性
为什么网络传输时会存在不可靠问题?
因为距离变长了,就像人和人对话,距离近的时候你说一句我说一句基本都能听见,就算没听清还可以直接再问一遍。距离很远的时候,人和人对话就只能靠吼了,很有可能别人根本就听不见你在说什么,或者听不清你的问题,你两根本就不在一个频道上。
不可靠问题常见都有哪些不可靠场景?
比如 丢包、乱序、校验错误、重复等。
如果距离长了,存不存在绝对的可靠性?
1.我们认为只有收到了应答,历史消息才能确定对方已经收到了。
2.双方之间通信,一定存在着最新的数据,这个最新的数据是没有应答的。最新的消息无法保证可靠性。
通过上面两点,可以确定不存在绝对的可靠性,但是存在相对的可靠性,一个报文收到了应答就能保证该报文的可靠性,也就需要一个确认应答机制(ACK)。
TCP将每个字节的数据都进行了编号. 即为序列号。
客户端给服务器发送请求,服务器给客户端响应,但这个响应有可能既是应答也是请求,那么就意味着客户端还需要再给服务器应答,也就是说无论是客户端还是服务器都需要有应答,双方在通信时,除了正常的数据段,也会涵盖确认数据段。这个数据段可以认为就是一个TCP请求。
而且在实际工作中,客户端很有可能发送一批请求,服务器也会回复一批响应。那么问题来了,数据到达对面的顺序一定和发送顺序一致吗?答案是不一定。那么TCP数据段就需要有一个方式来标识数据段本身。那么就说明了TCP报头中一定会涵盖序号和确认序号。
每一个ACK都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 下一次你从哪里开始发。
那么这里为什么要有两组序号呢?一组序号不行吗?你发过来的数据段,我给你确认应答就是了。
因为这里是全双工的,你给我发来的数据段,我很有可能会在应答这个数据段中加入我想要给你发送的消息,如果只有一组序号,我就只能在应答这个数据段中给你确认你的数据段我收到了,但是我发送的数据没有序号让你应答了。
首先要知道TCP报文也是有类型的,服务器会接受到不同的客户端发来的各种各样的TCP报文,接收方要根据不同的TCP报文做出相对应的动作。
- SYN:请求建立连接; 客户端向服务器发起建立连接请求时就会将SYN标记位设置为1,此时他两就可以进行三次握手的动作了。
- FIN:通知对方, 本端要关闭了; 客户端想要和服务器断开连接时,就会将FIN标记位设置为1,此时他两就可以进行四次挥手的动作了。
- ACK:确认号是否有效; 我们在给对方应答时,会将此标记位设置为1;一般三次握手成功后在给对方发送消息时,此标记为都会被设置为1,因为不管是请求还是响应,都会承担着对历史消息的确认。
- PSH:提示接收端应用程序立刻从TCP缓冲区把数据读走; 接收端给我们响应的报头里会有它自己的接收缓冲区的剩余大小,当这个大小越来越小时,发送端就会给接收端发送一个将PSH标记位设置为1的报文,表示让接收端那边的应用程序赶紧读取数据。
- URG:紧急指针是否有效; 我们知道报文都是有序号的,是按序处理的,如果此时有一个报文很特殊,需要尽快被读取,那么这个报文的报头,内部的URG标记位就被设置为1。那么这个紧急的数据在哪里呢?总不能所有的有效载荷都是吧,此时16位紧急指针就起作用了,它里面存放的是偏移量,标识紧急数据在有效载荷中的偏移位置,那么位置有了,大小是多少呢?总不能是从偏移位置开始到结尾吧,不用着急,TCP紧急指针只能有一个字节的大小,也就是说紧急数据只能有一个字节。这个标记位和紧急指针一般都不会去用。
- RST: 对方要求重新建立连接; 首先有一个问题,三次握手一定能保证握手成功吗?不一定吧,那么四次挥手也一样。即便是链接建立成功了,在双方通信过程中,也可能会有链接单方面出现问题。比如客户端和服务器建立连接成功,在通信过程中,服务器因为特殊原因“掉线了”(如断电),服务器的操作系统已经重启了,自然链接就都没了。因为没有四次挥手,所以客户端认为链接还在,客户端正常的向服务器发送报文,服务器收到报文,发觉自己可能出现问题了,就给客户端发送一个报文,这个报文里,RST标记位是1,相当于是给客户端说我们两个重新建立一下链接吧,刚刚的链接出错了。
主机A发送数据给B之后,可能因为网络拥堵等原因,数据无法到达主机B;如果主机A在一个特定时间间隔内没有收到B发来的确认应答,就会进行重发;
但是, 主机A未收到B发来的确认应答, 也可能是因为ACK丢失了;
因此主机B会收到很多重复数据. 那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉。
发送方是如何判定丢包了呢?
有没有丢包其实发送方是不知道的,只不过是因为定的策略表示该数据收到应答的时间已经超时,所以判定丢包了,就会重新发送。那么对于接收方而言,他就可能会收到相同的数据,其实这也是不可靠的一种表现,那么接收方就需要对数据进行去重,如何去重呢?用序号,两个序号相同的报文,就认为他们两个数据是相同的。
因为有超时重传机制,那么发送端发出的数据,发送缓冲区里这块被发出的数据,并不像我们想象的那样立马就被移除了,而是必须在发送缓冲区里再维持一段时间,直至接收到对方的应答。
如果超时,超时的时间如何确定?
最理想的情况下,找到一个最小的时间,保证 "确认应答一定能在这个时间内返回"。但是这个时间的长短,随着网络环境的不同,是有差异的。如果超时时间设的太长,会影响整体的重传效率;如果超时时间设的太短,有可能会频繁发送重复的包;
TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。如果重发一次之后,仍然得不到应答,等待 2 * 500ms 后再进行重传。如果仍然得不到应答,等待 4 * 500ms 进行重传。 依次类推,以指数形式递增。累计到一定的重传次数, TCP认为网络或者对端主机出现异常,强制关闭连接。
在正常情况下, TCP要经过三次握手建立连接, 四次挥手断开连接。
我们知道三次握手不一定每次都成功,前两个请求丢失了都比较好解决,麻烦的是最后一个ACK丢失,但是现在已经有了配套的解决方案(RST)。链接建立成功之后,这个链接是需要被操作系统管理起来的,而维护这个链接是需要成本的(时间和空间的成本)。
那么这里有个问题,为什么是三次握手?一次握手、两次握手不行吗?那四次握手或者更多次呢?
首先一次握手肯定是不行的,一般都是客户端向服务器发起建立连接请求,客户端向服务器发送一个带有SYN的请求,那么服务器就认为链接已经建立好了,那么就要对该链接进行维护,客户端可以通过频繁刷新浏览器,不断的向服务器发起带有SYN的请求,那么就会造成SYN洪水,服务器的资源就被一个客户端搞没了,这是有问题的。
两次握手也是不行的,也会出现一次握手的情况,他可以完全不理会服务器发送的应答,一直频繁的发SYN请求,导致SYN洪水。而且一次两次是无法验证通信信道是全双工的。
而三次握手可以用最小的成本去验证全双工通信信道是畅通的,三次握手可以有效防止单机对服务器进行攻击。这里并避免不了服务器被攻击,而且服务器收到攻击,本身就不是TCP握手应该解决的。像ddos攻击TCP根本无能为力。
三次握手已经解决了很明显的漏洞,那么四次握手或多次握手也就没有必要了。
那为什么是四次挥手呢?
因为断开连接是双方的事情,需要征得双方同意,也就是说,你发送完消息,向我发起断开连接请求,我给你应答的同时我发送完消息,我也要向你发起断开连接请求,只有我们两个都收到应答之后链接才算断开。
服务端状态转化:
- [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状态,而被动断开连接的一方,两次挥手完成会进入CLOSE_WAIT状态。
四次挥手动作完成,但是主动断开连接的一方要维持一段时间的TIME_WAIT状态,为什么呢?
对方给我们发了一个断开连接的请求,我们会给一个应答,但是我们要保证这个应答要能被对方接收到。如果应答丢了,对方还会给我们再发送断开连接请求,如果我们发出应答后直接退出,很有可能会因为丢包问题导致对方没有断开连接,继而一直发送请求的问题。当然还有可能是,双方断开链接时,网络中还有滞留的报文,要保证滞留的报文进行消散。
当然这个状态还有一个更直观的表现就是,服务器先退出,立马再次运行,会出现bind错误。
这是因为,虽然server的应用程序终止了,但TCP协议层的连接并没有完全断开,因此不能再次监听同样的server端口.我们用netstat命令查看一下:
TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态。我们使用Ctrl-C终止了server,所以server是主动关闭连接的一方,在TIME_WAIT期间仍然不能再次监听同样的server端口;
MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同,在Centos7上默认配置的值是60s;可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看msl的值。
在server的TCP连接没有完全断开之前不允许重新监听, 某些情况下可能是不合理的。
服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短,但是每秒都有很大数量的客户端来请求)。这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃,就需要被服务器端主动清理掉),就会产生大量TIME_WAIT连接。
由于我们的请求量很大,就可能导致TIME_WAIT的连接数很多, 每个连接都会占用一个通信五元组(源ip、源端口、目的ip、 目的端口、协议)。 其中服务器的ip和端口和协议是固定的。 如果新来的客户端连接的ip和端口号和TIME_WAIT占用的链接重复了, 就会出现问题。
使用setsockopt() 设置socket描述符的 选项SO_REUSEADDR为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符,
修改一下之前的代码,我们把服务器关闭sock文件描述符的代码注释掉,看看会发生什么。
我们编译运行服务器。启动客户端链接, 查看 TCP 状态, 客户端服务器都为 ESTABLELISHED 状态, 没有问题。
然后我们关闭客户端程序, 观察 TCP 状态 此时服务器进入了 CLOSE_WAIT 状态, 结合我们四次挥手的流程图,可以认为四次挥手没有正确完成。
对于服务器上出现大量的 CLOSE_WAIT 状态, 原因就是服务器没有正确的关闭 socket, 导致四次挥手没有正确完成。这是一个 BUG. 只需要加上对应的 close 即可解决问题。也有可能是服务器有压力,可能一直在推送消息给客户端导致来不及close。
TCP这里不论是客户端还是服务器都有自己的发送缓冲区和接收缓冲区,我们在发送数据时,速度太快,对方来不及接收,导致对方接收缓冲区被打满,这个时候如果我们继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应;速度太慢,又会影响对方上层的业务处理。所以快了不行,慢了也不行,必须要合适才行。那么问题来了,我们如何得知我们发送的数据量是合适的呢?
那么此时就需要对方给我们一个反馈,这个反馈就是我们需要知道对方接收缓冲区剩余空间的大小。就好比两个人对话,一方说的很快,那么另一方就说你说慢点,我都听不清了。
你在给对方发数据的时候,对方有可能也在给你发,所以发数据时要在16位窗口那里填上自己的接收缓冲区剩余空间的大小。双方在互相发送报文时,会一点一点的更新自己的接收缓冲区剩余空间的大小,这样就相当于双方交换了接受能力,根据接受能力控制传输速度,这个机制就叫做流量控制。
接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段, 通过ACK端通知发送端;窗口大小字段越大,说明网络的吞吐量越高;
接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端;发送端接受到这个窗口之后,就会减慢自己的发送速度;
如果接收端缓冲区满了,就会将窗口置为0;这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端。
那么问题来了,16位数字最大表示65535,那么TCP窗口最大就是65535字节么?
实际上, TCP首部40字节选项中还包含了一个窗口扩大因子M, 实际窗口大小是 窗口字段的值左移 M 位。
在上面说过有确认应答策略,对每一个发送的数据段,都要给一个ACK确认应答。收到ACK后再发送下一个数据段。这样做有一个比较大的缺点,就是性能较差,尤其是数据往返的时间较长的时候。
既然这样一发一收的方式性能较低, 那么我们一次发送多条数据,就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)。
我们发送数据,在没有收到应答之前,我们必须将自己已经发送的数据保存起来,这是为了支持超时重传机制,那么这部分数据保存在哪里呢?保存在自己的发送缓冲区的滑动窗口中。
这个发送缓冲区并不像我们想象的那样,它是一个类似于下图的一个结构:
为了更好地理解,滑动窗口可以认为是两个数组下标(start,end),所谓的窗口移动本质是这两个下标在更新,也就是说收到一个应答start右移一位,发送一个报文end右移一位。
窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。比如上面发送数据的图,发送前四个段的时候,不需要等待任何ACK, 直接发送;
收到第一个ACK后,滑动窗口向后移动,继续发送第五个段的数据; 依次类推,操作系统内核为了维护这个滑动窗口, 需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答; 只有确认应答过的数据, 才能从缓冲区删掉;窗口越大,则网络的吞吐率就越高。滑动窗口大小表示要发送数据报文的大小。
此时有个问题会在我们脑海浮现,收到确定应答的时候,如果这个应答对应的不是滑动窗口内最左侧的那个报文,而是中间的或者结尾的,怎么办?还要滑动吗?
如果收到的应答对应的不是最左侧那个报文,我们可以认为丢包了,这个丢包分两种情况:
情况一:数据包已经抵达, ACK被丢了。
这种情况下,部分ACK丢了并不要紧,因为确定序号表示的是XXX号之前所有的数据全部都收到了,因此可以通过后续的ACK进行确认,滑动窗口也不会受到影响,start下标会正常向右移动。
当1000~2000这段报文丢失之后,发送端会一直收到 确认序号为1001 这样的ACK,就像是在提醒发送端 "我想要的是 序号为1001这段数据"一样;
如果发送端主机连续三次收到了同样一个 "1001" 这样的应答,就会将对应的数据 1001 - 2000 重新发送;这个时候接收端收到了 1001 之后, 再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已经收到了, 被放到了接收端操作系统内核的接收缓冲区中。发送端收到ACK中确认序号为7001的时,start下标会直接移动到序号为7001处。
这种机制被称为 "高速重发控制"(也叫 "快重传")。
滑动窗口本质上是在可靠性的基础上,为了能够发送一批数据,提升数据发送的效率而实现的。
如果客户端向服务器发送了1000个报文,丢了一个或两个,我们会认为是我们发送出了问题,但是如果客户端发送了1000个报文,服务器只收到一个报文,丢了999个报文,我们不会认识是我们发送出了问题,而是认为 “网络” 出现了问题。那么我们丢了这么多的报文,此时还应该继续重传吗?
当然不能把丢的报文再继续重新发送一遍了,如果继续发送可能还会继续丢失大量报文,做了无用功,而且 “网络” 本身出现问题,我们继续发送可能会加重“网络” 的病情。可能有的人会说我就一个客户端,怎么可能导致网络出现问题啊,大错特错!服务器不只是为某一个客户端服务的,它会为很多个客户端服务的,而“网络”中又会有很多的客户端和服务器,如果在同一个时间段内,大家都大量的发送报文,是会导致“网络”阻塞的。如果你不管不顾的重传,因为大家都用的TCP协议,大家的策略都是一样的,这就会导致“网络”的病情加重。此时就应该发送一个或少量的报文,先试探一下,看看网络拥堵的状态,由于大家的策略是一样的,网络就会慢慢恢复正常,等网络恢复正常后再决定传输数据的速度,这个我们称为拥塞控制。
TCP的可靠性不仅考虑了双方主机的问题,还考虑了网络的问题。
虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据。但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题。因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵。在不清楚当前网络状态下, 贸然发送大量的数据,是很有可能引起雪上加霜的。
TCP引入 慢启动 机制, 先发少量的数据,探探路,摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据。这个慢启动机制是所有的TCP协议都会遵守的。
此处需要引入一个概念叫拥塞窗口。我们要解决一个问题时,不是等问题出现了再解决,而是防患于未然。这个拥塞窗口就是一个数字,我们发送的数据量超过这个数字时,就会把发送数据的速度降下来,避免出现网络拥堵的问题,当然问题出现了我们就用慢启动机制去解决。窗口大小表示对方的接受能力,而拥塞窗口用来表征网络的接收能力。
发送开始的时候, 定义拥塞窗口大小为1,每次收到一个ACK应答, 拥塞窗口加1;
每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口。
也就是说滑动窗口的大小是拥塞窗口和对端主机反馈的窗口中的较小值,
像上面图片这样的拥塞窗口增长速度是指数级别的, "慢启动" 只是指初使时慢,但是增长速度非常快。为什么这里的增长速度的算法是指数级的?
因为如果最开始几次少量的数据都收到了响应,那么我们是不是可以认为网络恢复正常了呢,既然网络恢复正常了,那就要让发送的效率也尽快的提上来,所以这里采用的是指数级的策略。那么问题来了,我们都知道指数级的增长是非常恐怖的,如果拥塞窗口的数特别大,超过了对方的接受能力怎么办?
别担心,因为发送的数据量是由有滑动窗口的大小决定的,而滑动窗口取的是拥塞窗口和对端主机反馈的窗口中的较小值,即使拥塞窗口很大,那也只能说明网络状况很好,是不会影响发送数据量的。
可见TCP在这里的设计是非常优秀的,前期的慢有利于网络的恢复,后面的快有利于客户端到服务器之间数据传输的恢复。
为了不增长的那么快,因此不能使拥塞窗口单纯的加倍。此处引入一个叫做慢启动的阈值,当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。
当TCP开始启动的时候,慢启动阈值等于窗口最大值;在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回1。
少量的丢包,我们仅仅只触发超时重传,大量的丢包,我们就认为网络拥塞。当TCP开始通信时,网络吞吐量会逐渐上升,随着网络发生拥堵,吞吐量会立即下降。拥塞控制归根结底是TCP协议想尽可能快的把数据传给对方,但是又要避免给网络造成太大的压力的折中方案。
TCP拥塞控制这样的过程就好像热恋的感觉,刚开始认识感情升温较慢,稍微熟悉了之后,感情升温很快,认识一段时间后,感情比较稳定;但也在慢慢的增长,后面因为一些事情发生矛盾,两人分手,感情急速下降;而后有一方开始挽回,另一方也愿意复合,两人再次进入情感升温阶段,但是此时感情迅速增长的阈值比上次的低,很容易到达,然后趋于平淡,后面再次争吵... 拥塞窗口就像一个变量始终是在变化的,因为网络是在变的,但是无论怎么变化,发送数据量是拥塞窗口和对方的接收能力两个之间的较小值。
某一端主机向另一端主机发送数据,如果接收数据的主机立即返回ACK应答,这时返回的窗口可能比较小。
举个例子:假设接收端缓冲区为1M,一次收到了500K的数据; 如果立刻应答,返回的窗口就是500K。但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了。在这种情况下,接收端处理还远没有达到自己的极限, 即使窗口再放大一些,也能处理过来。如果接收端稍微等一会再应答,比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M。当然这个只是大概率事件,并不是说一定会发生。
那么窗口越大,网络吞吐量就越大,传输效率就越高,我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。
那么所有的包都可以延迟应答么? 肯定也不是,延迟应答分两种:
1. 数量限制: 每隔N个包就应答一次;
2. 时间限制: 超过最大延迟时间就应答一次;
具体的数量和超时时间,操作系统不同也有差异, 一般N取2,超时时间取200ms。
看下图,这个延迟应答和前面的确认应答两个就关联起来了,这样就可以极大的提高TCP数据发送的效率。
在延迟应答的基础上,我们发现,很多情况下,客户端服务器在应用层也是 "一发一收" 的。 意味着客户端给服务器说了 "How are you", 服务器也会给客户端回一个 "Fine, thank you"。那么这个时候ACK就可以搭顺风车,和服务器回应的 "Fine, thank you" 一起回给客户端。
创建一个TCP的socket, 同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区。
- 调用write时,数据会先写入发送缓冲区中,如果发送的字节数太长,会被拆分成多个TCP的数据包发出。具体怎么拆分,拆分成多少个,这不是应用层要操心的事,是操作系统按协议拆分的。如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者等其他合适的时机发送出去。
- 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区,然后应用程序可以调用read从接收缓冲区拿数据。
另一方面,TCP的一个连接,既有发送缓冲区,也有接收缓冲区, 那么对于这一个连接,既可以读数据, 也可以写数据,这个概念叫做 全双工。
由于缓冲区的存在, TCP程序的读和写不需要一 一匹配,例如:
- 写100个字节数据时,可以调用一次write写100个字节, 也可以调用100次write,每次写一个字节。
- 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的,既可以一次read 100个字节,也可以一次read一个字节,重复100次。
首先要明确, 粘包问题中的 "包" , 是指的应用层的数据包。
在TCP的协议报头中,没有如同UDP一样的 "报文长度" 这样的字段,但是有一个序号这样的字段。站在传输层的角度,TCP是一个一个报文过来的,按照序号排好序放在缓冲区中。
站在应用层的角度,看到的只是一串连续的字节数据,那么应用程序看到了这么一连串的字节数据,不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。读不到一个完整的数据包就没办法解析数据,这就是粘包问题。
那么如何避免粘包问题呢? 归根结底就是一句话,明确两个包之间的边界。
如果一个包是定长的,那直接按照固定长度读取即可。
那么对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。还可以在包和包之间使用明确的分隔符(应用层协议, 是程序猿自己来定的,只要保证分隔符不和正文冲突即可)。
那么对于UDP协议来说, 是否也存在 "粘包问题" 呢?
对于UDP来讲,如果还没有向上层交付数据,UDP的报文长度仍然在。同时, UDP是一个一个的把数据交付给应用层,这样就有很明确的数据边界。站在应用层的角度,使用UDP的时候, 要么收到完整的UDP报文,要么不收,不会出现"半个"的情况。
那为什么TCP不和UDP那样在报头中添加整个报文的长度?
因为TCP不需要,它是面向字节流的,它向上交付时也是按字节交付的,校验和可以帮助验证报文的完整性,序号可以确认报文的边界。
进程终止: 我们要知道建立连接不是进程自己建立的,而是调用操作系统的接口建立的,本质上是两个主机建立了连接,而进程异常终止,操作系统会回收进程的资源,当然也会释放进程申请的文件描述符,那么仍然就可以发起四次挥手这和正常关闭没什么区别。
机器重启或关机:他和进程异常终止的情况是一样的,因为在正常在关机时,如果有进程还在运行,那么电脑会问你是否强制终止进程,进程被终止,资源也会被回收,所以说这两个情况一样。
机器掉电/网线断开:正常来讲,进程终止了操作系统并没有终止,所以还会进行四次挥手动作。但是机器掉电的话操作系统也没了,当然也就没办法挥手了,断网也一样,事发突然 ,操作系统根本来不及发起挥手,想发也没网了。 虽然发送端直接掉了,但是接收端认为连接还在, 一旦接收端有写入操作, 接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在, 如果对方不在, 也会把连接释放掉。
另外,应用层的某些协议,也有一些这样的检测机制。例如HTTP长连接中, 也会定期检测对方的状态。 例如QQ,在QQ断线之后,也会定期尝试重新连接。
为什么TCP这么复杂?因为要保证可靠性, 同时又尽可能的提高性能。
- 可靠性:校验和、序列号(按序到达)、确认应答、超时重发、连接管理、流量控制、拥塞控制。
- 提高性能:滑动窗口、快速重传、延迟应答、捎带应答。
- 其他:定时器(超时重传定时器, 保活定时器, TIME_WAIT定时器等)
HTTP HTTPS
SSH Telnet
FTP SMTP
当然, 也包括你自己写TCP程序时自定义的应用层协议;
我们说了TCP是可靠连接,那么是不是TCP一定就优于UDP呢? TCP和UDP之间的优点和缺点, 不能简单,绝对的进行比较。TCP和UDP都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定。
- TCP用于可靠传输的情况, 应用于文件传输, 重要状态更新等场景。
- UDP用于对高速传输和实时性要求较高的通信领域,例如,早期的QQ,视频传输等。另外UDP可以用于广播。
先用一个故事帮助理解:
平时我们在外面商场吃饭时,有某一家的菜很好吃,那个店里面就坐满了人,那么后面来的顾客要么就等,要么就换一家(大部分直接换一家,因为这家店门口没有休息的地方)。后面老板发现店里虽然每天人满为患,但是因为很少有人等座位,有时一个位置要等半个小时一个小时才会来人,一对比业绩少了很多,此时老板就想了办法,在门口加了几个凳子一点小零食供顾客等待位置。过了一段时间,营业额有明显增长,老板很开心呀,一激动直接把排队等待位置加了100米直接排到商场出口,而后又过了一段时间营业额不但没有增长,反倒比不加排队的营业额还差,为什么呢?首先支出无形间增大了,凳子和小零食都是支出,又因为很多人见排队太长就直接走了,不会傻乎乎的等,所以出现了这种情况。
我们要知道排队的本质是让资源在有空闲的时候可以立马被使用,提高资源利用率。所以我们不能不排队,但是也不能排队太长。TCP协议要为上层维护一个连接队列,这个队列是受listen的第二个参数影响的。
用之前的TCP的网络计算器做实验,对服务器,将listen的第二个参数设置为2,并且不调用accept。
直接启动 3 个客户端同时连接服务器,用 netstat -ntp 查看服务器状态,此时一切正常。
但是启动第四个客户端时,发现服务器对于第四个连接的状态存在问题了。
橙色框框内的客户端状态正常, 但是服务器端出现了 SYN_RECV 状态, 而不是 ESTABLISHED 状态。
这是因为, Linux内核协议栈为一个TCP连接管理使用两个队列:
- 1. 半链接队列(用来保存处于SYN_SENT和SYN_RECV状态的请求)。
- 2. 全连接队列(accpetd队列)(用来保存处于established状态,但是应用层没有调用accept取走的请求)。
而全连接队列的长度会受到 listen 第二个参数的影响。全连接队列满了的时候,就无法继续让当前连接的状态进入 established 状态了。这个队列的长度通过上述实验可知,是 listen 的第二个参数 + 1。