TCP全称为传输控制协议,需要对数据进行一个传输控制。
宏观上,在发送方的缓冲区中只是数据部分,经过传输层后添加报头。接收方在接收到报文时,需要将报头和数据分离。
x
,这x*4=20
,得到x=5
,转换成二进制为:0101
TCP为了确保可靠性,有了确认应答机制。
对于一般通信,总有最新的信息没有应答:
上述接收方发送完“可以的”之后,发送方没有回应,因此接收方并不知道发送方是否接收到消息。
在日常生活中,从左往右只要收到了应答就能保证左侧发送方的数据对方一定收到了。
什么叫做可靠性:把事情办成了,对方知道,没有做成,对方也知道。
总的来说,双方采用确认应答机制来保证两个朝向上的可靠性。
为了确保发送方知道接收方应答的是哪一个消息,在报头中有序号
确认序号
字段。确认序号原则上是序号+1
。
确认序号表示:当前确认序号之前的数据已经全部收到,并不单只确认序号-1的序号被收到。
无论是发送还是应答,发送的都是TCP报头或者TCP报头+数据
为什么报头字段中需要有序号和确认序号? 双方在进行正常通信时,TCP支持全双工通信,客户端给服务器发送消息后客户端需要接收到来自服务端的应答,此时如果服务器需要给客户端发送信息并且需要收到来自客户端的应答。
如何理解序号?
如果将发送缓冲看做是一个数组char sendbuffer[65525]
:
既然是数组,本身就有序号即下标。假设当前从第1个字节开始,长度为100,那么当前发送序号就是100。因此可以称序号为该发送缓冲区的数组下标。发送端的发送缓冲区有自己的序号,那么接收端的发送缓冲区也有自己的序号,这样双方都可以携带自己的序号来进行互相发送。
每一个 ACK 都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 下 一次你从哪里开始发
客户端发给服务器可能有建立连接的请求、断开连接的请求、确认报文、正常数据等等,因此TCP协议要有处理不同类型报文的能力,也就是说,TCP报文有不同的类型。
在TCP报头中,有6个标记位,这6个标记位就是为了区分不同的类型。
应答:ACK+确认序号 捎带应答:ACK+确认序号+数据
主机A给主机B发送信息,在特定的时间间隔内,主机B没有给主机A应答,此时数据丢包。主机A需要给主机B重新补发数据,这称之为超时重传。
规定:主机B收到数据就是主机A收到ACK;主机A没有收到ACK则说明主机B没有收到数据。 在这里,主机A没有收到主机B发来的应答,可能是这个应答丢失,但是主机B可能接收到了数据,这里是不确定的,因此规定了在特定的时间间隔没有收到对应的ACK,主机A就会重新发送数据。
这里可能会出现一个问题 :主机B可能收到两个一样的报文。在一次通信过程中,同一个报文的序号是一样的,可以通过序号进行去重报文。
除此之外,序号可以保证TCP报文的按序到达,按序交付给上层。
因此这里总结一下序号的三个作用:
这里的特定的时间间隔不能太长也不能太短,网络的状态是会变化的,数据从A到B的时间是变化的,因此特定时间间隔和网络状态相关联,当网络状态好的时候,特定的时间间隔短;当网络状态不好的时候,特定的时间间隔应该长一点,更报文更大的容忍度。因此特定的时间间隔是一个浮动的时间间隔。
500ms
为一个单位进行控制, 每次判定超时重发的超时时间都是 500ms
的整数倍。具体的超时间隔计算,贴上小林coding的介绍。
listen
状态,随时等待客户端连接此时双方完成了建立连接前的协商,这个过程称之为建立前的三次握手
应用层调用connect
进行网络连接,实质上只是发起了三次握手
accept
不参与三次握手的过程,connect
成功后,会返回一个文件描述符给应用层,供用户使用。
总结:三次握手是由其中一方发起,即connect
,握手的过程是由双方的TCP协议自动完成的。
三次握手建立连接的目的不是为了一定要建立连接成功,但是一旦三次握手成功,一定认为建立连接成功。
三次握手中,最后一次发送ACK如果丢失会出现什么后果?
客户端对服务器建立好连接后,客户端接下来就会向服务器发送数据。三次握手中,客户端最后一次发送ACK丢失没有被服务器接收到,服务器没有认为此时连接建立好了,但是客户端认为建立好了。此时的客户端就会直接向服务器发送数据,服务器接收到数据。服务器认为服务端误认为连接建立好了,服务器需要对客户端进行应答,在应答的时候将对应的RST
(RST
是rese
t连接重置标志位。)标记位置为1。此时的客户端会认为三次握手并没有建立好,服务器要求客户端重新发起三次握手。
补充知识:RST
是rese
t连接重置标志位,收到该标志的主机要对异常连接释放,重新发起建立连接。
天下没有不散的宴席,对于TCP的断开连接需要进行四次挥手,断开连接需要双方都同意。两次FIN
是双方发起断开,两次ACK
是双方同意。
客户端没有数据发送给服务器时,客户端断开连接,得到服务器的应答,此时的服务器依然没有断开连接,依然在给客户端发送数据,数据发送完了之后上层也调用close
,此时发送FIN,然后得到客户端的应答。
客户端断开连接,文件描述符已经关闭了,但是服务器还没有断开,依然在发送数据,客户端的上层怎么读取数据?
在Linux中,关闭文件描述符除了close
之外还有shutdown
:
#include <sys/socket.h>
int shutdown(int sockfd, int how);
这里how
可以是关闭写,关闭读,关闭读写。如果how
是SHUT_RDWR
就代表close
。
四次挥手实质上是双方告知对方都没有数据可以发送给对方。
close本质是进行四次挥手
在上一个HTTP,我们在访问一次端口后,按下ctrl
+c
后断开连接,紧接着再次连接服务器发现连接失败:
解释:客户端进程已经退出,可能连接还是存在,正在执行四次挥手。如果此时立即重启,此时的连接还在,对应的端口号被占用,因此绑定可能会失败。
TCP连接建立的过程中,数据需要被组织成报文,每个报文都包含了必要的控制信息,比如:序列号、确认号、标志位、校验和等。这些报文需要按照一定的格式进行描述和组织,在内核中通过数据结构来管理。
在操作系统的TCP/IP协议栈中,内核需要管理多个连接状态,每个连接都有一个对应的 内核数据结构。每当新的连接建立时,内核需要分配内存来创建并维护这些数据结构。这就需要通过 malloc 或其他内存分配机制来分配空间,并在连接关闭时释放这些资源。
因此维护连接是有时间成本和空间成本的。
如果将建立连接变成一次握手:
客户端给服务器发送一个的SYN
,服务器就建立了连接。此时如果一个客户端给服务器发送大量的SYN
(称之为SYN
洪水),客户端几乎没有成本,但是服务器瞬间建立了大量连接,每个连接都有一定的成本,会导致服务器可用资源越来越少。
如果将建立连接变成两次握手:
客户端发送一个报文得到服务器的ACK
,这个ACK
客户端可以不受理,但是此时服务器依然有大量连接,也会造成服务器可用资源越来越少。
采用三次握手,在安全角度来看,可能也会存在上述SYN
洪水的问题,但是三次握手保证了最后一次报文时客户端给服务器发送的,因此要想三次握手建立好连接,客户端需要先建立好连接。
为什么需要三次握手建立连接理由:
客户端调用close()
函数接口,客户端向服务器发送FIN
,此时服务器给客户端应答ACK
,此时的服务器处于CLOSE_WAIT
状态。如果服务器一直没有调用close()
,即不关闭文件描述符,服务器将一直处于CLOSE_WAIT
状态。
通过代码查看一下现象:
TcpServer.hpp
中服务器端的关闭文件描述符操作注释掉:
netstat -natp
查看一下当前状态:
CLOSE_WAIT
状态。
CLOSE_WAIT
状态,转换为LAST _ACK
状态
如果服务器卡顿,使用netstat -natp
命令查看一下是不是有大量的close_wait
状态,如果有表示此时有大量文件描述符fd
泄漏。
主动断开连接,自己处于TIME_WAIT
状态
TIME_WAIT
状态一般等待的时间为2*MSL
,MSL
时间具体可以通过cat /proc/sys/net/ipv4/tcp_fin_timeout
查看:
为什么是 TIME_WAIT 的时间是 2MSL?
MSL 是 TCP 报文的最大生存时间, 因此 TIME_WAIT 持续存在 2MSL 的话,就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服 务器立刻重启, 可能会收到来自上一个进程的迟到的数据, 但是这种数据很可能是错 误的); 同时也是在理论上保证最后一个报文可靠到达(假设最后一个 ACK 丢失, 那么 服务器会再重发一个FIN. 这时虽然客户端的进程不在了, 但是 TCP 连接还在, 仍然 可以重发LAST_ACK)
通过代码查看一下现象:
TIME_WAIT
状态不是持续存在的,如果处于TIME_WAIT
状态,服务器无法再使用同样的端口去连接,会出现绑定失败。当TIME_WAIT
状态不存在了,此时可以继续使用原来的端口号进行连接,不会出现绑定失败的情况。
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
sockfd
:套接字文件描述符。它是通过 socket() 函数创建的套接字返回值,用于标识要设置选项的目标套接字。
level
:指定套接字选项的协议层次。常见的 level 值包括:
SOL_SOCKET
:表示在套接字层面设置选项(例如,设置发送缓冲区大小、关闭Nagle算法等)。IPPROTO_TCP
:表示设置与 TCP 相关的选项(如 TCP 的最大重传次数、延迟等)。IPPROTO_IP
:表示设置与 IP 相关的选项。SOL_IPV6
:表示设置与 IPv6 相关的选项。SO_REUSEADDR
:允许地址重用。SO_RCVBUF
:设置接收缓冲区大小。SO_RCVBUF
:设置接收缓冲区大小。SO_KEEPALIVE
:启用 TCP keep-alive 功能等。
当 level 为 IPPROTO_TCP 时,可以设置:TCP_NODELAY
:禁用 Nagle 算法,允许小数据包立即发送。TCP_MAXSEG
:设置最大分段大小。virtual void ReUseAddr() override
{
int opt=1;
::setsockopt(_sockfd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
}
此时断开服务器连接紧接着立马重启就会再次连接,不会出现绑定失败的现象:
发送方在发送数据本质是将发送方的发送缓冲区数据拷贝到接收方的接受缓冲区中,如果接受缓冲区满了,对于新来的报文将会丢弃。也就是说,发送方不能无脑给接收方发送数据,需要根据对方接收能力来控制自己的发送速度,这称之为流量控制。
发送放放根据接收缓冲区剩余空间大小来判断接收缓冲区的接收能力。
如何知道剩余缓冲区的大小呢?
ACK
端通知发送端。如果对方的窗口大小一致不更新,窗口大小一直处于0,发送方如何让接收方尽快向上交付呢?
PSH
标志位,是推送的意思,如果报文中携带了PSH
标志位,告知对方操作系统的TCP尽快让缓冲区数据交付给上层处理。PSH
标志位,让对方尽快交付。
16 位数字最大表示 65535, 那么 TCP 窗口最大就是 65535 字节么?
16 位紧急指针实际上是紧急数据的偏移量,标记了偏移量,就可以在数据中找出对应数据。
16位紧急指针指示给出紧急数据开始的位置,在TCP中紧急数据只有一个字节。
在流量控制中,已经知道对方的接收能力,根据对方的接收能力发送数据,发送方具体如何发送?
在超时重传中,将数据发出后在超时时间以内,已经发送的报文不能被丢弃,需要保存起来。报文需要保存在哪里?
主机A在给主机B发送一批数据,要保证主机B能接收到这一批数据:
发送方规定一个概念叫做滑动窗口。在滑动窗口以内的数据可以直接发送,暂时不用收到应答。
滑动窗口的本质是发送缓冲区的一部分:
滑动窗口只能向右滑动,因为滑动窗口之前的数据已经发送给,不需要再去发送。
滑动窗口的大小不是一直不变的,根据对方的接收能力,滑动窗口可以有适当的变化大小。
滑动窗口可以为0,当对方接收能力为0时,滑动窗口可以变为0。
理解滑动窗口的更新:
滑动窗口可以理解为是两个指针维护的一个区域:int win_start
、int win_end
让滑动窗口向右移动无非是win_start++
、win_end++
;滑动窗口变小就是让win_start
加的快一点、win_end
加的慢一点。
收到对方的ACK时,发送缓冲区中的滑动窗口如何更新呢? 收到对方的应答ACK,报头中的确认序号为滑动窗口起始位置,滑动窗口结尾是滑动窗口起始位置加上窗口大小。
丢包问题:
这种机制称之为快重传。超时重传是重传策略兜底的,快重传是给重传策略提高策略的。
如果丢包了,滑动窗口如何更新? 丢包问题分为三种情况:
如果报文没有丢失,收到的应答丢失不会有影响,后面会有应答。 2000的报文丢失了
win_start
不进行向右滑动ACK
,然后滑动窗口继续向右移动中间报文丢失的问题实际上就是最左侧报文丢失。中间报文前面的报文丢失,滑动窗口会向右移动,直到2001-3001报文为止。因此中间报文转换到了新窗口的最左侧报文丢失问题。
因此,在滑动窗口中丢包问题都可以转换成最左侧报文丢失问题
对于已经发送且已经确认部分的数据,在缓冲区是否需要清零? 不需要再清零,已发送已确认的数据代表已经废弃的数据。缓冲区可以看成一个环形的缓冲区,对于已发送已确认的数据下次滑动窗口到这里的时候,再次拷贝新数据到这里即可。
在重传策略以及滑动窗口中考虑的都是发送方和接受放的能力来取决发送数据量和接收数据量。但是没有考虑网络的状况。
因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络 状态下, 贸然发送大量的数据, 是很有可能引起雪上加霜的。
网络是被所有人共享的,造成网络拥堵不是一个用户造成的,一旦网络拥堵,造成的是所有用户都网络拥堵。
TCP如何识别出来网络的问题? 作为发送方,发送数据时发现大量丢包,此时认为是网络的问题。这种情况下不能使用重传策略,因为此时网络处于拥堵状态,再去快速重传只会使得网络更堵。而是使用慢开始策略。
TCP 引入 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据。
这里新增了一个窗口,叫做拥塞窗口。因此此时的滑动窗口 = min(应答窗口,拥塞窗口)
网络的状况是浮动的,拥塞窗口的大小也是浮动的。主机要想直到拥塞窗口的大小,需要通过多轮尝试才能获取拥塞窗口的大小。
像上面这样的拥塞窗口增长速度, 是指数级别的.慢启动只是指初使时慢, 但是增长速度非常快。 初始时慢,可以慢慢减少网路发送,让网络恢复。网络恢复后,通信过程也需要恢复,中后期增长快。
为了不增长的那么快,因此不能使拥塞窗口单纯的加倍,因此需要设置一个阈值,当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。
当 TCP 通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降;拥塞控制,归根结底是 TCP 协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。
如果接收数据的主机立刻返回 ACK 应答, 这时候返回的窗口可能比较小。
窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。
延迟应答策略:
在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是 "一发一收"的. 意味着客户端给服务器说了 “How are you”, 服务器也会给客户端回一个 “Fine,thank you”;
创建一个 TCP 的 socket
, 同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区
双方在进行通信的时候,调用write
数据会先写入发送缓冲区。如果发送的字节数太长,会被拆分成多个TCP的数据包;如果字节太短,会先在缓冲区等待长度适当或者其他合适的机会再发送出去。
由于缓冲区的存在,TCP程序的读和写不需要一一匹配: 写 100 个字节数据时, 可以调用一次 write 写 100 个字节, 也可以调用 100 次write, 每次写一个字节。 读 100 个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100 个字节, 也可以一次 read 一个字节, 重复 100 次。
这样可能就会出现粘包问题
粘包问题中的 “包” , 是指的应用层的数据包。
站在传输层的角度, TCP 是一个一个报文过来的. 按照序号排好序放在缓冲区中;站在应用层的角度, 看到的只是一串连续的字节数据。应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分, 是一个完整的应用层数据包。
如何避免粘包问题?明确两个包之间的边界。对于定长的包, 保证每次都按固定大小读取即可; 对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包的结束位置,还可以在包和包之间使用明确的分隔符。
在UDP中,有一个16位的UDP长度以及定长报头,果还没有上层交付数据, UDP 的报文长度仍然在. 同时, UDP 是一个一个把数据交付给应用层. 就有很明确的数据边界。站在应用层的站在应用层的角度, 使用 UDP 的时候, 要么收到完整的 UDP 报文, 要么不收. 不会出现"半个"的情况