一、TCP三次挥手
TCP 是一种面向连接的单播协议,在发送数据前,通信双方必须在彼此间建立一条连接。所谓的“连接”,其实是客户端和服务器的内存里保存的一份关于对方的信息,如 IP 地址、端口号等。
TCP 可以看成是一种字节流,它会处理 IP 层或以下的层的丢包、重复以及错误问题。在连接的建立过程中,双方需要交换一些连接的参数。这些参数可以放在 TCP 头部。
TCP 提供了一种可靠、面向连接、字节流、传输层的服务,采用三次握手(保证传送数据安全的一种机制)建立一个连接。采用四次挥手来关闭一个连接。
三次握手是协议本身的内容,不是程序员写程序时需要写入的内容,三次握手是保证双方互相建立了连接。
三次握手是发生在客户端连接的时候,当调用connect(),底层会通过TCP协议会进行三次握手。
为了保证双方都有发送和接受的能力:
第一次握手,客户端给服务端发送请求,服务端知道客户端拥有发送的能力,自己有接收的能力;
第二次握手,服务端给客户端回复请求,客户端知道自己和服务端都有拥有发送和接收的能力;
第三次握手,客户端给服务器就发送请求,服务端知道客户端有接收的能力,自己有发送的能力。
当然四次握手也是可以的,但是最少得三次握手。
序号与确认序号:
为字节流中的每个字节设置一个序号,接收到后会回复一个接受序号,也是下一次发送字节的序号,保证字节的完整。
三次握手关于数据时序的说明:
第一次握手:
1、客户端将SYN标志位置1;
2、随机生成一个序号seq=J,这个序号后边可以携带数据(数据大小);
第二次握手:
1、服务器端接收客户端的连接:ACK=1;
2、服务器会回发一个确认序号,确认序号在(seq=J)基础上+数据长度+SIN/FIN(按一个字节计算);
ack=J+数据长度+1;
3、服务器端会向客户端发起连接请求:SYN标示位置为1;
4、随机生成一个序号seq=K,这个序号后边可以携带数据(数据大小);
第三次握手:
1、客户端接收服务器的连接:ACK=1;
2、客户端会回发一个确认序号,确认序号在(seq=K)基础上+数据长度+SIN/FIN(按一个字节计算);
ack=K+数据长度+1;
二、TCP滑动窗口
滑动窗口是一种流量控制技术,早期的网络中,通信双方不会考虑网络的拥挤情况直接发送数据。由于大家不知道网络拥塞状况,同时发送数据,导致中间节点阻塞掉包,谁也发不了数据,所以就有了滑动窗口机制来解决此问题。滑动窗口协议是用来改善吞吐量的一种技术,即容许发送方在接收任何应答之前传送附加的包。接收方告诉发送方在某一时刻能送多少包(称窗口尺寸)。
TCP 中采用滑动窗口来进行传输控制,滑动窗口的大小意味着接收方还有多大的缓冲区可以用于接收数据。发送方可以通过滑动窗口的大小来确定应该发送多少字节的数据。当滑动窗口为 0
时,发送方一般不能再发送数据报。
窗口理解为缓冲区的大小,滑动窗口的大小会随着发送数据和接收数据而变化。通信的双方都有发送缓冲区和接收缓冲区。
服务器:
发送缓冲区(发送缓冲区的窗口)
接收缓冲区(接受缓冲区的窗口)
客户端:
发送缓冲区(发送缓冲区的窗口)
接收缓冲区(接受缓冲区的窗口)
1、发送方的缓冲区:
白色格子:空闲的空间;
灰色格子:数据已经被发送出去了,但是还没有被接受。
紫色格子:还没有发送出去的数据;
2、接收方的缓冲区:
白色格子:空闲的空间;
紫色格子:已经接收到的数据
MMS:Maximun segment size(一条数据的最大数据量);
win:滑动窗口大小;
1、客户端向服务器发起连接,客户端的滑动窗口是4096,一次能接收的最大数据量为1460;
2、服务器行客户端发送回复ACK=1(接收连接情况),告诉服务器的窗口大小为6144;一次接收最大数据量为1024;
3、第三次握手;’
4、第(4-9)次,客户端连续给服务器发送6K数据,每次发送1K;
5、第10次,服务器告诉服务器发送6K数据已经接收到了,存储在缓冲区中,缓冲区数据已经处理了2K,窗口大小是2k;
6、第11次,服务器告诉服务器发送6K数据已经接收到了,存储在缓冲区中,缓冲区数据已经处理了4K,窗口大小是4k;
7、第12次,客户端给服务器发送了1K的数据,
8、第13次,第一次挥手,客户端主动请求和服务断开连接,并且给服务器发送了1K的数据;
9、第14次,第二次挥手。服务器回复ACK,8194,同意断开连接的请求,告诉客户端已经接收到刚才发送的2K数据。滑动窗口为2K;
10、第15-16次,服务器通知客户端滑动窗口的大小;
11、第17次,第三次挥手。服务器端给客户端发送FIN,请求断开连接;
12、第18次,第四次挥手。客户端同意了服务器端的断开请求。
三、TCP四次挥手
四次挥手发生在断开连接的时候,在程序中调用close函数会使用TCP协议进行四次挥手, 对对应的信息进行释放。
客户端和服务端都可以主动发起断开连接,谁先调用close()谁就是发起;因为在TCP连接时,采用三次握手建立的连接时双向的,所以在断开连接的时候,也需要双向断开。
四、TCP并发通信
要实现TCP通信服务器处理并发的任务,使用多线程或者多进程来解决。
思路:
1、一个父进程,多个子进程;
2、父进程负责等待并接受客户端的连接;
3、子进程:完成通信,接受一个客户端连接,就创建一个子进程通信。
四、TCP实现多进程通信
server_process.c 服务器代码:
#define _XOPEN_SOURCE
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <signal.h>
#include <wait.h>
#include <errno.h>
void recyleChild(int arg)
{
while(1)
{
int ret=waitpid(-1,NULL,WNOHANG);
if(ret==-1)
{
//所有的子进程都被回收了;
break;
}
else if(ret==0)
{
//还有子进程活着;
break;
}
else if(ret>0)
{
//被回收了
printf("子进程%d被回收了\n",ret);
}
}
}
int main()
{
//注册信号捕捉
struct sigaction act;
act.sa_flags=0;
sigemptyset(&act.sa_mask);
act.sa_handler=recyleChild;
sigaction(SIGCHLD,&act,NULL);
//创建socket
int lfd =socket(AF_INET,SOCK_STREAM,0);
if(lfd==-1)
{
perror("socket");
exit(-1);
}
//绑定
struct sockaddr_in saddr;
saddr.sin_addr.s_addr=0;
saddr.sin_port=htons(9999);
saddr.sin_family=AF_INET;
int ret = bind(lfd,(const struct sockaddr*)&saddr,sizeof(saddr));
if(ret==-1)
{
perror("bind");
exit(-1);
}
//监听
ret=listen(lfd,8);
if(ret==-1)
{
perror("listen");
exit(-1);
}
//不断循环,接收客户端连接;
while(1)
{
struct sockaddr_in cliaddr;
int len =sizeof(cliaddr);
//接收连接
int cfd = accept(lfd,(struct sockaddr*)&cliaddr,&len);
if(cfd==-1)
{
if(errno==EINTR)
{
continue;
}
perror("accept");
exit(-1);
}
//每一个连接进来就创建一个子进程进程客户端通信;
pid_t pid=fork();
if(pid==0)
{
//子进程
//获取客户端信息
char cliIP[16];
inet_ntop(AF_INET,&cliaddr.sin_addr.s_addr,cliIP,sizeof(cliIP));
unsigned short cliPort=ntohs(cliaddr.sin_port);
printf("cliIp is :%s,port is %d\n",cliIP,cliPort);
//接收客户端发来的数据
char recvBuff[1024]={0};
while(1)
{
int len1=read(cfd,&(recvBuff),sizeof(recvBuff)+1);
if(len1==-1)
{
perror("read");
exit(-1);
}
else if(len1>0)
{
printf("recive client data:%s\n",recvBuff);
}
else if(len1==0)
{
printf("client close\n");
}
write(cfd,recvBuff,strlen(recvBuff)+1);
}
close(cfd);
exit(0);
}
}
close(lfd);
return 0;
}
client.c客户端代码
//TCP通信客户端
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main()
{
//1、创建套接字
int fd = socket(AF_INET,SOCK_STREAM,0);
if(fd==-1)
{
perror("socket");
exit(-1);
}
//2、连接服务器端
struct sockaddr_in serve_addr;
serve_addr.sin_family=AF_INET;
inet_pton(AF_INET,"192.168.254.171",&serve_addr.sin_addr.s_addr);
serve_addr.sin_port=htons(9999);
int ret= connect(fd,(const struct sockaddr *)&serve_addr,sizeof(serve_addr));
if(ret==-1)
{
perror("connect");
exit(-1);
}
//3、进行通信
char readbuff[1024]={0};
// char writebuff[1024]={0};
int i=0;
while(1)
{
// memset(writebuff,0,sizeof(writebuff));
//printf("请输入内容:\n");
//scanf("%s",writebuff);
//char * data="I am client";
sprintf(readbuff,"data:%d\n",i++);
//给服务端发送数据
write(fd, &readbuff,strlen(readbuff));
sleep(1);
int len =read(fd,readbuff,sizeof(readbuff));
if(len==-1)
{
perror("read");
exit(-1);
}else if(len>0)
{
printf("recive client data:%s\n",readbuff);
}
else if(len==0)
{
//表示服务器端断开连接。
printf("serve closed...\n");
break;
}
}
//关闭文件描述符;
close(fd);
return 0;
}
运行结果:
服务器结果:
客户端1运行结果:
客户端2运行结果:
结果分析:
1、服务器端
首先创建socket函数,在进行绑定,封装服务器的IP、端口号等相关信息;然后进行监听,等待客户端的连接,客户端的连接是在while循环中进行的,因为需要不断接收客户端的连接。连接成功一个客户端后,创建一个进程,在子进程完成服务器与客户端的通信。关于子进程的回收是利用信号进行回收,因为如果利用wait函数进行回收,会造成程序在对应位置进行阻塞。
2、客户端
首先创还能socket函数,然后连接服务器,链接成功后进行通信,进行数据的收发,最后回收文件描述符。一个客户端在一个子进程中通信。这也是TCP实现多进程通信的方法原理。
五、TCP实现多线程通信
利用TCP实现多线程通信与多进程通信的区别在于,当服务端连接到客户端后,与每一个客户端进行通信时,是创建进程进行通信函数还是线程进行通信函数,也就是说其客户端的代码是相同的,区别在于服务器端的代码;
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <pthread.h>
struct sockinfo//该结构体用于存放服务端发送给子进程关于客户端的信息,因为进程只能传递一个参数。
{
int fd;//通信文件描述符;
struct sockaddr_in addr;
pthread_t tid;//线程号;
};
struct sockinfo sockinfos[128];//定义全局变量,用于存放客户端的信息;
void * working (void *arg)
{
//获取客户端信息
struct sockinfo *pinfo=(struct sockinfo*)arg;
char cliIP[16];
inet_ntop(AF_INET,&pinfo->addr.sin_addr.s_addr,cliIP,sizeof(cliIP));
unsigned short cliPort=ntohs(pinfo->addr.sin_port);
printf("cliIp is :%s,port is %d\n",cliIP,cliPort);
//接收客户端发来的数据
char recvBuff[1024]={0};
while(1)
{
int len1=read(pinfo->fd,&(recvBuff),sizeof(recvBuff)+1);
if(len1==-1)
{
perror("read");
exit(-1);
}
else if(len1>0)
{
printf("recive client data:%s\n",recvBuff);
}
else if(len1==0)
{
printf("client close\n");
}
write(pinfo->fd,recvBuff,strlen(recvBuff)+1);
}
close(pinfo->fd);
exit(0);
//子线程与客户端进行通信; cfd; 客户端的信息;线程号;
return NULL;
}
int main()
{
//创建socket
int lfd =socket(AF_INET,SOCK_STREAM,0);
if(lfd==-1)
{
perror("socket");
exit(-1);
}
//绑定
struct sockaddr_in saddr;
saddr.sin_addr.s_addr=0;
saddr.sin_port=htons(9999);
saddr.sin_family=AF_INET;
int ret = bind(lfd,(const struct sockaddr*)&saddr,sizeof(saddr));
if(ret==-1)
{
perror("bind");
exit(-1);
}
//监听
ret=listen(lfd,8);
if(ret==-1)
{
perror("listen");
exit(-1);
}
// 初始化数据
int max = sizeof(sockinfos)/sizeof( sockinfos[0]);
for(int i=0;i<max;i++)
{
bzero(&sockinfos[i],sizeof(sockinfos[i]));
sockinfos[i].fd=-1;
sockinfos[i].tid=-1;
}
while(1)
{
struct sockaddr_in cliaddr;
int len =sizeof(cliaddr);
//接收连接
int cfd = accept(lfd,(struct sockaddr*)&cliaddr,&len);
struct sockinfo *pinfo;
for(int i=0;i<max;i++)
{
//从这个数组中可以找到一个可以用的sockInfo元素;
if (sockinfos[i].fd==-1)
{
pinfo=&sockinfos[i];
break;
}
if(i==max-1)
{
sleep(1);
i--;
}
}
pinfo->fd=cfd;
memcpy(&pinfo->addr,&cliaddr,len);
pthread_t tid;
// 每一个连接进来,创建一个子线程与客户端进行通信
pthread_create(&pinfo->tid,NULL,working,pinfo);
pthread_detach(pinfo->tid);
}
close(lfd);
return 0;
}
其运行结果同多进程是相同的,其主要的改变就是线程的创建以及客户端信息的传递。
六、TCP状态转变
状态转变发生在三次握手与四次挥手之间,在中间的数据传输时,TCP的状态时不会发生转变的。通信双方都存在状态转变。
三次握手,客户端先发起,客户端首先是CLOSE状态,当进行connect连接第一次握手后,客户端状态会转变为SYN_SENT,服务器刚开始为监听状态LISTEN,后来当第一次握手后成为SYN_RCVD状态。然后第二次握手后,客户端转变为ESTABLISHED状态。第三次握手后,服务端转变为ESTABLISHED状态。当通信双方都是ESTABLISHED状态时,才能进正常通信数据的传输。
四次挥手,谁发起都可以,假设是客户端发起,客户端发送FIN调用close函数,执行第一次挥手后,客户端状态变为FIN_WAIT_1,服务器端接受到FIN与ACK后,其状态改为CLOSE_WAIT,然后执行第二次挥手,客户端接收到后,其状态转变为,FIN_WAIT2。等到第三次挥手后服务端调用close,服务器的状态转为LAST_AVK,客户端转变为TIME_WAIT,四次挥手后客户端与服务端都为CLOSE关闭状态。
红色实线代表客户端,绿色虚线代表服务端。
起点均为两个客户端与服务端的CLOSE状态;
客户端(CLOSE)主动打开,发送SYN,其状态转变为SYN_SENT;
服务端(CLOSE)--(LISTEN),收到SYN,发送SYN,ACK,其状态转变为SYN_RCVD;
客户端(SYN_SENT)收到SYN,ACK发送ACK,其状态状态转变为ESTABLISTHED;
服务端(SYN_RCVD)收到ACK,其状态转变为ESTABLISTHED;
通信(状态不发生变化)
客户端(ESTABLISTHED)关闭,发送FIN,ACK其状态转变为FIN_WAIT_1;
服务端(ESTABLISTHED)接收FIN,ACK,发送ACK,其状态转变为CLOSE_WAIT;
客户端(FIN_WAIT_1)接受ACK,其状态转变为FIN_WAIT_2;
服务端(ESTABLISTHED)关闭,发送FIN,其状态转变为LAST_ACK;
客户端(FIN_WAIT_2)接受FIN,并发送ACK,其状态转变为TIME_WAIT;
客户端与服务端都成为CLOSE状态;
TIME_WAIT:定时经过两倍报文段寿命后,2MSL, 为了保证安全性。
当 TCP 连接主动关闭方接收到被动关闭方发送的 FIN 和最终的 ACK 后,连接的主动关闭方必须处于TIME_WAIT 状态并持续 2MSL 时间。这样就能够让 TCP 连接的主动关闭方在它发送的 ACK 丢失的情况下重新发送最终的 ACK。
以客户端为主动关闭方为例,客户端接收到服务端的FIN后,并发送ACK,其不能保证服务端是否接收到,如果没有接收到,且客户端状态为CLOSE,这样服务端就永远不会回到CLOSE状态,相反,客户端状态为TIME_WAIT,TIME_WAIT状态, 这个状态会持续: 2msl。
在此期间,被动关闭方总是重传 FIN 直到它收到一个最终的 ACK,ACK丢失,服务端再次发送FIN,客户端就有时间发送ACK。
七、半关闭与端口复用
什么是半关闭状态?
例如:在前面讲的四次挥手中,客户端发送FIN和ACK,服务端发送了ACK,但是并没有向客户端发送FIN,则处于一个半关闭状态。
当 TCP 链接中 A 向 B 发送 FIN 请求关闭,另一端 B 回应 ACK 之后(A 端进入FIN_WAIT_2状态),并没有立即发送 FIN 给 A,A 方处于半连接状态(半开关),此时 A 可以接收 B 发送的数据,但是 A 已经不能再向 B 发送数据。
半关闭状态有什么作用?
实现数据的单方向传输;
利用API实现半关闭状态;
如果利用close进行半关闭的话,其fd直接关闭,既不能读也不能写,无法实现数据的单方向传递,所以,我们一般利用API实现半关闭状态。
使用 close 中止一个连接,但它只是减少描述符的引用计数,并不直接关闭连接,只有当描述符的引用计数为 0 时才关闭连接。shutdown 不考虑描述符的引用计数,直接关闭描述符。也可选择中止一个方向的连接,只中止读或只中止写。
注意:
1. 如果有多个进程共享一个套接字,close 每被调用一次,计数减 1 ,直到计数为 0 时,也就是所用进程都调用了 close,套接字将被释放。
2. 在多进程中如果一个进程调用了 shutdown(sfd, SHUT_RDWR) 后,其它的进程将无法进行通信。但如果一个进程 close(sfd) 将不会影响到其它进程。
端口复用
端口复用最常用的用途是:
防止服务器重启时之前绑定的端口还未释放;
程序突然退出而系统没有释放端口;
网络信息相关命令:
netsata
参数:
-a:所有的socket;
-p:显示正在使用socket的程序的名称;
-n:直接显示使用IP地址,而不通过域名服务器;
客户端与服务端进行连接,在经过服务端主动断开后,客户端还没有进行断开,如果要是再执行服务端,就会出现报错。并且在主动断开连接一方存在TIME_WAIT状态,2MSL时间,在此期间端口号一直被占用,不能在执行程序。 此时就需要进行端口复用,系统调用API:
不仅仅设置端口复用,还可以设置套接字;
相关参数:
sockfd:文件描述符,只能是套接字的文件描述符;
level:级别;SOL_SOCKET(端口复用的级别)
optname:选项名;SO_REUSEADDR,SOREUSEPORT;
optval:端口复用的值,整型:1可以复用。0不可以复用;
optlen:optval参数的大小;
返回值:成功返回0,错误返回-1;
端口复用的时间是在,服务器绑定端口之前。