网络编程2 三次挥手+多线程服务器编程
网络编程2 三次挥手+多线程服务器编程
三次握手建立连接,四次挥手关闭连接
为什么TCP是面向连接的安全可靠的传输????
TCP是面向连接的安全的数据传输, 在客户端与服务端建立建立的时候要经过三次握手的过程, 在客户端与服务端断开连接的时候要经历四次挥手的过程, 下图是客户端与服务端三次握手建立连接, 数据传输和断开连接四次挥手的全过程.
三次握手
三次握手(Three-way Handshake)是TCP协议建立连接时,客户端和服务器相互发送和接收数据包的过程,用来同步双方初始序列号。这个过程涉及到客户端和服务器之间的三次交互,因此得名“三次握手”。
以下是三次握手的基本步骤:
-
SYN(同步序列号):
- 客户端向服务器发送一个SYN包,这个包中包含了客户端的初始序列号。此时,客户端进入SYN_SEND状态,等待服务器的确认。
-
SYN-ACK(同步序列号应答):
- 服务器收到SYN包后,会向客户端发送一个SYN-ACK包,这个包中包含了服务器的初始序列号和对客户端初始序列号的确认(即客户端的初始序列号加1)。此时,服务器进入SYN_RECV状态。
-
ACK(应答):
- 客户端收到SYN-ACK包后,会向服务器发送一个ACK包,这个包中对服务器的初始序列号进行确认(即服务器的初始序列号加1)。此时,TCP连接建立成功,客户端和服务器都进入ESTABLISHED状态,可以开始传输数据了。
为什么是三次握手不是两次握手,为什么不在SYN-ACK就创建服务端和客户端的连接?
防止失效的连接请求报文段突然又传送到了服务器:
如果采用两次握手,那么可能会出现这样的场景:客户端发送SYN报文后由于网络拥堵等原因,这个SYN报文并未到达服务器,而是过了一段时间后才到达服务器。此时,服务器会认为这是一个新的连接请求,进而发送SYN-ACK报文并创建连接。但客户端实际上已经因为未收到服务器的响应而关闭了连接。这样一来,服务器就创建了一个无效的连接。而三次握手可以确保这种情况不会发生,因为客户端在收到SYN-ACK后必须发送ACK,服务器只有在收到这个ACK后才能确认连接是有效的。
确认双方的发送和接收能力:
两次握手只能保证一方(发送方)的发送和另一方(接收方)的接收能力是正常的。但是,三次握手可以确保双方都具备发送和接收能力。因为第三次握手(客户端发送ACK)可以确认服务器也能正常发送数据。
四次挥手
四次挥手(Four-way Handshake)是TCP协议用于释放一个已经建立的连接时,客户端和服务器之间相互发送和接收数据包的过程。之所以称为四次挥手,是因为这个过程涉及到了四个数据包(或称为报文段)的交换。以下是四次挥手的基本步骤:
-
FIN(结束):
- 客户端决定关闭连接时,会向服务器发送一个FIN包,这个包中包含了客户端的序列号。此时,客户端进入FIN_WAIT_1状态,等待服务器的确认。
-
ACK(应答):
- 服务器收到FIN包后,会发送一个ACK包作为确认,表示已经收到客户端关闭连接的请求。这个ACK包的序列号通常是客户端FIN包序列号加1。此时,服务器进入CLOSE_WAIT状态,同时TCP连接处于半关闭状态,即客户端已经没有要发送的数据了,但服务器若发送数据,则客户端仍要接受。
-
FIN(结束):
- 当服务器也决定关闭连接时(或已经没有数据需要发送给客户端),它会向客户端发送一个FIN包,并进入LAST_ACK状态,等待客户端的确认。
-
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循环中忙轮询.