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 中采用滑动窗口来进行传输控制,滑动窗口的大小意味着接收方还有多大的缓冲区可以用于接收数据。发送方可以通过滑动窗口的大小来确定应该发送多少字节的数据。当滑动窗口为 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次,第四次挥手。客户端同意了服务器端的断开请求。
四次挥手发生在断开连接的时候,在程序中调用close函数会使用TCP协议进行四次挥手, 对对应的信息进行释放。
客户端和服务端都可以主动发起断开连接,谁先调用close()谁就是发起;因为在TCP连接时,采用三次握手建立的连接时双向的,所以在断开连接的时候,也需要双向断开。
四、TCP并发通信
要实现TCP通信服务器处理并发的任务,使用多线程或者多进程来解决。
思路:
1、一个父进程,多个子进程;
2、父进程负责等待并接受客户端的连接;
3、子进程:完成通信,接受一个客户端连接,就创建一个子进程通信。
server_process.c 服务器代码:
#define _XOPEN_SOURCE #include #include #include #include #include #include #include #include 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 #include #include #include #include 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实现多线程通信与多进程通信的区别在于,当服务端连接到客户端后,与每一个客户端进行通信时,是创建进程进行通信函数还是线程进行通信函数,也就是说其客户端的代码是相同的,区别在于服务器端的代码;
#include #include #include #include #include #include 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;ifd=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的状态时不会发生转变的。通信双方都存在状态转变。
三次握手,客户端先发起,客户端首先是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;
端口复用的时间是在,服务器绑定端口之前。