本文将介绍TCP/IP四层结构模型中的传输层。
🍅传输层
传输层主要负责将数据从发送端传输到接收端。
🍋再谈端口号
我们之前已经知道,端口标识的是一个主机上进行的应用程序。 在TCP/IP协议中, 用 “源IP”, “源端口号”, “目的IP”, “目的端口号”, “协议号” 这样一个五元组来标识一个通信(可以通过netstat -n 查看);
🥝netstat指令
netstat是一个用来查看网络状态的重要工具.
- 语法:netstat [选项]。
- 功能:查看网络状态。
- 常用选项:
- -n:拒绝显示别名,能显示数字的全部转化成数字
- -l:仅列出有在 Listen (监听) 的服務状态
- -p:显示建立相关链接的程序名
- -t : (tcp)仅显示tcp相关选项
- -u :(udp)仅显示udp相关选项 -a (all)显示所有选项,默认不显示LISTEN相关
[lyl@VM-4-3-centos 2022-6-3]$ netstat -n
Active Internet connections (w/o servers)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 52 10.0.4.3:22 43.227.138.73:2594 ESTABLISHED
tcp 0 0 10.0.4.3:58924 169.254.0.138:8086 ESTABLISHED
tcp 0 0 10.0.4.3:51368 59.36.128.183:9988 ESTABLISHED
上面的列表依次为协议类型、接收队列、发送队列、本地网络的IP和端口号、远端网络的IP和端口号。其中关于接收队列,我们之前编写socket编程时监听listen中参数backlog就代表接收等待队列的最大值。
- 指令:pidof
- 功能:通过进程名查看服务器进程id。
- 语法:pidof [进程名]
[lyl@VM-4-3-centos 2022-6-1]$ pidof http_server
27345
🥝端口号划分范围
我们知道端口号是一个16位的短整型,因此其取值范围是0-65535.而端口号根据其取值可分为:
- 0 - 1023:知名端口号。HTTP、FTP、SSH这些广为人知的应用层协议,它们的端口号是固定的。
- 1024 - 65535:操作系统动态分配的端口号. 客户端程序的端口号, 就是由操作系统从这个范围分配的。
🥝常见的知名端口号(Well-Know Port Number):
有些服务器是非常常用的, 为了使用方便, 人们约定一些常用的服务器, 都是用以下这些固定的端口号:
- ftp服务器:使用21号端口。
- ssh服务器:使用22号端口。
- telnet服务器:使用23号端口。
- http服务器:使用80号端口。
- https服务器:使用443号端口。
服务器的端口号被放在/etc/services 这个文件下,我们可以用more /etc/services 这条指令来查看相关知名端口号。并且我们在写一个程序使用端口号时,应避免使用知名端口号。
🍋UDP协议
🥝UDP协议段格式
在介绍UDP协议之前,我们需要先明确两点:
- 1.任何协议都必须能够解决将自己的报头和有效载荷分离的问题。
- 2.任何协议都必须能够将自己的有效载荷交付给上层的哪一个进程。
UDP的协议格式如下: 对于UDP协议而言,其第一点是由定长报头解决的,即8字节的定长报头,能够让UDP协议将自己的报头和有效载荷进行分离。UDP的报头在底层是通过结构体+位段的方式实现的。 而关于第二点则是由UDP定长报头中的16位目的端口号来明确UDP要将自己的有效载荷交付给上层的哪一个进程,而我们之前套接字编程时之所以端口号port都是16位就是这个原因,同时我们bind亦是将端口号填充到UDP协议报文中。 至于16位UDP长度,就是UDP报文的长度即UDP协议的首部加上数据的总长度。另外16位UDP检验和,是为了校验是否出错,比如出现了数据丢包,那么这个UDP报文就会直接被丢弃。
🥝UDP协议的特点
我们之前已经了解过了,UDP协议类似于寄信的过程,发送端只管将数据发送过去,而不在乎数据是否被对端收到。因此UDP协议具有以下特点:
- 无连接: 知道对端的IP和端口号就直接进行传输, 不需要建立连接;
- 不可靠: 没有确认机制, 没有重传机制; 如果因为网络故障该段无法发到对方, UDP协议层也不会给应用层返回任何错误信息;
- 面向数据报(SOCK_DGRAM): 不能够灵活的控制读写数据的次数和数量
🍓面向数据报
面向数据报指的是不能够灵活控制读写数据的次数和数量,即应用层交给UDP多长的报文,UDP会原封不动的发送出去,既不会拆分,也不会合并。 比如说,用UDP传输50个字节的数据:如果发送端调用一次sendto, 发送50个字节, 那么接收端也必须调用对应的一次recvfrom, 接收50个字节; 而不能循环调用50次recvfrom, 每次接收1个字节。
🥝UDP的缓冲区
- UDP没有真正意义上的发送缓冲区. 调用sendto会直接交给内核, 由内核将数据传给网络层协议进行后续的传输动作;
- UDP具有接收缓冲区. 但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致; 如果缓冲区满了, 再到达的UDP数据就会被丢弃;
- UDP的socket既能够读,也能够写,这就是全双工的特性。
- 正是因为UDP的缓冲区特性,因此UDP是面向数据报的,而UDP程序的读和写必须要一一匹配。
🍓OS与缓冲区
由于缓冲区的存在,其实我们之前socket编程中见过的sendto、recvfrom、read、write、send、recv 这些接口本质并不是直接将数据发送到网络中,而是在发送端发送时将用户数据拷贝到内核缓冲区(UDP)或者发送缓冲区(TCP);而接收端接收时则是将数据从接收缓冲区拷贝给用户。 至于什么时候发送或是由谁发送,则是OS根据传输层协议决定的,这也正是传输层存在的意义。
🥝使用注意事项
我们注意到, UDP协议首部中有一个16位的最大长度. 也就是说一个UDP能传输的数据最大长度是64K(包含UDP首部). 然而64K在当今的互联网环境下, 是一个非常小的数字. 如果我们需要传输的数据超过64K, 就需要在应用层手动的分包, 多次发送, 并在接收端手动拼装;
🥝基于UDP的应用层协议
- NFS(Network File System): 网络文件系统
- TFTP(Trivial File Transfer Protocol): 简单文件传输协议
- DHCP(Dynamic Host Configuration Protocol): 动态主机配置协议
- BOOTP(Bootstrap Protocol): 引导程序协议/启动协议(用于无盘设备启动)
- DNS(Domain Name System): 域名解析协议
当然,我们自己写UDP程序时自定义的应用层协议也包括在内。
🍋TCP协议
TCP全称为 “传输控制协议(Transmission Control Protocol”).顾名思义,TCP要对数据传输进行详细的控制。
🥝TCP协议段格式
介绍TCP协议段格式之前,依旧是那两个问题: 1)协议如何将自己的报头和有效载荷进行分离。 2)协议如何将自己的有效载荷交付给上一级。 TCP协议也是通过定长报头来将自己的报头与有效载荷分离的,一个标准的TCP报头长度为20字节,如图所示: 其中,TCP首部组成有:
- 源/目的端口号:表示数据从哪个进程来,要到哪个进程去。
- 4位TCP首部长度:表示TCP首部有几个32bit(即有多少给4字节)。因此TCP的首部长度最大为15*4 = 60字节。
- 六位标志位:
URG: 紧急指针是否有效
ACK: 确认号是否有效
PSH: 提示接收端应用程序立刻从TCP缓冲区把数据读走
RST: 对方要求重新建立连接; 我们把携带RST标识的称为复位报文段
SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段
FIN: 通知对方, 本端要关闭了, 我们称携带FIN标识的为结束报文段
- 16位窗口大小:后面介绍滑动窗口和流量控制时详细讲解。
- 16位校验和: 发送端填充, CRC校验. 接收端校验不通过, 则认为数据有问题. 此处的检验和不光包含TCP首部, 也包含TCP数据部分.
- 16位紧急指针: 标识哪部分数据是紧急数据;
🥝TCP协议的特点
下面我将基于TCP的这几个特点来一一介绍TCP的机制。
🥝面向连接特性
TCP协议是面向连接的,即双方要进行通信,就需要先构建连接,而建立了连接就可以保证通信过程的可靠性。TCP的以下几个机制就是面向连接的体现。
🍓确认应答(ACK)机制
对于TCP双方发送的每一个报文,作为接收的一方都需要发送确认应答信息(ACK)来表示自己已经接收到了对端发送的报文,这样的机制即为确认应答机制,而这种机制保证了对端接收到了信息,也即保证了发送数据的可靠性。 其中关于TCP确认应答机制的详细细节为:TCP会将报文的每一个字节进行编号,即为序列号: 而每一个ACK都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 从而让发送端下一次从相应位置开始发。
🍓超时重传机制
主机A将数据发送给主机B后,可能因为网络拥塞等原因发生了丢包情况,那么这个情况下主机A将会收不到ACK确认应答报文;而超过了一定的时间间隔后,主机A就会将数据重新发送给主机B。 同样的,主机A收不到主机B的ACK应答,也可能是主机B的ACK报文发生了丢包,这种情况下主机A依旧会进行报文重传: 那么对于这种情况,主机B就可能会收到重复的报文,而TCP协议需要解决这种情况,即判断重复报文并去重,那么这就需要利用到前面所提到的序列号,来舍弃重复的内容。 以上就是TCP的超时重传机制,其同样预防了可能出现的丢包问题,保证了通信的可靠性。 那么这个超时的时间应该怎么确定呢?
- 最理想的情况下, 找到一个最小的时间, 保证 “确认应答一定能在这个时间内返回”.
- 但是这个时间的长短, 随着网络环境的不同, 是有差异的.
- 如果超时时间设的太长, 会影响整体的重传效率;
- 如果超时时间设的太短, 有可能会频繁发送重复的包;
因此,TCP为了保证无论在任何环境下都能比较高性能的通信, 会动态计算这个最大超时时间. - Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时时间都是500ms的整数倍.
- 如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传.
- 如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增.
- 累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接。
🍓连接管理机制
前面我们所说的都是基于TCP已经建立起连接的情况,那么TCP是怎么构建连接的呢? 其实,在正常情况下,TCP构建连接需要经过三次握手建立连接和四次挥手断开连接。
🍄三次握手和四次挥手
- 三次握手
三次握手是由客户端发起的,客户端将含有SYN(建立连接的请求)信息的TCP报文发送给服务器端,服务器端接收到后发送带有SYN + ACK信息的TCP报文给客户端,客户端接收到后将确认应答报文ACK发送给对端,服务器收到后三次握手完成,双方建立起连接。 联系我们之前所写的socket编程,其实三次握手是由客户端通过connect发起的,三次握手的过程发生在传输层,而应用层并不会干涉也不会关心三次握手的具体情况,应用层只在乎connect的返回值,若connect的返回值为0,则表示三次握手完成,连接成功。相对应的,accept的返回值的本质,是底层建立连接成功后,返回对应的链接。 - 四次挥手
以图中为例,四次挥手由客户端调用close发起FIN(结束报文段)请求,此时服务器收到结束报文段后返回确认报文段(ACK),此时服务器准备关闭连接(需要处理完之前的数据),而当服务器真正调用close关闭连接时,会向客户端发送FIN报文,并等待着客户端的ACK报文;同时客户端收到服务器发来的FIN报文时,发出ACK,服务器收到最后一个ACK后,彻底关闭连接。这就是四次挥手的过程。
🍄相关状态介绍
- 连接管理过程中双方的状态变化
服务器端: 1)三次握手阶段: [CLOSED -> LISTEN] 服务器端调用listen后进入LISTEN状态, 等待客户端连接; [LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段), 就将该连接放入内核等待队列中, 并向客户端发送SYN确认报文. [SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文, 就进入ESTABLISHED状态, 可以进行读写数据了. 2)四次挥手阶段 [ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调用close), 服务器会收到结束报文段, 服务器返回确认报文段并进入CLOSE_WAIT; [CLOSE_WAIT -> LAST_ACK] 进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据); 当服务器真正调用close关闭连接时, 会向客户端发送FIN, 此时服务器进入LAST_ACK状态, 等待最后一个ACK到来(这个ACK是客户端确认收到了FIN) [LAST_ACK -> CLOSED] 服务器收到了对FIN的ACK, 彻底关闭连接. 客户端 1)三次握手阶段 [CLOSED -> SYN_SENT] 客户端调用connect, 发送同步报文段; [SYN_SENT -> ESTABLISHED] connect调用成功, 则进入ESTABLISHED状态, 开始读写数据; 2)四次挥手阶段 [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状态. 上图中较粗的虚线表示服务端的状态变化情况;较粗的实线表示客户端的状态变化情况;CLOSED是一个假想的起始点,不是真实状态。
🍄TCP协议中TIME_WAIT状态导致bind error的原因
- 理解TIME_WAIT状态
在TCP服务器中,我们先启动server,然后再启动client,这时双方建立了连接,之后我们通过Ctrl + c终止到server进程,并立刻重启server,会发现bind失败: 此时我们调用netstat命令去查看相应的服务: 会发现虽然server应用程序已经终止了,但是其TCP协议层的连接并没有完全断开,因此此时8080号端口被占用,立即启动服务后就出现bind失败的情况。 其实TCP协议规定,主动断开连接的一方要处于TIME_WAIT状态,等待2个MSL(Maximum Segment Lifetime)的时间才能回到CLOSED状态。此处我们使用ctrl + c终止了server,因此服务器端是主动断开连接的一方,于是再TIME_WAIT状态期间不能再次监听相同的端口。 而MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同, 在Centos7上默认配置的值是60s; 可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看MSL的值: 至于为什么TIME_WAIT的时间是2MSL: MSL是TCP报文的最大生存时间, 因此TIME_WAIT持续存在2MSL的话; 就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启, 可能会收到; 来自上一个进程的迟到的数据, 但是这种数据很可能是错误的); 同时也是在理论上保证最后一个报文可靠到达(客户端主动断开连接,假设最后一个ACK丢失, 那么服务器会再重发一个FIN. 这时虽然客户端的进程不在了, 但是客户端的TCP连接还在, 仍然可以重发LAST_ACK )。 - 解决TIME_WAIT导致bind error的问题
在server的TCP连接没有完全断开之前不允许重新监听,这在某些情况下是不合理的: 服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短, 但是每秒都有很大数量的客户端来请求). 这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃, 就需要被服务器端主动清理掉), 就会产生大量TIME_WAIT连接. 由于我们的请求量很大, 就可能导致TIME_WAIT的连接数很多, 每个连接都会占用一个通信五元组(源ip, 源端口, 目的ip, 目的端口, 协议). 其中服务器的ip和端口和协议是固定的. 如果新来的客户端连接的ip和端口号和TIME_WAIT占用的链接重复了,就会出现问题。 因此,使用setsockopt() 设置socket描述符的 选项SO_REUSEADDR 为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符。
int opt = 1;
setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
🍄理解CLOSE_WAIT状态
在我们之前所实现的TCP服务器中,将server服务器中close(sock); 给删去: 此时我们编译运行服务器,启动客户端链接,使用netstat观察双方状态,均为ESTABLISHED ,没有问题。 接下来我们关闭客户端程序,再观察TCP状态: 发现此时server服务器处于CLOSE_WAIT状态,但结合我们四次挥手的流程图,可以认为四次挥手没有正确完成,一段时间后,当client的TCP连接已经完全断开后,server的连接状态仍为CLOSE_WAIT: 因此,在服务器不终止的情况下,一旦出现CLOSE_WAIT状态,那么其可能会持续很长一段时间;而如果我们发现服务器上由大量连接处于CLOSE_WAIT状态,就需要注意服务器是否出现了bug,导致没有正确的关闭sock文件描述符,致使四次挥手没法完成。
🥝TCP协议的效率保证机制
TCP协议通过其面向连接的特性确保了可靠性,这些机制原本可能会使TCP协议的网络传输效率远低于UDP,但是TCP还有一系列优秀的机制可以保障其效率不至于太低。
🍓滑动窗口
之前我们提到过TCP的确认应答策略,即对于连接双方发送的每一个数据端,都需要进行ACK确认应答,收到ACK后再发送下一个数据段,但这样做有一个明显的缺陷,就是性能较差,尤其是数据往返时间较长的时候: 既然这样一发一收的方式性能较低, 那么我们一次发送多条数据, 就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了). 而这种依次发送多条数据的方式就和滑动窗口有关了,其实我们可以将发送缓冲区划分为3个部分:上层数据已经发送且接收到ACK、上层数据已经或可以发送但未接收到ACK、上层数据(若还有的话)尚未发送的部分。 而上图中第二部分即为滑动窗口,也就是说滑动窗口本质是发送缓冲区的一部分,其因为发送的数据不断变化而区域也不断变化,就像在滑动一样,故称为滑动窗口。
- 滑动窗口的窗口大小指的是无需等待确认应答而可以继续发送数据的最大值. 上图的窗口大小就是4000个字节(四个段).
- 发送前四个段的时候, 不需要等待任何ACK,直接发送。
- 收到第一个ACK后, 滑动窗口向后移动, 继续发送第五个段的数据; 依次类推;
- 操作系统内核为了维护这个滑动窗口, 需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答; 只有确认应答过的数据, 才能从缓冲区删掉;
- 窗口越大, 则网络的吞吐率就越高;
[深入理解滑动窗口] 由于滑动窗口发送数据的特性,其只能整体不断的右移,并且滑动窗口可以缩小,也可以扩大。滑动窗口大小的变化是为了适应接收端的接收能力,在接收端也有一个接收缓冲区,如果接收端的用户层未能够即使处理接收缓冲区的数据,那么此时即使再发送数据过去也无济于事,因此,接收端的ACK报文中会保护对端的接受能力(这个接收能力其实就是我们之前TCP协议首部中没有介绍的16位窗口大小),而滑动窗口可以根据这个来调整窗口大小。 其次,发送缓冲区大小是有限的,那么滑动窗口一直向右移动,是否会移出滑动缓冲区呢? TCP的发送缓冲区可以看作一个数组char send_buffer[SIZE]; 而滑动窗口本质上就是数组的一部分,我们可以将这个数组看成一个环形的结构,那么滑动窗口一直在数组中移动,其实并不会移出发送缓冲区。 小结 1.滑动窗口可以让发送方一直发送大量数据,从而保证效率。 2.其次滑动窗口可以配合对端的接收能力,来实现流量控制。
🍓快重传
那么问题来了,如果在传输过程中发生了丢包,应该怎么解决,这里分两种情况: 情况一 数据包已经收到,但ACK丢了。 这种情况很好解决,只要后续的ACK报文被对端收到即可,那么对端就会从收到的ACK中下一个段的数据开始发送,比如关于1~1000字节的ACK报文丢包了,那么只要发送端接收到1001 ~ 2000字节的ACK,就说明1 ~ 2000字节的数据已经被收到了,那么1 ~ 1000字节的ACK丢包也不要紧。 情况二 发送的数据发生了丢包。 这个时候接收端会一直发送缺失报文的上一个报文的ACK,比如当某一段报文段丢失之后, 发送端会一直收到 1001 这样的ACK, 就像是在提醒发送端 “我想要的是 1001” 一样; 如果发送端主机连续三次收到了同样一个 “1001” 这样的应答, 就会将对应的数据 1001 - 2000 重新发送; 这个时候接收端收到了 1001 之后, 再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已 经收到了, 被放到了接收端操作系统内核的接收缓冲区中。 这种对付丢包问题的机制就被称为“高速重发机制”(也被叫做“快重传”)。
🍓流量控制
流量这个概念我们都不陌生,我们使用的移动数据就叫做流量,而流量控制则是TCP协议中保证效率的一个重要措施。 接收端处理数据的速度是有限的,如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送,就会造成丢包, 继而引起丢包重传等等一系列连锁反应。 因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control);
- 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段, 通过ACK端通知发送端;
窗口大小字段越大, 说明网络的吞吐量越高; - 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;
- 发送端接受到这个窗口之后, 就会减慢自己的发送速度;
- 如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端。
至于接收端如何把窗口大小告诉发送端呢? 之前我们介绍的TCP首部中, 有一个16位窗口字段, 就是存放了窗口大小信息; 那么问题来了, 16位数字最大表示65535, 那么TCP窗口最大就是65535字节么? 实际上, TCP首部40字节选项中还包含了一个窗口扩大因子M, 实际窗口大小是 窗口字段的值左移 M 位。
🍓拥塞控制
随着计算机的不断发展,现在网络上的计算机数量越来越多,而虽然TCP协议有滑动窗口这个可以保证传输效率的机制,但是如果在传输过程的一开始就向网络中发送大量的数据,那么一旦此时网络状态已经比较拥堵,在不清楚当前网络状态时,贸然向网络中发送大量数据,可能会导致更严重的拥塞。 因此,TCP引入慢启动机制,即先发送少量的数据来检测此时的网络拥堵状态,进而决定用何种速度来发送数据。 此处引入一个拥塞窗口的概念,即发送开始时定义拥塞窗口的大小为1,而每收到一个ACK,拥塞窗口的大小就加1,其次,每次发送数据包时,将拥塞窗口的大小与接收端主机反馈的窗口大小作比较,选择其中较小者作为实际发送的窗口,即发送窗口大小 = min(拥塞窗口, 接收端反馈窗口); 其实,像上面这样的拥塞窗口大小的增长速度是指数级别的。而所谓的“慢启动”,只不过是最开始的窗口大小非常小,但是增长的速度相当快,即“慢启动,快增长”。 而为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍;此处引入一个叫做慢启动的阈值;当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长. 少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞; 当TCP通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降; 由于网络是由所有人共享的,因此当每台主机都进行慢启动,那么就可以大大的缓解网络拥塞问题。 但是拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案。
🍓延迟应答
如果接收端的主机在接收到报文后立即返回ACK应答,那么此时返回的窗口大小可能很小。
假设接收端缓冲区为1M. 一次收到了800K的数据; 如果立刻应答, 返回的窗口就是200K; 但实际上可能处理端处理的速度很快, 10ms之内就把800K数据从缓冲区消费掉了; 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来; 如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M;
而接收端等待一段时间再应答的机制就被称为延迟应答。 总之,窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率; 延迟应答的方式有:数量限制(每隔N个包就应答一次)和时间限制(超过最大延迟时间就应答一次)。至于具体的数量和超时时间, 依操作系统不同也有差异。一般N取2, 超时时间取200ms;。 当然并非所有的包都可以延迟应答,这要依情况而定。
🍓捎带应答
在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是 “一发一收” 的. 这意味着客户端给服务器说“Have Lunch?” 服务器也会给客户端回一个"Yes, and you?" 那么这时的ACK就可以搭一个顺风车,与服务器会的“Yes, and you?”一起发送给客户端。
🥝面向字节流特性
我们介绍UDP是面向数据报的,即不能灵活的控制读写数据的次数和数量;而TCP协议则是面向字节流的,可以灵活的控制读写数据的次数和数量。
🍓TCP缓冲区
在我们常见一个TCP的socket时,会同时在内核中创建一个发送缓冲区和一个接收缓冲区。
- 调用write或者send时,数据会先拷贝到发送缓冲区中
- 如果发送的字节数太长,则会被拆分成多个TCP数据包发送;
- 相反,若发送字节数太短,则会先在缓冲区中等待,到缓冲区长度差不多或者其他合适时机再发送出去。
- 而接收数据的时候,数据会从网卡驱动持续到达内核的接收缓冲区;
- 然后应用持续调用read或者recv从接收缓冲区拿数据。
- TCP的一个连接,既有发送缓冲区,又有接收缓冲区, 即对于这一个连接,即可以读取数据,也可以写入数据,这个概念就叫做全双工。
正是由于缓冲区的存在,TCP程序的读和写操作才不需要一一匹配,举个例子: 写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节; 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次 read一个字节, 重复100次。
🥝粘包问题及异常情况
- 粘包问题
我们蒸包子的时候,通常为了防止包子粘在一起,会让包子和包子之间有些距离,而TCP中也会有像这样的粘包问题。
- 这里要明确一点,TCP中的包指的是应用层的数据包。
- 在TCP的协议头中,并没有和UDP一样的“报文长度”字段,但是TCP中有一个序号字段。
- 从传输层的角度看,TCP是一个一个报文发送过来的,按照序号排好放在缓冲区中。
- 从应用层角度看,则是看到一串连续的字节数据。
- 那么应用程序看到这一串字节数据,并不知道一个完整的应用层数据包是从哪个部分到哪个部分。
那么如何避免粘包问题呢?总之就是明确两个包之间的边界。
- 对于定长的包, 保证每次都按固定大小读取即可; 例如我们应用层介绍的Request结构, 是固定大小的,
- 那么就从缓冲区从头开始按sizeof(Request)依次读取即可;
- 对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包的结束位置;
- 对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议, 是程序猿自己来定的, 只要保证分隔符不和正文冲突即可);
至于UDP协议,由于其没有向上层交付数据,UDP报文长度依旧存在;并且UDP是将数据一个一个的交付给应用层,因此有很明确的数据边界。从而站在应用层的角度看,使用UDP时,要么收到完整的UDP报文,要么就不收,并不会出现只收到“半个”这样的情况。故对于UDP协议,是不存在“粘包问题” 的。
- TCP异常情况
1.进程终止: 进程终止会释放文件描述符,此时TCP连接还在,仍然可以发送FIN. 和正常关闭没有什么区别. 2.机器重启: 和进程终止的情况相同. 3.机器掉电/网线断开: 接收端认为连接还在, 一旦接收端有写入操作, 接收端发现连接已经不在了, 就会进行reset. 即使没有写入操作, TCP自己也内置了一个保活定时器, 会定期询问对方是否还在. 如果对方不在, 也会把连接释放.另外, 应用层的某些协议, 也有一些这样的检测机制. 例如HTTP长连接中, 也会定期检测对方的状态. 例如QQ, 在QQ断线之后, 也会定期尝试重新连接. TCP在面对异常情况可能会发送RST包(重新建立连接请求)。发送RST包的情况通常有:1、端口未打开;2、请求超时;3、提前关闭;4、在一个已关闭的socket上收到数据
🥝基于TCP应用层协议
- HTTP
- HTTPS
- SSH
- Telnet
- FTP
- SMTP
当然, 也包括你自己写TCP程序时自定义的应用层协议;
🥝TCP小结
TCP协议非常复杂,这是因为其既要保证可靠性,又要尽可能的保证效率。 TCP可靠性保证:
- 校验和
- 序列号(保证按序到达)
- 确认应答
- 超时重传
- 连接管理
- 流量控制
- 拥塞控制
TCP提高效率的措施:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
另外,TCP还有定时器(超时重传定时器、保活定时器、TIME_WAIT定时器等等)来保证可靠性和提高效率。
🥝TCP相关实验
🍓理解listen中的第二个参数
我们将server服务器中listen的第二个参数修改为2,并且不调用accept重新编译,并运行。 此时我们启动服务器并同时启动3个客户端来连接服务器;此时我们用netstat命令来查看连接状态,发现连接都正常: 但当我们启动第四个客户端时,再用netstat命令查看: 我们发现客户端状态是正常的,但是服务器端出现了SYN_RECV状态,而并非ESTABLISHED状态。 这是因为,Linux内核协议栈为一个TCP连接管理使用2个队列: 1.半连接队列(用来保存处于SYN_SENT和SYN_RECV状态的请求) 2.全连接队列(accepted队列)(用来保存处于ESTABLISHED状态,但应用层没有调用accept取走的请求) 而全连接队列的长度会受到listen第二个参数的影响。当全连接队列满了之后,就无法继续让当前连接的状态进入进入ESTABLISHED状态了。 而通过上述实验可知,全连接队列的长度就是listen的第二个参数 + 1,即backlog为后台等待连接请求(已建立连接,但未被应用层调用accept取走)队列的最大限制值。 另外,backlog的值不能太大,也不能没有。 首先,backlog这个数必须有的原因是底层通过全连接队列维护是提高效率的一种机制,因此必须有backlog来指明全连接队列的大小;其次,backlog的值不能太大,越靠后的连接等待的时间越长,那么就会失去被服务的耐性,并且,长的服务队列也需要消耗资源,而与其把资源消耗在长的服务队列中,不如利用到提高服务器的吞吐量上。
🍋总结
🥝TCP vs UDP
我们既然说了TCP是可靠连接,那么是不是TCP就一定优于UDP呢?这个问题已经老生常谈了。 TCP和UDP的优缺点不能简单、绝对的进行比较。
- TCP用于可靠传输的情况,应用于文件传输,重要状态更新等场景。
- UDP用于对高速传输和实时性要求较高的通信领域,比如早期的qq,视频传输等。另外UDP可以用于广播。
总之,TCP和UDP都是网络传输的工具,什么时机用,具体怎么用,需要根据具体的需求场景来判断。
🥝用UDP实现可靠传输
这个参考TCP的可靠性机制即可,也就是在应用层实现类似的逻辑。 比如:
- 引入序列号,保障数据顺序;
- 引入确认应答,确保接收端收到数据;
- 引入超时重传,若隔一段时间没有应答,就重发数据。
- …
|