IO多路复用的实现机制 - poll 用法总结

    技术2023-07-20  65

    一、基本知识

    poll的多路复用机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询(polling),根据描述符的状态进行处理,但是poll没有最大文件描述符数量上的限制。

    二、poll函数

    poll函数的原型声明:

    //使用:man 2 poll,查看poll函数的使用帮助信息(CentOS-7.6) #include <poll.h> int poll(struct pollfd *fds, nfds_t nfds, int timeout); #define _GNU_SOURCE /* See feature_test_macros(7) */ #include <poll.h> int ppoll( struct pollfd *fds, nfds_t nfds, const struct timespec *timeout_ts, const sigset_t *sigmask);

    【参数说明】

    (1)第1个参数fds:是一个struct pollfd结构体类型的数组,它指定所有我们感兴趣的文件描述符上发生的可读、可写和异常等事件。pollfd结构体的定义如下:

    struct pollfd { int fd; //文件描述符 short events; //等待的事件 short revents; //实际发生的事件,由内核填充 };

    每一个struct pollfd结构体指定了一个被监视的描述符,可以传递多个结构体,指示poll监视多个文件描述符,没有数量限制,由参数fds指针指向一个struct pollfd结构体数组来实现。要测试的I/O事件由events成员指定,poll函数在相应的revents成员中返回该描述符的状态。(每个描述符都有两个变量,一个为调用值,另一个为返回结果值,从而避免了select中使用值-结果参数,select函数的中间3个参数都是值-结果参数)。其中,events成员是监视该描述符的事件掩码,由用户自己来设置该值;revents成员是描述符的操作结果事件掩码,内核在调用返回时设置这个成员的值。

    events成员中请求的任何事件都可能在revents成员中返回。下图中列出了用于指定events标志以及测试revnets标志的一些常值:

    上图中分为了3个部分:第1部分是处理输入的4个常值,第二部分是处理输出的3个常值,第3部分是处理错误的3个常值。其中第3部分的3个常值不能在events中设置,但是当相应条件存在时就会在revents中返回。

    <说明> 上表中列举的符号常量定义在/usr/include/bits/poll.h文件中,参考的是CentOS-7.6系统。

    /* Event types that can be polled for. These bits may be set in `events' to indicate the interesting event types; they will appear in `revents' to indicate the status of the file descriptor. */ #define POLLIN 0x001 /* There is data to read. */ #define POLLPRI 0x002 /* There is urgent data to read. */ #define POLLOUT 0x004 /* Writing now will not block. */ #if defined __USE_XOPEN || defined __USE_XOPEN2K8 /* These values are defined in XPG4.2. */ # define POLLRDNORM 0x040 /* Normal data may be read. */ # define POLLRDBAND 0x080 /* Priority data may be read. */ # define POLLWRNORM 0x100 /* Writing now will not block. */ # define POLLWRBAND 0x200 /* Priority data may be written. */ #endif #ifdef __USE_GNU /* These are extensions for Linux. */ # define POLLMSG 0x400 # define POLLREMOVE 0x1000 # define POLLRDHUP 0x2000 //(since Linux 2.6.17) #endif /* Event types always implicitly polled for. These bits need not be set in `events', but they will appear in `revents' to indicate the status of the file descriptor. */ #define POLLERR 0x008 /* Error condition. */ #define POLLHUP 0x010 /* Hung up. */ #define POLLNVAL 0x020 /* Invalid polling request. */

    poll识别3类数据:普通(Normal)、优先级带(Priority Band)和高优先级(High Priority)。例如,我们要同时监视一个文件描述符的可读和可写事件,可以将events设置为POLLIN | POLLOUT。当poll函数返回时,我们可以检查revents中的标志:

    可读:items[i].revents & POLLIN

    可写:items[i].revents & POLLOUT

    如果POLLIN事件被设置,则文件描述符可以读取而不导致阻塞。如果POLLOUT被设置,则文件描述符可以写入而不导致阻塞。这些标志并不是互斥的:它们可能被同时设置,表示这个文件描述符的读取和写入操作都会正常返回而不阻塞。

    (2)第2个参数:nfds,表示的是被监控的描述符的个数,亦即fds指针指向的struct pollfd结构体数组的元素个数。

    <说明> 历史上这个参数曾被定义成功无符号整型(unsigned long),似乎过分大了,定义为无符号整型(unsigned int)可能就足够了。Unix98为该参数定义了名为nfds_t的新数据类型。在/usr/include/sys/poll.h文件中该数据类型的定义如下:

    typedef unsigned long int nfds_t; //CentOS7.6中,该数据类型是被定义为无符号长整型的

    (3)第3个参数:timeout,指定poll函数返回前等待超时的时间,单位是毫秒数。下表给出了它的可能取值:

    <说明>

    (1)如果timeout > 0 或者 为负值(一般设置为-1)时,poll函数将会被阻塞,直到被监控的描述符指定的I/O事件准备就绪或者发生错误时,poll才会返回;或者定时器到时也会返回(在timeout>0的情况下)。

    (2)timeout=0时,poll函数立刻返回,不阻塞进程,无论是否有描述符准备就绪。

    【返回值】

    1、成功,返回已就绪的描述符个数,即返回struct pollfd结构体中revents成员值非0的描述符个数;

    2、若定时器到时之前没有任何描述符就绪,则返回0。

    3、当发生错误时,返回值为-1,并设置相应的错误码给errno全局变量。错误码的可能取值如下:

    EFAULT:fds指针指向的结构体数组的地址超出进程的地址空间。EINTR:在请求事件发生前产生了一个信号事件。EINVAL:nfds的值超出了RLIMIT_NOFILE 的值。ENOMEM:没有多余的内存空间分配描述符表。

    <说明> 如果我们不再关心某个特定描述符,那么可以把与它对应的struct pollfd结构体的fd成员设置为一个负值(一般而言设置为-1)。poll函数将忽略这样的pollfd结构的events成员,同时返回时将它的revents成员的值置为0。相比于select函数,poll函数不再有FD_SETSIZE最大描述符数目的设定,因为分配一个pollfd结构体数组并把该数组中元素个数通知内核就行了,内核不再需要知道类似fd_set的固定大小的数据类型。

    事实上,传递给select函数的fd_set结构体类型变量的成员是一个整型数组,不过它的数组长度是个固定值,是由操作系统内部定义的FD_SETSIZE NFDBITS 这两个符号常量决定的,无法人为修改;而传递给poll函数的pollfd结构体数组,其结构体数组的长度是可以人为设定的。

    四、poll 与 select 的对比及其缺陷

    poll 与 select最大的区别就是poll没有最大描述符数量的限制,因此它仍然存在和select同样的缺陷。

    (1)和select函数一样,poll同样需要维护一个用来存放描述符的数据结构,当描述符的数量比较大时,会使得用户空间和内核空间在传递该数据结构时复制开销大。

    (2)poll 和 select一样,对描述符进行扫描的方式也是线性扫描,每次调用poll都需要遍历整个描述符集,不管那个描述符是不是活跃的,都需要遍历一遍。当描述符数量较多时,会占用大量CPU资源。

     (3)poll 和 select一样,不是线程安全的函数。

    五、示例程序

    程序描述:编写一个echo server程序,功能是客户端向服务器发送信息,服务器端接收数据后输出并原样返回给客户端,客户端接收到消息并输出到终端。代码如下:

    公共头文件:socket_common.h #include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include <netinet/in.h> #include <sys/socket.h> #include <poll.h> #include <unistd.h> #include <sys/types.h> #include <arpa/inet.h> //#define IPADDRESS "127.0.0.1" //#define PORT 8787 #define MAXLEN 1024 #define LISTENQ 5 #define OPEN_MAX 1000 #define INFTIM -1 服务端程序:poll_server.c /** 服务器端程序 */ #include "socket_common.h" //函数声明 static int prepare_tcp_listen(const char *ip,int port); static void do_poll(int listenfd); static void handle_client(struct pollfd *connfds,int count); int main(int argc,char *argv[]) { int sfd; if(argc < 2) { printf("usage: ./poll_server port\n"); exit(-1); } sfd=prepare_tcp_listen(NULL,atoi(argv[1])); do_poll(sfd); return 0; } static int prepare_tcp_listen(const char *ip,int port) { //创建socket套接字 int sfd=socket(AF_INET,SOCK_STREAM,0); if(sfd == -1) { perror("socket"); exit(-1); } struct sockaddr_in server_addr; bzero(&server_addr,sizeof(struct sockaddr_in)); //填充sockaddr_in结构体内容 server_addr.sin_family=AF_INET; server_addr.sin_port=htons(port); //server_addr.sin_addr.s_addr=inet_addr(ip); server_addr.sin_addr.s_addr=INADDR_ANY; //绑定IP地址和端口号 if(bind(sfd,(struct sockaddr*)&server_addr,sizeof(struct sockaddr)) == -1) { perror("bind"); close(sfd); exit(-1); } //监听客户机的连接请求 if(listen(sfd,LISTENQ) == -1) { perror("listen"); close(sfd); exit(-1); } return sfd; } static void do_poll(int listenfd) { int new_fd; struct pollfd clitfds[OPEN_MAX]; struct sockaddr_in client_addr; socklen_t clitaddrlen=sizeof(client_addr); int imax,i,nready; //初始化客户端连接描述符 for(i=0;i<OPEN_MAX;i++) clitfds[i].fd=-1; //初始添加第一个监听文件描述符 clitfds[0].fd=listenfd; clitfds[0].events=POLLIN; imax=0; //循环处理 for(;;) { //获取可用文件描述符的个数 nready=poll(clitfds,imax+1,INFTIM); //INFTIM=-1,表示永远等待 if(nready == -1) { perror("poll"); exit(-1); } //监测监听文件描述符是否准备好 if(clitfds[0].revents & POLLIN) //监听事件 { //接受客户端连接请求事件 if((new_fd=accept(listenfd,(struct sockaddr*)&client_addr,&clitaddrlen)) == -1) { if(errno == EINTR) //EINTR:系统调用被信号中断。 continue; else { perror("accept"); exit(-1); } } fprintf(stdout,"accept a new client: %s:%d\n", inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port)); //将新的连接文件描述符添加到clitfds结构体数组中 for(i=1;i<OPEN_MAX;i++) { if(clitfds[i].fd<0) { //添加连接文件描述符到读描述符集合中 clitfds[i].fd=new_fd; //将新的连接文件描述符添加到读描述符集合中 clitfds[i].events=POLLIN; break; } } if(i == OPEN_MAX) { fprintf(stderr,"too many clients!\n"); exit(-1); } //记录客户连接套接字的个数 imax=(i>imax)?i:imax; if(--nready <= 0) continue; } printf("connect success client num=%d\n",imax); //处理与客户端的通信过程 handle_client(clitfds,imax); } } static void handle_client(struct pollfd *connfds,int count) { int i,len; char buf[MAXLEN]; bzero(buf,sizeof(buf)); //扫描整个文件描述符的集合状态,检测有无就绪的文件描述符 for(i=1;i<=count;i++) { if(connfds[i].fd<0) continue; //检测客户端文件描述符是否准备好 if(connfds[i].revents & POLLIN) { //接收客户端发送过来的消息 if((len=read(connfds[i].fd,buf,MAXLEN)) == 0) { close(connfds[i].fd); connfds[i].fd=-1; continue; } write(STDOUT_FILENO,buf,len); //输出到终端屏幕 //向客户端发送buf内容 write(connfds[i].fd,buf,len); } } }

    服务端程序说明:服务端有两个文件描述符,一个是监听客户端连接请求的文件描述符listen_fd,另一个是处理客户端读写操作的文件描述符new_fd,每当有新的客户端连接上来的时候,就将新的new_fd添加到pollfd结构体数组clientfds当中,同时受监控的文件描述符数目加1。

    客户端程序:poll_client.c /** 客户端程序 */ #include "socket_common.h" //函数声明 int tcp_connect(const char *ip,int port); static void handle_connection(int sockfd); int main(int argc,char *argv[]) { if(argc < 3) { printf("usage: ./poll_client ip port\n"); exit(-1); } int cfd=tcp_connect(argv[1],atoi(argv[2])); //处理连接描述符 handle_connection(cfd); return 0; } //用于客户端向服务器端发起连接 int tcp_connect(const char *ip,int port) { int cfd=socket(AF_INET,SOCK_STREAM,0); if(cfd == -1) { perror("socket"); exit(-1); } struct sockaddr_in server_addr; memset(&server_addr,0,sizeof(struct sockaddr_in)); server_addr.sin_family=AF_INET; server_addr.sin_port=htons(port); server_addr.sin_addr.s_addr=inet_addr(ip); //将cfd连接到制定的服务器网络地址server_addr if(connect(cfd,(struct sockaddr*)&server_addr,sizeof(struct sockaddr)) == -1) { perror("connect"); close(cfd); exit(-1); } return cfd; } static void handle_connection(int sockfd) { char sendbuf[MAXLEN],recvbuf[MAXLEN]; struct pollfd pfds[2]; int len; //添加连接描述符 pfds[0].fd=sockfd; pfds[0].events=POLLIN; //添加标准输入描述符 pfds[1].fd=STDIN_FILENO; pfds[1].events=POLLIN; for(;;) //循环处理 { if(poll(pfds,2,-1) < 0) { perror("poll"); exit(-1); } //接收从服务器端发送过来的消息 if(pfds[0].revents & POLLIN) { if((len=read(sockfd,recvbuf,MAXLEN)) == 0) { fprintf(stderr,"client:server has closed!\n"); close(sockfd); exit(-1); } write(STDOUT_FILENO,recvbuf,len); //标准输出 } //测试标准输入是否准备好 if(pfds[1].revents & POLLIN) { if((len=read(STDIN_FILENO,sendbuf,MAXLEN)) == 0) //标准输入 { shutdown(sockfd,SHUT_WR); //终止socket通信,关闭连接的写这一半 continue; } write(sockfd,sendbuf,len); //发送消息给服务器端 } } }

     客户端程序说明:客户端程序设置了两个文件描述符,一个是用于监控来自服务端的可读数据;另一个是监控标准输入端的可读数据。poll函数监控这两个描述符的可读事件,可以看到,我们设置的超时条件是永久等待,在这两个描述符的可读I/O事件未就绪时,客户端进程将一直处于阻塞状态。

    Makefile #第1种方式 all:poll_server poll_client poll_server:poll_server.o gcc poll_server.o -o poll_server poll_client:poll_client.o gcc poll_client.o -o poll_client poll_server.o:poll_server.c gcc -c poll_server.c -o poll_server.o poll_client.o:poll_server.c gcc -c poll_client.c -o poll_client.o clean: rm -rf ./*.o ./poll_server ./poll_client

     该示例程序本人已经测试通过了的。

    题外话

    由于poll的多路复用机制仍然存在诸多问题,于是5年以后, 在2002, 大神 Davide Libenzi 实现了epoll。epoll 可以说是I/O多路复用最新的一个实现,epoll 修复了poll 和select绝大部分问题,比如:epoll 现在是线程安全的,epoll不仅会告诉描述符集中是否有描述符准备就绪,还会告诉你是哪个描述符准备就绪了,不用自己去找了。在下一篇博文中,会详细介绍epoll的用法。

    参考

    IO多路复用之poll总结

    《UNIX网络编程卷1:套接字联网API(第3版)》第6.10章节

     

    Processed: 0.010, SQL: 9