结合代码讲讲自己对于 I/O 多路复用SELECT、POLL的理解

结合代码讲讲自己对于 I/O 多路复用SELECT、POLL的理解


文字可以不看,但是代码及对应的中文注释一定要看,能看懂就行了

之前学select和epoll的时候看了很多网上的文章,都只是讲做了什么和有什么优点,没有讲清楚具体实现,今天看书 《Unix网络编程卷1:套接字联网API》(第3版),第六章中有select和epoll的具体操作,及对应代码,感觉终于算是理解了这两个到底是怎么做的。写个博客记录一下(主要记录流程,对于一些更加细节的如最大描述符个数,可读可写条件暂先不介绍),有理解不对的地方欢迎留言讨论。

基础概念(有基础可以直接看后边)

对于Linux系统,一切皆文件,进行网络服务器编程时,每有一个新的连接建立时,都会生成一个文件,而进程通过这个文件对应的文件描述 fd 对这个文件进行操作,从而实现读写等操作(通过对这个文件读写来实现与客户端的通信)。这个读写的过程就是I/O,传统的阻塞式I/O,对应下图:对于每一个I/O操作(文件描述符)


在这里插入图片描述

对于每一个文件,都需要一个进程(线程)来调用recvfrom,来判断这个文件现在是否可读可写,如果不行,那我这个阻塞在这里,直到可以。那这就是会有一个问题,如果连接太多(文件太多),我需要同时有很多很多个进程才能够实现,对所有连接的判断,浪费很多资源。
为了解决这个问题,提出了I/O多路复用这一类方法。如果对于I/O多路复用稍微有了解的话,应该都会知道主要有select, poll和epoll三种。经常能看到这个图:

在这里插入图片描述

这类方法通过一个进程(select,poll,epoll)来实现对所有文件的监听,并返回哪个可用。如果都不行的话,那这个进程同样会阻塞,但相较于阻塞式I/O,也就阻塞一个。这种一个进程监听所有文件的方法在文件多的时候要比阻塞式I/O要好的。
还有很多I/O方式,如非阻塞I/O,信号式I/O等,都能避免上述问题,但与本文无关,不再赘述。

SELECT

select是如何实现上边说的功能呢?我们先看看select函数。

#include <sys/select.h>
#include <sys/time.h>
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, 
		   const struct timeval *timeout)
params:
	maxfdp1, 需要测试的文件描述符个数
	readset,用于指示需要监听哪些文件可读
	writeset,用于指示需要监听哪些文件可写
	exceptset,用于指示需要监听哪些文件异常
	timeout,最大超时时间
return:
	可操作的文件描述符个数

其中, maxfdp1 这个很好理解,它的值就是最大文件描述符的值+1(文件描述符的值是从0开始,所有共有+1个),而readset,writeset,exceptset这三个形式类似,所以我们后续都只以readset为例。
从函数定义可以看出,readset是一个fd_set类型的数组,而fd_set我们可以理解为,这个数组中的每一位非零即一(具体实现可能并不是这样,但对于我们使用select,这样子理解是没问题的)。以书中的图为例,如果我们新建了一个客户端,目前还没连接,只有一个监听描述符。我们想要监听是否有客户端连接(对应的文件会变得可读),那么这个数组可以表示为这个样子。在这里插入图片描述
因为每个进程在创建时,都会默认产生三个文件分别对应标准输入、标准输出和标准错误输出,所以我们监听的文件对应的文件描述符是fd3
每当我们想监听一个文件是否可读时,只需要将readset中对应的位置置1就行了,比如一些客户端连接后,对应的文件描述符是fd4,fd5,fd6,并且想监听fd4, fd6是否可读,那么readset这个数组就变成了 [0,0,0,1,1,0,1…]。(一直监听fd3,来保证能接受新的连接)
select函数收到这个参数时,自己会懂得,然后开始监听,监听完成(select结束)后,将readset中不能读的值置0,所以,假如只有fd3,fd6可读,那么readset这个数组就变成了 [0,0,0,1,0,0,1…]。后续对readset进行循环遍历,对于为1的位,进行读操作就行了。
上述对于fd_set类型的数组的操作需要使用特定的四个API:

void FD_ZERO(fd_set *fdset);                   //将fdset中所有位置零,常用于初始化  
void FD_SET(ind fd, fd_set *fdset);            //将数组中fd对应的位置置1,表示要监听
void FD_CLR(int fd, fd_set *fdset);            //将数组中fd对应的位置置0,表示不再监听
int FD_ISSET(int fd, fd_set *fdset);           //判断数组中fd对应的位置是否为1

具体实践,把书中的实例代码精简一下后放上来(从这个github项目复制的,(https://github.com/unpbook/unpv13e/blob/master/tcpcliserv/tcpservselect01.c)想看全部代码的可以打开看看)。对于刚才讲的部分进行注释,(书中还使用数组client记录所有连接对应的文件描述符,比如client[0]=3表示第一个连接的文件描述符是3,client[1]=5表示第二个连接的文件描述符是5,感觉不影响对于select的使用,就不细讲了,放个图让大家大概了解一下,有兴趣的可以去看看书中6.8节)
在这里插入图片描述

/* include fig01 */
#include	"unp.h"

int
main(int argc, char **argv)
{
	int					i, maxi, maxfd, listenfd, connfd, sockfd;
	int					nready, client[FD_SETSIZE];
	ssize_t				n;
	fd_set				rset, allset; //每次循环都使用rset, allset记录所有需要监听可读的
									  //(因为这个代码只监视可读情况,所以all表示all need read)
	char				buf[MAXLINE];
	socklen_t			clilen;

	listenfd = Socket(AF_INET, SOCK_STREAM, 0); //用于监听连接的文件描述符

	Listen(listenfd, LISTENQ);

	maxfd = listenfd;			/* 目前只有一个用于连接的监听fd,所以他是最大的 */
	maxi = -1;					
	for (i = 0; i < FD_SETSIZE; i++)
		client[i] = -1;			/* 对client数组初始化,全为-1,表示目前还没有文件描述符*/
	FD_ZERO(&allset);          // 将allset初始化,所有位置0
	FD_SET(listenfd, &allset); /* 将allset中用于监听连接的文件描述符对应的位置置1,
	                              表示后续要监听这个是否可读*/

	for ( ; ; ) {              // 开始循环
		rset = allset;		// 由于select函数会改变传入的readset的值,所以每次循环要复制出一份,
		                    // 用于传入select
		nready = Select(maxfd+1, &rset, NULL, NULL, NULL); //开始监听,结束后,
														//rset中只有可读位置的文件描述符的值为1

		if (FD_ISSET(listenfd, &rset)) {	//判断是不是连接相关的文件描述符可读,
											//若是,表示有新的连接建立
			clilen = sizeof(cliaddr);
			connfd = Accept(listenfd, (SA *) &cliaddr, &clilen); //为新连接生成文件描述符
#ifdef	NOTDEF
			printf("new client: %s, port %d\n",
					Inet_ntop(AF_INET, &cliaddr.sin_addr, 4, NULL),
					ntohs(cliaddr.sin_port));
#endif

			for (i = 0; i < FD_SETSIZE; i++)
				if (client[i] < 0) {
					client[i] = connfd;	/* 将生成的文件描述符存入数组client中 */
					break;
				}
			if (i == FD_SETSIZE)
				err_quit("too many clients"); /*存的文件描述满了 */

			FD_SET(connfd, &allset);	/* 将allset中新连接的文件描述符对应的位置置1,
			                               表示开始监听这个连接是否可读*/
			if (connfd > maxfd)
				maxfd = connfd;			/* 更新当前最大的文件描述符,用于select */
			if (i > maxi)
				maxi = i;				/* 记录client数组中最大的存到哪了 */

			if (--nready <= 0)
				continue;				/* 如果nready-1 <= 0,说明刚才nreadt==1,说明刚才select
				              只发现了有新连接,而所有的连接都不读,直接continue就行了 */
		}

		for (i = 0; i <= maxi; i++) {	/* check all clients for data */
			if ( (sockfd = client[i]) < 0)  /*判断client数组中第i个位置有没对应的文件描述符*/
				continue;
			if (FD_ISSET(sockfd, &rset)) {  /*判断建立的连接对应位是否为1.
			                                 若为1,则表示可读*/
				if ( (n = Read(sockfd, buf, MAXLINE)) == 0) {
						/*若读出来为0,则说明这个连接要关闭了*/
					Close(sockfd);        /* 关闭对应的文件描述符 */
					FD_CLR(sockfd, &allset);  /* 不再监听是否可读 */
					client[i] = -1;       /* 从Client数组中删去对应的文件描述符 */
				} else
					Writen(sockfd, buf, n);

				if (--nready <= 0)
					break;				/* 没有可读的了 */
			}
		}
	}
}

可以看出来,哪怕知道有文件可读了,仍然需要遍历client中的所有文件描述符,才能确定到底是哪个,所以有着O(n)的复杂度(这一点不确定)

POLL

如果select懂了的话,poll就不难,二者的思想差不多,只用表示形式有差别。

#include <poll.h>
int poll(struct pollfd *fdarray, unsigned long nfds, int timeout);

params:
	fdarray:类似readset,用于记录哪些文件描述符需要监听
return:
	同样返回可操作的文件描述符个数

重点就是 pollfd,他的结构如下所示:

struct pollfd{
	int fd;                         //关心哪个文件描述符
	short events;                   //这个文件描述符关心什么操作(可读,可写...?)
	short revents;                  //这个文件描述符实际发生了什么(可读,可写?)

events和revents对应的值是由常值表示的,如(POLLRDNORM表示普通数据可读),而不是由数值表示的,具体的含义大家自己查阅吧。
可以看出来,相较于select分别使用readsetwriteset来记录需要关心的文件描述符,pollfdarray一次搞定。同样来看代码,更加简略一下(https://github.com/unpbook/unpv13e/blob/master/tcpcliserv/tcpservpoll01.c

/* include fig01 */
#include	"unp.h"
#include	<limits.h>		/* for OPEN_MAX */

int
main(int argc, char **argv)
{
	
	struct pollfd		client[OPEN_MAX];
	listenfd = Socket(AF_INET, SOCK_STREAM, 0);

	client[0].fd = listenfd;    /*将连接对应的文件描述符添加进来*/
	client[0].events = POLLRDNORM;  /*poll监听这个文件描述符是否可读*/

	for ( ; ; ) {
		nready = Poll(client, maxi+1, INFTIM); /*实现监听,对每个pollfd中的revents进行修
		                                        改表示监听结果*/

		if (client[0].revents & POLLRDNORM) {	/*连接可读,代表有新连接 */
			
			connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);

			for (i = 1; i < OPEN_MAX; i++)
				if (client[i].fd < 0) {
					client[i].fd = connfd;	/* 添加这个新连接的文件描述符 */
					break;
				}
			client[i].events = POLLRDNORM;  /* 表示监听这个文件描述符是否可读 */
		}

		for (i = 1; i <= maxi; i++) {	/* 对所有文件描述符逐一判断 */
			if ( (sockfd = client[i].fd) < 0)
				continue;
			if (client[i].revents & (POLLRDNORM | POLLERR)) {/* 判断文件描述符是否可读或有异常 */
				if ( (n = read(sockfd, buf, MAXLINE)) < 0) {
					if (errno == ECONNRESET) {
							/*出现异常*/
#ifdef	NOTDEF
						printf("client[%d] aborted connection\n", i);
#endif
						Close(sockfd);
						client[i].fd = -1;
					} else
						err_sys("read error");
				} else if (n == 0) {
						/*连接断开*/
#ifdef	NOTDEF
					printf("client[%d] closed connection\n", i);
#endif
					Close(sockfd);
					client[i].fd = -1;              /*poll不再关心这个文件描述符*/
				} else 
					Writen(sockfd, buf, n);

				if (--nready <= 0)
					break;				/* no more readable descriptors */
			}
		}
	}
}

上述纯粹是个人理解,撰写的出发点也是怕以后忘了,所以有些地方讲的不是很详细,但感觉对于select和poll流程还是讲明白了的。大家有什么问题,欢迎在讨论区交流。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值