I/O多路复用的实现机制 – poll 用法总结


一、基本知识

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

二、poll函数

poll函数的原型声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
//使用: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结构体的定义如下:

1
2
3
4
5
6
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系统。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/* 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文件中该数据类型的定义如下:

1
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
/**
服务器端程序
*/
#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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
/**
客户端程序
*/
#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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#第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章节