TCP协议详解(2)
连接管理机制(三次握手和四次挥手)
==三次握手==
三次握手是连接的机制!握手不一定成功!但是握手成功连接就认为是建立成功!四次挥手也是同理
三次握手的成功都是建立在各自的视角来看待的!客户端只要有来回三个报文后!那么客户端就就认为它成功了!哪怕那个最后的ACK报文还没有到服务器!(最后的这个报文是没有应答的!客户端也是无法知道的!)服务器只有接收到ACK报文后,才会认为三次握手成功!
为什么说三次握手不一定成功呢?
前面的两个报文如果丢了!都无伤大雅!因为对于客户端来说,无非就是进行重传!
因为服务端和客户端在成功握手不是同时完成的!有一定的时间间隔!——也就是说会有一个短暂的认知不一致状态!即:服务器认为没有握手成功!客户端认为已经握手成功!
==如果这时候第三个报文丢了呢,那么就会发生认知不一致!即服务端没有握手!但是客户端已经握手!然后客户端又给服务器发消息了会怎样呢?——我们上面说过TCP有一个标志位RST,那么服务端就会将报文的RST标志位置为1,发给客户端!让其重新开始握手!==
所以最后一个报文可以丢失!我们也不怕建立失败!因为我们有足够的解决方案来,解决建立失败产生的后果
因为服务端会一次面临很多的连接!所以连接是要被OS管理起来的!——就是使用先描述,后组织的方式(用结构体描述,用数据结构管理)——所以我们可以看出来维护一个连接是有成本的!
==那么为什么要进行三次握手呢?==
一次握手行不行呢?——即直接客户端给服务器发一个消息,说我要连你了,然后服务端不回答!就直接连上了!这样行不行呢?
肯定不行!因为假如客户端打一个死循环,然后疯狂的发起连接!因为客户端就一直维护已经建立好的连接了!客户端只要不断的发送SYN报文!一个SYN就是一个连接,这样子就会导致服务器可用的资源会越来越少!很快就可以仅凭单机就将整个服务器的资源都给占用!——==这就是SYN洪水!==
那么两次握手行不行呢?
我们所谓的连接建立时双方共识,而不是说站在上帝视角说是两个连接在一起,因为我们是TCP,所以未来一定是客户端去连接服务器,当时二次握手的时候,即当服务器的ACK报文发出去的那一刻!==服务器就已经认为握手成功!连接建立连接完成!——那么我们就出现了和第一次握手一样的问题!——不管客户端有没有收到ACK报文!但是服务端这边已经帮它维护好了一个连接!——那么SYN洪水依旧是有效的!==
那么三次握手为什么行呢?
1.TCP是全双工的!站在客户端和服务端都是即能收消息又能发消息!要在我们连接连接之前就要既能收又能发的!——而三次握手是用最小成本验证全双工通信信道是通畅的!
因为对于客户端要发出去一个报文,接受一个报文!而对于服务端也要发出去一个报文!接收一个报文!
我们这里也能看出来为什么一次/两次不行,因为无法验证客户端和服务器的通信信道是通畅的!
2.因为三次握手,在建立连接的时候是客户端先建立的!而服务器只有接收了ACK报文后才会建立维护连接!——所以可以有效防止单机对服务器进行爆破!
这个和上面的两种情况有什么不一样吗?对于SYN洪水有什么防范作用呢?
很简单,上面两种情况都是允许客户端在不建立连接的情况下(即客户端不用付出什么成本),让服务器建立维护连接!这样子很容易让单主机就直接让服务器的资源被占满!
但是三次握手要求,让服务器建立连接之前,客户端要先建立!这样单主机的客户端如何想要对服务器进行SYN洪水!首先的要求就是自身配置要比服务器高!(但是一般很难)否则服务器资源没有被占满!自身的资源反倒先用完了!——==这样子就有效的防止了单主机的SYN攻击!==
==但是者不意味着服务器就不会收到SYN攻击!——记住,服务器收到攻击!本身就不是TCP握手该解决的问题!TCP是解决的是通信问题!==
==三次握手照样会被攻击!单主机不行,那就多主机不就可以了?——例如:在全网内传播某个病毒,让很多人的机器感染了,给这些人的机器植入什么程序呢?——约定好某个时间段准时的同时向服务器发起TCP请求!只要机器数量足够多!那么就可以将服务器干爆因为服务器的连接已经消耗完了!其他人发起请求就连不上了!服务器就无法提供服务了!==
这批机器我们一般称之为肉鸡,这种攻击一般叫做ddos攻击!——这种攻击是解决不了的,因为连接都是正常请求!如果屏蔽掉,可能会影响普通用户的体验,所以只能用提高服务器配置来应对
==那么如果我们即使是三次握手,客户端也只发一次ACK请求,然后不断的循环发送对服务器有影响吗?——有!因为服务器会维持一个半连接!这个半连接也会占用资源!虽然半连接一段时间后会自动释放!但是这样子不断的进进出出也是会消耗服务器的性能的!——但是这也不是tcp三次握手该解决的!应该配合黑名单,白名单这样的策略来解决!tcp里面也有一些简单的策略SYN cookie来应对这样的情况==
四次握手/五次/六次...握手呢?
只要是三次握手能行!那么四次/五次/六次.....都行!
只不过如果是偶数次的握手就会导致,服务器先比客户端建立起连接!因为最后一次是由服务器发送的!那么如果客户端不接受最后一次的请求!那么服务器上面也可以挂满大量的连接!
最重要的是 三次握手都已经把问题最低成本的解决了!还要四次,五次干什么?
其实我们三次握手也可以理解为四次握手?——什么意思?
明白了三次握手之后!我们要谈一下TCP为什么要连接!——我们都说TCP面向连接所以可以保证可靠性!
那么为什么把连接建立好就可以保证可靠性呢?——因为TCP可以超时重传?确认应答?流量控制?
面向连接这件事本身是不能直接保证可靠性的!——面向连接本质就是交换几个报文创建一个结构体而已!
是间接保证可靠性的!——那么怎么间接保证可靠性呢?TCP是如何知道那个报文丢了?那个报文处于新建状态?连接状态?还是断开的状态?那些报文丢失还要重传,重传下一次的是多长?等等问题
==刚刚我们说的这一些特征,都是要维护在TCP的连接的结构体里面的!——正是因为有了三次握手的机制!所以给双方都形成了连接结构体的共识,这是因为有了连接结构体!才能更好的去完成超时重传!流量控制!确认应答等的数据基础!——三次握手是创建连接结构体的基础!所以三次握手间接保证了可靠性!==
==四次挥手==
为什么要四次挥手
这就是四次挥手的基本的样子!——==四次挥手也有可能变成三次挥手!——例如中间ACK+FIN这两个报文可以合起来!==
TCP状态转换
四次挥手状态的变化
在四次挥手期间任何一方都可能会断开连接!客户端或者服务端都可能!
==我们如何让服务端一直保持CLOSED_WAIT状态呢?——我们只要让服务器端不要进行close函数调用即可!客户端调用close服务器会被动的进行两次握手!但是服务器端不调用close我们就可以让其不对发起请求!那么服务器端就会一直处于CLOSE_WAIT状态==
//这个是让客户端主动断开的测试 #pragma once #include<iostream> #include<functional> #include<string> #include<string.h> #include<unistd.h> #include<sys/wait.h> #include<sys/types.h> #include<sys/socket.h> #include<arpa/inet.h> #include<netinet/in.h> #include<unistd.h> namespace server { enum { USAGE_ERR = 1, SOCKET_ERR, BIND_ERR, LISTEN_ERR, ACCEPT_ERR }; const static int gbacklog = 5; const static uint16_t gport = 8080; using func_t = std::function<void(const int &, int &)>; class httpServer { public: httpServer(func_t func,const uint16_t &port = gport) : port_(port), listensock_(-1),func_(func) { } void initServer() { //1.首先就是要创建套接字 //1.1创建一个套接字! listensock_ = socket(AF_INET,SOCK_STREAM,0); if(listensock_ == -1) { exit(SOCKET_ERR); } //1.2进行绑定! struct sockaddr_in peer; bzero(&peer,sizeof(peer)); peer.sin_family = AF_INET; peer.sin_addr.s_addr = INADDR_ANY; peer.sin_port = htons(port_); int n = bind(listensock_,(struct sockaddr*)&peer,sizeof(peer)); if(n == -1) { printf("bind error\n"); exit(BIND_ERR); } if (listen(listensock_, gbacklog) < 0) { exit(LISTEN_ERR); } } void start() { for (;;) { signal(SIGCHLD, SIG_IGN); // 直接忽略子进程信号,那么操作系统就会自动回收 struct sockaddr_in peer; socklen_t len = sizeof(peer); int sock = accept(listensock_, (struct sockaddr *)&peer, &len); std::cout << sock << std::endl; if (sock == -1) { continue; } pid_t id = fork(); if (id == 0) { close(listensock_); HandlerHttp(sock); /////////////////////////////////////////// //close(sock);//不要进行close! //exit(0); ////////////////////////////////////////////////// } close(sock); } } ~httpServer() { } private: void HandlerHttp(int sock) { while(1) { sleep(1); } } private: int listensock_; // tcp服务端也是要有自己的socket的!这个套接字的作用不是用于通信的!而是用于监听连接的! uint16_t port_;//tcp服务器的端口 func_t func_; }; }
最后主动关闭连接的一方确实也进入TIME_WAIT!
==如果我们的服务器出现了大量CLOSE_WAIT状态那么那么就有如下几种情况!==
1.服务器有bug!没有做close文件描述符的动作!——那么服务器就无法完成四次挥手!
2.服务器有压力!一直在推送消息个client!导致来不及close!
如果长期处于CLOSE_WAIT,那么服务器因为是被人关闭的!但是自己却没有调用clsoe,会让客户端也会一直保持FIN_WAIT2的状态!但是客户端在等一段时间后发现没有推送消息后,连接也可能会被干掉
==这个TIME_WAIT状态会被维持一段时间!随着一段时间后会自动消失!——这个TIME_WAIT状态表示四次挥手动作已经完成!但是主动断开连接的一方要维持一段时间!==
==为什么有TIME_WAIT状态呢?==
CLOSE_WAIT状态的存在是因为,连接其实还没有关闭我们能理解!
但是为什么还有一个TIME_WAIT状态呢?——四次挥手不是已经完成了吗?而且是主动断开的一方要来维持呢?一般要维持多久时间呢?
我们一般将从客户端到服务端,或者从服务端到客户端的最大时间我们称之为MSL(maximum segment lifetime)单向传输的最大时间!
==一般TIME_WAIT的时间是2倍的MSL==
那么为什么要维持这个状态呢?
我们可以通过
cat /proc/sys/net/ipv4/tcp_fin_timeout
来查看linux下的MSL像是笔者本人的linux机器上的时间就是60s
虽然我们一般情况下压根不会有网络中等待60s的情况!但是主要还是为了尽可能的让网络中残留的报文消散
规定TIME_WAIT的时间请读者参考UNP 2.7节;
TCP状态切换汇总
1.较粗的虚线表示服务端的状态变化情况;
2.较粗的实线表示客户端的状态变化情况;
3.CLOSED是一个假想的起始点 比特科技 , 不是真实状态
TIME_WAIT导致的bind失败
我们在进行socket编程的时候,我们可以发现一个现象,那就是——服务器有时候可以立即重启,有时候无法立即重启!——为什么会出现这种情况呢?==因为bind绑定端口失败了!==
//这是服务器主动断开 #pragma once #include<iostream> #include<functional> #include<string> #include<string.h> #include<unistd.h> #include<sys/wait.h> #include<sys/types.h> #include<sys/socket.h> #include<arpa/inet.h> #include<netinet/in.h> #include<unistd.h> namespace server { enum { USAGE_ERR = 1, SOCKET_ERR, BIND_ERR, LISTEN_ERR, ACCEPT_ERR }; const static int gbacklog = 5; const static uint16_t gport = 8080; using func_t = std::function<void(const int &, int &)>; class httpServer { public: httpServer(func_t func,const uint16_t &port = gport) : port_(port), listensock_(-1),func_(func) { } void initServer() { //1.首先就是要创建套接字 //1.1创建一个套接字! listensock_ = socket(AF_INET,SOCK_STREAM,0); if(listensock_ == -1) { exit(SOCKET_ERR); } //1.2进行绑定! struct sockaddr_in peer; bzero(&peer,sizeof(peer)); peer.sin_family = AF_INET; peer.sin_addr.s_addr = INADDR_ANY; peer.sin_port = htons(port_); int n = bind(listensock_,(struct sockaddr*)&peer,sizeof(peer)); if(n == -1) { printf("bind error\n"); exit(BIND_ERR); } if (listen(listensock_, gbacklog) < 0) { exit(LISTEN_ERR); } } void start() { for (;;) { signal(SIGCHLD, SIG_IGN); // 直接忽略子进程信号,那么操作系统就会自动回收 struct sockaddr_in peer; socklen_t len = sizeof(peer); int sock = accept(listensock_, (struct sockaddr *)&peer, &len); std::cout << sock << std::endl; if (sock == -1) { continue; } pid_t id = fork(); if (id == 0) { close(listensock_); HandlerHttp(sock); close(sock); exit(0); } close(sock); } } ~httpServer() { } private: void HandlerHttp(int sock) { char buffer[1024]; recv(sock,buffer,sizeof(buffer),0); } private: int listensock_; // tcp服务端也是要有自己的socket的!这个套接字的作用不是用于通信的!而是用于监听连接的! uint16_t port_;//tcp服务器的端口 func_t func_; }; }
==因为此时我们是主动的关闭服务器!——也就是说服务器是主动断开连接的一方!——那么这时候这个连接就会进入TIME_WAIT状态!==
==我们发现我们后面无论如何再进行bind,都会失败!==
那么这个问题有什么危害呢?实际爆发的场景有什么呢?
例如:618购物节, 这时候,购物软件的服务器会在这一天收到大量的请求,我们的服务器正好能承载100w个连接,但是100w零一个的时候就不行了!
而现在服务器正好里面有100w个连接,然后又来了一个新的连接!那么这时候服务器就整体崩掉了!——因为服务器是主动断开的那一方!所以里面维持着大量的TIME_WAIT状态的连接!这时候我们想要立即重启!但是因为是TIME_WAIT状态,要等60s!那是这是618期间!每一秒都是几百,几千万的流水!如果是60s那么对于公司的损失是十分的巨大的!
==所以我们就要要实现立即重启!==
解决这个问题也很简单!——设置一个套接字复用的选项即可!
setsockopt函数
第一个参数——是listen套接字
第二个参数——指在套接字层
第三个参数——指复用地址
第四个参数——这是一个bool值!
第五个参数——bool值的大小!
==这我们就可以保证一个端口即使处于TIME_WAIT状态,依旧可以被重新绑定==
==该函数的详细用法==
//这是服务器主动断开 #pragma once #include<iostream> #include<functional> #include<string> #include<string.h> #include<unistd.h> #include<sys/wait.h> #include<sys/types.h> #include<sys/socket.h> #include<arpa/inet.h> #include<netinet/in.h> #include<unistd.h> namespace server { class httpServer { public: ///... void initServer() { //1.首先就是要创建套接字 //1.1创建一个套接字! listensock_ = socket(AF_INET,SOCK_STREAM,0); if(listensock_ == -1) { exit(SOCKET_ERR); } //////////////////////////////////////////////////////////// //1.2设置地址复用! int opt = 1; setsockopt(listensock_,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt)); ////////////////////////////////////////////////////////////////// //1.2进行绑定! struct sockaddr_in peer; bzero(&peer,sizeof(peer)); peer.sin_family = AF_INET; peer.sin_addr.s_addr = INADDR_ANY; peer.sin_port = htons(port_); int n = bind(listensock_,(struct sockaddr*)&peer,sizeof(peer)); if(n == -1) { printf("bind error\n"); exit(BIND_ERR); } if (listen(listensock_, gbacklog) < 0) { exit(LISTEN_ERR); } } //... private: //... private: int listensock_; // tcp服务端也是要有自己的socket的!这个套接字的作用不是用于通信的!而是用于监听连接的! uint16_t port_;//tcp服务器的端口 func_t func_; }; }
==我们可以看到哪怕那个端口是处于TIME_WAIT状态!我们依旧能继续启动!==
流量控制
接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送, 就会造成丢包, 继而引起丢包重传等等一系列连锁反应.
因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control);
发送方怎么在第一次就知道对方的接收能力呢?——==我们在通信之前,早就做过了三次我握手!双方已经交换过报文了!TCP报文里面就有一个16位窗口大小,这样子双方就已经知道了对方接收缓冲区的大小了!==
1.接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段, 通过ACK端通知发送端;
2.==窗口大小字段越大, 说明网络的吞吐量越高==;
3.接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;
4.发送端接受到这个窗口之后, 就会减慢自己的发送速度;
5.==如果接收端缓冲区满了, 就会将窗口置为0==; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数 据段, 使接收端把窗口大小告诉发送端
那么问题来了, 16位数字最大表示65535, 那么TCP窗口最大就是65535字节么? 实际上, TCP首部40字节选项中还包含了一个窗口扩大因子M, 实际窗口大小是 窗口字段的值左移 M 位;
滑动窗口
而我们上面谈论过确认应答策略, 对每一个发送的数据段, 都要给一个ACK确认应答. 收到ACK后再发送下一个数据段. 这样做有一个比较大的缺点, 就是性能较差. 尤其是数据往返的时间较长的时候
既然这样一发一收的方式性能较低, 那么我们一次发送多条数据, 就可以大大的提高性能(其实是将多个段的等待时 间重叠在一起了)
当然前提是接收缓冲区,有足够的大小能容纳我们发送的数据!
这批数据,我们发送完第一个后就可以立刻发送第二个,第三个!——也就是说我们可以在没有历史报文做确认的情况下,可以直接发送后续报文!这样子可以大大的加快传送数据!
我们上面也说过那么如果我们发送数据,没有收到应答之前!我们必须将自己的已经发送的数据暂时保存起来!为了支持超时重传!——防止网络传输过程中数据丢包或者响应报文丢包!
==那么重点来了,这个数 据是保存在哪里呢?——保存在发送缓冲区!==
==那么现在我们要介绍一下什么是滑动窗口了!==
所以我们只是给这一段的发送缓冲区的空间取了一个名字叫做滑动窗口!
==那么为什么叫滑动窗口呢?——因为这是一个"滑动"区域==
我们将1001-2000,2001-3000,3001-4000,4001-5000的数据发送出去!
然后我们收到了一个确认应答!里面的确认序号就是2001,这就意味着前2000个字节接收方都收到!
然后整个窗口的结构会==整体向右移动!==这样子我们就将已经发送且接收到的数据,划分到了属于它的区域里面!
==通过不断的"滑动"窗口,来不断的重新划分已经确认且已发送,未确认且已发送,还未发送这三大区域==
但是上面对于移动窗口的理解太抽象了!我们更具体一点的看待移动窗口!
==滑动窗口代表的是一个发送方一次可以向对方发的数据的总量!==
那么窗口开始大小是怎么设定的?
滑动的大小和对方的接收能力有关!
win_start = 0;//开始的大小 win_end = win_start + tcp_win;//这个tcp_win就是我们三次握手的时候得知的从对方的报文里面得知的16位窗口大小!
==未来无论怎么滑动!我们都要保证对方能够正常的进行接收!==
滑动窗口的大小 = 对方通知给我的字节接收能力的大小——这个理解不太准确我们为了我们现在能够更简单的理解我们现在先怎么说
有读者或许会有一个问题,假如对方的接收能力是5000字节,那么滑动窗口就是5000了!那么为什么我们要发那么多的报文?直接一次性用一个报文发送出去不就可以了吗?而不是要1001-2000,2001-3000...多个报文发?
因为TCP协议是受下层约束的!MAC帧所能承载的数据总量是有上限的!底层的MAC帧是不允许的!所以只能拆分成一个个小报文来发送!
窗口一定会向右滑动吗?可以向左滑动么?
一个滑动窗口的左侧是已经发送,并且已经确认的报文!
如果start下标往左移动!那么会让已经发送且确认的数据在要求被确认一次!这是不合理的!——所以滑动窗口是不能也一定不会左移的!
如果接收方的上层一直不拿走接收缓冲区里面的数据!随着接收数据的变多,那么返回回来的16位窗口大小就会越来越小!——而滑动窗口就会相应的变小就是win_start下标一直++,win_end下标不变!==win_end下标长期处于不动的状态!==
==所以滑动窗口一定会向右移动吗?——不一定!可能向右移动,也可能会保持不动!==
窗口一定会一直不变吗?会变大吗?会变小吗?如果会变,确认的依据是会什么?
==窗口会一直不变吗?——肯定不会!他是浮动的!完全取决于对方的容量大小!可能不变!但是不会一直不变!==
会变大么?会变小吗?——都可能会!看服务器上层的处理速度!如果处理速度慢导致了拿取数据的速度,慢与接收数据的速度!那么窗口变小!
如果处理速度快!拿去数据的速度快与接收数据的速度!那么窗口变大
收到应答确认的时候,如果不会最左侧发送的报文的确认,而是中间的,结尾的怎么办?要滑动吗?
我们上面说的都是理想情况!但是万一我们收到是中间的或者结尾的报文呢?
因为我们接收方为了保证可靠性是会对接收到的报文进行排序!——如果我们接收到了不是最左侧报文的确认!==那么就只有一种可能丢包!==
==我们可以看到确认序号是十分的重要的!——除了告诉对的起始窗口之外!还是支持滑动窗口的滑动规则的指定!==
所以回到我们开始的问题:如果不会最左侧发送的报文的确认,而是中间的,结尾的怎么办?要滑动吗?
==不一定要滑动!根据确认序号来进行判断!到底有没有数据丢失!==
滑动窗口必须要滑动吗?会不会不动了呢?或者变为0
从上面的情况我们可以看出!不是必须要滑动的!——最极端的情况!我们发出去的全部报文都丢失!那么滑动窗口也只能不动了!等待超时重传!
可能会不动!也可能变为0
会一直向后滑动吗?如果空间不够了怎么办?
注意我们上面说过滑动窗口的大小 = 对方通知给我的字节接收能力的大小这个概念是不准确的!(或者说只正确一半)
我们上面说的所有策略,例如:超时重传,连接管理,丢包重传,按时重传,去重,滑动窗口,流量控制——目前所有的策略都是端到端的!
什么意思呢?就像是我们上面说的滑动窗口!是客户给服务器的!或者是服务器给客户的!——==是限定在你的主机与我的主机两个主机之间!==
==可是丢包的时候!除了接收方出问题!网络也可能出现问题!——也就是说如果中间这一部分出现问题了该怎么办?==