🚀在网络中两台主机进行通信,本质就是两台主机上的两个进程,进行进程间通信。我们知道ip地址是一个接入网络的设备在网络中的唯一标识,通过ip地址可以找到对应的主机,但是网络通信的本质是进程间通信,怎么找到对应主机上特定的进程呢?
1.通过进程的pid找到对应的进程
使用进程pid的确可以找到一台主机上的某个进程,这种方案确实可行,但是不是很好,因为一台主机上存在大量的进程,并不是所有进程都要进行网络通信,所以势必就要维护哪些进程进行网络通信,哪些进程不进行网络通信。那么不如直接用一个新的概念来针对进行网络通信的进程进行唯一标识。
2.通过端口号找到对应的进程
端口号用于一台主机上进行网络通信进程的标识,端口号的大小为两个字节范围:0-65535,这样通过ip+port的方式就可以唯一确定在网路中的某个主机上的某个进程。
🚀0-1023为知名端口号,用于一些知名应用层协议绑定的端口号。
ftp 21
ssh 22
telnet 23
http 80
https 443
🚀1024-65535,操作系统自动分配端口号,客户端的端口号,就是操作系统从这个范围内自动分配的。
🚀为什么服务端的端口号是固定的?客户端的端口号是操作系统随机分配的?
1.服务器要为大量的客户端提供服务,如果服务端的端口号经常改变就会导致,客户端在不知道新的端口号的情况下,连接不上服务器,这样造成的影响是比较大的,所以服务端固定端口号是合理的。
2.对于客户端而言,一台主机上会存在大量应用的客户端,那么在客户端编写的时候如果bind固定的端口号,就可能会导致多个应用抢占一个端口号的问题,例如QQ客户端使用1234号端口,但是一个其他应用在编写客户端时也固定的bind1024端口,就会导致这两个应用抢占统一个端口号,所以对于客户端而言操作系统为其动态分配端口号是合理的,可以避免多个客户端抢占一个端口号。那么页意味着理论上一台主机最多能够开启65535个客户端,0端口是无效端口,但是实际上并没有65535个客户端,因为操作系统在随机分配端口的时候是在1024-65535中随机分配的。
🚀可以,因为一个进程可以打开多个文件描述符,每一个文件描述符,不同的套接字提供不同类型的服务,每个套接字对应一个端口号。
🚀不可以,因为通过IP+port的方式就无法确定网络中的某一主机的某一进程了,一个port对应多个进程,那么无法确定具体是与哪个进程进行通信。
1.16位源端口号
2.16位目的端口号
3.16位udp长度
4.16位校验和:校验报文是否正确,校验失败的报文直接丢弃
5.有效载荷部分
🚀如何做到报文与有效载荷的分离:
根据udp协议可以知道报头固定8字节大小,当收到一个udp报文时,读取前八个字节的内容就是udp报头部分,根据16位udp长度减去8字节就是udp报文的有效载荷部分的长度。得到有效载荷部分后向上交付即可。
🚀udp报文长度问题
16位udp长度,标识一个udp报文最大为64K,当我们需要发送的报文大于64K时就需要在应用层手动分包,在对端应用层手动拼装。
🚀知道对端的IP地址和端口号就可以进行通信,不需要建立连接。
🚀由于udp协议没有确认应答机制,重传机制,所以当udp报文由于某种网络问题丢包,那么这个报文就会真的丢失,且udp层不会给应用层恢复任何错误信息。
🚀应用层交给udp的报文,udp就会照原样发送,既不会拆分也不会合并;例如通过sendto发送100字节的数据,那么对端只能通过recvfrom读取100字节的数据,不能读取10次,每次读取10字节。
🚀udp协议没有实际意义的发送缓冲区,当接收到应用层传来的报文,直接交付给网络层协议完成后序的传输动作;
🚀udp协议具有接收缓冲区,但是这个缓冲区不能保证接收到的udp报文的顺序,与对端发送的顺序一致;且当缓冲区满了时,再到达的udp报文会被直接丢弃。
1.16位源端口号/目的端口号
2.32位序号/32位确认序号
因为TCP连接具有可靠性,拥有确认应答和重传机制,那么就需要对每个报文进行编号,在应答报文中要填写确认序号,表示收到了XXX序号前的所有报文。
3.16位窗口大小
TCP要进行流量控制,每次接收端在给发送端发送确认报文时填写该字段,表示自己的接收缓冲区还能接收多少字节的数据,告诉发送端自己的接收能力,发送端不要发送过多的数据。
4.16位校验和
检验报文的正确性,校验失败后直接丢弃掉报文,不会向上交付。
5.4位首部长度
tcp报头的长度位固定20字节+选项长度,4位首部长度的单位是4字节,最长为60字节,根据首部长度可以做到报头与有效载荷的分离。
6.6大标记位
1.SYN:请求建立连接
2.FIN:通知对端,本端要关闭连接
3.ACK:确认报文,确认发送端发送的报文已经收到,搭配确认序号使用
4.URG:表示紧急指针是否有效
5.PSH:提示对端尽快将TCP接收缓冲区的数据读走
6.RST:要求重新建立连接
7.16位紧急指针
紧急数据(1字节)在有效载荷中的偏移量。
🚀在TCP的接收缓冲区内为每一个个字节的数据都有一个编号,发送端发送一个报文时,报头中的序号字段会填写成这个报文的起始位置对应的编号,接收端在接收到报文之后会给发送端回复一个ACK应答报文,ACK报文中的确认序号字段会被填写,填写的规则是:例如确认序号为X,表示告诉发送端,序号小于X的报文都已经收到了,下次请从X号报文开始发送。
🚀TCP还具有按序到达的机制,发送端一次可能发送多个报文,在接收端收到报文的顺序不一定与发送顺序一致,但是TCP会对收到的报文按照报文中的序号进行排序。确保被上层读走报文的顺序是和发送端报文发送顺序一致的。
🚀发送端发送的报文不能保证每次都会被接收端接收,但是接收端没收到的报文发送端一定是知道的,然后后续发送端会对丢失的报文作补发。
🚀对于发送端,当等待了一个时间间隔后,一直没收到来自接收端的ACK报文,那么发送端就会断定次报文发生了丢包,要做补发。
🚀发送端得知某个报文没有被接收端接收的原因有两种:1.报文在网络中丢失2.接收端回复的ACK报文丢失,但实际上接收端已经收到了该报文。
🚀那么这个时间间隔怎么确定呢?如果太短会导致可能会频繁发送重复的报文。如果太长,会影响到重传的效率。
Linux中,超时以500ms为一个单位进行控制,每次判断超时重传的时间为500ms的整数倍。
如果重发一次后,仍然收不到应答,等待2500ms后进行重传。
如果仍然收不到应答,等待4500ms进行重传,一次类推,以指数倍的形式增长。
当累计到一定的次数,TCP会认为网络或者对端主机出现异常,强制关闭连接。
🚀TCP协议是面向连接的,所以客户端/服务器双方通信前要先建立连接,客户端会主动建立连接,建立连接的过程如下图所示:
🚀状态变化:客户端:发送SYN请求后状态变为SYN_SENT,收到服务器端的SYN+ACK后状态变为 ESTABLISHED建立起连接
服务器端:在收到客户端的SYN请求,回复一个SYN+ACK后状态变为SYN_RECVED,再收客户端的ACK应答后状态变为ESTABLISHED,建立起连接。
🚀什么是连接?
在操作系统内一定存在大量的连接,所以操作系统就要对连接作管理,就要先描述在组织,所以在操作系统内必定存在维护连接的数据结构,所以创建维护连接是有成本的。
🚀为什么是三次握手?
1.交换通信双方的起始序号
2.三次握手是验证TCP全双工通信正常的最小成本
3.一旦出现异常(通畅指最后一次ACK没有收到)成本嫁接到客户端,服务端的成本较低。(最后一次ACK服务端没有收到,在服务端不会建立连接,就不会创建维护连接的数据结构,而客户端已经建立起了连接,当客户端尝试与服务端开始通信时,服务端会回复一个RST报文,要求重新建立连接)。
🚀为什么不是两次握手?
1.两次握手无法验证TCP全双工通信的信道正常,可以参照下面表格理解(表格数据的前提是每次双方发送的数据对端都能收到)。
2.两次握手存在明显缺陷,用以收到SYN泛洪攻击,例如:有一台不怀好意的主机伪造大量IP,给服务器发送SYN报文,发送后就close,换下一个IP重复发送SYN报文,这样短时间内服务器内维护了大量的实际不存在的连接。可能导致服务器资源枯竭无法正常服务。
过程 | 客户端接收能力 | 客户端发送能力 | 服务器接收能力 | 服务器发送能力 |
---|---|---|---|---|
SYN | - | √ | - | - |
SYN+ACK | - | - | √ | √ |
ACK | √ | - | - | - |
🚀为什么不是4/5/6/7…次握手呢?
1.对于偶数次握手,最后一次ACK是服务器发送的,意味着服务器端先建立起连接,那么一旦最后一次ACK丢失,所造成的成本嫁接到了服务器端,这时不合理的。
2.验证TCP全双工通信的信道正常,三次握手是最小的成本。
🚀在TCP中会维护一个全连接队列,服务端会将已经通过三次握手的的连接放在全连接队列中,等待上层调用connect读走连接。
🚀为什么要维护全连接队列?
1.充分利用服务器的资源,例如当服务器的负载达到了最大,已经不能在未新的客户端提供服务,这些客户端就会维护在全连接队列中, 一旦有客户端对出连接,这些在队列中的连接就会顶上去,充分利用服务器的资源。
2.尽可能的未客户端提供服务,服务器满载时,如果直接让客户端连接失败是不太好的,可以让客户端稍微等上一段时间,等待老的客户端退出,未新的客户端提供服务。
🚀验证全连接队列的存在,全连接队列长度等于 listen第二个参数 + 1.服务器端将listen第二个参数置1,将accpet关闭,这样服务端只能与两个客户端通过三次握手建立连接,对于再次来请求连接的客户端,服务器端会处于SYN_RECVED状态。
说明:断开连接与建立连接不同,对于断开连接而言,客户端与服务器是对等的,意思就是哪一端都可以主动释放连接。
🚀主动断开连接方状态变化:调用close();后向被动断开连接方发送FIN报文,状态变为FIN_WAIT1。当收到被动断开连接方的ACK应答后状态变为FIN_WAIT2。当收到被动断开连接的一方的FIN报文后,状态变为TIME_WAIT。等待两个MSL的时间后状态变为CLOSED。
🚀被动断开连接方状态变化:收到主动断开连接方的FIN报文后,状态变为CLOSE_WAIT状态,当被动断开连接的一方调用close断开连接后,状态变为LAST_ACK状态。在等到主动连接方的ACK后状态变为CLOSED。
🚀被动关闭一方收到主动断开连接的一方的FIN报文后,会将ESTABLISHED状态变为CLOSE_WAIT状态。如果被动关闭连接的一方迟迟不关闭文件描述符,那么将在一定时间内都处于CLOSE_WAIT状态,主动断开连接方迟迟收不到对端的ACK就会强制断开连接。对于被动关闭连接一方来说,这是一种资源泄漏问题,一旦终止掉程序后会出现大量的LAST_ACK状态,持续一段时间后断开连接,所以一定要及时关闭没用的文件描述符。
:下面通过实验来验证上述说法:
服务器端代码 (故意没关闭文件描述符)
#include
#include
#include
#include
#include
#include
#include
#define IP "0.0.0.0"
void* routine(void* args) {
pthread_detach(pthread_self());
int fd = (int64_t)args;
char buffer[4096] {0};
int n = read(fd,buffer,sizeof(buffer) - 1);
buffer[n] = 0;
std::cout << "-------------------------------\n";
std::cout << buffer << std::endl;
std::cout << "-------------------------------\n";
return nullptr;
}
int main(int argc,char* argv[]) {
if(argc != 2) {
abort();
}
uint16_t port = atoi(argv[1]);
//1.创建套接字
int sockfd = socket(AF_INET,SOCK_STREAM,0);
if(sockfd < 0) {
std::cerr << "创建套接字失败\n";
return -1;
}
//2.绑定ip port
struct sockaddr_in addr;
addr.sin_addr.s_addr = inet_addr(IP);
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
int ret = bind(sockfd,(struct sockaddr*)&addr,sizeof(addr));
if(ret < 0) {
std::cerr << "bind失败\n";
return -1;
}
//3.开启监听
ret = listen(sockfd,16);
if(ret < 0) {
std::cerr << "listen失败\n";
return -1;
}
//4.接收服务端请求
struct sockaddr_in client_addr;
socklen_t len;
for(;;) {
int newfd = accept(sockfd,(struct sockaddr*)&client_addr,&len);
if(newfd < 0) {
continue;
} else {
pthread_t tid;
pthread_create(&tid,nullptr,routine,(void*)(int64_t)newfd);
}
}
return 0;
}
客户端代码
#include
#include
#include
#include
#include
#include
#include
int main(int argc,char* argv[]) {
uint16_t port = atoi(argv[1]);
int sockfd = socket(AF_INET,SOCK_STREAM,0);
if(sockfd < 0) {
std::cerr << "创建套接字失败\n";
return -1;
}
//connect
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr("47.93.125.22");
addr.sin_port = htons(port);
int n = connect(sockfd,(struct sockaddr*)&addr,sizeof(addr));
if(n < 0) {
std::cout << "连接服务器失败\n";
return -1;
}
while(true) {
;
}
return 0;
}
🚀实验步骤:启动服务器,开启两个客户端,观察ESTABLISHED状态。然后关闭两个客户端,因为服务器端没有关闭fd,导致服务器端处于CLOSE_WAIT状态。关闭服务器,观察到LAST_ACK状态。
🚀建立连接
🚀关闭客户端
🚀关闭服务器
🚀上述查看连接状态是通过netstat指令来查看的,netstat是查看网络套接字连接状态的指令,其常用选项包括:
-n:以IP地址和端口号来代替名称
-l:查看监听状态的套接字
-a:查看所有状态的套接字
-t:查看tcp连接
-u:查看udp连接
-p:显示进程的pid
🚀主动断开连接的一方收到被动断开方的FIN报文时会进入TIME_WAIT状态,这时一个临时状态维持2*MSL时间,此时连接还在被维护。如果是服务器端主动断开的连接,如果在没有设置套接字属性的前提前,是不能立刻重启的,因为上一个连接还在维护,使用的端口号还在使用,除非换一个端口号重启服务器。
🚀主动断开连接一方维持2*MSL时间的TIME_WAIT状态的原因:
1.等待两个MSL(一个报文在网络中存在的最长时间)时间,保证之前在两个方向上的尚未被接收到的报文都已经消散(否则,例如重启服务器,可能会收到老的客户端发送的数据,这是不合理的,并且这种数据很可能是错误的)
2.尽可能的保证被动断开连接方是以正常的流程结束连接的,也就是说尽可能保证最后一次ACK被对端收到。
🚀如果服务器端压力过大崩溃想要立即重启不想等待/更换端口:可以设置监听套接字的属性:
bool opt = true;
setsockopt(fd,SOL_SOCK,SO_REUSEADDR | SO_REUSEPORT,&opt,sizeof(opt);
🚀接收端端会将自己接收缓冲区剩余空间的大小填入到窗口大小的字段中,通过ACK报文告诉发送端自己的接收能力。发送端发送的报文大小是不会超过对端接收能力的。如果接收端的缓冲区剩余容量很小,那么发送端就会减缓发送速度。
🚀如果窗口大小被设置为0,表明接收端已经无法再接收数据,当接收端的上层读走数据后,接收端会给发送端发送一个报文通知其可以发送数据了。但是为了防止这个报文丢包的情况,发送端也会定时的进行发送报文来探测对端的接收能力。
🚀窗口大小字段是16字节,不代表窗口的上限是65535字节,报头选项中有一个窗口扩大因子M,真实窗口大小等于窗口大小字段的值左移M位。
🚀实际TCP报文的发送过程不是像上面的图示同那样串行发送的(再收到上一个报文的确认后再发送下一个报文),这种发送方式是十分低效的。实际上是以滑动窗口的形式一次发送多个报文,这样报文的发送和收到对端的应答报文在时间上就会有重叠,也就是说发送端发送报文与接收端发送确认报文是并发的,这样就大大提高了发送效率。
🚀在发送端的接收缓冲区中会维护一个滑动窗口,其中这个滑动窗口会将发送缓冲区分割为4部分:
1.已经发送并且已经接收的报文
2.在发送窗口内已经发送但是未收到应答的报文
3.在发送窗口内(在接收端的接收能力内),但是还未发送的报文。
4.未发送的报文。
🚀维护滑动窗口需要有三个指针,front指针指向滑动窗口的起始位置,rear指向滑动窗口的末尾,p指向窗口内未发送报文中序号最小的报文处。窗口的大小取决于对端的接收能力,front + 对端接收能力 = rear。
🚀滑动窗口是一个环状结构,也就是说在其向右滑动的过程中不会出现越界的情况,当指针移动超出缓冲区的范围时就会回指到缓冲区的开始。
🚀窗口是一直向右移动的不存在窗口左移的情况,并且会实时的根据接收端发来报文中的确认序号来更新滑动窗口的起始位置,根据报文中窗口大小字段更新滑动窗口结束位置。
🚀如果发送窗口中的第一个报文丢失?中间的报文丢失?最后一个报文丢失怎么办?
首先,如果是第一个报文丢失的话,根据确认应答机制,即使后面的报文都被接收了,也会对第一个报文进行重传,并且滑动窗口的起始位置不会更新。如果是中间和最后的报文丢失只需按照重传机制进行重传即可。
🚀出现丢包时如何进行重传的?
1.如果是ACK报文丢失,那么不要紧因为后序的ACK报文中会进行确认。
2.如果是数据包丢失,例如:如果发送了1001 - 8000 8个报文,其中1001 - 2000的这个报文丢失,如果说后面的报文被接收端收到了,那么在应答报文中的确认序号字段就会被填写为1001,当发送端主机连续收到三个确认序号为1001的报文的时候,发送端主机就知道1001号报文发生了丢包此时会直接重传此报文—这种方式叫做快重传。
当然,如果不满足上述情况就进行超时重传。
🚀进行网络通信,首先是在这个大的网络环境中的,网络的状态是不断变化的,网络的状态就会直接影响到我们网络通信时报文的传递效率。所以在实际的报文发送中网络的状态也是必不可少的考虑因素。
🚀对于发送端来讲,当突然出现大面积的丢包时,发送端主机会判定一定是发生了网络拥塞,就不会采取超时重传和快重传的策略。因为大量的重发报文无疑会加重网络的拥塞程度。所以针对网络拥塞,TCP有其对应的策略来应对。
🚀TCP中针对网络拥塞在内部维护了一个拥塞窗口的变量,对于发送端的发送窗口大小的实际取决于接收端的缓冲区剩余空间和拥塞窗口的较小值。
🚀当发生网络拥塞时,发送端会先发少量的报文去探测网络的状态,再决定以多大的速度去发送报文。这样发送少量的报文并不会加重网络的拥塞情况,并且还能获取到网络的状态。
发送端主机判定发生网络拥塞时,会将拥塞窗口的值置为1,在发送的报文没有发生丢包的情况下,拥塞窗口的大小会先按照指数的形式增长,当到达阈值后改为线性增长。----这种方式就叫做慢启动。TCP开始启动时慢启动的阈值被设置为窗口的最大值,每次超时重传时,慢启动的阈值会变为上次拥塞窗口大小的一半,并且拥塞窗口被置1.
🚀除了滑动窗口机制可以提高发送的效率,TCP还有其他策略提高报文的传输效率—捎带应答和延迟应答。
🚀上面说的接收端会给发送端发送ACK应答报文,由于是双向通信除此之外接收端也会给发送端发送报文,通常ACK报文和接收端要给发送端发送的报文会组合成一个报文,来进行发送从而提高通信效率—TCP三次握手时SYN + ACK就是个例子。
🚀如果接收端收到报文后立刻回复ACK报文,此时窗口大小可能较小,接收端会等待一小段时间让上层尽快读走一些数据,再返回ACK报文。例如接收缓冲区为1M,一次收到500K的数据时,如果立即ACK,窗口大小就是500K,但是10ms内上层可能就能将500K数据读走,所以接收端会稍等一会儿再应答,比如200ms。
🚀TCP协议是面向连接的,在通信双方实现真正通信之前要先建立建立连接—三次握手,在结束通信的时候要断开连接—四次挥手。
🚀在TCP报文中,每个报文都有其序号,接收端接收到报文后会在ACK报文中填写确认序号,告诉发送端哪些报文收到了,并且序号机制也能保证报文的按序到达,虽然TCP不能保证每次发送的报文都能被接收端接收,但是发送端一定知道接收端没有收到报文的情况(1.报文在网络中发生了丢包2.接收端发送的ACK报文丢失),根据重传策略进行重传。
🚀上面的做法是提高一些延迟时间,还可以不是每个报文都给ACK应答,可以每隔2个报文就ACK应答一次。
🚀什么是面向字节流呢?对于应用层封装好的报文,是被拷贝到TCP的发送缓冲区中,根据TCP的窗口机制进行实际的发送,就会导致一个报文会被拆成几个TCP报文发送,这样在应用层来看对于应用层封装的报文,就像是把一串字节数据拷贝到TCP发送缓冲区,对于接收端的应用层来说,它读取的并不是对端应用层封装的完整的报文,而是一串连续的字节数据。
🚀什么是粘包问题?
粘包指的是应用层的报文,在TCP报文的报头中并没有像UDP协议一样有报文长度字段,但是有序号的字段,站在传输层的角度,TCP报文是一个个发送的,按照序号排好序放在接收缓冲区中,站在应用层的角度,看到的就是一连串的字节数据,那么就会导致不知道从哪里开始到哪里结束是一个完整的应用层数据包。
🚀怎么解决粘包问题?
可以在应用层协议中设置一个报文长度的字段,从而就知道了一个报文的结束位置。
可以在包和包之间添加分隔符,就能明确的知道包和包之间的界限。
🚀UDP存在粘包问题吗?
站在UDP层,如果还没有向上层交付数据,UDP报文长度仍然存在,并且是将一个一个的报文交付给上层,有很明确的数据边界。
站在应用层的角度,在传输层使用UDP协议的情况下,要么读取到一个完成的UDP报文,要么读取不到,不会出现读取到半个报文的情况。