Linux网络编程之socket(一):socket函数讲解及客户端和服务端间TCP通信的源码实现及端口状态测试
- 前言:这个系列的博客是自己的一些coding test记录,在看《Linux高性能服务器编程》以及网上的很多博客时,很多对socket代码的描述让我觉得模棱两可、不够明确,这导致我在看socket通信的代码时,总是搞不清其中的某些变量究竟代表什么、很多操作究竟是对谁做的等等问题,于是,脑袋逐渐混乱......所以在历经艰难自认为理解了源码和理论之间的联系后,便动了写下这篇博客的念头,记录一下自己当时不理解的地方以及(当前自认为)正确的理解。这个系列的博客将逐一实现服务端和客户端、BIO、BIO+多线程、IO多路复用(select/poll/epoll)等源码,并分析代码和原理的对应关系。
- 开发环境:vmware16 + ubuntu20.04 LTS + vscode + gcc + gdb
一、TCP协议回顾
- 如果你会看到这篇文章,那我默认你已经知道什么是TCP协议了,本文的重点不在这里,所以不过多赘述。那为什么会有这一节呢?因为后续分析源码时,我想知道通信双方的端口状态,所以我要在这里放上TCP三次握手和四次挥手的状态图,便于后续结合源码分析。
二、服务端的源码实现及测试:server.c
- 首先我们列出服务端的流程图,并逐一介绍其供能:
socket()-->bind()-->listen()-->accept()-->send()/recv()-->close()- int socket(int domain,int type,int protocol):创建一个socket(返回值就是创建好的socket的socket文件描述符,如果创建失败则返回-1并设置errno),通过这个函数返回的socket文件描述符(其实是其所指的文件),我们就可以实现对某个进程(ip+port)的
监听/读写数据。从函数的返回值中可以看出,socket描述符就是一个int型的数值,那为什么通过一个int型的数值就能够对对端进程(文件)进行相应的操作呢?因为内核会为每个进程维护一个记录表,该表中记录着当前进程所打开的所有文件,而socket文件描述符就是当前文件在当前进程的记录表中的索引!- 是的,你没看错,socketfd描述符从功能上来看有监听和连接两种,但socket()创建的只是基础的主动套接字,这些套接字在经过某些函数的调用后,就会变成相应的功能套接字。例如,普通套接字绑定到一个进程之后调用listen()就将其变为了监听套接字,accept()就可以通过该监听套接字在监听队列中取出一个请求连接的客户端,然后进行3次握手,如果握手成功就会返回一个连接套接字,这个套接字唯一地标识了服务端和当前客户端之间的连接(
这里有点疑惑?两个套接字可以绑定到同一个地址上吗?我的理解是将套接字绑定到地址(一个地址唯一地标识了一个进程)上,就代表着这个文件是这个进程的,那一个进程可以拥有多个文件,监听socket指向的文件就只用来存申请信息,通信socket指向的文件就用来存和客户端的通信信息。下面也会测试,测试证明listenfd和connectfd的local address是相同的)。
- 是的,你没看错,socketfd描述符从功能上来看有监听和连接两种,但socket()创建的只是基础的主动套接字,这些套接字在经过某些函数的调用后,就会变成相应的功能套接字。例如,普通套接字绑定到一个进程之后调用listen()就将其变为了监听套接字,accept()就可以通过该监听套接字在监听队列中取出一个请求连接的客户端,然后进行3次握手,如果握手成功就会返回一个连接套接字,这个套接字唯一地标识了服务端和当前客户端之间的连接(
- int bind(int socketfd,const struct sockaddr* my_addr,socklen_t addrlen):将socketfd这个socket描述符绑定到my_addr这个地址的进程上,成功返回0、失败返回-1并设置errno(绑定:想象一下,socket()是创建了一个开关插座,bind是将这个插座安装到指定地址的进程上,然后通过这个插口就可以供客户端申请连接,ip+port就唯一指定了这个进程,sockerfd就是这个插座)。my_addr这个地址就是服务器的地址(
即当server.c运行起来之后,该程序的地址就是my_addr。换句话说,通过bind()我们为服务器程序指定了地址(更准确的来说应该是port,毕竟当前主机作服务器的话,ip已经限定住了)!要知道相比之下我们是不会为客户端指定地址的,而是让系统自动找个没人用的port给他就行了) - int listen(int sockfd,int backlog):listen()为sockfd这个描述符指向的文件创建监听队列来存放请求连接的客户端(即有哪些进程想插这个插座)。backlog指的是处于完全连接状态下的socket的上限(即最多有几个客户端可以
同时插这个插座,插上插座就表示和服务端建立起了完全连接状态)。listen()成功时返回0,失败则返回1并设置errno。 - int connectedfd=int accept(int serverfd,struct sockaddr *addr,socklen_t *addrlen):这里为了避免混淆,同时为了区别
监听套接字和读写套接字,我添加了一个变量来表示接受该函数的返回值。可以看出listenfd就是监听套接字,accept()通过该文件描述符来阻塞等待监听队列中的连接申请,得到请求连接的客户端的地址sockaddr,并在三次握手成功后,返回一个connectedfd,该文件描述符指向服务端为当前客户端打开的通信文件夹并唯一标识了这个连接,通过该描述符就可以对客户端进行数据的读写。 - TCP流数据读写的系统调用:
- socket可以用“文件的读写函数read()和write()”来进行读写,但是socket编程接口提供了几个专门用于socket数据读写的系统调用,它们增加了对数据读写的控制。其中用于TCP流数据读写的系统调用是:
- ssize_t recv(int sockfd,void *buf,size_t len,int flags):recv()读取连接套接字(
各自的连接套接字都是绑定在local address上的)上的数据。recv成功时返回实际读取到的数据长度,所以如果对端发送的数据长度n>本端的接受缓冲区尺寸len,就需要多次recv读取才能全读完(即多次recv的返回值相加==n)。 - ssize_t send(int sockfd,const void *buf,size_t len,int flags):send()往连接套接字上写入数据,send成功时返回实际写入的数据的长度。
- int close(int fd):关闭一个socket连接,其实就是关闭通信两端的
两个通信套接字。所以只有服务端关闭了自己的连接套接字+客户端关闭了自己的连接套接字,这个socket连接才真正结束(想一下4次挥手的状态图,这边会在下面的test中使用netstat监听一下端口状态)
- 接着让我们来实现服务端的源码:
#include <sys/socket.h> #include <sys/types.h> #include <sys/un.h> #include <netinet/in.h> // sockaddr_in 定义在<netinet/in.h>或<arpa/inet.h>中,该结构体解决了sockaddr(定义在<sys/socket.h> 里)的缺陷,把port和addr分开存储在两个变量中 #include <arpa/inet.h> // inet_addr #include <unistd.h> //socklen_t #include <stdio.h> #include <string.h> #include <stdlib.h> #include <errno.h> #define seraddr "192.168.2.128" //服务器的ip(点分十进制字符串表示) //#define seraddr "0.0.0.0" //表示任意网卡都可以和这个ip绑定 主机网卡对应的ip:127.0.0.1 #define serport 1234 //服务器的port #define BACKLOG 5 //处于 完全连接状态的socket 的上限 char rec_buf[1024]; int main(int argc,char **argv){ //定义一些需要用到的变量/对象 int listenfd=-1; //server's listening socket struct sockaddr_in s_addr={0}; //server's socket address struct sockaddr_in c_addr={0}; //client's socket address socklen_t c_addrlen=0; //client's sockaddr's length //1.创建socket描述符 listenfd=socket(AF_INET,SOCK_STREAM,0); printf("listenfd:%d\n",listenfd); //2.命名socket(将socket描述符绑定到一个socket地址上)----->给接受连接申请的文件搞个名字(即在文件表中的索引) 本机:192.168.2.128 s_addr.sin_family=AF_INET; //地址族(要和socket定义时一样) s_addr.sin_addr.s_addr=inet_addr(seraddr); //将 点分十进制字符串表示的ipv4地址 转化为 网络字节序证书表示的ipv4地址 s_addr.sin_port=htons(serport); //将 无符号短整形的主机字节序 转化为 短整形的网络字节序 //把listenfd绑定到要监听的端口上----->感觉理解为s_addr这个进程打开一个文件用于接受连接请求,然后返回该文件在文件表中的索引,该索引值就是listenfd比较好(不然到下面理解成两个插座绑定在一个进程上很难受) int ret=bind(listenfd,(struct sockaddr *)&s_addr,sizeof(struct sockaddr)); //好的,从现在开始,我们就可以通过listenfd这个索引去服务端进程的文件表中找到用于接受连接请求的文件 if(ret==-1){ perror("bind"); //配合errno全局变量,打印一个系统错误信息 exit(-1); } //3.监听socket listen(listenfd,BACKLOG); //创建监听队列,以存放待处理的客户端链接 while(1){ //NOTE:想测试一下服务端不重启的情况下,listenfd的对端地址是否会改变:结果是不会! //4.接受连接 int connectedfd=accept(listenfd,(struct sockaddr *)&c_addr,&c_addrlen);//注意:这里的两个fd都是服务端打开的文件的,而c_addr是客户端的。 //这句代码的意思是,通过listenfd在文件中取出了c_addr这个客户端的申请,在握手成功后,返回服务端的通信套接字,这个通信套接字唯一地标识了服务端和这个客户端的通信连接 if(connectedfd){ printf("client_addr:%s\n",inet_ntoa(c_addr.sin_addr));//[这里的client ip输出是0.0.0.0表示,被同意连接的客户端可以是本机上的任意一个进程(port)] printf("connectedfd:%d\n",connectedfd);//用于和当前客户端通信的文件,是该进程打开的第几个文件 //读取listenfd的本地地址和远端地址(这一块与socket通信无关,只是为了测试各个套接字的本地地址和远端地址) struct sockaddr_in listenfd_local_addr; socklen_t listenfd_local_addr_len=sizeof(struct sockaddr_in); int r1=getsockname(listenfd,(struct sockaddr*)&listenfd_local_addr,&listenfd_local_addr_len); struct sockaddr_in listenfd_remote_addr; socklen_t listenfd_remote_addr_len=sizeof(struct sockaddr_in); int r2=getpeername(listenfd,(struct sockaddr*)&listenfd_remote_addr,&listenfd_remote_addr_len); printf("for listenfd,local address is:%s %d remote address is %s %d\n",inet_ntoa(listenfd_local_addr.sin_addr),ntohs(listenfd_local_addr.sin_port),inet_ntoa(listenfd_remote_addr.sin_addr),ntohs(listenfd_remote_addr.sin_port)); //读取connectedfd的本地地址和远端地址 struct sockaddr_in connected_local_addr; socklen_t connected_local_addr_len=sizeof(struct sockaddr_in); int r3=getsockname(connectedfd,(struct sockaddr*)&connected_local_addr,&connected_local_addr_len); struct sockaddr_in connected_remote_addr; socklen_t connected_remote_addr_len=sizeof(struct sockaddr_in); int r4=getpeername(connectedfd,(struct sockaddr*)&connected_remote_addr,&connected_remote_addr_len); printf("for connectedfd,local address is:%s %d remote address is %s %d\n",inet_ntoa(connected_local_addr.sin_addr),ntohs(connected_local_addr.sin_port),inet_ntoa(connected_remote_addr.sin_addr),ntohs(connected_remote_addr.sin_port)); memset(rec_buf,0,sizeof(rec_buf));//初始化 接受缓冲区 while(1){ int n_read=recv(connectedfd,rec_buf,sizeof(rec_buf),0); //n_read<=rec_buf.size() 如果发送的数据大于这个rec_buf,则recv函数多次进入接受缓冲区分批放入该数组 返回读到的数据长度(可能小于期望长度,因为可能Buf太小,一次读不完) if(n_read>1){ printf("len:%d client's msg:%s\n",n_read,rec_buf);//从连接套接字指向的文件中 读出客户端发过来的消息 send(connectedfd,rec_buf,sizeof(rec_buf),0); } else{ printf("%d\n",n_read); printf("client disconnect\n"); break; //ctrl+c 断开客户端 } } } close(connectedfd); }//test while(1)的 return 0; }
- server.c代码功能:可以看出该服务端代码就是按照上面那个流程图来实现的(先通过这个流程图来学服务端怎么实现和客户端的通信,实际上不可能这么实现,因为具体和某个客户端连接不会放在主线程里,这样就做不了其他事了:即只能处理一个客户端)。那我们这个server.c都做了些什么呢?首先我们为我们这个服务器进程定义了它要运行的socket地址(seraddr+serport),然后创建一个监听套接字将其绑定到这个socket地址上(安装插座),再通过listen来为服务器进程创建监听队列(其实是两个SYN队列:未完成连接队列和ACCEPT队列:已完成连接队列,真正执行对这个队列监听的是accept()),然后通过accpet()阻塞监听这个队列。通过这4步,当服务端程序运行起来之后,它的socket地址就被指定为seraddr+serport,并且socketfd会被listen()转化成监听套接字并创建监听队列存放请求的连接,然后accept()来阻塞地监听有哪些客户端在尝试和服务器连接(即哪些客户端请求连接到这个socket地址上了)。当有一个客户端和服务器成功握手后,accept()就会为服务端再创建一个连接套接字,然后服务端通过该套接字的描述符来和客户端通信(将客户端发过来的数据发回去),当使用ctrl+c强制中断(
注意!这是主动行为!即发送FIN开启4次挥手)客户端的程序后,recv返回0(Linux高性能服务器p81,recv可能会返回0,这代表对方已经关闭连接了),于是服务端就退出循环,并关闭自己的连接套接字。(此时主动中断的一端端口进入time_wait状态,另一端端口已经可以再次使用)。【NOTE】该代码中有一段getsockname和getpeername的代码,这段代码与socket通信无关,只是为了知道每个套接字究竟是绑在哪个端口上的,在初看socket代码时,书和网上很多博客对这块都表述不清。
- 测试server.c:
-
我们运行server的代码,然后通过netcat来模仿客户端和server通信,并通过netstat来监测不同情况下的端口状态。
我们运行server.c可以发现我们创建的套接字的值为3(对比下面accept()得到的客户端的套接字的值为4),即serverfd所指的地方是当前进程打开的第三个文件(socketfd的值是进程打开的文件在文件表中的索引)。然后程序阻塞在accept()处等待客户端的连接。
当我们用netcat请求连接到服务端并且和服务端握手成功后,服务端将自己的connectedfd打印出来(即当前用于和客户端通信的文件是当前服务端进程打开的第几个我文件)。然后通过getsockname()和getpeername()分别打印listenfd和connectedfd的local address和remote address(
Q:为什么listenfd的remote address和connectedfd的remote address不同?在下面和客户端的测试时,我们可以知道connectedfd的remote address就是客户端的loacl address,那listenfd的remote address是哪里呢?)。最后服务端在recv()处阻塞等待客户端的数据。接着,我们通过netcat向服务端发送数据,可以看出服务端通过connectedfd读到数据后先将数据打印出来,然后再通过connectedfd将该数据原封不动的发回去,并且我们再终端中可以看到返回结果正确。(可以看出,数据的读/写是通过自己的connectedfd进行的,可以理解为两端都有自己的一个通信文件夹,你写入的数据会被内核送到对方的文件夹里,所以自己的文件夹里就是对方发送的数据)。
最后我们通过ctrl+c强制结束客户端进程,可以看出recv在客户端断开后会返回0,通过这个条件服务端也就知道当前客户端断开连接了,于是再次accept()等待新客户端的连接申请。那此时客户端端口的状态是什么样的呢?我们通过netstat来监听connectedfd的remote address,可以发现它处于TIME_WAIT状态,回到顶部看一下4次挥手时客户端的状态图,状态正确,发起挥手的一端需要在回复对方ACK后进入一段2MSL的TIME_WAIT状态,以确保对方收到关闭connectedfd请求的ACK,能够顺利释放和当前客户端的连接套接字。
在理想情况下,4次挥手是由客户端主动提出的,那
如果服务端因为异常先挥手会怎么样呢?接下来我们重新建立一个连接并同样通过netstat来看看这整个连接过程中(由服务器主动断开连接)服务端和客户端端口的状态,比较和上面客户端主动断开的过程中,有哪些状态不同。我们使用左侧的终端来充当客户端,右侧的终端来检测通信中各个时刻的端口状态。先看一下netstat会显示哪些东西,然后来进行测试。
- 当连接建立成功后,我们使用netstat来看看1234端口的网络状态,可以看出双端的连接都已经建立好了(回到顶部看三次握手的图),因为连接申请由客户端发起,所以第一条连接是客户端作为本地,服务端作为远端(即三次握手的前两步),然后第二条间接对应后两步,可以看出两端之间的socket连接已经成功建立,并且双方都没有要发送/接受的数据(各自的recv-Q和send-Q都是0)。
- 接着使用客户端向服务端发送数据,可以发现在正常通信状态下,双端的端口状态都是ESTABLISHED。
- 然后我们在服务端执行ctrl+c强制中断服务端的程序,此时服务端所在的1234端口状态为FIN_WAIT2,客户端所在的38234端口状态为CLOSE_WAIT。为什么服务端没进TIME_WAIT状态呢?因为服务端是netcat模拟的,在收到服务端的断开请求后只会回一个ack,而不会请求断开(即不会自动第三次挥手)所以此时服务端处于FIN_WAIT2,客户端处于CLOSE_WAIT。
- 在上面已经完成两次挥手的状态下,我们分别在客户端发送一条消息/通过ctrl+c执行断开连接申请,可以发现如果我们发送一条消息"he"后再去查看端口状态,可以发现两个端口上都没有网络连接了(Q?为什么?是因为异常程序都直接终止了吗?为什么没有继续最后两次挥手?--->
有一篇博客里说:当服务端进入FIN_WAIT2状态下,客户端还可以发送一次数据,服务器收到数据后就会向客户端发送RST并进入CLOSED状态,从测试结果来看是这样的)。但是如果我们在客户端执行ctrl+c(即客户端发出断开请求,对应第三次挥手),则服务端进入TIME_WAIT状态。此时四次挥手的过程是顺利的,但是我们无法立即再次执行服务端程序,因为它需要等待TIME_WAIT状态结束,所以断开连接这个动作应该由客户端发起。
- 当连接建立成功后,我们使用netstat来看看1234端口的网络状态,可以看出双端的连接都已经建立好了(回到顶部看三次握手的图),因为连接申请由客户端发起,所以第一条连接是客户端作为本地,服务端作为远端(即三次握手的前两步),然后第二条间接对应后两步,可以看出两端之间的socket连接已经成功建立,并且双方都没有要发送/接受的数据(各自的recv-Q和send-Q都是0)。
-
至此,服务端和客户端通信的在双方主动断开的情况下的状态都已经分析完了,可以发现不同状态下的打印结果和TCP流程图上的状态一致。
-
接着我们再看看多个客服端断开连接后,服务端端口的断开状态:
可以发现如果有大量并发的客户端close(),那服务器上会有非常多的TIME_WAIT。这可能会导致服务器的端口(connectfd指向的)被耗尽。比较好的解决方式是采用长连接模式。这一块具体可以看知乎:tangguangyao的高并发下的TIME_WAIT一文,感觉讲的很清楚
三、客户端的源码实现:client.c
- 首先我们列出客户端的流程图,并逐一介绍其供能:
socket()-->connect()-->send()/recv()-->close()- int connect(int sockfd,const struct sockaddr *serv_addr,socklen_t addrlen):客户端通过connect()函数,请求连接serv_addr这个socket地址,如果握手成功,就返回0,并且
在local address上绑定一个文件描述符sockfd(是一个connectedfd),sockfd就唯一地标识了这个连接,客户端就可以通过这个sockfd来进行和服务端之间数据的读写。如果握手失败则返回-1并设置errno。- 这个函数中的sockfd是我想着重介绍的,无论是《Linux高性能服务器编程》还是网上很多博客中,他们的代码和文字描述都没有说清楚这个是绑定在哪端的(这里的地址还是服务端的,很难不让人误解)。最后B站中的一位UP主(奇妙编程学院)制作的视频和StackOverflow上的回答,帮我脑海中建立起了connect()的画面:
也就是说,bind()只负责把插座安装到local address的进程上,而connect()不仅负责把插座安装到local address的进程上,还要连一根导线到serv_addr上。这样一来就比较好理解了,通信双方都有一个自己的connectedfd,通过该connectedf往自己进程打开的通信文件夹中写入数据,然后写入的数据会被这根导线送到对方的文件夹里,在这样一个逻辑下通信双方就都可以只通过自己的connectedfd就进行数据的读写了。
- 这个函数中的sockfd是我想着重介绍的,无论是《Linux高性能服务器编程》还是网上很多博客中,他们的代码和文字描述都没有说清楚这个是绑定在哪端的(这里的地址还是服务端的,很难不让人误解)。最后B站中的一位UP主(奇妙编程学院)制作的视频和StackOverflow上的回答,帮我脑海中建立起了connect()的画面:
- 接着让我们来实现客户端的源码:
#include <stdio.h> #include <sys/socket.h> #include <sys/types.h> #include <netinet/in.h> //sockaddr_in类 #include <unistd.h> //socklen_t 类 #include <arpa/inet.h> #include <stdlib.h> #include <string.h> #define seraddr "192.168.2.128" //#define seraddr "127.0.0.1" 代表本地主机的ip #define serport 1234 #define clientaddr "192.168.2.128" #define clientport 56986 char send_buf[1024]; char recv_buf[1024]; int main(int argc,char **argv){ //1.创建socket套接字 int connectedfd=-1; //绑定在客户端地址上的,用于和服务端通信的套接字描述符(即该索引指向客户端打开的用于和服务端通信的文件) connectedfd=socket(AF_INET,SOCK_STREAM,0);//TCP/IP协议族、流服务TCP、具体协议0 //2.初始化服务器地址对象,然后对该地址发起连接(即在客户端这边的插座上,连一根管子到服务端(s_addr这个地址)的插座上) struct sockaddr_in s_addr={0}; //服务器socket地址 socklen_t s_addrlen=0; s_addr.sin_family=AF_INET; s_addr.sin_addr.s_addr=inet_addr(seraddr); s_addr.sin_port=htons(serport); //test:通过bind,指定客户端的port---->因为服务端listenfd的远端地址不知道是哪里 //struct sockaddr_in c_addr={0}; //服务器socket地址 //socklen_t c_addrlen=0; //c_addr.sin_family=AF_INET; //c_addr.sin_addr.s_addr=inet_addr(clientaddr); //c_addr.sin_port=htons(clientport); //int r=bind(connectedfd,(struct sockaddr *)&c_addr,sizeof(struct sockaddr)); //3.发起连接-----》connect将文件描述符connectedfd所指向的套接字(文件) 连接到s_addr所指定的地址 int ret=connect(connectedfd,(struct sockaddr *)&s_addr,sizeof(struct sockaddr_in)); if(ret==-1){ perror("connect"); exit(-1); } printf("connectedfd:%d\n",connectedfd); //读取listenfd的本地地址和远端地址(这一块与socket通信无关,只是为了测试各个套接字的本地地址和远端地址) struct sockaddr_in connectedfd_local_addr; socklen_t connectedfd_local_addr_len=sizeof(struct sockaddr_in); int r1=getsockname(connectedfd,(struct sockaddr*)&connectedfd_local_addr,&connectedfd_local_addr_len); struct sockaddr_in connectedfd_remote_addr; socklen_t connectedfd_remote_addr_len=sizeof(struct sockaddr_in); int r2=getpeername(connectedfd,(struct sockaddr*)&connectedfd_remote_addr,&connectedfd_remote_addr_len); printf("for connectedfd,local address is:%s %d remote address is %s %d\n",inet_ntoa(connectedfd_local_addr.sin_addr),ntohs(connectedfd_local_addr.sin_port),inet_ntoa(connectedfd_remote_addr.sin_addr),ntohs(connectedfd_remote_addr.sin_port)); while(1){ printf("please iiinput:"); memset(send_buf,0,sizeof(send_buf)); scanf("%[^\n]",send_buf);//使 可以发送中间有间隔的字符串 getchar();//清除缓冲区里的第一个\n int n_write=send(connectedfd,send_buf,strlen(send_buf),0);//通过文件描述符 往 客户端用于和服务端通信的socket文件中 写入数据 printf("send %d byte:%s to server\n",n_write,send_buf); memset(recv_buf,0,sizeof(recv_buf)); int n_read=recv(connectedfd,recv_buf,sizeof(recv_buf),0);//通过文件描述符 从 客户端用于和服务端通信的socket文件中 读出数据 if(n_read>0){ printf("servers's msg:%s\n",recv_buf); } else printf("current have no data\n"); } return 0; }
- client.c代码功能:可以看出客户端的逻辑很简单,就是创建一个socket,然后通过connect()将这个socket绑定到loacl address(即客户端进程)并在上面连一根管子连到serv_addr上,然后就可以通过这个socket的文件描述符connectedfd来和客户端进行通信了。(中间有一块注释掉的代码是为了测试listenfd的remote address的,与socket通信无关,见下面测试处)
- 使用client.c和server.c通信,并测试双方的端口状态:
- 首先运行服务端程序,使其阻塞等待监听队列中的连接申请,并看一下此时服务端的端口状态:
可以看出此时没有任何连接。
- 接着启动客户端,使其申请连接服务器,然后看此时的两端端口状态:
可以看出此时双方已经成功建立了连接,两个端口都处于ESTABLISHED状态。
我们再看看两端各自connectedfd描述符的getsockname()和getpeername()结果,验证了connectedfd都是绑定在本端local address上的,并且两端的connectedfd互为对端。 - 然后通过客户端给服务端发送消息,并查看两端状态:
可以看出处于完全连接状态下的两端端口就是ESTABLISHED状态。
- 此时我们使用ctrl+c强制中段客户端进程,然后查看两端断口状态:
可以看出,主动发起FIN的一端在连接断开(还没有完全断开)后,出于TIME_WAIT状态,此时另一端的断开已经可以被再次连接(回顾server.c测试,一个服务端可以有很多处于TIMIE_WAIT的对端,但此时并不影响它自己被新的服务端连接)
- 我们再使用客户端重新连接一次服务端,并通过ctrl+c主动断开服务端并查看两端端口状态,再
通过ctrl+c主动断开客户端(补发第三次挥手FIN)并查看两端端口状态:可以看出,当服务器主动断开连接时,服务器处于FIN_WAIT2,客户端处于CLOSE_WAIT,这是由于客户端此时还没有调用close(),相当于服务端ctrl+c触发close(),然后客户端回个ACK,但是客户端没有发close()。所以接着我们再ctrl+c客户端,服务端也就进入了TIME_WAIT状态,测试完成。至此我们就将服务端和客户端的代码、连接状态等都对应到TCP和socket的理论上了。
- 【Question】:从两端各自终端的输出结果中可以看出,我还做了另一个测试,就是重启/不重启服务端的情况下,多个listenfd的remote address是否相同?如果绑定客户端进程的地址,listenfd的remote address是否会等于客户端地址?测试结果发现,每次重启服务端,listenfd的remote address会改变(像客户端的local address一样是系统自动分配的)并且就算绑定了客户端进程的地址也和这个无关。但是如果不重启服务端,多个客户端依次连接的情况下,listenfd的remote address是不会改变的,关于Listenfd的remote address我没搜到具体是指向哪里,但是我猜测可能就是指向Listen()创建的监听队列的(内核),这个队列和listenfd所指的文件夹形成了一对连接?如果有大佬知道答案,还烦请告知。
四、总结及测试工具介绍:
- 总结:本文我们介绍了socket网络编程中的函数,明确了其中各个变量是什么,通过具体化的变量命名使代码和通信过程一一对应,接着分别实现了服务端(单线程BI0)和客户端的代码,最后实现了服务端单线程BIO下和客户端通信实例并测试通信全程的端口状态。
- 测试工具:
- netcat:可以充当客户端来测试服务器/充当服务器来测试客户端
- netcat充当客户端:
nc ip port(意思是,用netcat来主动连接ip+port这个地址,此时netcat就是客户端,可以直接在上面打字来和ip+port这个服务端通信) - netcat充当服务器:
nc -l port(意思是,用netcat来监听port这个端口,此时netcat就是服务器,客户端给port发送的消息都会在netcat所在的终端中直接打印出来)
- netcat充当客户端:
- netcat:可以充当客户端来测试服务器/充当服务器来测试客户端
- netstat:
netstat -nt | grep port用于查看通信状态下的端口的状态
五、学习过程中看到的一些写的很好的博客(对我理解有帮助):
stackoverflow.com/questions/2…
zhuanlan.zhihu.com/p/180543251
学习中记录,如果有误还请指出。