
传输层协议深度拆解端口寻址、UDP 报文与 TCP 可靠性机制全解析一、端口号端口号范围划分认识知名端口号Well-Known Port Numbernetstatpidof二、UDP协议1. UDP协议端格式2. UDP的特点3. 面向数据报4. UDP的缓冲区5. UDP使用注意事项6. 基于UDP的应用层协议三、TCP协议1. TCP协议段格式2. TCP的可靠性保障2.1 确认应答机制ACK2.2 工作模式理解2.3 序号和确认序号2.4 16位窗口大小2.5 6位标记位2.6 超时重传机制2.7 连接管理机制2.8 滑动窗口2.9 流量控制2.10 拥塞控制2.11 延迟应答2.12 捎带应答2.13 面向字节流2.14 粘包问题2.15 TCP异常情况2.16 TCP小结2.17 基于TCP应用层协议2.18 TCP/UDP对比2.19 用UDP实现可靠传输经典面试题2.20 listen的第二个参数一、端口号端口号Port标识了一个主机上进行通信的不同的应用程序。在TCP/IP协议中用“源IP”、“源端口号”、“目的IP”、“目的端口号”、“协议号”这样一个五元组来标识一个通信可以通过netstat -n查看。通过源IP地址、目标IP地址、协议号、源端口号和目标端口号这5个数字识别一个通信。端口号范围划分0-1023知名端口号HTTP、FTP、SSH等这些广为使用的应用层协议它们的端口号都是固定的。1024-65535操作系统动态分配的端口号。客户端程序的端口号就是由操作系统从这个范围分配的。认识知名端口号Well-Known Port Number有些服务器是非常常用的为了使用方便人们约定一些常用的服务器都是用以下这些固定的端口号ssh服务器使用22端口ftp服务器使用21端口telnet服务器使用23端口http服务器使用80端口https服务器使用443执行下面的命令可以看到知名端口号cat /etc/services我们自己写一个程序使用端口号时要避开这些知名端口号。一个进程可以绑定多个端口号但是每个端口号只能被一个进程绑定。如果一个进程绑定的端口号被占用则会报错。netstatnetstat是一个用来查看网络状态的重要工具。语法netstat [选项]功能查看网络状态常用选项n拒绝显示别名能显示数字的全部转化成数字显示不了的就显示正常字符串l仅列出有在Listen监听的服务状态就是处于监听状态的进程p显示建立相关链接的程序名会显示进程名t(tcp)仅显示tcp相关选项u(udp)仅显示udp相关选项a(all)显示所有选项默认不显示LISTEN相关的选项pidof在查看服务器的进程id时非常方便。语法pidof [进程名]功能通过进程名查看进程id。拿到进程id后可以通过kill命令来终止进程pidof [进程名] | xargs kill -9二、UDP协议1. UDP协议端格式UDP协议端格式如下16位源端口号16位目的端口号16位UDP长度16位校验和数据可选我们之前写代码时应用层端口号是uint16_t类型是因为UDP协议端口号是16位的。校验和是16位的用于检测数据是否在传输过程中被修改。校验和的计算方法是将源端口号、目的端口号、长度、校验和、数据可选这些字段全部相加。将相加的结果取反就是校验和。16位UDP长度表示整个数据报UDP首部UDP数据的最大长度。如果校验和出错就会直接丢弃。那么怎么实现报头和有效载荷的分离呢UDP采用定长报头的方式。因为报头的两行是8字节所以分离时固定取前8字节作为报头剩下的就是有效载荷。然后通过目的端口号来判断数据是属于哪个进程的。数据就是上层拷贝下来的有效字段。协议的结构化理解因为我们知道传输层是属于linux内核的所以本质上协议就是一种结构化的数据。因为linux是c语言实现的我们可以想象成一个结构体结构体中包含了报头和有效载荷。structudp_hdr{uint16_tsource_port;uint16_tdest_port;uint16_tlen;uint16_tchecksum;};或者位段实现structudp_hdr{uint16_tsource_port:16;uint16_tdest_port:16;uint16_tlen:16;uint16_tchecksum:16;};所以未来就可以使用两个指针指向报头和有效载荷char*udp_hdrmalloc(XXX);// XXXX是数据报的长度char*dataudp_hdrsizeof(structudp_hdr);后续直接使用udp_hdr强制类型转换为struct udp_hdr类型就可以访问报头中的字段了。2. UDP的特点UDP传输的过程类似于寄信。无连接知道对端的IP和端口号就直接进行传输不需要建立连接。不可靠没有确认机制没有重传机制如果因为网络故障该段无法发到对方UDP协议层也不会给应用层返回任何错误信息。面向数据报不能够灵活的控制读写数据的次数和数量。3. 面向数据报应用层交给UDP多长的报文UDP原样发送既不会拆分也不会合并。类似于发送邮件一次发送一个邮件一次接收一个邮件。用UDP传输100个字节的数据如果发送端调用一次sendto发送100个字节那么接收端也必须调用对应的一次recvfrom接收100个字节而不能循环调用10次recvfrom每次接收10个字节。4. UDP的缓冲区UDP没有真正意义上的发送缓冲区。调用sendto会直接交给内核由内核将数据传给网络层协议进行后续的传输动作。UDP具有接收缓冲区。但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致如果缓冲区满了再到达的UDP数据就会被丢弃。UDP的socket既能读也能写这个概念叫做全双工。5. UDP使用注意事项我们注意到UDP协议首部中有一个16位的最大长度也就是 2^16。也就是说一个UDP能传输的数据最大长度是64K包含UDP首部。然而64K在当今的互联网环境下是一个非常小的数字。如果我们需要传输的数据超过64K就需要在应用层手动的分包多次发送并在接收端手动拼装。6. 基于UDP的应用层协议NFS网络文件系统TFTP简单文件传输协议DHCP动态主机配置协议BOOTP启动协议用于无盘设备启动DNS域名解析协议DNS域名解析协议实际上就是你输入域名服务器会返回对应的IP地址。浏览器有内置的DNS解析服务器的地址当我们输入域名时浏览器会先从内置的DNS解析服务器中获取IP地址如果获取到就直接使用IP地址然后发送请求。当然也包括你自己写UDP程序时自定义的应用层协议。三、TCP协议TCP全称为“传输控制协议Transmission Control Protocol”。人如其名要对数据的传输进行一个详细的控制。所谓的传输控制就是因为所有的应用层调用的发送实际上都是拷贝数据不是直接发送而是拷贝到传输层的缓冲区中然后数据什么时候发、发送多少、出错时的处理等都是传输层TCP要控制的。1. TCP协议段格式16位源端口号16位目的端口号32位序号32位确认序号4位首部长度6位保留位16位校验和16位紧急指针选项数据源/目的端口号表示数据是从哪个进程来到哪个进程去。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字节头部选项暂时忽略。tcp协议是有标准长度的标准长度是20字节。但是这个长度可以被应用层协议修改。所以我们先读取20字节然后转化成一个struct体。然后根据struct体中的4位首部长度来计算。首部长度就是TCP协议段的总长度因为包含选项的话就会超过20字节。4位bit位就是0000-1111在计算时要乘以4字节所以实际长度的取值范围是20字节到60字节。所以实际上就算什么选项也不写首部长度也不会是0000而是0101也就是50101*4字节20字节。如果首部长度不是0101就表示有选项所以就可以使用首部长度*4字节-20字节来计算数据的长度。那么为什么TCP报头中没有有效载荷长度字段呢因为TCP是基于字节流的协议我们只需要保证把数据拷贝到缓冲区里面。TCP不需要对有效载荷做任何解释数据的可靠性已经保证了按需到达了并且顺序正确然后放到你的缓冲区里面然后应用层再根据需要进行处理。收到一个网络报文后内核是如何找到对应进程的收到一个网络报文后内核通过解析传输层头部中的目的端口号利用操作系统维护的端口与套接字的映射关系通常采用哈希表快速找到对应的套接字对象。由于Linux采用“一切皆文件”的设计哲学套接字本质上也是一个文件。进程在创建套接字时系统会返回一个文件描述符fd该fd实际上是进程的files_struct文件描述符表中的一个下标。通过这个下标可以找到对应的struct file结构体其中不仅包含文件的各种操作函数指针如读写方法还关联着该套接字在内核中的接收缓冲区和发送缓冲区。网络数据从网卡经过协议栈解析后实际上是被放入该套接字关联的内核接收缓冲区中的。因此进程可以通过read(fd)或write(fd)等标准的文件系统调用像操作普通文件一样对TCP套接字进行数据读写。整个流程可以概括为网络报文 → 端口号 → 传输层套接字 → struct file → 进程文件描述符表 → 进程通过fd操作套接字缓冲区。和UDP一样TCP报头实际上是一个结构体只是这个结构体的字段更多。和UDP一样添加报头就是在数据前面添加这个结构体的字段然后再发送。2. TCP的可靠性保障2.1 确认应答机制ACK因为网络通信往往距离很远所以需要确认应答机制来保证数据的可靠性。确认应答机制就是发送端发送数据后等待接收端返回确认确认后发送端才会发送下一个数据。但是虽然有确认机制但是仍然没有办法保证绝对的可靠性。因为最新发送的消息是没法确认的。但是存在相对的可靠性只要收到了确认就表示数据到达了。TCP将每个字节的数据都进行了编号即为序列号。每一个ACK都带有对应的确认序列号意思是告诉发送者我已经收到了哪些数据下一次你从哪里开始发。2.2 工作模式理解我们之前的了解是在正常通信的时候除了正常的数据段还有其他一些特殊的数据段。比如连接请求段、连接确认段、断开连接段等。即使是特殊的数据段也需要被确认也是一个完整的tcp数据段。在之前的理解中客户端在发起连接请求后服务器端会返回一个连接确认段客户端收到确认段后才会发送正常的数据段。但是在实际通信中在发起连接请求后服务器端会返回数据这个数据就既充当了确认段也充当了正常的数据段。我想说的是在实际通信中不是一次确认一次应答有可能会一次发送多个数据段然后返回时返回多个确认。2.3 序号和确认序号因为在TCP中通信的双方是平等的只有数据段和确认数据段所以不管谁是客户端还是服务端不存在发送端和接收端的概念。所以我们学习TCP只用关注一个方向就可以了。我们之前说过在实际通信中不是一次确认一次应答有可能会一次发送多个数据段然后返回时返回多个确认应答。那么数据在到达接收端后到达顺序和发送顺序是相同的吗答案是不一定。未来发送的多个数据段在得到确认时发送方是怎么知道那个确认是对应哪个数据段的所以TCP数据段中需要包含字段来标识每个数据段本身所以就有了序号和确认序号。所以在应答数据段里面一定会包含确认序号。就是一个数据段里面的序号是10那么在确认数据段里面确认序号就是ack:11。确认序号是数据段的序号1。因为数据段的序号是从0开始的所以确认序号是从1开始的。其实这个确认序号既表示了确认收到了当前数据段也表示了收到了在这个序号之前的所有数据段。也就是说确认序号是下一个数据段的序号。所以假设一共发送了四个数据段10111213那么确认序号就是11121314。但是如果发送的数据段12丢失了那么确认序号就是12。因为目前只能确认收到了1011即使13收到了也不能确认收到了12。所以确认序号就是12。为什么要有两组序号因为TCP是全双工协议所以不仅是一方给对方发送数据对方也会给我发送数据。接收缓冲区和发送缓冲区都可以简单理解为一个数组那么是数组就会有下标。TCP将每个字节的数据都进行了编号即为序列号。每一个ACK都带有对应的确认序列号意思是告诉发送者我已经收到了哪些数据下一次你从哪里开始发。2.4 16位窗口大小因为在网络通信的时候有可能发送方发送数据非常快而接收方接收处理数据非常慢。一直发送到接收方处理不了就会丢失数据接收缓冲区满了。也有可能发送方发送的太慢了导致接收方即使接收到数据也没法处理。所以TCP发送数据时快了不行慢了也不行。所以我作为发送方我怎么知道接收方处理能力是多少所以我们需要知道对方的接收缓冲区的剩余空间的大小。因为TCP是全双工的所以对方也要知道我的接收缓冲区大小。所以16位窗口大小会填入自己的接收缓冲区大小。这个也叫做流量控制。2.5 6位标记位TCP报文也是有类型的。作为服务器是会接收到各种客户端发来的各种报文的所以客户端要根据不同的报文提供不同的方法。如果是一个连接请求的报文那么我服务端就要和对方做好三次握手如果是常规的报文那么我就要有对应的处理方法。所以有了6位标记位来区分不同的报文。假设是连接请求报文那么会把SYN标记位设置为1。如果是连接确认报文不管这个报文是否包含数据段那么会把ACK标记位设置为1。FIN标记位为1表示发送方想关闭连接。PSH标记位为1表示发送方想立即发送数据。URG标记位为1表示发送方有紧急数据需要立即处理。因为数据段是有序号的所以正常情况下是按照序号处理的。但是如果有紧急数据那么就会把紧急数据的序号放到紧急指针字段中让接收方立即处理紧急数据。这个紧急数据不再存放到接收缓冲区中而是通过报文中的紧急指针字段来标识。16位紧急指针字段是紧急数据在数据段中的偏移量。注意这个紧急数据只有一个字节大小所以在找到紧急数据的位置后读取一个字节就是紧急数据。RST标记位为1表示发送方想重置连接。我们知道TCP建立连接时需要三次握手那么你能保证一定能成功建立连接吗即使我们建立连接成功也不能保证在通信过程中不会因为网络问题导致单方面关闭连接。就会导致一方觉得连接被关闭了一边觉得连接还存在。那么存在的那边就会直接发送报文但是关闭方会觉得连接没有建立你不应该发送报文这个时候关闭方会给对方发送一个报文并且携带RST标志位让对方和自己重新建立连接。答案是不一定。因为在建立连接时有可能会因为网络问题导致建立连接失败。所以重置连接标记位为1表示发送方想重置连接。挥手也是同理。2.6 超时重传机制主机A发送数据给B之后可能因为网络拥堵等原因数据无法到达主机B如果主机A在一个特定时间间隔内没有收到B发来的确认应答就会进行重发但是主机A未收到B发来的确认应答也可能是因为ACK丢失了。那么这个时候主机A就和上面一样会进行超时重传。因此主机B会收到很多重复数据。那么TCP协议需要能够识别出那些包是重复的包并且把重复的丢弃掉。这时候我们可以利用前面提到的序列号就可以很容易做到去重的效果。因为可能会有发送失败的情况所以发送方在发送完数据后会等待一段时间不会立刻把数据删除。那么这个数据会存放到哪里缓冲区呢答案是发送缓冲区。注意计算机中的删除都不是真的删除删除是覆盖数据。超时时间怎么定首先一定不是固定的因为TCP要保证效率所以超时时间不能太短。发送到达时间是由网络决定的但是网络是变化的所以超时时间不是固定的。最理想的情况下找到一个最小的时间保证“确认应答一定能在这个时间内返回”。但是这个时间的长短随着网络环境的不同是有差异的。如果超时时间设的太长会影响整体的重传效率如果超时时间设的太短有可能会频繁发送重复的包。TCP为了保证无论在任何环境下都能比较高性能的通信因此会动态计算这个最大超时时间。Linux中BSD Unix和Windows也是如此超时以500ms为一个单位进行控制每次判定超时重发的超时时间都是500ms的整数倍。如果重发一次之后仍然得不到应答等待 2*500ms 后再进行重传。如果仍然得不到应答等待 4*500ms 进行重传。依次类推以指数形式递增。累计到一定的重传次数TCP认为网络或者对端主机出现异常强制关闭连接。2.7 连接管理机制在正常情况下TCP要经过三次握手建立连接四次挥手断开连接。注意三次握手是机制不代表一定会成功。三次握手首先客户端会向服务端发送一个tcp报文报文的SYN标志位设置为1这个时候客户端会有自己的状态SYN-SENT。服务器收到客户端报文后会发送一个tcp报文报文的SYN标志位设置为1ACK标志位设置为1这个时候服务器会有自己的状态SYN-RCVD。客户端收到服务器报文后会发送一个tcp报文报文的ACK标志位设置为1这个时候客户端会有自己的状态ESTABLISHED服务器会有自己的状态ESTABLISHED。注意三次握手是站在各自的视角来看待的客户端只要有两次发送一次接收就算建立连接即便最后一次ack丢失了也可以被视为建立连接成功。服务端只要有两次发送一次接收就可以被视为建立连接成功。只要不满足双方的条件就会认为连接建立失败这个时候就会有超时重传机制。为什么要三次握手前两次握手我们并不担心ack丢失因为前两次握手都会有ack确认。我们担心的是最后一次ack丢失。但是我们也不害怕最后一次ack丢失造成的后果因为如果最后一次ack丢失了客户端向服务端发送的报文服务端会认为没有建立连接然后会发送一个带有RST标志位的报文给客户端让客户端重新建立连接。或者客户端长时间没有收到服务端的ack也会认为没有建立连接然后超时重传。但是服务器往往会和多个客户端建立连接所以服务器需要管理多个连接。tcp是传输层属于os所以os会先描述再组织管理多个连接。但是管理是要成本的。三次握手是保证双方都能成功建立连接的最小次数一次或者两次握手是不能保证建立成功的四次是可以保证建立成功但是成本上去了。并且三次握手可以有效防止单机对服务器的攻击但是不能防止多台客户端对服务器的攻击SYN洪水攻击DDoS攻击。为什么要四次挥手TCP断开连接虽然涉及通信双方但并不需要双方“事先同意”任意一方都可以主动发起断开请求另一方则被动响应这一过程。首先主动发起关闭的一方会发送一个携带FIN标志位的报文并进入FIN-WAIT-1状态被动方收到该FIN请求后会立即回复一个携带ACK标志位的报文进行确认并进入CLOSE_WAIT状态此时主动方收到ACK后即转入FIN-WAIT-2状态等待被动方完成剩余数据的传输。如果被动方在close_wait状态下我们不主动关闭连接close(fd)被动方会一直处在close_wait状态下。随后被动方在准备好关闭时同样会主动相对于自己而言发送一个携带FIN标志位的报文并进入LAST-ACK状态主动方收到这个FIN报文后发送最后一个携带ACK标志位的报文进行确认并进入TIME-WAIT状态等待2MSL后自动转为CLOSED而被动方在收到这个最终ACK后则直接进入CLOSED状态至此连接彻底关闭。需要特别注意实际上ACK和FIN是由被动方分两步在不同时机发出的且最终主动方停留的状态是TIME-WAIT。主动方在TIME-WAIT状态下等待一段时间后自动进入CLOSED状态。为什么要等待一段时间为了确保所有数据都发送完毕且没有数据包丢失。因为我们担心的是最后一次ack丢失。如果最后一次ack丢失了主动方会认为没有等待就直接释放连接了但是被动方会认为没有收到ack会重新补发fin报文但是主动方已经释放了连接了会造成重复发送fin报文导致连接关闭失败。所以主动方需要等待一段时间等待一段时间后确保所有数据都发送完毕且没有数据包丢失被动方不再发送fin报文了才会释放连接。服务端状态转化[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]客户端要等待一个2MSLMax Segment Life报文最大生存时间的时间才会进入CLOSED状态。理解TIME_WAIT状态现在做一个测试首先启动server然后启动client然后用Ctrl-C使server终止这时马上再运行server结果是server bind error: Address already in use。这是因为虽然server的应用程序终止了但TCP协议层的连接并没有完全断开因此不能再次监听同样的server端口。TCP协议规定主动关闭连接的一方要处于TIME_WAIT状态等待两个MSLmaximum segment lifetime的时间后才能回到CLOSED状态。我们使用Ctrl-C终止了server所以server是主动关闭连接的一方在TIME_WAIT期间仍然不能再次监听同样的server端口。MSL在RFC1122中规定为两分钟但是各操作系统的实现不同在Centos7上默认配置的值是60s。可以通过cat /proc/sys/net/ipv4/tcp_fin_timeout查看msl的值。为什么是TIME_WAIT的时间是2MSLMSL是TCP报文的最大生存时间因此TIME_WAIT持续存在2MSL的话就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失否则服务器立刻重启可能会收到来自上一个进程的迟到的数据但是这种数据很可能是错误的同时也是在理论上保证最后一个报文可靠到达假设最后一个ACK丢失那么服务器会再重发一个FIN。这时虽然客户端的进程不在了但是TCP连接还在仍然可以重发LAST_ACK。解决TIME_WAIT状态引起的bind失败的方法在server的TCP连接没有完全断开之前不允许重新监听某些情况下可能是不合理的。服务器需要处理非常大量的客户端的连接每个连接的生存时间可能很短但是每秒都有很大数量的客户端来请求。这个时候如果由服务器端主动关闭连接比如某些客户端不活跃就需要被服务器端主动清理掉就会产生大量TIME_WAIT连接。由于我们的请求量很大就可能导致TIME_WAIT的连接数很多每个连接都会占用一个通信五元组源ip源端口目的ip目的端口协议。其中服务器的ip和端口和协议是固定的。如果新来的客户端连接的ip和端口号和TIME_WAIT占用的链接重复了就会出现问题。使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1表示允许创建端口号相同但IP地址不同的多个socket描述符intopt1;setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,opt,sizeof(opt));理解CLOSE_WAIT状态以之前写过的TCP服务器为例我们稍加修改将new_sock.Close();这个代码去掉。我们编译运行服务器。启动客户端链接查看TCP状态客户端服务器都为ESTABLISHED状态没有问题。然后我们关闭客户端程序观察TCP状态tcp 0 0 0.0.0.0:9090 0.0.0.0:* LISTEN 5038/./dict_server tcp 0 0 127.0.0.1:49958 127.0.0.1:9090 FIN_WAIT2 - tcp 0 0 127.0.0.1:9090 127.0.0.1:49958 CLOSE_WAIT 5038/./dict_server此时服务器进入了CLOSE_WAIT状态结合我们四次挥手的流程图可以认为四次挥手没有正确完成。小结对于服务器上出现大量的CLOSE_WAIT状态原因就是服务器没有正确的关闭socket导致四次挥手没有正确完成。这是一个BUG。只需要加上对应的close即可解决问题。2.8 滑动窗口刚才我们讨论了确认应答策略对每一个发送的数据段都要给一个ACK确认应答。收到ACK后再发送下一个数据段。这样做有一个比较大的缺点就是性能较差。尤其是数据往返的时间较长的时候。既然这样一发一收的方式性能较低那么我们一次发送多条数据就可以大大的提高性能其实是将多个段的等待时间重叠在一起了。窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。上图的窗口大小就是4000个字节四个段。发送前四个段的时候不需要等待任何ACK直接发送。收到第一个ACK后滑动窗口向后移动继续发送第五个段的数据依次类推。操作系统内核为了维护这个滑动窗口需要开辟发送缓冲区来记录当前还有哪些数据没有应答只有确认应答过的数据才能从缓冲区删掉。窗口越大则网络的吞吐率就越高。滑动窗口的理解我们可以把整个发送缓冲区看作是一个数组滑动窗口就是这个数组的一个子数组。我们把左端点叫做win_start下标右端点叫做win_end下标。所以所谓的滑动就是下标win_start和win_end的移动。1. 滑动窗口的大小是怎么设定的未来怎么变化目前我们认为滑动窗口的大小是和对方的接收能力有关所以win_start 0win_end win_start window_size16位窗口大小。所以目前我们认为滑动窗口的大小 对方通知我的接收窗口大小。2. 滑动窗口一定会向左或者向右滑动吗一定不会向左滑动因为左边是已经确认的段不能向左滑动。不一定向右滑动因为右边是未发送的段有可能对方一直通知你的窗口大小是没有变化的所以滑动窗口的大小也不会变化。所以是有可能滑动窗口向右滑动的有可能不会向右滑动。3. 滑动窗口是怎么移动的我们说过确认序号就是我们下一个要发送的数据段的序号。所以当一个ack到了win_start就可以直接等于ack的确认序号。win_end win_start window_size16位窗口大小。就会出现一种情况对方一直确认win_start变大但是窗口大小是一直减小的所以滑动窗口的大小会一直减小一直到win_start win_end。所以窗口是动态变化的会变大也会变小变化的依据是对方的接收能力。4. 如果我们收到应答的时候收到的应答不是滑动窗口最左端的ack是中间部分的ack那么我们怎么处理确认序号就是我们下一个要发送的数据段的序号。所以当一个ack到了win_start就可以直接等于ack的确认序号。这个时候要分两种情况讨论假设win_start 1000win_end 5000。1数据没丢只是ack丢失了。只是前面的数据段没有被确认ack丢了但是后面的数据段没有丢失ack是收到的。我们确认序号的定义是ack确认序号表示在确认序号之前的所有的数据段都被确认了。所以滑动窗口可以直接向右滑动。2数据真的丢失了。左端的数据丢失了但是中间部分的数据没有丢失假设中间部分的确认序号是3000是收到报文了的。这个时候其实我们收到的ack序号是1000不会是3000。5. 发送缓冲区是一个数组那么就会有最右端那么一直往右滑动会超过最右端吗其实在底层缓冲区被设计成了一个环形数组所以滑动窗口会循环移动。那么如果出现了丢包如何进行重传这里分两种情况讨论情况一数据包已经抵达ACK被丢了。这种情况下部分ACK丢了并不要紧因为可以通过后续的ACK进行确认。情况二数据包就直接丢了。当某一段报文段丢失之后发送端会一直收到1001这样的ACK就像是在提醒发送端“我想要的是1001”一样。如果发送端主机连续三次收到了同样一个“1001”这样的应答就会将对应的数据1001-2000重新发送。这个时候接收端收到了1001之后再次返回的ACK就是7001了因为2001-7000接收端其实之前就已经收到了被放到了接收端操作系统内核的接收缓冲区中。这种机制被称为“高速重发控制”也叫“快重传”。2.9 流量控制接收端处理数据的速度是有限的。如果发送端发的太快导致接收端的缓冲区被打满这个时候如果发送端继续发送就会造成丢包继而引起丢包重传等等一系列连锁反应。因此TCP支持根据接收端的处理能力来决定发送端的发送速度。这个机制就叫做流量控制Flow Control。接收端将自己可以接收的缓冲区大小放入TCP首部中的“窗口大小”字段通过ACK端通知发送端。窗口大小字段越大说明网络的吞吐量越高。接收端一旦发现自己的缓冲区快满了就会将窗口大小设置成一个更小的值通知给发送端。发送端接受到这个窗口之后就会减慢自己的发送速度。如果接收端缓冲区满了就会将窗口置为0这时发送方不再发送数据但是需要定期发送一个窗口探测数据段使接收端把窗口大小告诉发送端。接收端如何把窗口大小告诉发送端呢回忆我们的TCP首部中有一个16位窗口字段就是存放了窗口大小信息。那么问题来了16位数字最大表示65535那么TCP窗口最大就是65535字节么实际上TCP首部40字节选项中还包含了一个窗口扩大因子M实际窗口大小是窗口字段的值左移M位。2.10 拥塞控制我们之前考虑的一直都是端到端的问题没有思考过网络的问题。其实网络的问题也会出问题。在双方进行通信时丢弃小部分包我们是可以接受的我们有超时机制来处理。但是丢弃大部分包我们就不能接受了这个往往是网络的问题。因为我们有滑动窗口来进行流量控制所以不会出现传输过多导致的对方没法接受那么多包的情况所以只能是网络的问题了。TCP的可靠性不仅考虑了端到端的问题还考虑了网络的问题。那么出现这种情况我们应该立刻重传吗答案是不能。如果我们继续重传就会导致本来就出问题的网络问题更加严重因为网络里面不止两台机器有很多机器在通信所以如果立刻重传会导致网络问题雪上加霜。所以我们会等待网络恢复一段时间后再重传。TCP引入了慢启动机制先发送少量数据探探路摸清楚网络的恢复情况再决定按照什么速度来发送数据。这里引入一个概念叫做拥塞窗口就是一个数字在超过这个数字就有可能会出现拥塞的情况。拥塞窗口就是描述网络的拥塞程度的一个指标。所以我们在发送数据的时候往往要考虑对方的接受能力报文中的窗口大小也要考虑网络的拥塞程度拥塞窗口。所以实际滑动窗口的大小就是min(报文中的窗口大小, 拥塞窗口)。一般来说拥塞窗口会大于报文中的窗口大小。所以在刚开始发送的时候定义一个拥塞窗口为1然后每次收到一个ack就将拥塞窗口的大小乘以2然后发送下一个包。像这种刚开始的时候比较少然后后面成指数增长的情况就叫做慢启动指的是开始慢后面快。为了不增长的那么快我们需要一个机制来控制不能使拥塞窗口的大小单纯地加倍。所以引入了一个概念慢启动阈值。当拥塞窗口的大小超过慢启动阈值时就会将拥塞窗口的大小不再指数增长而是线性增长。开始时使用指数增长是为了快速恢复网络的正常运行而使用线性增长是为了避免网络的拥塞。当TCP开始启动的时候慢启动阈值等于窗口最大值。在每次超时重发的时候慢启动阈值会变成原来的一半同时拥塞窗口置回1。少量的丢包我们仅仅是触发超时重传大量的丢包我们就认为网络拥塞。当TCP通信开始后网络吞吐量会逐渐上升随着网络发生拥堵吞吐量会立刻下降。拥塞控制归根结底是TCP协议想尽可能快的把数据传输给对方但是又要避免给网络造成太大压力的折中方案。2.11 延迟应答如果接收数据的主机立刻返回ACK应答这时候返回的窗口可能比较小。假设接收端缓冲区为1M。一次收到了500K的数据如果立刻应答返回的窗口就是500K但实际上可能处理端处理的速度很快10ms之内就把500K数据从缓冲区消费掉了在这种情况下接收端处理还远没有达到自己的极限即使窗口再放大一些也能处理过来如果接收端稍微等一会再应答比如等待200ms再应答那么这个时候返回的窗口大小就是1M。一定要记得窗口越大网络吞吐量就越大传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。那么所有的包都可以延迟应答么肯定也不是。数量限制每隔N个包就应答一次。时间限制超过最大延迟时间就应答一次。具体的数量和超时时间依操作系统不同也有差异。一般N取2超时时间取200ms。TCP其实不用每一个报文都要返回ack有的情况会用一个ack来应答多个报文。因为我们知道ack的序列号就代表了在这个ack之前的所有报文都被接收了所以我们可以用一个ack来应答多个报文。2.12 捎带应答在延迟应答的基础上我们发现很多情况下客户端服务器在应用层也是“一发一收”的。意味着客户端给服务器说了“How are you”服务器也会给客户端回一个“Fine, thank you”。那么这个时候ACK就可以搭顺风车和服务器回应的“Fine, thank you”一起回给客户端。2.13 面向字节流创建一个TCP的socket同时在内核中创建一个发送缓冲区和一个接收缓冲区。调用write时数据会先写入发送缓冲区中。如果发送的字节数太长会被拆分成多个TCP的数据包发出。如果发送的字节数太短就会先在缓冲区里等待等到缓冲区长度差不多了或者其他合适的时机发送出去。接收数据的时候数据也是从网卡驱动程序到达内核的接收缓冲区。然后应用程序可以调用read从接收缓冲区拿数据。另一方面TCP的一个连接既有发送缓冲区也有接收缓冲区那么对于这一个连接既可以读数据也可以写数据。这个概念叫做全双工。由于缓冲区的存在TCP程序的读和写不需要一一匹配例如写100个字节数据时可以调用一次write写100个字节也可以调用100次write每次写一个字节。读100个字节数据时也完全不需要考虑写的时候是怎么写的既可以一次read 100个字节也可以一次read一个字节重复100次。像这种不受限制的读写方式就叫做面向字节流。而像UDP这种面向报文的协议则是一次发送一个报文一次接收一个报文就不会出现你发10个报文我一次就一起接收10个报文的情况必须是一对一的关系。面向字节流就是想怎么读怎么读只要你应用层做好读取上的处理就可以实现面向字节流的传输。2.14 粘包问题首先要明确粘包问题中的“包”是指的应用层的数据包。在TCP的协议头中没有如同UDP一样的“报文长度”这样的字段但是有一个序号这样的字段。站在传输层的角度TCP是一个一个报文过来的。按照序号排好序放在缓冲区中。站在应用层的角度看到的只是一串连续的字节数据。那么应用程序看到了这么一连串的字节数据就不知道从哪个部分开始到哪个部分是一个完整的应用层数据包。那么如何避免粘包问题呢归根结底就是一句话明确两个包之间的边界。对于定长的包保证每次都按固定大小读取即可。例如上面的Request结构是固定大小的那么就从缓冲区从头开始按sizeof(Request)依次读取即可。对于变长的包可以在包头的位置约定一个包总长度的字段从而就知道了包的结束位置。对于变长的包还可以在包和包之间使用明确的分隔符应用层协议是程序猿自己来定的只要保证分隔符不和正文冲突即可。思考对于UDP协议来说是否也存在“粘包问题”呢对于UDP如果还没有上层交付数据UDP的报文长度仍然在。同时UDP是一个一个把数据交付给应用层。就有很明确的数据边界。站在应用层的角度使用UDP的时候要么收到完整的UDP报文要么不收。不会出现“半个”的情况。2.15 TCP异常情况进程终止进程终止会释放文件描述符仍然可以发送FIN。和正常关闭没有什么区别。因为这些资源都是os在管理的进程终止后os会自动释放这些资源。和你自己调用close()关闭连接是一样的。机器重启和进程终止的情况相同。因为机器重启之前就会关闭进程。机器掉电/网线断开接收端认为连接还在一旦接收端有写入操作接收端发现连接已经不在了就会进行reset。即使没有写入操作TCP自己也内置了一个保活定时器会定期询问对方是否还在。如果对方不在也会把连接释放。另外应用层的某些协议也有一些这样的检测机制。例如HTTP长连接中也会定期检测对方的状态。例如QQ在QQ断线之后也会定期尝试重新连接。2.16 TCP小结为什么TCP这么复杂因为要保证可靠性同时又尽可能的提高性能。可靠性校验和序列号按序到达确认应答超时重发连接管理流量控制拥塞控制提高性能滑动窗口快速重传延迟应答捎带应答其他定时器超时重传定时器保活定时器TIME_WAIT定时器等2.17 基于TCP应用层协议HTTPHTTPSSSHTelnetFTPSMTP当然也包括你自己写TCP程序时自定义的应用层协议。2.18 TCP/UDP对比我们说了TCP是可靠连接那么是不是TCP一定就优于UDP呢TCP和UDP之间的优点和缺点不能简单绝对的进行比较。TCP用于可靠传输的情况应用于文件传输重要状态更新等场景。UDP用于对高速传输和实时性要求较高的通信领域例如早期的QQ视频传输等。另外UDP可以用于广播。归根结底TCP和UDP都是程序员的工具什么时机用具体怎么用还是要根据具体的需求场景去判定。2.19 用UDP实现可靠传输经典面试题参考TCP的可靠性机制在应用层实现类似的逻辑。例如引入序列号保证数据顺序。引入确认应答确保对端收到了数据。引入超时重传如果隔一段时间没有应答就重发数据。2.20 listen的第二个参数tcp协议要为上层维护一个连接队列用于存储等待连接的客户端。这个队列的大小就是listen的第二个参数。这个队列的存在是为了保证我们资源的利用率不会存在资源浪费的情况。为了保证我们的资源在高峰期的时候一直持续的处理连接而不是在高峰期的时候等待连接到来而是直接从队列中取连接。但是这个连接队列不能太小也不能太大。如果太小则会导致连接丢失。如果太大则会导致资源浪费。所以我们要根据实际情况来调整listen的第二个参数。理解listen的第二个参数基于刚才封装的TcpSocket实现以下测试代码。对于服务器listen的第二个参数设置为2并且不调用accept。此时启动3个客户端同时连接服务器用netstat查看服务器状态一切正常。但是启动第四个客户端时发现服务器对于第四个连接的状态存在问题了。客户端状态正常但是服务器端出现了SYN_RECV状态而不是ESTABLISHED状态。这是因为Linux内核协议栈为一个tcp连接管理使用两个队列半链接队列用来保存处于SYN_SENT和SYN_RECV状态的请求全连接队列accept队列用来保存处于established状态但是应用层没有调用accept取走的请求而全连接队列的长度会受到listen第二个参数的影响。全连接队列满了的时候就无法继续让当前连接的状态进入established状态了。这个队列的长度通过上述实验可知是listen的第二个参数1。后续的连接就只能是半链接状态。如果这个半连接状态持续一段时间仍然没有建立成功则会自动释放。