I/O多路复用

参考资料:
select、poll、epoll之间的区别总结[整理]

  • select , poll , epoll

epoll机制:epoll_create、epoll_ctl、epoll_wait、close

I/O多路复用就是通过一种机制,可以监视多个描述符,一旦内核发现某个描述符就绪(一般是读就绪或者写就绪),即进程指定的一个或者多个IO条件就绪,能够通知进程进行相应的读写操作

优势

与多进程和多线程技术相比,使用I/O多路复用技术,系统不必创建或维护进程/线程,从而大大减小了系统的开销

select

准许进程指示内核等待多个事件中的任何一个发送,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒

支持的最大文件描述符数量默认是1024

select函数

#include 
#include 

int select(int maxfdp1,fd_set* readset,fd_set* writeset,fd_set* exceptset,const struct timeval *timeout)
/*
maxfdp1:
待测试的描述字个数,它的值是待测试的最大描述字加1(因此把该参数命名为maxfdp1),描述字0、1、2...maxfdp1-1均将被测试

readset、writeset、exceptset:
指定我们要让内核测试读、写和异常条件的描述字。如果对某一个的条件不感兴趣,可以把它设为空指针
struct fd_set可以理解为一个集合,存放文件描述符

timeout:
告知内核等待所指定描述字中的任何一个就绪可花多少时间
(1)设为null:永远等待
(2)设为0:轮询
(3)设为正数:等待一段时间
struct timeval{
    long tv_sec;   //seconds
    long tv_usec;  //microseconds
};

return(int):
就绪描述符的数目,超时返回0,出错返回-1
*/

调用过程

(1)使用copy_from_user从用户空间拷贝fd_set到内核空间

(2)注册回调函数__pollwait

(3)遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll)

(4)以tcp_poll为例,其核心实现就是__pollwait,也就是上面注册的回调函数。

(5)__pollwait的主要工作就是把current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。

(6)poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。

(7)如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout使调用select的进程(也就是current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。

(8)把fd_set从内核空间拷贝到用户空间。

poll

与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,只是描述fd集合的方式不同。但poll没有最大文件描述符数量的限制,也不需要显式地请求异常情况报告。

poll函数

# include 

int poll(struct pollfd * fds, unsigned int nfds, int timeout);
/*
fds:
struct pollfd {
int fd;        // 文件描述符
short events;  // 等待的事件(用户设置)
short revents; // 实际发生了的事件(内核调用返回时设置)
} ;

nfds:
监视的文件描述符个数

timeout:
指定等待的毫秒数
(1)设为负数:使poll()一直挂起直到一个指定事件发生
(2)设为0:立即返回
(3)设为正数:等待一段时间

return:
(1)success:返回结构体中revents域不为0的文件描述符个数;如果在超时前没有任何事件发生,返回0
(2)error:返回-1
*/

epoll

epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次

epoll接口

#include 

// 创建一个epoll的句柄
int epoll_create(int size);
/*
size:
告诉内核这个监听的数目一共有多大

return:
一个epoll专用的文件描述符,其实是申请了内核空间

注意:
创建好epoll句柄后,它就是会占用一个fd值,所以使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽
*/

// 操作监听事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
epfd:
epoll_create()的返回值

op:
要进行的操作
EPOLL_CTL_ADD 注册
EPOLL_CTL_MOD 修改
EPOLL_CTL_DEL 删除

fd:
需要监听的fd

event:
告诉内核需要监听什么事件
struct epoll_event {
  __uint32_t events;  // Epoll events 
  epoll_data_t data;  // User data variable
};
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;

return:
调用成功返回0,不成功返回-1
*/

// 等待事件的产生
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
/*
events:
用来从内核得到事件的集合

maxevents:
告之内核这个events有多大,每次能处理的事件数

timeout:
指定等待的毫秒数,同poll()的timeout

return:
需要处理的事件数目,如返回0表示已超时
*/

对于epoll_event的举例:

struct epoll_event ev;
ev.data.fd=listenfd;
ev.events=EPOLLIN|EPOLLET;
// 注册事件
epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);

epoll_wait原理
等侍注册在epfd上的socket fd的事件的发生。如果发生,则将发生的socket fd和事件类型放入到events数组中。并且将注册在epfd上的socket fd的事件类型给清空,所以如果下一个循环你还要关注这个socket fd的话,则需要用epoll_ctl(epfd,EPOLL_CTL_MOD,listenfd,&ev)来重新设置socket fd的事件类型。这时不用EPOLL_CTL_ADD,因为socket fd并未清空,只是事件类型清空。这一步非常重要。

总结

  • select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间

  • select的几大缺点:

    1. 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大

    2. 每次调用select都需要在内核遍历传递进来的fd集合(把current进程轮流加入fd对应的设备等待队列中),这个开销在fd很多时也很大

    3. select支持的文件描述符数量太小了,默认是1024

  • epoll的解决方案:

    1. 调用epoll_ctl()函数注册新的事件时,把所有的fd拷贝进内核,在epoll_wait时不再拷贝,保证每个fd在整个过程中只会拷贝一次
    2. 只在epoll_ctl()时把current进程挂一遍(注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列),并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表。epoll_wait()的工作只需要在这个就绪链表中查看有没有就绪的fd,不用遍历fd集合
    3. epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目

你可能感兴趣的:(I/O多路复用)