目录
协议前菜
端口号
编辑端口号范围划分
认识知名端口号(Well-Know Port Number)
netstat
pidof
传输层协议
UDP协议
UDP协议端格式
UDP的特点
面向数据报
UDP的缓冲区
UDP使用注意事项
基于UDP的应用层协议
TCP协议
TCP协议概念
TCP协议段格式
标志位详解
确认应答(ACK)机制
序列号 && 确认序号
对于为什么有确认序号和序号
超时重传机制
连接管理机制(三次握手四次挥手)
服务端状态转化
客户端状态转化
为什么是三次?
两次呢?
四次挥手
下图是TCP状态转换的一个汇总
理解TIME_WAIT状态
为什么是TIME_WAIT的时间是2MSL
解决TIME_WAIT状态引起的bind失败的方法
理解 CLOSE_WAIT 状态
滑动窗口
流量控制
拥塞控制
延迟应答
捎带应答
面向字节流
粘包问题
关于listen的第二个参数
TCP异常情况
TCP小结
基于TCP应用层协议
TCP/UDP对比
C语言总结 在这 常见八大排序 在这
作者和朋友建立的社区: 非科班转码社区-CSDN社区云 💖 💛 💙
期待hxd的支持哈 🎉 🎉 🎉
最后是打鸡血环节: 想多了都是问题,做多了都是答案 🚀 🚀 🚀
最近作者和好友建立了一个公众号
公众号介绍:
专注于自学编程领域。由USTC、WHU、SDU等高校学生、ACM竞赛选手、CSDN万粉博主、双非上岸BAT学长原创。分享业内资讯、硬核原创资源、职业规划等,和大家一起努力、成长。( 二维码在文章底部哈! )
协议前菜
端口号
端口号(Port)标识了一个主机上进行通信的不同的应用程序
在 TCP/IP 协议中 , 用 " 源 IP", " 源端口号 ", " 目的 IP", " 目的端口号 ", " 协议号 " 这样一个五元组来标识一个通信 ( 可以通过netstat -n查看 )端口号范围划分
0 - 1023: 知名端口号, HTTP, FTP, SSH等这些广为使用的应用层协议, 他们的端口号都是固定的。
1024 - 65535: 操作系统动态分配的端口号. 客户端程序的端口号, 就是由操作系统从这个范围分配的。
认识知名端口号(Well-Know Port Number)
有些服务器是非常常用的 , 为了使用方便 , 人们约定一些常用的服务器 , 都是用以下这些固定的端口号:执行下面的命令, 可以看到知名端口号
cat /etc/services
我们自己写一个程序使用端口号时, 要避开这些知名端口号
netstat
netstat是一个用来查看网络状态的重要工具
语法 : netstat [ 选项 ]功能 :查看网络状态常用选项 :
- n 拒绝显示别名,能显示数字的全部转化成数字
- l 仅列出有在 Listen (监听) 的服務状态
- p 显示建立相关链接的程序名
- t (tcp)仅显示tcp相关选项
- u (udp)仅显示udp相关选项
- a (all)显示所有选项,默认不显示LISTEN相关
netstat是一个用来查看网络状态的重要工具
netstat -nltp/ntp 常用(我们经常去启动服务时,想看服务是否成功启动,就带l,看是否LISTEN。看链接什么的就去掉l。)pidof
在查看服务器的进程 id 时非常方便语法 : pidof [ 进程名 ]功能 :通过进程名 , 查看进程 id传输层协议
1. 传输层根据传来的端口号,查哈希表找到对应的进程(进程和端口号是通过哈希关联的)
2. linux下一切皆文件,我们的网络套接字在底层也是以文件的形式存在的,就是文件描述符,也就是进程和文件的对应关系。换言之,我们只要能找到进程,我们就能找到该进程对应的网络文件,文件打开是有自己的内核缓冲区的,所以当网络收过来的数据在内核当中,他首先通过目的端口号找到目的进程,然后再根据目标进程所打开的文件,找到该文件打开的内核缓冲区,然后把数据填到缓冲区里,这样进程就可以以文件的方式读取文件了。
(根据目的端口号查hash表,找到进程,再根据进程结合文件描述符,找到文件,再把数据拷贝到文件缓冲区里,最后就跟读文件一样就把数据传上去了)PS:1. 添加报头的本质就是拷贝对象2. 在网络中发送数据(比如send)其实并不是我们发送的,是我们把数据拷贝到了TCP/UDP对应的缓冲区中,怎么发送/什么时候发都是OS决定的。(我们讲文件的时候,其实并不是我们把数据写到了磁盘/文件里,而是我们把数据写到了OS的缓冲区中(比如write),然后OS经过自己的策略把数据刷新到外设比如磁盘上(所以write其实是拷贝函数,是把应用层的数据拷贝到内核,再有OS定向的用自己的策略发送到磁盘上))
UDP协议
UDP协议端格式
16 位 UDP 长度 , 表示整个数据报 (UDP 首部 +UDP 数据 ) 的最大长度。如果校验和出错 , 就会直接丢弃。UDP的特点
无连接 : 知道对端的 IP 和端口号就直接进行传输 , 不需要建立连接 ;不可靠 : 没有确认机制 , 没有重传机制 ; 如果因为网络故障该段无法发到对方 , UDP 协议层也不会给应用层返回任何错误信息;面向数据报: 不能够灵活的控制读写数据的次数和数量;面向数据报
应用层交给 UDP 多长的报文 , UDP 原样发送 , 既不会拆分 , 也不会合并用 UDP 传输 100 个字节的数据:如果发送端调用一次 sendto, 发送 100 个字节 , 那么接收端也必须调用对应的一次 recvfrom, 接收 100 个字节; 而不能循环调用 10 次 recvfrom, 每次接收 10 个字节;UDP的缓冲区
UDP 没有真正意义上的 发送缓冲区 . 调用 sendto 会直接交给内核 , 由内核将数据传给网络层协议进行后续的传输动作;UDP 具有接收缓冲区 . 但是这个接收缓冲区不能保证收到的 UDP 报的顺序和发送 UDP 报的顺序一致 ; 如果缓冲区满了, 再到达的 UDP 数据就会被丢弃;UDP 的 socket 既能读 , 也能写 , 这个概念叫做 全双工。UDP使用注意事项
我们注意到 , UDP 协议首部中有一个 16 位的最大长度 . 也就是说一个 UDP 能传输的数据最大长度是 64K( 包含 UDP 首部)。然而 64K 在当今的互联网环境下 , 是一个非常小的数字。如果我们需要传输的数据超过 64K, 就需要在应用层手动的分包 , 多次发送 , 并在接收端手动拼装。基于UDP的应用层协议
- NFS: 网络文件系统
- TFTP: 简单文件传输协议
- DHCP: 动态主机配置协议
- BOOTP: 启动协议(用于无盘设备启动)
- DNS: 域名解析协议
当然 , 也包括你自己写 UDP 程序时自定义的应用层协议 ;
TCP协议
TCP协议概念
TCP 全称为 " 传输控制协议 (Transmission Control Protocol"). 人如其名 , 要对数据的传输进行一个详细的控制;没有100%可靠的协议,但是有局部100%可靠的协议,虽然最新的消息我们没有应答,但是之前的消息我们可以做到,收到了应答,就说明我刚发的消息对方100%收到了。TCP协议段格式
- 源/目的端口号: 表示数据是从哪个进程来, 到哪个进程去;
- 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字节头部选项: 暂时忽略;
标志位详解
SYN:只要报文是建立链接的请求,SYN需要被设置为1。
FIN:该报文是一个断开链接的请求报文。
ACK:确认标记位,表示该报文算术对历史报文的确认(一般大部分正常通信的情况下,ACK都是1)
PSH:接受数据的低水位线:比如接受缓冲区可有100byte,但是要有20,才去通知上层有数据了。但是如果有PSH,他就会影响本地的OS让他尽快去通知上层,让其读取,这就是PSH状态位的含意。(以前我们都是自己阻塞去调用read的,但是如果我们以后不想阻塞等待,就需要有人尽快的或者使用OS默认的策略来(当数据有了或者超过数据低水位线了)通知你,而我们要是不想要OS默认,而是想立即去读,我们就把PSH标记位设置,此时这个数据就会被我们对于的server端收到之后,放到缓冲区里更多的做一个工作,通知上层来可以读取)(PSH(催促报文)是Client催Server去尽快拿接受缓冲区数据的,PSH是Client发送的。这是极端的情况,用于理解PSH,上面那个是准确的描述)
URG:紧急指针标记位,报文在发送的时候,我们是要保证按序到达的,也就是说如果有一些数据优先级更高,但是序号较晚,无法做到数据被有限紧急处理。
RST:如果客户端只要是发送了请求,就认为是连接建立成功了,但是如果最后发送的ACK丢失了呢?此时服务端因为没有收到ACK所以认为连接依旧没有建立成功,此时客户端给服务端发送消息,但是服务端看到客户端消息之后,发现并没有完成三次握手(要遵守协议),所以就意识到客户端三次握手可能失败了,就会给客户端发送ACK响应,响应的时候将响应报文的**RST**(reset)标记位置为一,代表的就是告诉客户端,将你的连接进行重置。(tcp报文中reset标记位被置一,代表需要关闭连接需要重新连接,所以叫reset)
PS:
在数据通信前就已经有了三次握手,里面就会协商双方的数据通信能力,所以不怕第一次发送数据时大于对方接受能力(不担心他来不及接受的问题)。
(PS:最后一次ACK(三次握手时)也可以携带数据)如何做到?
那tcp有些场景,有可能有些数据是需要server端优先去读取,那么我们就需要把tcp的标志位设置为URG,那么就代表该数据可以直接忽略他的序号而被上层直接进行读取处理,这样的报文就叫做我们的紧急指针报文。(注意设置了之后代表的是可以被优先读取)
但是又有问题,那么这些数据都是在接受缓冲区的,你说要紧急读取,那他在哪呢?
这就有了我们的16位紧急指针!他就代表我们的要紧急读取的数据在缓冲区特定偏移量的位置。那多大呢?注意,只能有一个字节,也就是说,紧急发送的数据,只能有一个字节。
那什么情况下会这样用呢?
首先%99情况用不到。其次一般用紧急指针传递的数据都是有额外含义的数据并不只是数据本身。(就有点像是宏一样,数字对应已经预定好的信息,目的就是为了未来主机或者服务出问题的时候,去获取他们的状态的)所以我们把URG标记所表示的数据一般称之为带外数据(就是用同一个tcp链接,不走你的接受缓冲区,而是被上层优先处理,这就是带外数据)带外数据通常用来检测已经毫无反应的机器的状态(已经挂掉不想/但是跑的很慢可以查到)(紧急指针在机房中的应用场景)。确认应答(ACK)机制
序列号 && 确认序号
TCP将每个字节的数据都进行了编号. 即为序列号
每一个ACK都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 下一次你从哪里开始发。
确认序号比对应的序号**大1**,并且代表**前面的**序号**都**确认了。(序号我们来代表一个报文所对应的序号,而确认序号是用来确认特定我所收到的报文之前的序号全部收到了(**比如C发送了 1 2 3 5 6 S返回的确认序号就是4!**))
对于为什么有确认序号和序号
因为TCP协议是全双工的,我在给你发消息的同时,我也可以收消息。
即服务器在确认消息的时候,同时发送了新的消息,给应答就要填充确认序号,而TCP是保证可靠性的,在服务器发消息那也需要序号,那就是说S要有序号和确认序号一起返回给C。即序号和确认序号是用来保证可靠性的--确认应答。(序号是给对方确认的,确认序号是对方给我确认的)
超时重传机制
主机 A 发送数据给 B 之后 , 可能因为网络拥堵等原因 , 数据无法到达主机 B;如果主机 A 在一个特定时间间隔内没有收到 B 发来的确认应答 , 就会进行重发 ;但是 , 主机 A 未收到 B 发来的确认应答 , 也可能是因为 ACK 丢失了;因此主机 B 会收到很多重复数据 . 那么 TCP 协议需要能够识别出那些包是重复的包 , 并且把重复的丢弃掉 .这时候我们可以利用前面提到的序列号 , 就可以很容易做到去重的效果 .那么, 如果超时的时间如何确定?
- 最理想的情况下, 找到一个最小的时间, 保证 "确认应答一定能在这个时间内返回";
- 但是这个时间的长短, 随着网络环境的不同, 是有差异的;
- 如果超时时间设的太长, 会影响整体的重传效率;
- 如果超时时间设的太短, 有可能会频繁发送重复的包;
TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间
- Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时;
- 时间都是500ms的整数倍;
- 如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传;
- 如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增;
- 累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接;
连接管理机制(三次握手四次挥手)
在正常情况下 , 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 状态。为什么是三次?
并且我们发现最后一次是没有应答的,即是否被对方收到是不一定的,所以三次称握手不一定成功。(建立连接不一定成功)(前两次可以保证,因为有应答)
比如如果是一次握手就可以,那就是说只要发送SYN就可能建立连接,那么这样是非常容易被攻击的。(就比如一直发送SYN(这种攻击方式被称为SYN洪水)(服务器资源被消耗导致正常的无法正常运行))两次呢?
其实和一次是类似的,服务端只要发出去,那也就是认为连接建立好了,我们依旧发送大量SYN给服务端,然后服务器收到了再发一个报文,没有确认里面就建立了连接,同样会被消耗大量资源(要维护大量连接)。
前两次不行是因为每次都是让server端认为连接已经建立好了,而三次就不一样了,可以把确认连接的机会交给server端,因为只有三次握手最后一次握手成功了之后,你的server端来结束三次握手。客户端,三次握手当他最后的ACK发送出去的时候,他并不知道这个报文有没有给server端收到,所以第三次丢失是客户端最害怕的事情,但是这并不影响服务端!因为服务端只要三次握手了,服务端才是最后确认ACK的人。即正常的情况下客户端给服务端发送大量的SYN,我给你ACK那么你就必须得给我ACK然后我才认为连接建立好了,换句话说,只要我们服务端和客户端建立好了连接,只要服务端有了维护连接的结构体,那么客户端必须维护,所以你想要用一台机器给我发送大量的SYN攻击,我的服务器也会把你拉下水,都会建立相同连接数,所以单主机攻击是非常困难的,因为客户端的资源也在不断减少,但服务器的资源一般更多。(三次握手客户端最后一次发送ACK就认为连接建立好了,就要去建立结构维护,这样服务器就把最好一次报文丢失的成本嫁接给了客户端,因为客户端面对的群体比较小,出上一些闲置的/非法的连接并不影响,但是服务端出现问题问题就大了,这也是为什么是三次握手(基数次))那是不是三次握手就不会收到SYN洪水呢?
其实不是,三次握手只不过是以最小的成本,较小的握手次数,来避免直接的SYN洪水攻击,但至少保证,不会随随便便一台机器就可以把服务端搞挂掉。
为什么三次握手第二个理由:
客户端是一发一收,服务端也是,这样就可以用最小的成本去验证全双工。
再往上增加次数,也没有意义,因为不能解决任何问题(安全问题并不是主要在握手时考虑),而三次刚刚好以最小的成本,做出了最大的意义。四次挥手
谁发送FIN就说明谁向另一方断开连接了,此时就不能向对方发送数据了,但是可以确认。
上面这就是四次挥手。
至于为什么不是两次(两次FIN),原因就在于需要知道对方确认收到了你的消息,所以有两次ACK回应,所以是四次挥手。
对于服务器发送FIN和ACK为什么不一起是因为你想和我断开连接,不代表我也想和你断开连接,我有可能还有数据没发完。(但是巧合情况,刚好服务器回应你的时候也想和你断开连接,那便是三次挥手了(特殊情况)(也就是后面说的捎带应答))看图:
只要是客户端发送FIN状态就立马变为FIN_WAIT_1(谁发谁变),服务端收到消息,回应ACK,一回应立马变为CLOSE_WAIT后面类推。(CLOSE_WAIT就是半关闭状态,说白了,就是没关可以进行发消息。主动断开连接的一方最终要变为TIME_WAIT状态,他的特点就是发送最后一个ACK后,理论上是可以释放连接资源,但是他会等待一段时间才进入CLOSED状态)(只有第四次发送ACK的时候,才是双方真正的关闭了IO能力,才是把连接真正的关掉了,前面是关闭通信能力)下图是TCP状态转换的一个汇总
较粗的虚线表示服务端的状态变化情况 ;较粗的实线表示客户端的状态变化情况 ;CLOSED 是一个假想的起始点 , 不是真实状态 ;理解TIME_WAIT状态
- 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的值。
- 规定TIME_WAIT的时间请读者参考UNP 2.7节。
为什么是TIME_WAIT的时间是2MSL
- MSL是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描述符理解 CLOSE_WAIT 状态
//要改
对于服务器上出现大量的 CLOSE_WAIT 状态 , 原因就是服务器没有正确的关闭 socket, 导致四次挥手没有正确完成. 这是一个 BUG. 只需要加上对应的 close 即可解决问题滑动窗口
刚才我们讨论了确认应答策略 , 对每一个发送的数据段 , 都要给一个 ACK 确认应答 . 收到 ACK 后再发送下一个数据段 .这样做有一个比较大的缺点, 就是性能较差 . 尤其是数据往返的时间较长的时候。既然这样一发一收的方式性能较低 , 那么我们一次发送多条数据 , 就可以大大的提高性能 ( 其实是将多个段的等待时间重叠在一起了)。
- 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值. 上图的窗口大小就是4000个字节(四个段)。
- 发送前四个段的时候, 不需要等待任何ACK, 直接发送。
- 收到第一个ACK后, 滑动窗口向后移动, 继续发送第五个段的数据; 依次类推。
- 操作系统内核为了维护这个滑动窗口, 需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答; 只有确认应答过的数据, 才能从缓冲区删掉。
- 窗口越大, 则网络的吞吐率就越高。
左边是已经发送并且已经应答的区域
中间写了
后面是还没发送的数据
左边的数据收到了就会向右滑动那么如果出现了丢包, 如何进行重传? 这里分两种情况讨论:
情况一: 数据包已经抵达, ACK被丢了
这种情况下, 部分ACK丢了并不要紧, 因为可以通过后续的ACK进行确认;
情况二: 数据包就直接丢了
- 当某一段报文段丢失之后, 发送端会一直收到 1001 这样的ACK, 就像是在提醒发送端 "我想要的是 1001"一样
- 如果发送端主机连续三次收到了同样一个 "1001" 这样的应答, 就会将对应的数据 1001 - 2000 重新发送。
- 这个时候接收端收到了 1001 之后, 再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已经收到了, 被放到了接收端操作系统内核的接收缓冲区中。
这种机制被称为 "高速重发控制"(也叫 "快重传") 。
流量控制
接收端处理数据的速度是有限的 . 如果发送端发的太快 , 导致接收端的缓冲区被打满 , 这个时候如果发送端继续发送 ,就会造成丢包, 继而引起丢包重传等等一系列连锁反应。因此 TCP 支持根据接收端的处理能力 , 来决定发送端的发送速度 . 这个机制就叫做 流量控制 (Flow Control) 。
- 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段, 通过ACK端通知发送端;
- 窗口大小字段越大, 说明网络的吞吐量越高;
- 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;
- 发送端接受到这个窗口之后, 就会减慢自己的发送速度;
- 如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端;
接收端如何把窗口大小告诉发送端呢 ? 回忆我们的 TCP 首部中 , 有一个 16 位窗口字段 , 就是存放了窗口大小信息 ;那么问题来了 , 16 位数字最大表示 65535, 那么 TCP 窗口最大就是 65535 字节么 ?实际上 , TCP 首部 40 字节选项中还包含了一个窗口扩大因子 M, 实际窗口大小是 窗口字段的值左移 M 位 ;拥塞控制
解决网络出问题(软件问题)的情况
虽然 TCP 有了滑动窗口这个大杀器 , 能够高效可靠的发送大量的数据 . 但是如果在刚开始阶段就发送大量的数据 , 仍然可能引发问题。因为网络上有很多的计算机 , 可能当前的网络状态就已经比较拥堵 . 在不清楚当前网络状态下 , 贸然发送大量的数据 ,是很有可能引起雪上加霜的。TCP 引入 慢启动 机制 , 先发少量的数据 , 探探路 , 摸清当前的网络拥堵状态 , 再决定按照多大的速度传输数据。
- 此处引入一个概念程为拥塞窗口
- 发送开始的时候, 定义拥塞窗口大小为1;
- 每次收到一个ACK应答, 拥塞窗口加1;
- 每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口;
像上面这样的拥塞窗口增长速度, 是指数级别的. "慢启动" 只是指初使时慢, 但是增长速度非常快。
- 为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍。
- 此处引入一个叫做慢启动的阈值。
- 当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长。
- 当TCP开始启动的时候, 慢启动阈值等于窗口最大值;
- 在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回1;
少量的丢包 , 我们仅仅是触发超时重传 ; 大量的丢包 , 我们就认为网络拥塞 ;当 TCP 通信开始后 , 网络吞吐量会逐渐上升 ; 随着网络发生拥堵 , 吞吐量会立刻下降 ;拥塞控制 , 归根结底是 TCP 协议想尽可能快的把数据传输给对方 , 但是又要避免给网络造成太大压力的折中方案延迟应答
如果接收数据的主机立刻返回 ACK 应答 , 这时候返回的窗口可能比较小
- 假设接收端缓冲区为1M. 一次收到了500K的数据; 如果立刻应答, 返回的窗口就是500K;
- 但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了;
- 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;
- 如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M;
一定要记得 , 窗口越大 , 网络吞吐量就越大 , 传输效率就越高 . 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率;那么所有的包都可以延迟应答么 ? 肯定也不是 ;
- 数量限制: 每隔N个包就应答一次;
- 时间限制: 超过最大延迟时间就应答一次;
具体的数量和超时时间 , 依操作系统不同也有差异 ; 一般 N 取 2, 超时时间取 200ms;捎带应答
当我们接受消息返回ACK的同时给ACK带上数据,也就是说回复的时候带上我想发送的数据,这才是tcp通信的真相。
在延迟应答的基础上 , 我们发现 , 很多情况下 , 客户端服务器在应用层也是 " 一发一收 " 的 . 意味着客户端给服务器说了 "How are you", 服务器也会给客户端回一个 "Fine, thank you";那么这个时候 ACK 就可以搭顺风车 , 和服务器回应的 "Fine, thank you" 一起回给客户端。面向字节流
创建一个TCP的socket, 同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;
硬件(网卡)接受数据的时候是可以通过硬件中断(有个中断向量表。每一个中断要被处理,是要有中断向量表的,用中断号做下标,搞的一个函数指针数组,然后用中断号下标去指向特定的方法就可以了)让OS知道有数据的,让OS调用拷贝函数(解包,向上交付)把数据加载到接受缓冲区(tcp)/内存。(就像键盘输入一样)然后应用程序可以read从接受缓冲区读取数据。
- 调用write时, 数据会先写入发送缓冲区中;
- 如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
- 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
- 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
- 然后应用程序可以调用read从接收缓冲区拿数据;
- 另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做 全双工;
由于缓冲区的存在 , TCP 程序的读和写不需要一一匹配 , 例如 :
- 写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节;
- 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次read一个字节, 重复100次;
粘包问题
明确报文和报文的边界
- 首先要明确, 粘包问题中的 "包" , 是指的应用层的数据包;
- 在TCP的协议头中, 没有如同UDP一样的 "报文长度" 这样的字段, 但是有一个序号这样的字段;
- 站在传输层的角度, TCP是一个一个报文过来的. 按照序号排好序放在缓冲区中;
- 站在应用层的角度, 看到的只是一串连续的字节数据;
- 那么应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分, 是一个完整的应用层数据包;
那么如何避免粘包问题呢 ? 归根结底就是一句话 , 明确两个包之间的边界
- 对于定长的包, 保证每次都按固定大小读取即可; 例如上面的Request结构, 是固定大小的, 那么就从缓冲区从头开始按sizeof(Request)依次读取即可;
- 对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包的结束位置;
- 对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议, 是程序猿自己来定的, 只要保证分隔符不和正文冲突即可);
对于UDP协议来说, 是否也存在 "粘包问题" 呢?
- 对于UDP, 如果还没有上层交付数据, UDP的报文长度仍然在. 同时, UDP是一个一个把数据交付给应用层. 就有很明确的数据边界。
- 站在应用层的角度, 使用UDP的时候, 要么收到完整的UDP报文, 要么不收. 不会出现"半个"的情况。
关于listen的第二个参数
1. 直接telnet,不accept,连接可以建立(也就是说accept和我们的三次握手没有关系,不调用,底层也会自动三次握手成功,调用accept只是把底层已经三次握手建立的连接拿上来,经此而已)
我们此时listen第二个参数是设置的2
我们一直发送了四次,发现有一个State不一样,也就是说当前连接还没有完成为什么呢?
这次我们是没有去accept的,但是如果将来有可能服务器非常忙,来不及accept(一瞬间来了几百上千的连接)那么我们底层的连接就要在OS层面上进行排队。
(listen的第二个参数叫做底层的全连接长度,算法是:n+1,表示在不accept的情况下,你最多能维护多少个全连接,如果收到更多,那么tcp就不再进行三次握手,而是收到这个请求,已半连接的形式存在着(太久了就会销毁),等后续有新连接关闭退出了,我们再把这个连接建立成功)2. 然后我们现在qiut关闭两个连接(客户端主动断开连接),而我们服务端是没有关闭他的(关闭他需要先accpt再close),所以我们服务端的连接状态就是CLOSE_WAIT。
所以我们知道了,如果我们的服务端不关闭对应的文件描述符,那么所处的状态就叫做CLOSE_WAIT状态。
然后我们再变一下,此时我们accept,但是还是不close,经过测试,我们服务端的连接状态还是CLOSE_WAIT。(所以说只要不close他,那么客户端断开连接之后,我们服务端所处的连接状态一直都是CLOSE_WAIT)3. 我们在服务器运行切保持连接的情况下,ctrl+c关闭服务端,这样使我们的服务端为断开连接的一方,我们也发现他的状态将由ESTABLISHED变为TIME_WAIT。
只要走到了TIME_WAIT,断开连接的一方就认为自己已经把四次挥手的工作做完了。
然后又过了一段时间,我们去查看,就发现TIME_WAIT等待的时间到了之后,连接就释放掉了,这也代表这连接真正关闭。
PS:如果客户端服务端建立好连接了,如果CLOSE_WAITE一直存在的话,他也是消耗资源的,如果在未来我们去netstat的时候,发现服务器上挂了大量的CLOSE_WAITE,那么基本上只有一种可能性,那就是我们自己写的网络服务器,把文件描述符获取上来了,但是没有调用close,因为没有关,所以他的状态就没有办法主动的再进行四次挥手,所以最终出现了这个问题。(所以如果往后发现服务器非常卡,就可以netstat查看一下是不是服务器上挂了大量的CLOSE_WAITE这样状态的连接(办关闭状态,等待调用close)(挂连接是要消耗资源的))
TCP异常情况
进程终止: 进程终止会释放文件描述符 , 仍然可以发送 FIN. 和正常关闭没有什么区别 .机器重启: 和进程终止的情况相同。机器掉电/网线断开: 接收端认为连接还在 , 一旦接收端有写入操作 , 接收端发现连接已经不在了 , 就会进行 reset, 即 使没有写入操作, TCP 自己也内置了一个保活定时器 , 会定期询问对方是否还在 . 如果对方不在 , 也会把连接释放。另外 , 应用层的某些协议 , 也有一些这样的检测机制 . 例如 HTTP 长连接中 , 也会定期检测对方的状态 . 例如 QQ, 在 QQ 断线之后, 也会定期尝试重新连接。比如说两个主机正在通信,服务端突然进程终止了。在我们双方进行通信时,双方一个或两个终止了,网络也是文件,打开文件的生命周期是随进程的,所以进程终止时,OS会自动关闭我们之前所打开的文件,在网络这里就相当于会自动进行四次挥手(因为三次握手也是网络自动做的,是tcp做的,tcp也属于OS),所以进程终止时,我们是不用担心的,即连接建立了我们把机器重启或者关机是不会出现问题的(就比如我们打开的有网页,去关机的时候,他就会有提示,是否关闭重启,如果是,他就会关闭应用(说明关机OS是需要关闭打开的应用的,包括我们曾经打开的客户端或者服务器(涉及网络就要进行四次挥手)),如果否,他就是取消关机)。服务器ctrl+c后(先断开),就发现状态变成了TIME_WAIT。
就是说在TIME_WAIT期间想要重启服务是不行的,必须要等一会,等netstat 查不到刚刚那个连接就可以重新启动了。(因为文件描述符的声明周期是随进程的,虽然我们代码里面没有close,但是我们进程ctrl+c了,OS在终止杀掉进程的时候,底层会自动实现握手过程。就好比我们之前写文件的时候,文件描述符没有关,但是进程退出之后,OS会帮你关一样的。)
现在有问题,我都已经完成了四次挥手,为什么还要有TIME_WAIT状态呢?
是因为最后一次ACK是否被对方收到是不确定的,所以就怕他丢了,这样服务器就只能以异常情况关闭连接。解决问题的方式就是让主动发起连接的一方,退出发送最后一个报文时,先不要直接变成CLOSE,先等一等,要让对方尽量收到ACK(如果丢了另一方就大概可能会进行超时重传FIN(这个就是需要控制好TIME_WAIT的等待时间),而使发送方重新发送ACK)。(这个时间一般就是2倍的MSL(最大传送时间)(至少保证一来一回即一个FIN一个ACK),MSL就是两端传送的最大时间)解决TIME_WAIT状态引起的bind失败的方法
TIME_WAIT状态时,连接虽然名存实亡,但他依旧还是存在,此时bind的时候就意味着要bind的ip和端口依旧被占用,所以bind失败,但是这是有问题的。看WPS
setsockopt()
可以用两个,也可以只要上面那个,就写在创建套接字后面PS:accept并不参与三次握手,而是从底层拿取连接!
TCP小结
可靠性 :校验和序列号 ( 按序到达 )确认应答超时重发连接管理流量控制拥塞控制提高性能 :滑动窗口快速重传延迟应答捎带应答其他 :定时器 ( 超时重传定时器 , 保活定时器 , TIME_WAIT 定时器等 )基于TCP应用层协议
HTTP HTTPS SSH Telnet FTP SMTP当然 , 也包括你自己写 TCP 程序时自定义的应用层协议;
TCP/UDP对比
我们说了 TCP 是可靠连接 , 那么是不是 TCP 一定就优于 UDP 呢 ? TCP 和 UDP 之间的优点和缺点 , 不能简单 , 绝对的进行比较
- TCP用于可靠传输的情况, 应用于文件传输, 重要状态更新等场景;
- UDP用于对高速传输和实时性要求较高的通信领域, 例如, 早期的QQ, 视频传输等. 另外UDP可以用于广播;
归根结底 , TCP 和 UDP 都是程序员的工具 , 什么时机用 , 具体怎么用 , 还是要根据具体的需求场景去判定。
最后的最后,创作不易,希望读者三连支持💖
赠人玫瑰,手有余香💖