结合代码讲讲自己对于 I/O 多路复用SELECT、POLL的理解
文字可以不看,但是代码及对应的中文注释一定要看,能看懂就行了
之前学select和epoll的时候看了很多网上的文章,都只是讲做了什么和有什么优点,没有讲清楚具体实现,今天看书 《Unix网络编程卷1:套接字联网API》(第3版),第六章中有select和epoll的具体操作,及对应代码,感觉终于算是理解了这两个到底是怎么做的。写个博客记录一下(主要记录流程,对于一些更加细节的如最大描述符个数,可读可写条件暂先不介绍),有理解不对的地方欢迎留言讨论。
基础概念(有基础可以直接看后边)
对于Linux系统,一切皆文件,进行网络服务器编程时,每有一个新的连接建立时,都会生成一个文件,而进程通过这个文件对应的文件描述 fd
对这个文件进行操作,从而实现读写等操作(通过对这个文件读写来实现与客户端的通信)。这个读写的过程就是I/O,传统的阻塞式I/O,对应下图:对于每一个I/O操作(文件描述符)
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/94cf114e024c41ff49f7b891a66fed3f.png)
对于每一个文件,都需要一个进程(线程)来调用recvfrom
,来判断这个文件现在是否可读可写,如果不行,那我这个阻塞在这里,直到可以。那这就是会有一个问题,如果连接太多(文件太多),我需要同时有很多很多个进程才能够实现,对所有连接的判断,浪费很多资源。
为了解决这个问题,提出了I/O多路复用这一类方法。如果对于I/O多路复用稍微有了解的话,应该都会知道主要有select, poll和epoll三种。经常能看到这个图:
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/8b0922f2e5d2e53b8063484ba2d0f802.png)
这类方法通过一个进程(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
分别使用readset
和writeset
来记录需要关心的文件描述符,poll
中fdarray
一次搞定。同样来看代码,更加简略一下(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流程还是讲明白了的。大家有什么问题,欢迎在讨论区交流。