147 Linux 网络编程3 ,高并发服务器 --多路I/O转接服务器 - select

时间:2024-03-24 16:40:32

从前面的知识学习了如何通过socket ,多进程,多线程创建一个高并发服务器,但是在实际工作中,我们并不会用到前面的方法 去弄一个高并发服务器,有更加好用的方法,就是多路I/O转接器

零 多路I/O转接服务器

多路IO转接服务器也叫做多任务IO服务器。该类服务器实现的主旨思想是,不再由应用程序自己监视客户端连接,取而代之由内核替应用程序监视文件。

主要使用的方法有三种 select , poll , epoll

其中select 可以跨平台

poll 和 epoll不能跨平台,只能在linux上使用。

重点是epoll

次重点是 select

poll知道就行了,再学习后我们会知道原因。

本章主要学习 select。

一 select 

  1. select能监听的文件描述符个数受限于FD_SETSIZE,一般为1024,单纯改变进程打开的文件描述符个数并不能改变select监听文件个数
  2. 解决1024以下客户端时使用select是很合适的,但如果链接客户端过多,select采用的是轮询模型,会大大降低服务器响应效率,不应在select上投入更多精力。

select 函数借助内核监听两件事,:客户端连接,数据通信事件。

核心函数: select函数 ,FD_CLR,FD_ISSET,FD_SET,FD_ZERO

int select(int nfds, fd_set *readfds, fd_set *writefds,
            fd_set *exceptfds, struct timeval *timeout);

void FD_CLR(int fd, fd_set *set); //把文件描述符集合里fd位清0

int FD_ISSET(int fd, fd_set *set); //测试文件描述符集合里fd是否置1

void FD_SET(int fd, fd_set *set); //把文件描述符集合里fd位置1

void FD_ZERO(fd_set *set); //把文件描述符集合里所有位清0

#include <sys/select.h>
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
			fd_set *exceptfds, struct timeval *timeout);

	nfds: 		监控的文件描述符集里最大文件描述符加1,因为此参数会告诉内核检测前多少个文件描述符的状态
	readfds:	监控有读数据到达文件描述符集合,传入传出参数
	writefds:	监控写数据到达文件描述符集合,传入传出参数
	exceptfds:	监控异常发生达文件描述符集合,如带外数据到达异常,传入传出参数
	timeout:	定时阻塞监控时间,3种情况
				1.NULL,永远等下去
				2.设置timeval,等待固定时间
				3.设置timeval里时间均为0,检查描述字后立即返回,轮询
	struct timeval {
		long tv_sec; /* seconds */
		long tv_usec; /* microseconds */
	};
	void FD_CLR(int fd, fd_set *set); 	//把文件描述符集合里fd位清0
	int FD_ISSET(int fd, fd_set *set); 	//测试文件描述符集合里fd是否置1
	void FD_SET(int fd, fd_set *set); 	//把文件描述符集合里fd位置1
	void FD_ZERO(fd_set *set); 			//把文件描述符集合里所有位清0

返回值:所有监听的集合中,满足事件发生的总数

server 端代码实现1

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <ctype.h>
#include <sys/types.h>         
#include <sys/socket.h>
#include <errno.h>
#include <sys/select.h>


#include "wrap.h"

//select 实现高并发代码示例
int main() {
	int listenfd;

	int ret = 0;
	//第一步:创建socket。 打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符,应用程序可以像读写文件一样用read/write在网络上收发数据,如果socket()调用出错则返回-1

	listenfd = Socket(AF_INET, SOCK_STREAM, 0);

	//第二步 build IP+PORT到网络地址 到socket 创建出来的listenfd
	struct sockaddr_in servaddr;
	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
	servaddr.sin_port = htons(8000);

	Bind(listenfd, (struct sockaddr *) &servaddr,sizeof(servaddr));
	
	//第三步,设置可以同时监听的最大的数量为1024
	Listen(listenfd, 1024);

	//第四步,这时候 
	//void FD_CLR(int fd, fd_set *set);
	//int  FD_ISSET(int fd, fd_set *set);
	//void FD_SET(int fd, fd_set *set);
	//void FD_ZERO(fd_set *set);

	fd_set readfds;
	FD_ZERO(&readfds);
	FD_SET(listenfd, &readfds);
	fd_set allfds =  readfds;

	int maxfd = listenfd;

	//第五步,这时候就要弄一个循环去监听了。
	//第一次的时候,肯定是只监听了listenfd一个,都是后面,如果有cfd连接上的话,那就监听的多了
	//因此要使用一个maxfd 作为备份记录
	//select 函数的意义是:
//	int select(int nfds, fd_set *readfds, fd_set *writefds,
//		fd_set *exceptfds, struct timeval *timeout);
//
//	nfds: 		监控的文件描述符集里最大文件描述符加1,因为此参数会告诉内核检测前多少个文件描述符的状态
//	readfds:	监控有读数据到达文件描述符集合,传入传出参数
//	writefds:	监控写数据到达文件描述符集合,传入传出参数
//	exceptfds:	监控异常发生达文件描述符集合, 如带外数据到达异常,传入传出参数
//	timeout:	定时阻塞监控时间,3种情况
//	1.NULL,永远等下去
//	2.设置timeval,等待固定时间
//	3.设置timeval里时间均为0,检查描述字后立即返回,轮询
//	struct timeval {
//	long tv_sec; /* seconds */
//	long tv_usec; /* microseconds */
//};返回值:所有监听的集合中,满足事件发生的总数

	int nready = 0;
	struct sockaddr_in clie_addr;
	int clie_addrlen = sizeof(clie_addr);
	int connectfd = 0;
	char buf[BUFSIZ];
	char str[INET_ADDRSTRLEN];

	while (1) {
		readfds = allfds;
		nready = select(maxfd + 1, &readfds,NULL,NULL,NULL);
		//nready为所有监听的集合中,满足事件发生的总数
		//第三个参数timeval我们设置的是NULL,表示阻塞等待
		//因此如果有事件发生,那么就会走到后面的代码,且readfds集合中会改动成为真正
		//有监听事件发生后,第一步做错误判断处理
		if (nready < 0 ) {
			//说明有error发生
			perr_exit("select error");
		}
		//第六步,判断nready中是否有 新的连接事件发生,也就是说listenfd是否
		if (FD_ISSET(listenfd, &readfds)) {
			//走到这里,说明有新的链接过来了,那么我们要做如下的几件事
			//6.1 server赶紧连接,注意,这时候并不会阻塞,因为listenfd已经有了读取事件才会走到这里
			connectfd = Accept(listenfd, (struct sockaddr *)&clie_addr, &clie_addrlen);
			//这里加一行log,目的是将连接的客户端的信息打印出来
			printf("received from %s at PORT %d\n",
				inet_ntop(AF_INET, &clie_addr.sin_addr, str, sizeof(str)),
				ntohs(clie_addr.sin_port));
			//这里添加一行判断,如果connectedfd已经超过1024,则不支持,select对于每个进程或者线程只能支持最多1024个
			if (connectfd == FD_SETSIZE) { /* 达到select能监控的文件个数上限 1024 */
				fputs("too many clients\n", stderr);
				exit(1);
			}
			//6.2 将这个新的链接添加到 监听的readfds中

			FD_SET(connectfd,&allfds); /* 向监控文件描述符集合allset添加新的文件描述符connfd */
		

			//6.3 更新maxfd
			if (connectfd > maxfd) {
				maxfd = connectfd;    /* 有可能最大的connectfd 需要变化,这是因为当有新的connectfd之前,如果没有任何的connectfd没有断开,则会变成最大,但是如果中间有connect断开,则这个新的connectfd的值,有可能不是最大。select第一个参数需要 */
									//假设之前来了,4,5,6,这时候客户端4已经断开了,这时候 connectfd就等于4,如果4,5,6都没有断开过,则这时候新来的会是7
			}

			//6.4 判断select 的返回值 是否只有一个,且是listenfd事件,如果走到这里说明这一个已经被处理了,因此就没有必要往下再执行了
			if (--nready == 0 ) {
				continue;
			}

		}



		//第七步,走到这里说明nready中有一个或者多个 客户端发送数据的事件过来。
		//我们当前的写法是通过循环1024次,挨个往出拿,/* 检测哪个clients 有数据就绪 */
		char buf[BUFSIZ] = {0};
		int realreadnum = 0;
		for (int i = listenfd+1; i < 1024;++i) {
			bzero(buf, BUFSIZ);
			if (FD_ISSET(i, &readfds)) {
			REREADPOINT:
				realreadnum = Read(i, buf, BUFSIZ); //真正的读取到了数据
				if (realreadnum == 0 ) {//在网络环境下,read函数返回0,说明是对端关闭了,也就是说,客户端关闭了
					//那么就应该关闭当前的connect端,并将该监听从 allfds中 移除
					Close(i);
					FD_CLR(i, &allfds);
				}
				else if (realreadnum == -1) {
					
					if (errno == EINTR) {
						//说明是被信号打断的,一般要重新read
						printf("信号打断\n");
						goto REREADPOINT;
					}
					else if(errno == EAGAIN || errno == EWOULDBLOCK)
					{
						printf(" WOULDBLOCK \n");
						//说明在打开文件的时候是使用的O_NONBLOCK方式打开的,但是没有读取到数据
						//当前代码是不会走到这里的,因为前面代码select的最后一个参数用的NULL,是阻塞的
						//一般在这里 也要重新读,但是这里有个问题,如果一直都读取不到,会不会死循环?
						goto REREADPOINT;
					}
					else if (errno == ECONNRESET) {
						//ECONNRESET 说明连接被重置了,因此要将该cfd关闭,并重新移除监听队列
						Close(i);
						FD_CLR(i, &allfds);
					}
					else {
						//这就是真正的有问题了,注意这里不要exit程序,应该只是让打印log
						//不退出程序是因为,这时候还有其他的链接连上的
						perror("read num <0");
					}
				}
				else if (realreadnum > 0 ) {
					//真正的读取到了客户端发送过来的数据
					for (int j = 0; j < realreadnum;++j) {
						buf[j] = toupper(buf[j]);
					}
					Write(i, buf,realreadnum);
					Write(STDOUT_FILENO,buf,realreadnum);
				}
				if (--nready == 0) { //有可能50个已经连接上的链接中,这时候只有3个有数据发送过来了,还是要从50个中遍历,但是不同的是,假设是 5,19,30,那么写这一块代码就能节省时间了,当遍历到30后,--nready 就会等于0,直接跳出
					break;    /* 跳出for, 但还在while中 */
				}
			}
		}

	}


	return ret;

}

server 端代码优化

可以看到,select函数在有客户端和服务器端沟通的过程中,需要依次遍历对比知道具体是哪一个connectfd有数据发送过来,这很影响工作效率,因此有了如下的优化。

优化的整体思路是:当有了connectfd链接后,就将这些connectfd记录到另一个数组中,并记录这个数组的最大下标。当客户端的链接断开的时候,则将当前的下标重置

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <ctype.h>
#include <sys/types.h>         
#include <sys/socket.h>
#include <errno.h>
#include <sys/select.h>


#include "wrap.h"
//从select1.c我们看到,代码还有可以完善的地方,这里就完善一下
//1.最后每次都要循环1020次,遍历查看哪些cfd有数据发送过来,这是不合理的
//因此我们需要搞一个数组,用这个数组记录真正的有数据访问的cfd
//并且弄一个int 值,记录这个数组的最大下标 maxi

//select 实现高并发代码示例
int main() {
	int listenfd;
	//额外添加代码 start
	int client[FD_SETSIZE];  /* 自定义数组client, 防止遍历1024个文件描述符  FD_SETSIZE默认为1024 */
	int maxi;

	maxi = -1;                                                  /* 将来用作client[]的下标, 初始值指向0个元素之前下标位置 */
	for (int i = 0; i < FD_SETSIZE; i++)
		client[i] = -1;                                         /* 用-1初始化client[] */

	//额外添加代码 end

	int ret = 0;
	//第一步:创建socket。 打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符,应用程序可以像读写文件一样用read/write在网络上收发数据,如果socket()调用出错则返回-1

	listenfd = Socket(AF_INET, SOCK_STREAM, 0);

	//端口复用的代码添加上,端口复用的代码需要写在bind之前,socket之后
	int opt = 1; //1表示可以让端口复用,0表示不让端口复用

	setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

	//第二步 build IP+PORT到网络地址 到socket 创建出来的listenfd
	struct sockaddr_in servaddr;
	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
	servaddr.sin_port = htons(8000);

	Bind(listenfd, (struct sockaddr *) &servaddr, sizeof(servaddr));

	//第三步,设置可以同时监听的最大的数量为1024
	Listen(listenfd, 1024);

	//第四步,这时候 
	//void FD_CLR(int fd, fd_set *set);
	//int  FD_ISSET(int fd, fd_set *set);
	//void FD_SET(int fd, fd_set *set);
	//void FD_ZERO(fd_set *set);

	fd_set readfds;
	FD_ZERO(&readfds);
	FD_SET(listenfd, &readfds);
	fd_set allfds = readfds;

	int maxfd = listenfd;

	//第五步,这时候就要弄一个循环去监听了。
	//第一次的时候,肯定是只监听了listenfd一个,都是后面,如果有cfd连接上的话,那就监听的多了
	//因此要使用一个maxfd 作为备份记录
	//select 函数的意义是:
//	int select(int nfds, fd_set *readfds, fd_set *writefds,
//		fd_set *exceptfds, struct timeval *timeout);
//
//	nfds: 		监控的文件描述符集里最大文件描述符加1,因为此参数会告诉内核检测前多少个文件描述符的状态
//	readfds:	监控有读数据到达文件描述符集合,传入传出参数
//	writefds:	监控写数据到达文件描述符集合,传入传出参数
//	exceptfds:	监控异常发生达文件描述符集合, 如带外数据到达异常,传入传出参数
//	timeout:	定时阻塞监控时间,3种情况
//	1.NULL,永远等下去
//	2.设置timeval,等待固定时间
//	3.设置timeval里时间均为0,检查描述字后立即返回,轮询
//	struct timeval {
//	long tv_sec; /* seconds */
//	long tv_usec; /* microseconds */
//};返回值:所有监听的集合中,满足事件发生的总数

	int nready = 0;
	struct sockaddr_in clie_addr;
	int clie_addrlen = sizeof(clie_addr);
	int connectfd = 0;
	char buf[BUFSIZ];
	char str[INET_ADDRSTRLEN];

	while (1) {
		readfds = allfds;
		nready = select(maxfd + 1, &readfds, NULL, NULL, NULL);
		//nready为所有监听的集合中,满足事件发生的总数
		//第三个参数timeval我们设置的是NULL,表示阻塞等待
		//因此如果有事件发生,那么就会走到后面的代码,且readfds集合中会改动成为真正
		//有监听事件发生后,第一步做错误判断处理
		if (nready < 0) {
			//说明有error发生
			perr_exit("select error");
		}
		//第六步,判断nready中是否有 新的连接事件发生,也就是说listenfd是否
		if (FD_ISSET(listenfd, &readfds)) {
			//走到这里,说明有新的链接过来了,那么我们要做如下的几件事
			//6.1 server赶紧连接,注意,这时候并不会阻塞,因为listenfd已经有了读取事件才会走到这里
			connectfd = Accept(listenfd, (struct sockaddr *)&clie_addr, &clie_addrlen);
			//这里加一行log,目的是将连接的客户端的信息打印出来
			printf("received from %s at PORT %d\n",
				inet_ntop(AF_INET, &clie_addr.sin_addr, str, sizeof(str)),
				ntohs(clie_addr.sin_port));

			//额外添加代码 start
			int i = 0;
			for (; i < FD_SETSIZE; i++)
				if (client[i] < 0) {                            /* 找client[]中没有使用的位置 */
					client[i] = connectfd;                         /* 保存accept返回的文件描述符到client[]里 */
					break;
				}
			//额外添加代码 end
			//这里添加一行判断,如果connectedfd已经超过1024,则不支持,select对于每个进程或者线程只能支持最多1024个
			if (connectfd == FD_SETSIZE) { /* 达到select能监控的文件个数上限 1024 */
				fputs("too many clients\n", stderr);
				exit(1);
			}
			//6.2 将这个新的链接添加到 监听的readfds中

			FD_SET(connectfd, &allfds); /* 向监控文件描述符集合allset添加新的文件描述符connfd */


			//6.3 更新maxfd
			if (connectfd > maxfd) {
				maxfd = connectfd;    /* 有可能最大的connectfd 需要变化,这是因为当有新的connectfd之前,如果没有任何的connectfd没有断开,则会变成最大,但是如果中间有connect断开,则这个新的connectfd的值,有可能不是最大。select第一个参数需要 */
									//假设之前来了,4,5,6,这时候客户端4已经断开了,这时候 connectfd就等于4,如果4,5,6都没有断开过,则这时候新来的会是7
			}
			//额外添加代码 start
			if (i > maxi) {
				maxi = i;           /* 保证maxi存的总是client[]最后一个元素下标 */
			}
			//额外添加代码 end


			//6.4 判断select 的返回值 是否只有一个,且是listenfd事件,如果走到这里说明这一个已经被处理了,因此就没有必要往下再执行了
			if (--nready == 0) {
				continue;
			}

		}



		//第七步,走到这里说明nready中有一个或者多个 客户端发送数据的事件过来。
		//我们当前的写法是通过循环1024次,挨个往出拿,/* 检测哪个clients 有数据就绪 */
		char buf[BUFSIZ] = { 0 };
		int realreadnum = 0;
		//for (int i = listenfd + 1; i < 1024; ++i) {
		//	bzero(buf, BUFSIZ);
		//	if (FD_ISSET(i, &readfds)) {
		//	REREADPOINT:
		//		realreadnum = Read(i, buf, BUFSIZ); //真正的读取到了数据
		//		if (realreadnum == 0) {//在网络环境下,read函数返回0,说明是真的读取到文件末尾了
		//			//既然已经读取到了文件末尾,那么就应该关闭当前的connect端,并将该监听从 allfds中 移除
		//			Close(i);
		//			FD_CLR(i, &allfds);
		//		}
		//		else if (realreadnum == -1) {

		//			if (errno == EINTR) {
		//				//说明是被信号打断的,一般要重新read
		//				printf("信号打断\n");
		//				goto REREADPOINT;
		//			}
		//			else if (errno == EAGAIN || errno == EWOULDBLOCK)
		//			{
		//				printf(" WOULDBLOCK \n");
		//				//说明在打开文件的时候是使用的O_NONBLOCK方式打开的,但是没有读取到数据
		//				//当前代码是不会走到这里的,因为前面代码select的最后一个参数用的NULL,是阻塞的
		//				//一般在这里 也要重新读,但是这里有个问题,如果一直都读取不到,会不会死循环?
		//				goto REREADPOINT;
		//			}
		//			else if (errno == ECONNRESET) {
		//				//ECONNRESET 说明连接被重置了,因此要将该cfd关闭,并重新移除监听队列
		//				Close(i);
		//				FD_CLR(i, &allfds);
		//			}
		//			else {
		//				//这就是真正的有问题了,注意这里不要exit程序,应该只是让打印log
		//				//不退出程序是因为,这时候还有其他的链接连上的
		//				perror("read num <0");
		//			}
		//		}

		//		else if (realreadnum > 0) {
		//			//真正的读取到了客户端发送过来的数据
		//			for (int j = 0; j < realreadnum; ++j) {
		//				buf[j] = toupper(buf[j]);
		//			}
		//			Write(i, buf, realreadnum);
		//			Write(STDOUT_FILENO, buf, realreadnum);
		//		}
		//	}
		//}

		//额外添加代码 start

		int sockfd = 0;
		for (int i = 0; i <= maxi; i++) {  /* 检测哪个clients 有数据就绪 */
			if ((sockfd = client[i]) < 0)
				continue;
			bzero(buf, BUFSIZ);
			if (FD_ISSET(sockfd, &readfds)) {
			REREADPOINT:
				realreadnum = Read(sockfd, buf, BUFSIZ); //真正的读取到了数据
				if (realreadnum == 0) {//在网络环境下,read函数返回0,说明是对端关闭了,也就是说,客户端关闭了
					//那么就应该关闭当前的connect端,并将该监听从 allfds中 移除
					Close(sockfd);
					FD_CLR(sockfd, &allfds);
					printf("read done\n");
					//额外添加的代码 start
					client[i] = -1;
					//额外添加的代码 end
				}
				else if (realreadnum == -1) {

					if (errno == EINTR) {
						//说明是被信号打断的,一般要重新read
						printf("信号打断\n");
						goto REREADPOINT;
					}
					else if (errno == EAGAIN || errno == EWOULDBLOCK)
					{
						printf(" WOULDBLOCK \n");
						//说明在打开文件的时候是使用的O_NONBLOCK方式打开的,但是没有读取到数据
						//当前代码是不会走到这里的,因为前面代码select的最后一个参数用的NULL,是阻塞的
						//一般在这里 也要重新读,但是这里有个问题,如果一直都读取不到,会不会死循环?
						goto REREADPOINT;
					}
					else if (errno == ECONNRESET) {
						//ECONNRESET 说明连接被重置了,因此要将该cfd关闭,并重新移除监听队列
						Close(sockfd);
						FD_CLR(sockfd, &allfds);
						//额外添加的代码 start
						client[i] = -1;
						//额外添加的代码 end
					}
					else {
						//这就是真正的有问题了,注意这里不要exit程序,应该只是让打印log
						//不退出程序是因为,这时候还有其他的链接连上的
						perror("read num <0");
					}
				}

				else if (realreadnum > 0) {
					//真正的读取到了客户端发送过来的数据
					for (int j = 0; j < realreadnum; ++j) {
						buf[j] = toupper(buf[j]);
					}
					Write(sockfd, buf, realreadnum);
					Write(STDOUT_FILENO, buf, realreadnum);
				}
				if (--nready == 0) { //有可能50个已经连接上的链接中,这时候只有3个有数据发送过来了,还是要从50个中遍历,但是不同的是,假设是 5,19,30,那么写这一块代码就能节省时间了,当遍历到30后,--nready 就会等于0,直接跳出
					break;    /* 跳出for, 但还在while中 */
				}
			}
		}
		//额外添加代码 end

}


	return ret;

}