在上一篇博文中提到了五种IO模型,关于这五种IO模型可以参考博文IO模型浅析-阻塞、非阻塞、IO复用、信号驱动、异步IO、同步IO,本篇主要介绍IO多路复用的使用和编程。
IO多路复用的概念
多路复用是一种机制,可以用来监听多种描述符,如果其中任意一个描述符处于就绪的状态,就会返回消息给对应的进程通知其采取下一步的操作。
IO多路复用的优势
当进程需要等待多个描述符的时候,通常情况下进程会开启多个线程,每个线程等待一个描述符就绪,但是多路复用可以同时监听多个描述符,进程中无需开启线程,减少系统开销,在这种情况下多路复用的性能要比使用多线程的性能要好很多。
相关API介绍
在linux中,关于多路复用的使用,有三种不同的API,select、poll和epoll
Select介绍
select的使用需要引入sys/select.h头文件,API函数比较简单,函数原型如下:
int select (int __nfds, fd_set *__restrict __readfds,
fd_set *__restrict __writefds,
fd_set *__restrict __exceptfds,
struct timeval *__restrict __timeout);
fd_set
其中有一个很重要的结构体fd_set,该结构体可以看作是一个描述符的集合,可以将fa_set看作是一个位图,类似于操作系统中的位图,其中每个整数的每一bit代表一个描述符,。
举个简单的例子,fd_set中元素的个数为2,初始化都为0,则fd_set中含有两个整数0,假设一个整数的长度8位(为了好举例子),则展开fd_set的结构就是 00000000 0000000,如果这个时候添加一个描述符为3,则对应fd_set编程 00000000 00001000,可以看到在这种情况下,第一个整数标记描述符0
7,第二个整数标记815,依次类推。
fd_set有四个关联的api
void FD_ZERO(fd_set *fdset) //清空fdset,将所有bit置为0
void FD_SET(int fd, fd_set *fdset) //将fd对应的bit置为1
void FD_CLR(int fd, fd_set *fdset) //将fd对应的bit置为0
void FD_ISSET(int fd, fd_set *fdset) //判断fd对应的bit是否为1,也就是fd是否就绪
select函数中存在三个fd_set集合,分别代表三种事件,__readfds表示读描述符集合,__writefds表示读描述符集合,__exceptfds表示读描述符集合,当对应的fd_set = NULL时,表示不监听该类描述符。
__nfds
__nfds是fd_set中最大的描述符+1,当调用select的时候,内核态会判断fd_set中描述符是否就绪,__nfds告诉内核最多判断到哪一个描述符。
timeval
struct timeval {
long tv_sec; //秒
long tv_usec; //微秒
}
参数__timeout指定select的工作方式:
- __timeout= NULL,表示select永远等待下去,直到其中至少存在一个描述符就绪
- __timeout结构体中秒或者微妙是一个大于0的整数,表示select等待一段固定的事件,若该短时间内未有描述符就绪则返回
- __timeout= 0,表示不等待,直接返回
函数返回
select函数返回产生事件的描述符的数量,如果为-1表示产生错误
值得注意的是,比如用户态要监听描述符1和3的读事件,则将readset对应bit置为1,当调用select函数之后,若只有1描述符就绪,则readset对应bit为1,但是描述符3对应的位置为0,这就需要注意,每次调用select的时候,都需要重新初始化并赋值readset结构体,将需要监听的描述符对应的bit置为1,而不能直接使用readset,因为这个时候readset已经被内核改变了。
Poll介绍
select中,每个fd_set结构体最多只能标识1024个描述符,在poll中去掉了这种限制,使用poll需要引入头文件sys/poll.h,poll调用的API如下:
int poll (struct pollfd *__fds, nfds_t __nfds, int __timeout);
pollfd
struct pollfd {
int fd; // poll的文件描述符
short int events; // poll关心的事件类型
short int revents; // 发生的事件类型
};
Poll使用结构体pollfd来指定一个需要监听的描述符,结构体中fd为需要监听的文件描述符,events为需要监听的事件类型,而revents为经过poll调用之后返回的事件类型,在调用poll的时候,一般会传入一个pollfd的结构体数组,数组的元素个数表示监控的描述符个数,所以pollfd相对于select,没有最大1024个描述符的限制。
事件类型有多种,在bits/poll.h中定义了多种事件类型,主要如下:
#define POLLIN 0x001 // 有数据可读
#define POLLPRI 0x002 // 有紧迫数据可读
#define POLLOUT 0x004 // 现在写数据不会导致阻塞
# define POLLRDNORM 0x040 // 有普通数据可读
# define POLLRDBAND 0x080 // 有优先数据可读
# define POLLWRNORM 0x100 // 写普通数据不会导致阻塞
# define POLLWRBAND 0x200 // 写优先数据不会导致阻塞
#define POLLERR 0x008 // 发生错误
#define POLLHUP 0x010 // 挂起
#define POLLNVAL 0x020 // 无效文件描述符
当一个文件描述符要同时监听读写事件时,可以写成 events = POLLIN | POLLOUT
可以看到,poll中使用结构体保存一个文件描述符关心的事件,而在select中,统一使用fd_set,一个fd_set中可以是所有需要监听读事件的文件描述符,也可以是所有需要写事件的文件描述符。
相比来说,poll比select更加的灵活,在调用poll之后,无需像select一样需要重新对文件描述符初始化,因为poll返回的事件写在了pollfd->revents成员中。
__fds
__fds的作用同select中的__nfds,表示pollfd数组中最大的下标索引
__timeout
- __timeout = -1:poll阻塞直到有事件产生
- __timeout = 0:poll立刻返回
- __timeout != -1 && __timeout != 0:poll阻塞__timeout对应的时候,如果超过该时间没有事件产生则返回
函数返回
poll函数返回产生事件的描述符的数量,如果返回0表示超时,如果为-1表示产生错误
Epoll介绍
epoll中,使用一个描述符来管理多个文件描述符,使用epoll需要引入头文件sys/epoll.h,epoll相关的api函数如下:
int epoll_create (int __size);
int epoll_ctl (int __epfd, int __op, int __fd, struct epoll_event *__event);
int epoll_wait (int __epfd, struct epoll_event *__events, int __maxevents, int __timeout);
epoll_event
typedef union epoll_data {
void *ptr; // 可以用改指针指向自定义的参数
int fd; // 可以用改成员指向epoll所监控的文件描述符
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; // epoll事件
epoll_data_t data; // 用户数据
} __EPOLL_PACKED;
epoll_event结构体中,首先是一个events的整型变量,类似于pollfd->events,表示要监控的事件,events支持的事件类型在sys/epoll.h的头文件中,跟pollfd中的事件类型基本移植,如下,这里只写出一部分:
enum EPOLL_EVENTS {
EPOLLIN = 0x001,
#define EPOLLIN EPOLLIN // 有数据可读
EPOLLPRI = 0x002,
#define EPOLLPRI EPOLLPRI // 有紧迫数据可读
EPOLLOUT = 0x004,
#define EPOLLOUT EPOLLOUT // 现在写数据不会导致阻塞
EPOLLRDNORM = 0x040,
#define EPOLLRDNORM EPOLLRDNORM // 有普通数据可读
EPOLLRDBAND = 0x080,
#define EPOLLRDBAND EPOLLRDBAND // 有优先数据可读
EPOLLWRNORM = 0x100,
#define EPOLLWRNORM EPOLLWRNORM // 写普通数据不会导致阻塞
EPOLLWRBAND = 0x200,
#define EPOLLWRBAND EPOLLWRBAND // 写优先数据不会导致阻塞
...
EPOLLERR = 0x008,
#define EPOLLERR EPOLLERR // 发生错误
EPOLLHUP = 0x010,
#define EPOLLHUP EPOLLHUP // 挂起
EPOLLRDHUP = 0x2000,
...
};
epoll_event中的data指向一个共用体结构,可以用该共用体保存自定义的参数,或者指向被监控的文件描述符。
epoll_create
int epoll_create (int __size);
epoll_create函数创建一个epoll实例并返回,该实例可以用于监控__size个文件描述符
epoll_ctl
int epoll_ctl (int __epfd, int __op, int __fd, struct epoll_event *__event);
该函数用来向epoll中注册事件函数,其中__epfd为epoll_create返回的epoll实例,__op表示要进行的操作,__fd为要进行监控的文件描述符,__event要监控的事件。
__op可用的类型定义在sys/epoll.h头文件中,如下:
#define EPOLL_CTL_ADD 1 // 添加文件描述符
#define EPOLL_CTL_DEL 2 // 删除文件描述符
#define EPOLL_CTL_MOD 3 // 修改文件描述符(指的是epoll_ctl中传入的__event)
该函数如果调用成功返回0,否则返回-1。
epoll_wait
int epoll_wait (int __epfd, struct epoll_event *__events, int __maxevents, int __timeout);
epoll_wait类似与select中的select函数、poll中的poll函数,等待内核返回监听描述符的事件产生,其中__epfd是epoll_create创建的epoll实例,__events数组为epoll_wait要返回的已经产生的事件集合,其中第i个元素成员的__events[i]->data->fd表示产生该事件的描述符,__maxevents为希望返回的最大的事件数量(通常为__events的大小),__timeout和poll中的__timeout相同。该函数返回已经就绪的事件的数量,如果为-1表示出错。
select、poll、epoll比较
select和poll的机制基本相同,只不过poll没有select最大文件描述符的限制,在具体使用的时候,有如下缺点:
- 每次调用select或者poll,都需要将监听的fd_set或者pollfd发送给内核态,如果需要监听大量的文件描述符,这样的效率是很低下的
- 在内核态中,每次需要对传入的文件描述符进行轮询,查询是否有对应的事件产生。
epoll的高效在于将这些分开,首先epoll不是在每次调用epoll_wait的时候,将描述符传送给内核,而是在epoll_ctl的时候传送描述符给内核,当调用epoll_wait的收,不用每次都接收
不像select和poll使用一个单独的API函数,在epoll中,使用epoll_create创建一个epoll实例,然后当调用epoll_ctl新增监听描述符的时候,这个时候才将用户态的描述符发送到内核态,因为epoll_wait调用的频率肯定要比epoll_create的频率要高,所以当epoll_wait的时候无需传送任何描述符到用户态;
关于第二点,在内核态中,使用一个描述符就绪的链表,当描述符就绪的时候,在内核态中会使用回调函数,该函数会将对应的描述符添加入就绪链表中,那么当epoll_wait调用的时候,就不需要遍历所有的描述符查看是否有就绪的事件,而是直接查看链表是否为空。
总结
可以使用一个生活中的场景来对三者的区别做个总结,仍然接着笔者的上一篇博文IO模型浅析-阻塞、非阻塞、IO复用、信号驱动、异步IO、同步IO中吃饭的例子:
在这个例子中,服务员和餐厅代表内核,客户“你”就是用户态进程,可能觉得这个例子写的不好,在这里写下加深记忆。
select和poll:你去餐厅请客吃饭,你是个豪爽的人,点了很多菜,你告诉服务员对应种类的菜有多少上多少,服务员将菜名一一写在纸上。然后你开始问服务员饭菜有好了么,服务员看着你的菜单一大串,头皮发麻,于是按着菜单的顺序去厨房查看饭菜有没有好,如果菜没有好就划掉菜单中对应的菜,终于找出了所有已经烧好的饭菜,服务员把饭菜端给了你。可是这个时候菜单上只能看到已经准备好的菜了,没准备好的菜看不清了,你觉得这个服务员做事很傻逼,没办法将就点,谁让你性格好呢,于是你重新写了一份菜单(可能这个过程中你又想点一些新的菜或者删除一些菜)。接下来你又去问饭菜好没好,服务员又开始按照菜单的顺序去厨房查看饭菜有没有好。。。(select和poll的主要区别就在于,select中的菜单是有限的,而poll中的菜单是无限的,你可以点任意种类的菜)
epoll:你去餐厅请客吃饭,你是个豪爽的人,点了很多菜,你告诉服务员对应种类的菜有多少上多少,服务员将菜名一一录入到餐厅后台的菜单管理软件中,厨房的师傅烧好一道菜在管理软件中标记完成一下,然后在烧好的菜上挂上对应的桌号放在取菜区,这个时候你来问服务员饭菜有准备好的么,服务员于是查一下管理软件,有标记欸,于是从取菜区取出对应桌号的饭菜送给你,清空标记。过了段时间,你又想点一道新的菜,于是叫来服务员,服务员在菜单软件中添加一栏。接下来你又去问饭菜好没好,服务员又开始看菜单软件中是否有标记完成的信息。。。
另外关于epoll的高效还有很多细节,例如使用mmap将用户空间和内核空间的地址映射到同一块物理内存地址,使用红黑树存储要监听的事件等等,具体的细节可以参考博文select、poll、epoll之间的区别总结整理、高并发网络编程之epoll详解、Linux下的I/O复用与epoll详解、彻底学会使用epoll(一)——ET模式实现分析等几篇文章。
接下来使用select、pol、epoll实现一个tcp反射程序
参考资料
UNIX网络变成卷1:套接字联网API
作者:yearsj
转载请注明出处:https://www.cnblogs.com/yearsj/p/9647135.html segmentfault对应博文:https://segmentfault.com/a/1190000016400053