多路复用
1、说明
socket编程的demo中使用的都是最基本的,但是一般不会真正用在项目中的代码。而实际项目中,需要面临复杂多变的需求环境,比如有多个socket连接,或者服务需要监听的时候,可能有很多socket连接进来。面对这种情况,最直接最简单的想法是,一个socket连接创建一个线程去处理。当然,在socket连接数较少的情况下,这种方式无可厚非,但是如果连接数量较大,就会出现意外情况。
我们都知道,linux下一个线程默认所占的内存是8M(可以使用ulimit -s查看),那么加入,1000个socket连接,建立1000线程,光线程的开销就高达8G多,更遑论其他业务还要使用内存了。
而且,在很多情况下,socket建立连接之后,并不是要一直通信,而是间隔通信,那么占用一个独立的线程来“照顾”这个连接显得很不明智。
针对这种情况,就需要采用多路复用机制,所谓多路复用,就是一个进程见识多个socket描述符,一旦某个socket描述符就绪(可读写或者异常)了,就会通知应用程序,进行相应的处理。
1.1、多路复用的几种机制
目前的多路复用机制有三种,select、poll 和 epoll。这三种机制各有优劣
2、函数简介
2.1、select
头文件和函数声明:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
FD_ZERO(int fd, fd_set* fds) // 清空集合
FD_SET(int fd, fd_set* fds) // 将给定的描述符加入集合
FD_ISSET(int fd, fd_set* fds) // 判断指定描述符是否在集合中
FD_CLR(int fd, fd_set* fds) // 将给定的描述符从文件中删除
描述:
监听多个文件描述符的属性变化。
函数返回后,需要便利fd_set来找到就绪的描述符
参数说明:
nfds: 需要监听的描述符的范围,一般是最大描述符+1,比如,现在需要监听 0/1/2/3/4/5 这几个描述符,则参数设置为6,在linux下,值最大是1024
readfds: 监听到的可读的描述符的set,所有可读的描述符都会存储到这里
writefds: 监听到的可写的描述符的set
exceptfds: 监听到的异常的描述符set
timeout: select 方法的超时时间,这个参数决定 select 的运行机制,可能有三种值
- NULL,设置为空指针,则select阻塞运行
- 0,select 非阻塞运行,机制变为轮询,注意,不是参数传递0,参数传递0表示NULL
- >0,在指定的时间内阻塞,但有事件或者超时之后返回,返回值为有事件的描述符数量
返回值:
select 返回有事件的描述符数量,可以在对应的set中找到具体的描述符,错误则返回-1
优点: 跨平台
缺点:
- 描述符的数量受到限制,比如linux下最大1024;
- 每次调用,都需要将set从用户态复制到内核态,这在描述符多时,开销很大;
- 采用便利轮询的方式,多了无意义的损耗,比如,只需要监听99的描述符,但是内核会遍历0~100的描述符;
2.2、poll
头文件和函数声明:
#include <poll.h>
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
struct pollfd {
int fd; /* 描述符 */
short events; /* 需要监听的事件 */
short revents; /* 实际发生的事件 */
};
函数描述: 监听多个文件描述符的属性变化,和select类似,但是有很大区别,使用一个 pollfd 指针来替代 select 的三个set的功能
参数描述:
fd: 结构体指针,可以传入多个结构体,每个结构体都是一个被监听的描述符
nfds: 指定传入的结构体的数量
timeout: 超时时间,单位毫秒,-1 表示阻塞
返回值: 返回有事件的描述符数量,函数返回后,需要轮询来找到发生事件的描述符,错误则返回-1
pollfd 结构体:
fd: 表示描述符
events: 需要监听的事件掩码,取值如下
revents: 实际发生的事件掩码,取值如下
宏定义 | 可作events的值 | 可作revents的值 | 说明 |
---|---|---|---|
POLLIN | y | y | 数据可读 |
POLLRDNORM | y | y | 普通数据可读 |
POLLRDBAND | y | y | 优先数据可读 |
POLLPRI | y | y | 紧迫带数据可读 |
POLLOUT | y | y | 数据可写,不会阻塞 |
POLLWRNORM | y | y | 普通数据可写,不会阻塞 |
POLLWRBAND | y | y | 优先级带数据可写,不会阻塞 |
POLLMSGSIGPOLL | y | y | 消息可用 |
非法事件
宏定义 | 可作events的值 | 可作revents的值 | 说明 |
---|---|---|---|
POLLERR | y | 发生错误 | |
POLLHUP | y | 发生挂起 | |
POLLNVAL | y | 描述不是打开的文件 |
POLLIN | POLLPRI 等价于 select 的读事件,而 POLLIN 等价于 POLLRDNORM | POLLRDBAND
POLLOUT | POLLWRBAND 等价于 select 的写事件,而 POLLOUT 等价于 POLLWRNORM
这些事件不是互斥的,可以同时设置
优缺点:
和 select 相比,poll 没有了数量的限制,但是数量太大也会影响效率
poll 同样有着将传入的描述符从用户态复制到内核态的缺点,开销随着描述符数量的增大而线性增大
2.3、epoll
epoll是后来提出的,作为 select 和 poll 的增强版本
epoll 的操作需要三个接口,头文件和声明如下:
#include <sys/epoll.h>
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);
2.3.1、epoll_create
int epoll_create(int size);
创建一个 epoll 专用的描述符,size 为监听的数目
size 参数并没有限制 epoll 监听的描述符的最大限制,而是作为内部分配数据结构的一个建议,linux自动2.6.8版本之后,该参数被忽略,只要大于0就行
返回一个描述符,使用结束时,需要close(),错误则返回-1
2.3.2、epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll 的事件注册,告诉 epoll 要监听哪些事件
返回0表示注册成功,-1表示失败
参数:
epfd: epoll 专用的描述符,由epoll_create() 返回
op: 该函数的作用,即注册(EPOLL_CTL_ADD)、修改(EPOLL_CTL_MOD)和删除(EPOLL_CTL_DEL)
fd: 需要监听的描述符
event: 告诉内核要监听什么事件,声明如下:
// 保存触发事件的某个文件描述符相关的数据(与具体使用方式有关)
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
// 感兴趣的事件和被触发的事件
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
epoll_event 中的 events 可以是以下宏定义的集合
宏定义 | 说明 |
---|---|
EPOLLIN | 表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭) |
EPOLLOUT | 表示对应的文件描述符可以写 |
EPOLLPRI | 表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来) |
EPOLLERR | 表示对应的文件描述符发生错误 |
EPOLLHUP | 表示对应的文件描述符被挂断 |
EPOLLET | 将 EPOLL 设为边缘触发(Edge Trigger)模式,这是相对于水平触发(Level Trigger)来说的 |
EPOLLONESHOT | 只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里 |
2.3.3、epoll_wait
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待 epoll 监听下的 IO 事件
参数:
epfd: epoll 描述符
events: epoll 会把发生的事件赋值到events中
maxevents: 表明这个 events 的大小
timeout: 超时时间,单位毫秒,-1表示阻塞
返回值: 返回需要处理的事件数目,0表示超时未有事件,-1表示失败
2.4、其他方法
FD_ZERO(fd_set *fdset); //清空一个描述符集合
FD_SET(fd_set *fdset, int fd); //添加fd到描述符集合中
FD_CLR(fd_set *fdset, int fd); //从描述符集合中删除一个fd
FD_ISSET(int fd,fd_set *fdset); //检查fd是否在描述符集合中
3、epoll
epoll 的工作模式有两种:LT(level trigger) 和 ET(edge trigger),默认LT模式,区别如下:
LT模式: 当 epoll_wait 检测到描述符事件发生,并通知应用程序,应用程序可以不利己处理该事件,下次调用 epoll_wait 时,还是会通知此事件
ET模式: 当 epoll_wait 检测到描述符事件发生,并通知应用程序,应用程序必须立即处理该事件。如果不处理,则下次调用时,不会再次通知此事件
3.1、LT模式和ET模式
LT模式:
默认模式,支持阻塞和非阻塞方式,内核会通知事件发生,若果不做任何操作,则内核下次还是会通知,编程不易出错,select/poll都是这种模式
ET模式:
告诉你工作模式,只支持非阻塞。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一 个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)
3.2、优缺点
- 监听数量不受限制,理论上上限是最大可以打开的文件数目,这个数目一般远大于2048,linux上可以使用 cat /proc/sys/fs/file-max 命令查看。select的最大缺点就是进程打开的fd是有数量限制的。这对 于连接数量比较大的服务器来说根本不能满足。虽然也可以选择多进程的解决方案( Apache就是这样实现的),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。
- IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数。
- 文件描述符只需要复制一次到内核,不需要每一次调用函数都进行文件描述符的内核复制
如果没有大量的idle -connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle- connection,就会发现epoll的效率大大高于select/poll。