AndreaDO

导航

网络编程2 三次挥手+多线程服务器编程

网络编程2 三次挥手+多线程服务器编程

三次握手建立连接,四次挥手关闭连接

为什么TCP是面向连接的安全可靠的传输????
TCP是面向连接的安全的数据传输, 在客户端与服务端建立建立的时候要经过三次握手的过程, 在客户端与服务端断开连接的时候要经历四次挥手的过程, 下图是客户端与服务端三次握手建立连接, 数据传输和断开连接四次挥手的全过程.

三次握手

三次握手(Three-way Handshake)是TCP协议建立连接时,客户端和服务器相互发送和接收数据包的过程,用来同步双方初始序列号。这个过程涉及到客户端和服务器之间的三次交互,因此得名“三次握手”。

以下是三次握手的基本步骤:

  1. SYN(同步序列号)

    • 客户端向服务器发送一个SYN包,这个包中包含了客户端的初始序列号。此时,客户端进入SYN_SEND状态,等待服务器的确认。
  2. SYN-ACK(同步序列号应答)

    • 服务器收到SYN包后,会向客户端发送一个SYN-ACK包,这个包中包含了服务器的初始序列号和对客户端初始序列号的确认(即客户端的初始序列号加1)。此时,服务器进入SYN_RECV状态。
  3. ACK(应答)

    • 客户端收到SYN-ACK包后,会向服务器发送一个ACK包,这个包中对服务器的初始序列号进行确认(即服务器的初始序列号加1)。此时,TCP连接建立成功,客户端和服务器都进入ESTABLISHED状态,可以开始传输数据了。

为什么是三次握手不是两次握手,为什么不在SYN-ACK就创建服务端和客户端的连接?

防止失效的连接请求报文段突然又传送到了服务器:

如果采用两次握手,那么可能会出现这样的场景:客户端发送SYN报文后由于网络拥堵等原因,这个SYN报文并未到达服务器,而是过了一段时间后才到达服务器。此时,服务器会认为这是一个新的连接请求,进而发送SYN-ACK报文并创建连接。但客户端实际上已经因为未收到服务器的响应而关闭了连接。这样一来,服务器就创建了一个无效的连接。而三次握手可以确保这种情况不会发生,因为客户端在收到SYN-ACK后必须发送ACK,服务器只有在收到这个ACK后才能确认连接是有效的。

确认双方的发送和接收能力:
两次握手只能保证一方(发送方)的发送和另一方(接收方)的接收能力是正常的。但是,三次握手可以确保双方都具备发送和接收能力。因为第三次握手(客户端发送ACK)可以确认服务器也能正常发送数据。

四次挥手

四次挥手(Four-way Handshake)是TCP协议用于释放一个已经建立的连接时,客户端和服务器之间相互发送和接收数据包的过程。之所以称为四次挥手,是因为这个过程涉及到了四个数据包(或称为报文段)的交换。以下是四次挥手的基本步骤:

  1. FIN(结束)

    • 客户端决定关闭连接时,会向服务器发送一个FIN包,这个包中包含了客户端的序列号。此时,客户端进入FIN_WAIT_1状态,等待服务器的确认。
  2. ACK(应答)

    • 服务器收到FIN包后,会发送一个ACK包作为确认,表示已经收到客户端关闭连接的请求。这个ACK包的序列号通常是客户端FIN包序列号加1。此时,服务器进入CLOSE_WAIT状态,同时TCP连接处于半关闭状态,即客户端已经没有要发送的数据了,但服务器若发送数据,则客户端仍要接受。
  3. FIN(结束)

    • 当服务器也决定关闭连接时(或已经没有数据需要发送给客户端),它会向客户端发送一个FIN包,并进入LAST_ACK状态,等待客户端的确认。
  4. ACK(应答)

    • 客户端收到服务器的FIN包后,会发送一个ACK包作为确认,表示已经收到服务器关闭连接的请求。这个ACK包的序列号通常是服务器FIN包序列号加1。此时,客户端进入TIME_WAIT状态,等待一段时间(通常是2MSL,即最长报文段寿命的两倍)以确保服务器收到ACK包,然后客户端进入CLOSED状态,连接彻底关闭。服务器收到客户端的ACK包后,也进入CLOSED状态,连接完全关闭。

通过四次挥手,TCP连接可以被优雅地关闭,确保双方都能正确地释放资源并结束连接。这个过程中,每个数据包都起到了确认和通知的作用,确保了连接的可靠关闭。

值得注意的是,四次挥手中最后一步客户端发送的ACK并不是必须的,TCP协议允许在半关闭(half-close)的情况下只发送FIN和ACK。但大多数情况下,四次挥手都被完整地执行以确保双方都能安全地关闭连接。此外,TIME_WAIT状态的存在是为了处理网络中可能出现的延迟报文段,防止新的连接出现“旧连接”的数据。

滑动窗口

引入窗口概念的原因
TCP 是每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了, 再发送下一个。

这个模式就有点像我和你面对面聊天,你一句我一句。但这种方式的缺点是效率比较低的。

如果你说完一句话,我在处理其他事情,没有及时回复你,那你不是要干等着我做完其他事情后,我回复你,你才能说下一句话,很显然这不现实。
所以,这样的传输方式有一个缺点:数据包的往返时间越长,通信的效率就越低。

为解决这个问题,TCP 引入了窗口这个概念。即使在往返时间较长的情况下,它也不会降低网络通信的效率。

那么有了窗口,就可以指定窗口大小,窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值。

窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。

假设窗口大小为 3 个 TCP 段,那么发送方就可以「连续发送」 3 个 TCP 段,并且中途若有 ACK 丢失,可以通过「下一个确认应答进行确认」。如下图:

图中的 ACK 600 确认应答报文丢失,也没关系,因为可以通过下一个确认应答进行确认,只要发送方收到了 ACK 700 确认应答,就意味着 700 之前的所有数据「接收方」都收到了。这个模式就叫累计确认或者累计应答
窗口大小由哪一方决定?
TCP 头里有一个字段叫 Window,也就是窗口大小。

这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。

所以,通常窗口的大小是由接收方的窗口大小来决定的。

发送方发送的数据大小不能超过接收方的窗口大小,否则接收方就无法正常接收到数据。

发送方的滑动窗口
我们先来看看发送方的窗口,下图就是发送方缓存的数据,根据处理的情况分成四个部分,其中深蓝色方框是发送窗口,紫色方框是可用窗口:

1 是已发送并收到 ACK确认的数据:1~31 字节

2 是已发送但未收到 ACK确认的数据:32~45 字节

3 是未发送但总大小在接收方处理范围内(接收方还有空间):46~51字节

4 是未发送但总大小超过接收方处理范围(接收方没有空间):52字节以后

详细博客

TCP协议中的“滑动窗口(Sliding Window)”机制是一种流量控制方法,用于解决发送方与接收方之间处理速度不匹配的问题,以及防止数据丢失。具体来说,滑动窗口机制允许发送方在发送一个数据包之后,不等待接收方的确认(ACK号)返回,而是直接发送后续的一系列数据包,充分利用ACK号的等待时间。然而,如果不等待ACK号就连续发送数据包,可能会出现发送频率超过接收方处理能力的情况,导致接收方的缓冲区溢出,进而造成数据丢失。

滑动窗口机制通过以下方式解决这个问题:接收方将数据暂存到缓冲区并执行接收操作。当接收操作完成后,接收缓冲区中的空间会被释放出来,并通过TCP头部中的窗口字段将自己当前能接收的数据量告诉发送方。这样,发送方就能根据接收方的窗口大小来调整自己的发送速率,确保不会发送过多的数据,从而避免超出接收方的处理能力。

滑动窗口的大小,即能够接收的最大数据量,会根据网络状况实时动态调整。这种机制有效地平衡了发送方和接收方的处理速度,提高了数据传输的效率,同时也保证了数据的完整性和可靠性。

mss和MTU

mss和MTU
MTU: 最大传输单元
MTU:通信术语最大传输单元(Maximum Transmission Unit,MTU)
是指一种通信协议的某一层上面所能通过的最大数据包大小(以字节为单位). 最大传输单元这个参数通常与通信接口有关(网络接口卡、串口等), 这个值如果设置为太大会导致丢包重传的时候重传的数据量较大, 图中的最大值是1500, 其实是一个经验值.

mss: 最大报文长度, 只是在建立连接的时候, 告诉对方我最大能够接收多少数据, 在数据通信的过程中就没有mss了.

粘包问题

粘包: 多次数据发送, 收尾相连, 接收端接收的时候不能正确区分第一次发 送多少, 第二次发送多少.

粘包问题分析和解决??
方案1: 包头+数据
如4位的数据长度+数据 -----------> 00101234567890
其中0010表示数据长度, 1234567890表示10个字节长度的数据.
另外, 发送端和接收端可以协商更为复杂的报文结构, 这个报文结 构就相当于双方约定的一个协议.

方案2: 添加结尾标记.
如结尾最后一个字符为\n $等.

方案3: 数据包定长
如发送方和接收方约定, 每次只发送128个字节的内容, 接收方接收定 长128个字节就可以了.

多并发服务器

如何支持多个客户端---支持多并发的服务器
由于accept和read函数都会阻塞, 如当read的时候, 不能调用accept接受新的连接, 当accept阻塞等待的时候不能read读数据.

第一种方案: 使用多进程, 可以让父进程接受新连接, 让子进程处理与客户端通信
思路: 让父进程accept接受新连接, 然后fork子进程, 让子进程处理通信, 子进程处理完成后退出, 父进程使用SIGCHLD信号回收子进程.

代码实现:

//多进程版本的网络服务器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>
#include "wrap.h"

int main()
{
	//创建socket
	int lfd = Socket(AF_INET, SOCK_STREAM, 0);
	
	//绑定
	struct sockaddr_in serv;
	bzero(&serv, sizeof(serv));
	serv.sin_family = AF_INET;
	serv.sin_port = htons(8888);
	serv.sin_addr.s_addr = htonl(INADDR_ANY);
	Bind(lfd, (struct sockaddr *)&serv, sizeof(serv));
	
	//设置监听
	Listen(lfd, 128);
	
	pid_t pid;
	int cfd;
	char sIP[16];
	socklen_t len;
	struct sockaddr_in client;
	while(1)
	{
		//接受新的连接
		len = sizeof(client);
		memset(sIP, 0x00, sizeof(sIP));
		cfd = Accept(lfd, (struct sockaddr *)&client, &len);
		printf("client:[%s] [%d]\n", inet_ntop(AF_INET, &client.sin_addr.s_addr, sIP, sizeof(sIP)), ntohs(client.sin_port));
		
		//接受一个新的连接, 创建一个子进程,让子进程完成数据的收发操作
		pid = fork();
		if(pid<0)
		{
			perror("fork error");
			exit(-1);
		}
		else if(pid>0)
		{
			//关闭通信文件描述符cfd
			close(cfd);			
		}
		else if(pid==0)
		{
			//关闭监听文件描述符
			close(lfd);
			
			int i=0;
			int n;
			char buf[1024];
			
			while(1)
			{
				//读数据
				n = Read(cfd, buf, sizeof(buf));
				if(n<=0)
				{
					printf("read error or client closed, n==[%d]\n", n);
					break;
				}
				
				printf("[%d]---->:n==[%d], buf==[%s]\n", ntohs(client.sin_port), n, buf);
				
				//将小写转换为大写
				for(i=0; i<n; i++)
				{
					buf[i] = toupper(buf[i]);
				}
				//发送数据
				Write(cfd, buf, n);
			}
			
			//关闭cfd
			close(cfd);
			exit(0);
		}
	}
	
	//关闭监听文件描述符
	close(lfd);
	return 0;
}

第二种方案: 使用多线程, 让主线程接受新连接, 让子线程处理与客户端通信; 使用多线程要将线程设置为分离属性, 让线程在退出之后自己回收资源.

代码实现

//多线程版本的高并发服务器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>
#include <pthread.h>
#include "wrap.h"

//子线程回调函数
void *thread_work(void *arg)
{
	sleep(20);
	int cfd = *(int *)arg;
	printf("cfd==[%d]\n", cfd);
	
	int i;
	int n;
	char buf[1024];
	
	while(1)
	{
		//read数据
		memset(buf, 0x00, sizeof(buf));
		n = Read(cfd, buf, sizeof(buf));
		if(n<=0)
		{
			printf("read error or client closed,n==[%d]\n", n);
			break;
		}
		printf("n==[%d], buf==[%s]\n", n, buf);
		
		for(i=0; i<n; i++)
		{
			buf[i] = toupper(buf[i]);
		}
		//发送数据给客户端
		Write(cfd, buf, n);	
	}
	
	//关闭通信文件描述符
	close(cfd);
	
	pthread_exit(NULL);
}
int main()
{
	//创建socket
	int lfd = Socket(AF_INET, SOCK_STREAM, 0);
	
	//设置端口复用
	int opt = 1;
	setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(int));
	
	//绑定
	struct sockaddr_in serv;
	bzero(&serv, sizeof(serv));
	serv.sin_family = AF_INET;
	serv.sin_port = htons(8888);
	serv.sin_addr.s_addr = htonl(INADDR_ANY);
	Bind(lfd, (struct sockaddr *)&serv, sizeof(serv));
	
	//设置监听
	Listen(lfd, 128);
	
	int cfd;
	pthread_t threadID;
	while(1)
	{
		//接受新的连接
		cfd = Accept(lfd, NULL, NULL);
		
		//创建子线程
		pthread_create(&threadID, NULL, thread_work, &cfd);
		
		//设置子线程为分离属性
		pthread_detach(threadID);
	}

	//关闭监听文件描述符
	close(lfd);
	
	return 0;
}

思考: 如何不使用多进程或者多线程完成多个客户端的连接请求
可以将accept和read函数设置为非阻塞, 调用fcntl函数可以将文件描述符设置为非阻塞, 让后再while循环中忙轮询.

posted on 2024-03-15 09:54  AndreaDO  阅读(13)  评论(0编辑  收藏  举报