linux网络编程套接字
一、预备知识
1、IP地址和MAC地址
IP地址
IP协议有两个版本, IPv4和IPv6. 我们整个的课程, 凡是提到IP协议, 没有特殊说明的, 默认都是指IPv4
IP地址是在IP协议中, 用来标识网络中不同主机的地址;
对于IPv4来说, IP地址是一个4字节, 32位的整数;
我们通常也使用 “点分十进制” 的字符串表示IP地址, 例如 192.168.0.1 ; 用点分割的每一个数字表示一个字节, 范围是 0 - 255;
MAC地址
MAC地址用来识别数据链路层中相连的节点;
长度为48位, 及6个字节. 一般用16进制数字加上冒号的形式来表示(例如: 08:00:27:03:fb:19)
在网卡出厂时就确定了, 不能修改. mac地址通常是唯一的(虚拟机中的mac地址不是真实的mac地址, 可能会冲突; 也有些网卡支持用户配置mac地址).
在IP数据包头部中, 有两个IP地址, 分别叫做源IP地址, 和目的IP地址。但是只有IP地址并不能完成通信,想象一下发qq消息的例子, 有了IP地址能够把消息发送到对方的机器上,但是还需要有一个其他的标识来区分出, 这个数据要给哪个程序进行解析。所以就还需要一个端口号来标识将消息发送给对方机器的哪一个进程。
端口号
端口号(port)是传输层协议的内容。
端口号是一个2字节16位的整数。
端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理。
IP地址 + 端口号能够标识网络上的某一台主机的某一个进程。
一个端口号只能被一个进程占用。
一个进程可以绑定多个端口号; 但是一个端口号不能被多个进程绑定。
传输层协议(TCP和UDP)的数据段中有两个端口号, 分别叫做源端口号和目的端口号。
这样在网络中通过ip地址+端口号就可以唯一确定一个进程了。
二、简单的UDP网络程序
下面我们来编写一个UDP的网络程序。
我们先写出来makefile文件。
然后我们创建一个UdpServer类来封装服务器。当创建一个UdpServer服务器时需要传入端口号和ip地址。
然后我们在main函数中创建一个UdpServer类类型对象,然后用户运行server程序时需要通过命令行参数传入端口号和ip地址。我们需要将用户传入的端口号和ip地址用来创建UdpServer类类型对象。然后我们调用initServer函数来初始化服务器,然后再调用Start函数来启动服务器。这就是udp服务器启动的大致流程。
下面我们来初始化服务器。在进行网络通信之前,我们需要创建套接字用来存储网络中的数据。我们可以使用socket函数来创建套接字。socket函数的返回值当作文件描述符就可以,网络通信就类似于IO操作。第一个参数domain为域,即哪一种类型的套接字,可以设置这个套接字为本地通信还是网络通信。第二个参数为通信类型,如果第一个参数设置了为网络通信,那么第二个参数可以设置通信的类型是以流式的方式还是以数据报的方式。第三个参数为协议,默认为0即可。
当创建了套接字后,我们还需要使用一个变量来记录socket函数的返回值,因为下面的一些接口还需要socket函数的返回值。所以我们添加了一个成员变量_sock来记录socket函数的返回值。
当我们创建了套接字之后我们还需要进行套接字绑定,即将用户设置的ip地址和port端口号在内核中和我们当前的进程进行强关联。下面我们使用bind函数来进行套接字绑定。bind函数的第一个参数为socket函数的返回值,第二个参数为一个填充ip地址和port的struct sockaddr类型的结构体指针。即我们需要创建一个struct sockaddr类型的结构体对象,然后将服务器的ip地址和端口号设置到这个结构体对象中,然后将这个结构体对象的地址传到bind函数中。第三个参数为结构体对象的大小。
我们看到struct sockaddr_in结构体中有一个sin_port变量记录端口号,还有一个sin_addr变量记录ip地址。
下面我们创建一个struct sockaddr_in结构体对象,然后使用bzero函数将这个结构体对象的内存初始化为0。并且将结构体对象的sin_family赋值为AF_INET,表示网络通信。
下面的这两个头文件包含了网络通信的一些数据类型。
当服务器收到客户端发来的数据后,也可能会将自己的IP地址和端口号发送给客户端,所以服务器需要先将自己的IP地址和端口号发送到网络中,而将数据发送到网络中之前是需要做一些处理的。端口号我们可以直接使用htons函数来进行转换即可。
我们平时看到的ip地址类似于这样的格式"192.168.110.110",这样的IP地址是点分十进制字符串风格的IP地址,但是在网络中传输数据时,一个IP地址使用4个字节保存就够了,所以我们在向网络中发送数据时需要将点分十进制字符串风格的IP地址转换为4字节的IP地址,然后再将4字节主机序列转换为网络序列。
我们可以使用下面的这些函数来进行格式转换。其中我们可以使用inet_addr函数来将点分十进制字符串风格的IP地址直接转换为网络序列。
并且因为bind函数的第二个参数为一个struct sockaddr * 类型的指针,而我们使用网络通信创建的是一个struct sockaddr_in类型的结构体对象,所以我们需要进行强转。
下面我们再来实现服务器的Start函数,即启动服务器。作为一款网络服务器是永远都不会退出的,即服务器启动后,会变为一个常驻进程,即这个进程永远在内存中存在,所以我们在服务器中不能有内存泄漏,那样最后会因为没有内存而使服务器程序挂掉。
当服务器启动后,就会接收到客户端发来的数据,所以我们在Start函数中需要读取其它主机发送来的数据。我们可以通过recvfrom函数来读取网络中传过来的数据。recvfrom函数的第一个参数为套接字,第二个和第三个参数为缓冲区,即读取的数据存在哪里。返回值为成功读取的数据的大小,读取失败为-1。第四个参数为0时为阻塞方式读取。最后两个参数为输出型参数。第五个参数我们看到为一个struct sockaddr * 类型的指针,为一个返回型参数。因为当服务器收到数据后,还想要知道是谁发送过来的数据,此时就可以创建一个struct sockaddr类型的结构体对象peer,然后将这个结构体对象的地址传入recvfrom函数的第五个参数,这样发送端的IP地址和端口号等信息就会被写入到结构体对象中。第六个参数为一个输入输出型参数,当传入到recvfrom函数中时,addrlen表示结构体对象peer的大小。当执行完recvfrom函数后,addrlen表示实际读到的peer结构体对象的大小。
下面我们将从网络中获取的客户端的IP地址和端口号转换后打印出来。
当服务器收到数据后,需要向客户端发送一个收到数据的消息,即写回数据。可以使用sendto函数来进行写回数据。sendto函数的参数和recvfrom函数的参数类似,只不过sendto函数的第五个参数和第六个参数不再是输出型参数,而是记录了要发送客户端的IP地址和端口号等信息。
下面我们再实现UdpServer的析构函数,即将套接字进行关闭。
下面我们先进行测试,我们看到服务器程序成功启动了。
我们使用netstat命令来查看网络链接。
netstat -anup
下面我们来实现客户端。
我们在运行客户端程序时,需要传入服务器的IP地址和端口号。
在客户端程序中也需要创建套接字,这样才能从套接字中读取到数据。
我们需要注意的是在客户端中不需要显示bind,而是让操作系统自动随机选择客户端程序的端口号。因为如果程序员bind了一个固定的ip和port后,当客户端程序在用户的设备上运行时,如果此时用户的设备中port端口号被其它程序占用了,那么客户端程序就不能运行了。所以client一般不需要显式的bind指定的port,而是让操作系统自动分配。
下面的就是客户端向服务器发送消息的步骤。
我们看到上面客户端向服务器发送消息时,只涉及到了服务器的IP地址和端口号,那么操作系统什么时候分配给客户端IP地址和端口号呢?
其实当client首次发送消息给服务器的时候,OS会自动给client bind它的IP和PORT。下面我们让客户端接收服务器端发回来的消息,并且我们将消息打印出来。temp结构体对象中的IP地址和端口号为服务器的IP地址和端口号。
下面我们来进行测试。
我们需要注意的是当将服务器的IP地址设为127.0.0.1时表示本地环回,即client和server发送的数据只在本地协议栈中进行数据流动,并不会把数据发送到网络中,这通常在本地网络服务器测试的时候使用。
我们看到下面操作系统自动为客户端分配的端口号。
上面我们实现的服务器的IP地址和端口号为用户指定的,所以当客户端发送数据时,需要向指定IP地址和端口号中发送数据,服务器才可以接收到。下面我们将服务器改为可以接收任意IP地址发送的数据。
我们看到客户端向任意IP地址的8080端口发送消息时,服务器端都可以收到消息。
下面我们接收客户端发送过来的一些命令,然后执行这些命令。
popen函数会在接收到命令后,创建一个子进程执行这个命令。所以我们可以使用popen函数来完成创建子进程的步骤。
我们让服务器接收客户端发来的命令并且运行,然后将运行的结果返回给客户端。并且我们使用strcasestr函数来查找客户端发送的命令中是否有rm等命令,如果有的话就不执行这些命令。
我们看到客户端发送的命令服务器成功执行了,并且服务器将执行结果返回给了客户端。
我们还可以将服务器设为接收所有IP地址发送的数据,那么客户端向哪个IP地址发送的消息服务器都可以接收到。
下面我们来使用UDP实现一个简单的群聊功能,即每一个用户都会先发消息给服务器,然后服务器收到消息后将这个用户发的消息广播给每一个用户,这样每一个用户就都可以看到这一条消息了,这样就实现了群聊功能。
我们先在服务器中加一个unordered_map容器用来存储客户端的IP地址+端口号和sockaddr_in结构体的数据。
当服务器收到客户端发送的消息后,就将这个客户端的IP地址+端口号拼接为字符串,然后再_users中查看是否有当前客户端的信息,如果没有当前客户端的信息,那么就将当前客户端添加到_users中。然后服务器进行广播发送消息时就会也发送给这个新的客户端信息了。
然后我们进行测试时发现,当有两个客户端向服务器发送消息时,服务器可以收到消息,并且服务器也将消息成功广播式的发送给所有客户端了,但是我们看到如果客户端1发送了消息,那么只有客户端1收到了服务器返回的消息,而客户端2中并没有打印服务器发送的消息。这是因为客户端2被阻塞在了输出信息的代码处,如果不输入信息,那么客户端2的代码就无法向下执行,也就无法打印出来服务器发送的消息了。
我们看到只有当用户输出了信息后,客户端的代码才向下执行sendto函数发送消息和recvfrom函数读取消息。
为了解决上面的客户端不输入信息就无法收到服务器发送的消息的问题,我们可以在客户端创建两个线程,一个线程用来接收用户输入的消息,另一个线程用来读取服务器发来的消息,这样我们就解决了上面的问题。
我们将以前封装的thread拿过来使用。
然后我们创建两个线程,sender线程调用udpSend函数来接收用户的信息并且发送给服务器。recver线程调用udpRecv函数用来接收服务器的信息。我们就将客户端的端口号和IP地址设为全局变量,这样sender线程和recver线程就都可以使用端口号和IP地址了。
下面我们在udpSend函数中实现让sender线程一直接收用户输入的信息并且发送给服务器。
在udpRecv函数中实现让recver线程一直接收服务器发送过来的信息并且进行打印。我们发现不管是sender线程的向sock中写数据还是recver线程的向sock中读取数据,使用的都是一个sock,并且sock代表的其实就是一个文件,那么如果sender线程向sock文件中刚写入了一个数据,recver线程的sock文件中会更新这个数据吗?答案是会的,因为UDP是全双工通信,即可以同时进行收发而不受干扰。
我们将client.cpp编译时带上-lpthread。然后我们进行测试。我们看到此时客户端1发送的消息客户端2中也会显示出来,而且客户端2发送的消息客户端1也可以收到。
下面我们在服务器中将客户端发送的消息前面再加上这个客户端的IP地址和端口号,这样就可以标识是哪个客户端发送的消息了。
我们看到每一个信息的前面都加上了发送消息的客户端的IP地址和端口号,这样就可以标识是哪个客户端发送的消息了。
我们看到上面的客户端1和客户端2发送的消息和收到的消息都混在一起了,这样不方便观察,下面我们来将客户端发送的消息和收到的消息分开打印出来。
我们先使用mkfifo创建两个命名管道文件。
然后将提示信息使用标准错误打印出来。
然后我们将客户端1输出到显示器的信息重定向输出到clientA命名管道文件,然后使用cat显示clientA文件中的客户端1输出的信息。然后客户端2将输出到显示器的信息重定向输出到clientB命名管道文件,然后使用cat显示clientB文件中的客户端2输出的信息。我们看到这样就将客户端的输入输出的信息分开显示了。
这样我们就使用UDP简单实现了一个群聊的功能。我们在上面将客户端使用多线程实现了,其实服务端我们也可以改为多线程实现,可以在服务器中创建一个消息队列,用来存储客户端发送的消息。然后可以让服务器也为多线程,即一个线程用来将收到的消息放到消息队列,然后另一个线程从消息队列中拿取数据并进行发送。这样就和客户端类似了,即一个线程用来接收客户端发送的信息并且存到消息队列中,然后另一个线程用来将消息队列中的消息发送给每个客户端。
在windows系统下运行客户端程序
上面我们实现的群聊服务的客户端和服务端都需要在linux系统下运行,但是在现实中,用户使用的系统基本都是windows系统,所以我们需要将客户端程序可以在windows系统下运行,然后我们将服务器在linux下运行,这样客户端程序才符合大部分用户。
那么将客户端程序在windows下实现是否需要重新实现客户端程序呢?因为windows系统的关于套接字的系统调用接口和linux下的套接字的系统调用接口的使用方法基本类似,所以我们不需要进行大改,只需要改一些细节就可以将linux下写的客户端程序在windows系统下运行。
我们可以搜索出在windows下使用UDP进行SOCKET编程的客户端的源码。
下面我们将客户端源码复制到VS下,然后将_tmain函数修改为main函数。这样代码就为一个简单的客户端程序了,该客户端程序可以向服务器发送信息。
下面我们根据我们的需求,将客户端程序改为可以向服务器发送消息,并且可以收到服务器发送过来的消息的客户端程序。
然后我们发现报出了一个错误,我们先将这个错误进行屏蔽,
然后我们需要将linux下的服务器打开,然后通过windows下的客户端就可以发送给linux下的服务器消息了,而且linux下的服务器也可以将消息返回给windows下的客户端程序。
三、简单的TCP网络程序
上面我们使用基于UDP的SOCKET网络编程写了一个简单的群聊程序,下面我们来使用TCP来实现一个程序,以便我们可以更好的理解TCP。
下面是该程序的makefile文件。
下面为TcpServer服务器的框架。我们在使用TcpServer服务器时,需要创建一个TcpServer服务器对象,然后调用initServer函数来初始化服务器,然后再调用start函数来启动服务器。
基于TCP的通信的第一步也是先创建套接字,与UDP通信不同的是调用socket函数的第二个参数为SOCK_STREAM,即表示TCP通信。我们前面说了套接字就类似于文件描述符,从套接字中获取数据就类似于从文件中获取数据。不同的就是文件中的数据是我们通过本地主机写入的,而套接字中的数据可以通过网络从其它主机中获取数据,然后本地主机再从套接字中获取数据,这样就实现了不同主机上的进程通信。下面我们打印_sock的值,可以看到_sock的值为3,这更说明了_sock套接字就类似于文件描述符。
下面我们需要将套接字进行bind绑定。我们需要知道创建socket就是将进程与文件相关联,然后进程就可以从文件中,即套接字中获取数据。bind绑定就是将文件和网络相关联,即文件中的数据来源于网络。当我们将数据发到网络中,或者从网络中获取数据时都要将格式先转换。我们将服务器接收的ip设置任意,这样服务器就可以接收任意ip地址发过来的数据,而如果我们将服务器接收的ip地址写死,那么服务器就只会接收这一个ip地址的信息。即如果将服务器设置为INADDR_ANY,那么客户端向任意ip地址发送的数据,服务器都可以收到。而如果将服务器接收数据的ip地址设为127.0.0.1,那么只有当客户端向127.0.0.1这个ip地址发送数据时,服务器才会接收数据。并且还有一个我们需要注意的点,一台主机中同一时刻只能有一个进程绑定指定端口号,如果我们启动一个服务器程序绑定了8080端口号,当再次启动另一个服务器程序绑定8080端口号时就会出现bind绑定错误。
因为TCP是面向连接的,所以当我们正式通信之前需要先建立连接,所以我们需要在基于TCP通信的服务器中加一个监听的步骤。我们可以通过listen系统调用来监听,使服务器一直处于监听状态,如果有客户端想要和服务器进行通信,那么就需要先建立连接。listen函数的第一个参数就为套接字,第二个参数我们后面解释。这样我们的TcpServer服务器就初始化成功了。
因为服务器是需要一直运行的,所以我们需要将服务器一直在循环中处理客户端发送的请求。下面的运行结果中我们看到如果两个服务器程序都使用8080端口号,那么第二个服务器程序就会出现bind绑定错误。
我们可以通过netstat -antp命令来查看tcp连接。
因为TCP通信之前需要建立连接,而在建立连接时就需要先获取连接,accept函数就是获取连接的,并且服务器可能需要知道是谁发送的数据,那么第二个参数和第三个参数为返回型参数,当返回后struct sockaddr对象里面的端口号和IP地址就是客户端的端口号和IP地址。并且我们需要注意的是accept函数的返回值也为一个套接字。那么accept返回的套接字和_sock有什么区别呢?我们可以通过一个例子来理解这两个套接字的不同作用,例如一个餐厅中张三为拉客的服务员,当张三拉到一桌客人后就从后厨喊来服务员李四来服务这一桌客人,然后张三继续去门口拉客;当张三又拉到一桌客人后,就从后厨再叫来服务器王五服务这一桌新拉的客人;然后张三继续再去门口拉客。在上面这个例子中,我们可以将张三理解为_sock套接字,即服务器通过_sock套接字来获取新连接,而李四、王五就相当于accept返回的套接字,这个套接字就是供这一个新的TCP连接来使用。
通常我们将基于TCP的服务器中获取新连接的套接字称为listensock监听套接字,即listen函数就通过监听这个套接字来分析是否有新的TCP连接要连接到服务器。而我们将accept函数返回的套接字为servicesock服务套接字,即这个新的TCP连接要和服务器之间进行通信就使用这个servicesock套接字。下面我们将这两个套接字的名称修改,以便更好的区分这两个套接字。然后我们从src结构体对象中拿到客户端的IP地址和端口号进行打印,这样我们就获取了一次客户端与服务器的TCP连接。
下面我们就可以基于这一次连接来进行客户端和服务器的通信服务了。我们创建一个service函数用来处理客户端和服务器之间的通信。因为套接字就类似于文件描述符,所以我们可以直接使用文件的系统调用来操作套接字。
下面我们来进行测试。我们将服务器启动,然后通过netstat -nltp命令来查看处于监听状态的TCP连接。
netstat -nltp
因为我们还没有实现客户端,所以我们可以使用telnet这个工具来充当服务端帮助我们完成服务器的测试。如果没有安装telnet这个工具,我们需要先安装这个工具。
然后我们使用telnet工具并且提供IP地址和端口号连接到服务器。
然后按ctrl + ],会出现telnet>,然后再按回车,就可以输出要发送给服务器的数据了。telnet会将消息发送给服务器,并且还会将服务器发送给客户端的消息进行打印。
telnet退出时输入ctrl + ],然后在telnet>后面输入quit按回车就退出了,此时就会断开这次客户端和服务器的TCP连接。
下面我们启动两个客户端,并且这两个客户端都向服务器发送消息,然后我们看到服务器只响应了客户端1的消息,而客户端2发送的消息服务器并没有响应。当我们将客户端1退出后,我们看到服务器才和客户端2进行连接,然后服务器处理客户端2的请求。
我们发现服务器一次只能处理一个客户端,这是因为我们的服务器为单进程,当建立连接后,就会进入service函数内进行数据通信,即在service函数中阻塞等待信息。如果不退出service函数,服务器就无法执行下面的代码,即就无法进入start中的while循环中的下一个循环来建立新的连接。
所以我们可以写一个多进程版本来解决上面的问题。我们创建一个子进程来处理客户端发来的请求,即每当有一个客户端请求过来时,我们就创建一个子进程来解决这个请求,然后父进程继续向后执行。因为我们没有让父进程调用waitpid函数来等待子进程,所以子进程退出后会处于僵尸状态。但是我们在start中手动设置了忽略SIGCHLD信号,即如果子进程退出后会向父进程发送SIGCHLD信号,而我们手动设置了忽略SIGCHLD信号,那么当子进程退出的时候就会自动释放自己的僵尸状态,即让父进程回收自己的资源。
然后我们让子进程和父进程各自关闭不需要的文件描述符,如果父进程的servicesock不关闭的话,那么会导致父进程的文件描述符越来越少,即会导致文件描述符泄漏,所以在父进程中必须要关闭servicesock文件描述符。然后在子进程中我们调用service函数来向客户端提供服务。这样子进程用来向客户端提供服务,然后父进程继续监听或者建立其它连接。如果父进程再次接收到了另一个客户端的连接请求,那么父进程会再创建一个子进程来为这个客户端提供服务。然后父进程继续监听或者建立其它连接。
然后我们再次使用telnet来模拟客户端,并且同时使用两个客户端连接到服务器,这两个客户端都想服务器发送消息,我们看到这一次服务器可以响应这两个客户端的消息,并且当一个客户端退出后,服务器还可以为另一个客户端提供服务。这就是因为我们在服务器中为每一个客户端都创建了一个子进程来提供服务,我们看到当有两个客户端时,父进程就创建了两个子进程分别来为两个客户端提供服务。
而当有一个客户端退出后,那么服务器中的为这个客户端提供服务的子进程也退出了。并且这个子进程在退出时会向父进程发送SIGCHLD信号来通知父进程回收自己的资源。
下面我们来实现客户端的代码。
客户端创建好套接字后是不需要进行bind绑定的,因为客户端不知道以后运行时的具体端口号,所以客户端的端口号让操作系统自动分配。并且因为客户端只需要主动连接服务器来让服务器为自己提供服务,所以客户端也不需要监听状态,即不需要监听其它主机是否要连接自己。
但是客户端需要连接服务器,客户端可以通过connect这个函数来和服务器建立连接。该函数的第一个参数为套接字,第二个参数需要传入一个记录了服务器端口号和IP地址的struct sockaddr结构体对象,第三个参数为这个结构体对象的大小。connect函数的后两个参数和sendto函数的后两个参数的一致。所以我们在使用connect建立连接之前,需要先创建一个struct sockaddr结构体,然后将这个结构体中记录客户端要连接的服务器的IP地址和端口号等信息。
当客户端和服务器建立连接成功后,客户端就可以向服务器发送请求了。客户端向服务器发送数据可以直接通过write接口来将数据写到套接字中,但是还有一个send接口也可以使用,send函数的参数和返回值和write都类似,就是send函数最后多了一个flags参数,这个参数设置为0即可。
客户端读取服务器发送过来的数据可以通过read接口直接从套接字中读取数据,也可以通过recv这个接口来从套接字中读取数据。recv函数的返回值和参数也和read函数类似,就是recv函数最后多了一个flags参数,这个参数设置为0即可。
下面我们就使用send接口来将客户端的消息发送给服务器,然后通过recv接口来读取服务器返回的消息。
下面我们来测试使用客户端向服务器发送消息,并且接收服务器返回的消息。我们看到测试的结果和我们使用telnet工具模拟客户端测试的结果相同,每当有一个客户端与服务器建立连接之后,服务器都会创建一个子进程来为这个客户端提供服务。
上面我们通过让父进程创建子进程,然后在父进程中捕获子进程退出时发送的SIGCHLD信号,然后对这个信号的处理动作手动设为忽略来让父进程回收子进程的资源,这样父进程就不需要阻塞式的等待子进程了。下面我们再通过另外一种写法来避免父进程阻塞式的等待子进程。我们让子进程再创建一个孙子进程,那么这个孙子进程的父进程就是子进程了。然后我们让子进程退出,让孙子进程调用service函数来为客户端提供服务,这样因为子进程退出了,所以孙子进程就变为了孤儿进程,然后会被操作系统的0号进程所收养,并且当这个孙子进程退出后,回收资源的工作也由它的新的父进程0号进程来完成了。然后因为子进程创建完孙子进程就退出了,所以父进程在waitpid中并不会等待很久就会收到子进程退出的消息。
我们看到这样实现服务器也可以正常响应客户端的请求。
我们知道操作系统创建一个进程的开销比创建一个线程的开销大很多,而且进程间切换的开销比线程间切换的开销大很多,所以我们可以试着将服务器改为多线程版本的。
我们先创建出来一个ThreadData类用来记录客户端的一些信息,然后将这个类类型作为参数传递给线程的回调函数,那么在线程的回调函数中就可以拿到客户端的数据,然后回调函数再调用service函数来向客户端提供服务,这样就完成了主线程创建一个新线程来为客户端提供服务。
我们让主线程创建的新线程都调用threadRoutine回调函数,并且将客户端的信息封装好作为参数传递给线程回调函数threadRoutine,threadRoutine函数中将参数解析出来,然后调用service函数并且将客户端的IP地址,端口号和这次服务的套接字都传递给service函数,这样就完成了新线程为客户端提供服务的准备工作。
上面的多线程版本的程序中还存在一个问题,即我们没有让主线程调用join来等待新线程,如果主线程不对新线程进行join等待,那么会造成新线程为“僵尸线程”,造成资源浪费。但是如果主线程等待新线程的话,那么只能阻塞式等待。所以我们在新线程中让每个线程都进行线程分离。这样就不需要主线程等待新线程了。还需要注意的是,我们需要在线程的回调函数中将套接字文件进行关闭,因为主线程不会join等着新线程的退出,所以只有线程自己知道自己什么时候才会退出,故需要在该线程自己的回调函数中将该线程使用的套接字文件进行关闭。而如果让主线程进行关闭的话,那么有可能新线程还在使用这个套接字文件,而主线程已经将这个套接字文件进行关闭了,所以需要新线程在回调函数中将使用的套接字文件进行关闭。
我们看到当创建两个客户端向服务器发送消息时,服务器也创建了两个线程来分别服务这两个客户端。并且当我们关闭一个客户端后,服务器中为这个客户端提供服务的线程也会退出。
下面我们再来将这个程序进行完善,让服务器先提前创建一批线程,然后客户端发送请求后,服务器就不需要创建线程了,而是直接从提前创建的线程中选取一个线程来处理客户端发送的请求。我们可以将以前写过的线程池拿来使用了。
我们将以前实现的线程池的文件夹拷贝过来,也可以将之前写的线程池打包为一个库,然后让这个程序引用这个库。我们将原来拷贝过来的打印日志的log.hpp删除,然后引用ThreadPool文件夹里面的log.hpp文件和其它我们用到的头文件。
然后我们在服务器中添加一个线程池对象指针,并且在服务器的构造函数中调用线程池的getThreadPool函数来创建一个线程池,并且让这个线程池对象中创建10个线程。
然后在服务器的start函数中让线程池启动。
我们当时实现线程池时向线程池中添加的Task为一次运算,下面我们将每一次客户端和服务器的连接作为一个任务添加到线程池的任务队列中。
我们将Task任务封装成下面这样。
下面我们就来实现线程池版本。
我们直接根据客户端的IP地址和端口号还有这次连接的套接字和service函数来建立一个Task任务,然后将这个任务添加到线程池的任务队列中,线程池就会选择一个已经启动的线程来处理任务队列中的任务。需要注意的是我们需要在线程回调函数service函数的最后将这次连接的套接字文件进行关闭。
下面我们来进行测试。我们看到当服务器启动时,就会创建10个线程,然后这些线程就会等待线程池中的任务队列中的任务,如果出现任务了,那么这些线程就会竞争式的处理这个任务。
下面我们来将是哪一个线程处理的这个客户端的请求打印出来。
我们在线程的回调函数中加上name这个参数,然后将线程的名字打印出来即可看到是哪个线程在处理这一次客户端请求。
这样我们就可以看到线程池中是哪个线程在为哪个客户端提供服务了。
上面我们就实现了线程池版本的服务器,但是线程池版本的服务器存在一个问题,即服务器只能同时为10个客户端提供服务,如果再有客户端连接到服务器,那么这个客户端的连接就会被放到线程池的任务队列中,而不是服务器直接响应客户端。直到有其它客户端退出后,此时有空闲的线程了,才会响应客户端请求。向我们前面出现的创建进程和创建线程的版本就不会出现这种问题,但是创建线程和创建进程的服务器如果遇到恶意程序一直请求服务器,那么服务器就会一直创建线程或者进程,然后服务器的内存不够后,服务器就会崩溃。我们上面写的客户端一直与服务器建立连接并且进行通信的情况是很少见的,现实中都是客户端向服务器请求数据,然后服务器将数据发送给客户端,这样就完成了一次请求。如果真的有这样的客户端和服务器保持长连接的情况,我们后面有其它方案来解决。
下面我们来将程序改为服务器将客户端发来的数据都转换为大写,然后再将数据返回给客户端。这个情况就是客户端和服务器只要建立短连接即可,即服务器将客户端的请求处理完后就可以直接断开连接了。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!