URG:它为了标志紧急指针是否有效。
ACK:标识确认号是否有效。
PSH:提示接收端应用程序立即将接收缓冲区的数据拿走。
RST:它是为了处理异常连接的, 告诉连接不一致的一方,我们的连接还没有建立好,要求对方重新建立连接。我们把携带RST标识的称为复位报文段。
SYN:请求建立连接; 我们把携带SYN标识的称为同步报文段。
FIN:通知对方, 本端要关闭连接了, 我们称携带FIN标识的为结束报文段。
接收端收到一条报文后,向发送端发送一条确认ACK,此ACK的作用就是告诉发送端:接收端已经成功的收到了消息,并且希望收到下一条报文的序列号是什么。这个确认号就是期望的下一个报文的序号。
每一个ACK都带有对应的确认序列号,意思是告诉发送者,我们已经收到了哪些数据,下一个发送数据应该从哪里开始。 如上图,主机A给主机B发送了1-1000的数据,ACK应答,携带了1001序列号。告诉主机A,我已经接受到了1-1000数据,下一次你从1001开始发送数据。
TCP在传输数据过程中,还加入了超时重传机制。假设主机A发送数据给主机B,主机B没有收到数据包,主机B自然就不会应答,如果主机A在一个特定时间间隔内没有收到主机B发来的确认应答,就会进行重发,这就是超时重传机制。
当然还存在另一种可能就是主机A未收到B发来的确认应答,也可能是因为ACK丢失了。
因此主机B会收到很多重复数据,那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的包丢弃掉,这时候我们可以利用前面提到的16位序列号, 就可以很容易做到去重的效果。
在理想的情况下,可以找到一个小的时间来保证 "确认应答"一定能在这个时间内返回。但是这个时间的长短,随着网络环境的不同是有差异的。如果超时时间设的太长,会影响整体的重传效率。如果超时时间设的太短,有可能会频繁发送重复的包。TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
Linux中超时时间以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。如果重发一次之后,仍然得不到应答,等待2*500ms后再进行重传。如果仍然得不到应答,等待4*500ms进行重传。依次类推,以指数形式递增,当累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。
在正常情况下, TCP要经过三次握手建立连接,四次挥手断开连接。
当我们实现一个TCP服务器时,我们把这个服务器运行起来然后将服务器关闭掉,再次重新启动服务器会发现一个问题:就是不能马上再次绑定这个端口号和ip,需要等一会才可以重新绑定,其实等的这一会就是TIME_WAIT状态。
首先,TIME_WAIT是为了防止最后一个ACK丢失,如果没有TIME_WAIT,那么主动断开连接的一方就已经关闭连接,但是另一方还没有断开连接,它收不到确认ACK会认为自己上次发送的FIN报文丢失会重发该报文,但是另一方已经断开连接了,这就会造成连接不一致的问题,所以TIME_WAIT是必须的。
MSL是TCP报文在发送缓冲区的最大生存时间,如果TIME_WAIT持续存在2MSL的话就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失。(否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的)。同时也是在理论上保证最后一个报文可靠到达。(假设最后一个ACK丢失, 那么服务器会再重发一个FIN,这时虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发LAST_ACK,这就会导致问题)
在server的TCP连接没有完全断开之前不允许重新绑定,也就是TIME_WAIT时间没有过,但是这样不允许立即绑定在某些情况下是不合理的:
解决方法:使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符。
如果客户端是主动断开连接的一方,在服务器端假设没有关闭新连接,这时服务器端就会产生一个CLOSE_WAIT状态,因为服务器没有去关闭连接,所以这个CLOSE_WAIT状态很容易测试出来,这时四次挥手没有结束,只完成了两次。
#include "tcp_socket.hpp"
typedef void (*Handler)(string& req, string* res);
class TcpServer
{
public:
TcpServer(string ip, uint16_t port)
:_ip(ip)
,_port(port)
{}
void Start(Handler handler)
{
//1.创建socket
listen_sock.Socket();
//2.绑定ip和端口号
listen_sock.Bind(_ip, _port);
//3.监听
listen_sock.Listen(5);
while(1)
{
TcpSocket new_sock;
string ip;
uint16_t port;
//4.接收连接
listen_sock.Accept(&new_sock, &ip, &port);
cout <<"client:" << ip.c_str() << " connect" << endl;
while(1)
{
//5.连接成功读取客户端请求
string req;
bool ret = new_sock.Recv(&req);
cout << ret << endl;
if(!ret)
{
//此处服务器端不关闭新连接,导致CLOSE_WAIT状态
//new_sock.Close();
break;
}
//6.处理请求
string res;
handler(req, &res);
//写回处理结果
new_sock.Send(res);
cout << "客户:" << ip.c_str() << " REQ:" << req << ". RES:" << res << endl;
}
}
}
private:
TcpSocket listen_sock;
string _ip;
uint16_t _port;
};
如果服务器上出现大量的CLOSE_WAIT状态,原因就是服务器没有正确的关闭 socket,导致四次挥手没有正确完成。这是可能是一个BUG,只需要加上对应的 close即可解决问题。
确认应答策略对每一个发送的数据段都要给一个ACK确认应答,接收方收到ACK后再发送下一个数据段,但是这样做有一个比较大的缺点,就是性能较差,尤其是数据往返的时间较长的时候。既然一发一收的方式性能较低,那么我们考虑一次发送多条数据,就可以大大的提高性能,它是将多个段的等待时间重叠在一起。
窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。上图的窗口大小就是4000个字节(四个段)。发送前四个段的时候,不需要等待任何ACK直接发送即可。当收到第一个ACK后滑动窗口向后移动,继续发送第五个段的数据,然后依次类推。操作系统内核为了维护这个滑动窗口,需要开辟发送缓冲区来记录当前还有哪些数据没有应答。只有确认应答过的数据,才能从缓冲区删掉,窗口越大,则网络的吞吐率就越高。滑动窗口左边代表已经发送过并且确认,可以从发送缓冲区中删除了,滑动窗口里边代表发送出去但是没有确认,滑动窗口右边代表还没有发送的数据。
接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被装满,这个时候如果发送端继续发送,就会造成丢包,然后引起丢包重传等等一系列连锁反应。因此TCP支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制(Flow Control)。
接收端如何把窗口大小告诉发送端呢? 在的TCP首部中,有一个16位窗口字段,就是存放了窗口大小信息,16位数字大表示65535,那么TCP窗口大就是65535字节吗? 实际上TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是 窗口字段的值左移M位。接收端窗口如果更新,会向发送端发送一个更新通知,如果这个更新通知在中途丢失了,会导致无法继续通信,所以发送端要定时发送窗口探测包。
虽然TCP有了滑动窗口这个大杀器能够高效可靠的发送大量的数据,但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题,因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵,在不清楚当前网络状态下,贸然发送大量的数据是很有可能引起雪上加霜的,造成网络更加堵塞。
TCP引入慢启动机制,先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
图中的cwnd为拥塞窗口,在发送开始的时候定义拥塞窗口大小为1,每次收到一个ACK应答拥塞窗口加1。每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口。
像上面这样的拥塞窗口增长速度,是指数级别的。"慢启动"只是指初使时慢,但是增长速度非常快。为了不增长的那么快,因此不能使拥塞窗口单纯的加倍,此处引入一个叫做慢启动的阈值当拥塞窗口超过这个阈值的时候,不再按照指数方式增长, 而是按照线性方式增长。
少量的丢包,我们仅仅是触发超时重传。大量的丢包,我们就认为网络拥塞。当TCP通信开始后,网络吞吐量会逐渐上升。随着网络发生拥堵,吞吐量会立刻下降。拥塞控制归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。
拥塞控制是防止过多的数据注入到网络中,可以使网络中的路由器或链路不致过载,是一个全局性的过程。 流量控制是点对点通信量的控制,是一个端到端的问题,主要就是权衡发送端发送数据的速率,以便接收端来得及接收。
无论是在慢开始阶段还是在拥塞避免阶段,只要发送方判断网络出现拥塞(其根据就是没有收到确认,虽然没有收到确认可能是其他原因的分组丢失,但是因为无法判定,所以都当做拥塞来处理),这时就把慢开始门限设置为出现拥塞时的门限的一半。然后把拥塞窗口设置为1,执行慢开始算法。
如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小。假设接收端缓冲区为1M 一次收到了500K的数据。如果立刻应答,返回的窗口就是500K。 但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了,在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些也能处理过来。如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M。
窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。
注:具体的数量和超时时间, 依操作系统不同也有差异; 一般N取2, 超时时间取200ms
在延迟应答的基础上,存在很多情况下,客户端服务器在应用层也是"一发一收" 的。 意味着客户端给服务器说了"How are you", 服务器也会给客户端回一个"Fine, thank you"。那么这个时候ACK就可以搭顺风车,和服务器回应的 “Fine, thank you” 一起回给客户端
当我们创建一个TCP的socket,同时在内核中创建一个发送缓冲区和一个接收缓冲区。
由于缓冲区的存在,TCP程序的读和写不需要一一匹配。例如: 写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节; 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次 read一个字节, 重复100次
粘包问题中的 "包"是指的应用层的数据包。在TCP的协议头中,没有如同UDP一样的 "报文长度"这样的字段,但是有一个序号这样的字段。站在传输层的角度, TCP是一个一个报文过来的,按照序号排好序放在缓冲区中,但是站在应用层的角度,它看到的只是一串连续的字节数据。应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分结束是一个完整的应用层数据包,这就是粘包问题。
对于UDP协议,如果还没有上层交付数据, UDP的报文长度仍然在。 同时UDP是一个一个把数据交付给应用层,这样就有存在明确的数据边界,站在应用层的角度, 使用UDP的时候要么收到完整的UDP报文要么不收,不会出现"半个"的情况。
————————————————
版权声明:本文为CSDN博主「Hansionz」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/hansionz/article/details/86435127