作者:@小萌新
专栏:@网络
作者简介:大二学生 希望能和大家一起进步
本篇博客简介:补充下对于TCP协议的各种理解
TCP协议相关实验
- TCP相关试验
- 理解CLOSE_WAIT状态
- 理解TIME_WAIT状态
- 解决TIME_WAIT状态引起的bind失败的方法
- 理解listen的第二个参数
- SYN洪水攻击
- TCP和UDP
- TCP和UDP协议对比
- 用UDP实现可靠传输(经典面试题)
TCP相关试验
理解CLOSE_WAIT状态
我们要理解CLOSE_WAIT状态 首先要知道什么时候会出现CLOSE_WAIT状态
当我们的客户端和服务器通信的时候 客户端调用close函数关闭其文件描述符 此时客户端在底层就会像服务器发送FIN报文 服务器给予FIN报文响应的ACK报文之后就会变成CLOSE_WAIT状态 而客户端会在第一次发送FIN报文的时候变为FIN_WAIT1状态 在收到ACK报文后变为FIN_WAIT2状态
我们可以编写一个简单的TCP服务器来模拟出该现象 不过事实上我们只需要写出服务器的代码就可以了 客户端的代码我们可以使用一些现成的工具比如说TELNET
服务器代码如下
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <pthread.h>
using namespace std;
const int PORT = 8081;
const int NUM = 5;
void* RUN(void* arg)
{
pthread_detach(pthread_self());
int fd = *(int*)arg;
delete arg;
while(1)
{
cout << "sockfd" << fd << "is severing the client" << endl;
sleep(1);
}
return nullptr;
}
int main()
{
int listen_socket = socket(AF_INET , SOCK_STREAM , 0);
if (listen_socket < 0)
{
cerr << "listen_socket error" << endl;
exit(1);
}
struct sockaddr_in local;
memset(&local , '\0' , sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(PORT);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(listen_socket , (struct sockaddr*)&local , sizeof(local)) < 0)
{
cerr << "bind error" << endl;
exit(2);
}
if(listen(listen_socket , NUM) < 0)
{
cerr << "listen error" << endl;
exit(3);
}
// peer
struct sockaddr_in peer;
memset(&peer , '\0' , sizeof(peer));
socklen_t len = sizeof(peer);
for(;;)
{
int sockfd = accept(listen_socket , (struct sockaddr*)&peer , &len);
if (sockfd < 0)
{
cerr << "accept error" << endl;
continue;
}
cout << "get a new linl:" << endl;
int* p = new int(sockfd);
pthread_t tid;
pthread_create(&tid , nullptr , RUN , (void*)p);
}
return 0;
}
我们使用netstat查看 可以发现两条连接 一条是客户端的连接 一条是服务器的连接
当我们现在将Telnet推出后客户端维护的连接状态会变为FIN_WAIT2 (因为它调用close函数到收到响应这段时间太短了 以至于我们捕捉不到FIN_WAIT1状态) 服务器的状态就会变为CLOSE_WAIT状态 因为我们在内部没有调用close函数 所以说服务器不会发起最后的第三次挥手
理解TIME_WAIT状态
当客户端和服务器进行通信的时候 客户端调用close函数关闭对应的文件描述符 如果服务器收到后也调用close函数进行了关闭 那么此时双方将正常完成四次挥手
但是主动发起四次挥手的一方 在四次挥手之后并不会立即进入COLSED状态 而是会短暂的进入TIME_WAIT状态中等待一段时间
我们继续刚刚的试验 由于telnet推出后服务器没有调用close函数 所以说客户端的变为了TIME_WAIT 服务器的状态变为了CLOSE_WAIT
之后如果我们想要见到TIME_WAIT状态 我们就只需要方服务器调用close函数就可以 但是我们在服务器端的源码中并没有调用close函数 所以说我们要想办法关闭服务器打开的文件描述符
我们在前面一篇博客 TCP协议详解中说明了TCP连接异常断开的几种情况 其中我们说过 如果进程崩溃那么其实是和正常完成四次挥手没有区别的 而进程崩溃实际上就是操作系统给进行发送了信号 所以说我只需要我们使用键盘让OS向该进程发送信号就能够模拟服务器正常调用close函数的情况
再刨根问底一点 为什么操作系统向进程发送信号之后就相当于会调用close函数呢? 这里其实是因为文件描述符的生命周期是随进程的 而进程在被操作系统杀死后操作系统会回收资源 这些资源中肯定就包括文件描述符了
所以说此时我们先让telnet退出 紧接着让服务器退出 就能见到TIME_WAIT状态了
解决TIME_WAIT状态引起的bind失败的方法
我们在前面介绍过 四次挥手的时候主动发起挥手一方在四次挥手结束的时候会进入TIME_WAIT状态一段时间
如果服务器在有客户端的情况下主动退出了 就相当于是服务器先进行了四次挥手 那么服务器就将要进入TIME_WAIT一段时间
如果我们此时想要重新启动服务器绑定该端口号我们会发现我们绑定失败了
这是因为在TIME_WAIT期间 这个连接并没有被完全释放 这也就意味着我们的端口是被占用着的 此时服务器想要继续绑定该端口号就只能等待TIME_WAIT时间结束
当时我们服务器崩溃时最重要的就是让服务器重新启动并且是绑定原来的端口号 (因为一般一些服务的端口号都是固定的 如果你修改了端口号的话在网络中别人可能就不知道你了)如果想要让服务器崩溃后在TIME_WAIT期间也能立马重新启动 需要让服务器在调用socket函数创建套接字后 继续调用setsockopt函数设置端口复用 这也是编写服务器代码时的推荐做法
setsockopt函数
setsockopt函数可以设置端口复用 该函数的函数原型如下:
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
返回值说明:
- 设置成功返回0 失败返回-1 同时错误码会被设置
参数说明:
- sockfd:需要设置的套接字对应的文件描述符
- level:被设置选项的层次 比如在套接字层设置选项对应就是SOL_SOCKET
- optname:需要设置的选项 该选项的可取值与设置的level参数有关
- optval:指向存放选项待设置的新值的指针
- optlen:待设置的新值的长度
我们这里要设置的就是监听套接字 将监听套接字在套接字层设置端口复用选项SO_REUSEADDR 该选项设置为非零值表示开启端口复用
int opt = 1;
setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
当我们主动发送信号让服务器崩溃之后我们立刻再次启动服务器 我们发现此时就不会出现bind error错误了
理解listen的第二个参数
在我们编写TCP套接字服务器的时候 在进行了套接字的创建和绑定之后需要调用listen函数将服务器变为监听状态 在这之后我们就能使用accept函数建立连接了
我们在调用listen函数的时候需要输入两个参数 第一个参数是我们要设置为监听状态的套接字 第二个参数是一个数字 它表示服务器可以建立的最大连接数
下面我们会通过一个试验来试验下listen的第二个参数是不是这个意思 试验步骤如下 :
- 首先编写tcp套接字的服务器代码 关于我们这次的代码 我们只需要创建套接字 绑定 监听但是服务器初始化之后我们不调用accept函数建立连接
- 为了方便验证 我们这里将listen的第二个参数设置为2
服务器代码如下
#include <iostream>
using namespace std;
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <pthread.h>
using namespace std;
int main()
{
// create socket
int listen_sockfd = socket(AF_INET , SOCK_STREAM , 0);
if (listen_sockfd < 0 )
{
cerr << "socket error" << endl;
exit(1);
}
int opt = 1;
setsockopt(listen_sockfd , SOL_SOCKET , SO_REUSEADDR , &opt , sizeof(opt));
// bind
struct sockaddr_in local;
memset(&local , '\0' , sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8081);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(listen_sockfd , (struct sockaddr*)&local , sizeof(local)) < 0 )
{
cerr << "bind error" << endl;
exit(2);
}
// listen
listen(listen_sockfd , 2);
for(;;)
{
;
}
return 0;
}
在运行./sever
服务之后 我们可以使用 netstat -nlp
t命令 来查看处于监听状态的进程
接下来使用Postman向我们的服务器发送一个请求
关于怎么使用Postman大家可以参考这个视频
如何使用Postman
说明: 需要说明的是 我们这里之所以不选用浏览器(浏览器的就是基于TCP协议的) 是因为浏览器如果没有收到服务端的响应可能会重复发送 这个现象在我们介绍应用层的HTTP协议的时候有涉及
我们通过Postman发起请求之后可以使用下面的命令查看建立完毕的连接
netstat -npt | head -2 && netstat -npt | grep 8081
我们可以发现该连接现在处于ESTABLISHED状态 接下来我们继续时候Postman发送第二 三个请求
由于我们的服务器并没有调用accept函数获取已经建立好的连接 所以说服务器已经建立好的连接是三个
我们继续尝试下使用Postman发送第四个请求
我们查看当前服务器的网络连接之后发现了这样子的现象
此时我们并没有增加ESTABLISHED状态了 而是增加了一个SYN_RECV状态
而我们回看三次握手的场景
我们可以发现SYN_RECV状态出现在客户端给服务器发送建立连接请求但是服务器没有响应的时候
也就是说刚刚的Postman发送请求之后 我们服务器并没有回应客户端的请求
过一段时间之后我们继续服务建立连接的状态
我们可以发现在一段时间过后三次握手失败 该SYN_RECV连接就被自动释放了
而在Postman上表现为这样子
总结下上面的试验现象
- 不论有多少的客户端请求连接 最终在我们的服务器端只会有三个连接建立成功
- 当发来第四个SYN请求的时候 服务只是收到了却不做响应 等待三次握手失败后自动释放连接
listen的第二个参数
我们直接对于上面的现象给出解释
实际上我们在进行TCP连接管理的时候会用到两个连接队列
- 全连接队列(accept队列) 全连接队列用于保存处于ESTABLISHED状态 但没有被上层调用accept取走的连接
- 半连接队列 半连接队列用于保存处于SYN_SENT和SYN_RCVD状态的连接 也就是还未完成三次握手的连接
我们在这里谈及Listen第二个参数的原因是 在linux操作系统中 全连接队列的长度就等于Listen的第二个参数+1 所以说我们服务器的Listen参数设置为2 此时服务器的全连接队列长度就为3 也就是说此时能够建立ESTABLISHED状态的连接就为3个
而当全连接队列满了之后服务器此时就不能够建立ESTABLISH连接了 此时如果客户端请求建立连接那么服务器不会进行SYN+ACK响应 而是会将该连接放在半连接队列中 状态变为SYN_RECV 在一段事件后如果三次握手还没有成功则会自动释放连接
此外如果半连接队列也满了 服务器会自动拒绝所有连接请求
如果被accept了全连接队列减少一个连接吗?
如果全连接中的队列被accept之后全连接队列会减少的
我们可以把服务器想象成一个饭店 这个饭店可以提供128人吃饭(服务器可以接收的连接数默认是128)
但是这个饭店太火爆了 几乎去的时候人都是满的 (参考下海底捞) 于是饭店老板便在饭店外面放了X+1个椅子(X为Listen的第二个参数 ) 坐在饭店椅子上的人就可以认为他们正处于EXTABLISHED状态 当饭店里面有人吃饭完饭店服务员就是使用accept函数让他们进来吃饭 此时我们就可以认为全连接队列少了一个连接 (此时在旁边站着的 也就是半连接队列的人就可以坐上椅子 变为EXTABLISHED状态)
为什么底层要维护连接队列?
其实我们在服务器压力较大的时候才能看出来连接队列的作用 我们上面的例子就能很好的说明原因 如果饭店没有满员的话服务员完全可以直接使用accept函数让人进去吃饭 没有必要让别人坐在外面的椅子上了
事实上 在服务器运行的时候我们就预先在服务器上面预先创建了多个线程 当主线程从底层accept上来连接之后这些连接就会交由其他线程处理
在服务器不繁忙的情况下连接一旦在底层建立好就会交由我们的服务线程处理了
可是当服务器繁忙的时候如果没有连接队列 我们的服务器就会拒绝其他的连接请求
如果拒绝之后我们的线程有处理完毕手上的连接了 但是此时没有任何新的连接请求发过来 那么此时该线程就会处于闲置状态
所以说连接队列的存在是必要的 它能够保证我们的服务器满负荷工作
连接队列长度问题
全连接队列的长度我们一般设置为5
事实上全连接的队列的长度不能太长也不能太短
如果说全连接的队列长度太长
- 这就意味着位于队列尾部的连接需要较长的时间才能享受到服务 此时客户端的请求需要较长时间才能收到响应
- 服务器维护这些连接也需要成本 如果能够维护很长的连接不如直接扩建“饭店”了
当然服务器全连接的长度也不能太短 比如说如果全连接队列的长度只有一 那么在连接数很大的情况下和没有连接队列没什么两样
全连接队列的长度
全连接队列的长度由两个值决定
- 用户层调用listen时传入的第二个参数backlog
- 系统变量net.core.somaxconn 默认值为128
事实上我们全连接队列的长度就等于这两个队列长度的较小值+1
SYN洪水攻击
在这个小节中我们会讲解SYN洪水攻击是什么以及应用层和传输层防范SYN洪水攻击的方式
正常三次握手方式
如上图所示
- 首先客户端会向服务器发送SYN连接请求 服务器收到之后会响应SYN+ACK给客户端并且将该连接放入半连接队列中
- 此时如果客户端接收到了服务器发送的SYN+ACK报文并回复ACK给服务器 那么服务器就会将该连接从半连接队列中放到全连接队列中
- 此时上层就可以通过调用accept函数 从全连接队列当中获取建立好的连接了
SYN洪水攻击
异常情况:
- 客户端在发起连接建立请求后突然死机或掉线 那么服务器发出的SYN+ACK就得不到对应的ACK应答
- 这时候服务器由于收不到对方的ACK就会触发超时重传机制 如果经过多次重发之后还是收不到ACK应答此时服务器就会关闭这个连接 我们将该时间称为SYN timeout
- 在SYN timeout时间内这个连接一直维护在半连接队列中
此时服务器虽然需要短暂维护这些异常连接 但这种情况毕竟是少数 不会对服务器造成太大影响
但是如果有用户恶意大量模拟这些情况
- 此时服务器就需要一个非常大的半连接队列 并且实际上这些连接都是无用的
- 当半连接队列被占满之后 新来的连接都会被服务器直接拒绝
- 我们把这种发送大量的SYN 但是却不对服务器的 SYN+ACK请求进行响应的行为 叫做SYN洪水攻击
如何防范SYN洪水攻击呢?
防范SYN洪水攻击的时候肯定不光要考虑传输层 还要考虑应用层
应用层:
- 我们应用层可以建立黑名单机制 如果一个ip在短时间内发送了大量的SYN请求我们就可以将这个ip封禁 对于此ip发送的SYN请求一律置之不理
传输层:
- 增大半连接队列长度 但是其实这个方案能够起到的效果很少 因为都说了是SYN洪水肯定 肯定能够发送大量的SYN请求如果我们增大长度对方只需要发送更多的SYN请求就好了
- 减少超时重发的次数 同样的这个也是治标不治本的做法
传输层最有效的方式应该是引入syncookie机制 当服务器收到一个连接建立请求后 会根据这个SYN包计算出一个cookie值 将其作为将要返回的SYN+ACK包的初始序号 然后将这个连接放到一个暂存队列当中
当服务器收到客户端的ACK响应时 会提取出当中的cookie值进行对比 对比成功则说明是一个正常连接 此时该连接就会从暂存队列当中移到全连接队列供上层读取
引入了syncookie机制的好处:
- 引入了syncookie机制之后 这些异常连接就不会堆积到半连接队列中了 而是会放在暂存队列中
- 对于正常的SYN请求 一般会立刻对于服务器的SYN+ACK进行ACK应答 所以说会很快建立正常连接成功
- 而异常连接或者说是SYN洪水攻击 不会对于服务器的SYN+ACK请求进行ACK应答 所以说异常连接都会堆积到队列中
TCP和UDP
TCP和UDP协议对比
在对比他们之间我们首先要知道TCP和UDP协议是什么
TCP协议
TCP协议叫做传输控制协议(Transmission Control Protocol)它是一种面向连接的 可靠的 基于字节流的传输层协议
TCP协议是面向连接的 如果两台主机之间想要进行通信就需要先建立连接 连接建立成功之后进行数据传输
其次TCP协议是保证可靠的协议 如果数据在传输的过程中出现了丢包 乱序等问题 TCP协议都有自己的相解决方案
UDP协议
UDP协议叫做用户数据报协议(User Datagram Protocol) 它是一种无需连接的 不可靠的 基于数据包的传输层协议
使用UDP协议的时候无需建立连接 如果两台主机之间想要进行通信 那么直接将数据发送给对端主机就可以了
上面的描述其实也就概括了UDP协议是不可靠的 如果数据在传输的过程中出现了丢包 乱序等问题 UDP协议都不知道 更别说解决了
TCP/UDP对比
TCP协议是可靠的 UDP协议是不可靠的 但是这并不意味着TCP协议比UDP协议要好 UDP协议不可靠也就意味着UDP协议足够简单 TCP可靠也就意味着TCP所做的准备工作也就越多 但是需要注意的是TCP所做的准备工作越多并不意味着TCP越慢 事实上它们的效率是差不多的
- TCP常用于需要可靠传输的情况 比如说文件传输 重要更新等场景
- UDP常用于对高速传输和实时性较高的通信领域 比如说QQ 视频传输等 此外UDP也可以用于广播
UDP协议和TCP协议不存在孰优孰劣这种说法 只有在某个场景中使用哪个协议更合适
用UDP实现可靠传输(经典面试题)
这里有一道经典的面试题:
使用UDP来实现可靠传输
我们提到传输层的可靠协议就能想到TCP协议了 所以说这道面试题的本质其实是在考查你对于TCP协议的了解程度
当我们知道了这道面试题的时候我们首先应该问面试官具体的应用场景 之后根据场景的需要往TCP协议上套
比如说:
- 引入序列号 保证数据按序到达
- 引入确认应答 确保对端接收到了数据
- 引入超时重传 如果隔一段时间没有应答 就进行数据重发
- … …