扩展3:搞懂select、poll、epoll

搞懂select、poll、epoll

IO多路复用(IO Multiplexing) 是这么一种机制:程序注册一组socket文件描述符给操作系统,表示“我要监视这些fd是否有IO事件发生,有了就告诉程序处理”。
IO复用模型:很多操作系统都实现了select和poll这两个系统调用。如果是追求性能的话,在BSD/macOS上提供了kqueue api;在Salorias中提供了/dev/poll(可惜该操作系统已经凉凉);而在Linux上提供了epoll api。。Java NIO,nginx等在对应的平台的上都是使用这些api实现。因为大部分情况下使用Linux做服务器,所以下文以也介绍Linux epoll多路复用是怎么工作的。

select

select方法

//select函数
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

它接受3个文件描述符的数组,分别监听读取(readfds),写入(writefds)和异常(expectfds)事件。那么一个 IO多路复用的代码大概是这样:

struct timeval tv = {.tv_sec = 1, .tv_usec = 0};

ssize_t nbytes;

while(1) {

    FD_ZERO(&read_fds);

    setnonblocking(fd1);

    setnonblocking(fd2);

    FD_SET(fd1, &read_fds);

    FD_SET(fd2, &read_fds);

    // 把要监听的fd拼到一个数组里,而且每次循环都得重来一次...

    if (select(FD_SETSIZE, &read_fds, NULL, NULL, &tv) < 0) { // block住,直到有事件到达

        perror("select出错了");

        exit(EXIT_FAILURE);

    }

    for (int i = 0; i < FD_SETSIZE; i++) {

        if (FD_ISSET(i, &read_fds)) {

            /* 检测到第[i]个读取fd已经收到了,这里假设buf总是大于到达的数据,所以可以一次read完 */

            if ((nbytes = read(i, buf, sizeof(buf))) >= 0) {

                process_data(nbytes, buf);

            } else {

                perror("读取出错了");

                exit(EXIT_FAILURE);

            }

        }

    }

}

首先,为了select需要构造一个fd数组(这里为了简化,没有构造要监听写入和异常事件的fd数组)。之后,用select监听了read_fds中的多个socket的读取时间。调用select后,程序会Block住,直到一个事件发生了,或者等到最大1秒钟(tv定义了这个时间长度)就返回。之后,需要遍历所有注册的fd,挨个检查哪个fd有事件到达(FD_ISSET返回true)。如果是,就说明数据已经到达了,可以读取fd了。读取后就可以进行数据的处理。
select的缺点:
select能够支持的最大的fd数组的长度是1024。这对要处理高并发的web服务器是不可接受的。
fd数组按照监听的事件分为了3个数组,为了这3个数组要分配3段内存去构造,而且每次调用select前都要重设它们(因为select会改这3个数组);调用select后,这3数组要从用户态复制一份到内核态;事件到达后,要遍历这3数组。很耗时。
select返回后要挨个遍历fd,找到被“SET”的那些进行处理。这样比较低效。
select是无状态的,即每次调用select,内核都要重新检查所有被注册的fd的状态。select返回后,这些状态就被返回了,内核不会记住它们;到了下一次调用,内核依然要重新检查一遍。于是查询的效率很低。

调用过程

在这里插入图片描述
(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

poll与select类似于。它大概长这样:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

poll的实现和select非常相似,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
它将用户传入的数组拷贝到内核空间
然后查询每个fd对应的设备状态:
如果设备就绪 在设备等待队列中加入一项继续遍历。
若遍历完所有fd后,都没发现就绪的设备 挂起当前进程,直到设备就绪或主动超时,被唤醒后它又再次遍历fd。这个过程经历多次无意义的遍历。
poll优化了select的一些问题。比如不再有3个数组,而是1个polldfd结构的数组了,并且也不需要每次重设了。数组的个数也没有了1024的限制。但其他的问题依旧:
依然是无状态的,性能的问题与select差不多一样;
应用程序仍然无法很方便的拿到那些“有事件发生的fd“,还是需要遍历所有注册的fd。

epoll

epoll使用步骤

epoll是Linux下的IO多路复用的实现。因为它非常有代表性,并且Linux也是目前最广泛被作为服务器的操作系统。细致的了解epoll对整个IO多路复用的工作原理非常有帮助。
与select和poll不同,要使用epoll是需要先创建一下的。
第一步:创建epoll

int epfd = epoll_create(10);

epoll_create在内核层创建了一个数据表,接口会返回一个“epoll的文件描述符”指向这个表。注意,接口参数是一个表达要监听事件列表的长度的数值。但不用太在意,因为epoll内部随后会根据事件注册和事件注销动态调整epoll中表格的大小。
在这里插入图片描述
epoll创建
为什么epoll要创建一个用文件描述符来指向的表呢?这里有两个好处:

  • epoll是有状态的,不像select和poll那样每次都要重新传入所有要监听的fd,这避免了很多无谓的数据复制。epoll的数据是用接口epoll_ctl来管理的(增、删、改)。
  • epoll文件描述符在进程被fork时(创建子线程),子进程是可以继承的。这可以给对多进程共享一份epoll数据,实现并行监听网络请求带来便利。
    epoll创建后,第二步是使用epoll_ctl接口来注册要监听的事件。
    第二步:注册监听事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

其中第一个参数就是上面创建的epfd。第二个参数op表示如何对文件名进行操作,共有3种。

  • EPOLL_CTL_ADD - 注册一个事件
  • EPOLL_CTL_DEL - 取消一个事件的注册
  • EPOLL_CTL_MOD - 修改一个事件的注册
    第三个参数是要操作的fd,这里必须是支持NIO的fd(比如socket)。第四个参数是一个epoll_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 */
};

比方说,想关注一个fd1的读取事件事件,并采用边缘触发(下文会解释什么是边缘触发),大概要这么写:

struct epoll_data ev;
ev.events = EPOLLIN | EPOLLET; // EPOLLIN表示读事件;EPOLLET表示边缘触发
ev.data.fd = fd1;

通过epoll_ctl就可以灵活的注册/取消注册/修改注册某个fd的某些事件。
在这里插入图片描述
管理fd事件注册
第三步,使用epoll_wait来等待事件的发生。
第三步:等待事件发生

int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);

特别留意,这一步是"block"的。只有当注册的事件至少有一个发生,或者timeout达到时,该调用才会返回。这与select和poll几乎一致。但不一样的地方是evlist,它是epoll_wait的返回数组,里面只包含那些被触发的事件对应的fd,而不是像select和poll那样返回所有注册的fd。
综合起来,一段比较完整的epoll代码大概是这样的。

#define MAX_EVENTS 10

struct epoll_event ev, events[MAX_EVENTS];

int nfds, epfd, fd1, fd2;


// 假设这里有两个socket,fd1和fd2,被初始化好。

// 设置为non blocking

setnonblocking(fd1);

setnonblocking(fd2);


// 创建epoll

epfd = epoll_create(MAX_EVENTS);

if (epollfd == -1) {

    perror("epoll_create1");

    exit(EXIT_FAILURE);

}


//注册事件

ev.events = EPOLLIN | EPOLLET;

ev.data.fd = fd1;

if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd1, &ev) == -1) {

    perror("epoll_ctl: error register fd1");

    exit(EXIT_FAILURE);

}

if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd2, &ev) == -1) {

    perror("epoll_ctl: error register fd2");

    exit(EXIT_FAILURE);

}


// 监听事件

for (;;) {

    nfds = epoll_wait(epdf, events, MAX_EVENTS, -1);

    if (nfds == -1) {

        perror("epoll_wait");

        exit(EXIT_FAILURE);

    }


    for (n = 0; n < nfds; ++n) { // 处理所有发生IO事件的fd

        process_event(events[n].data.fd);

        // 如果有必要,可以利用epoll_ctl继续对本fd注册下一次监听,然后重新epoll_wait

    }

}

所有的基于IO多路复用的代码都会遵循这样的写法:注册——监听事件——处理——再注册,无限循环下去。

触发模式

EPOLLLTEPOLLET两种:

  • LT,默认的模式(水平触发) 只要该fd还有数据可读,每次 epoll_wait 都会返回它的事件,提醒用户程序去操作,默认情况下,epoll使用水平触发,这与select和poll的行为完全一致。在水平触发下,epoll顶多算是一个“跑得更快的poll”。
  • ET是“高速”模式(边缘触发)

而一旦在注册事件时使用了EPOLLET标记(如上文中的例子),那么将其视为边沿触发(或者有地方叫边缘触发,一个意思)。那么到底什么水平触发和边沿触发呢?
考虑下图中的例子。有两个socket的fd——fd1和fd2。我们设定监听f1的“水平触发读事件“,监听fd2的”边沿触发读事件“。

  • 在时刻t1,使用epoll_wait监听他们的事件。
  • 在时刻t2时,两个fd都到了100bytes数据。
  • 在时刻t3, epoll_wait返回了两个fd进行处理。
  • 在时刻t4,我们故意不读取所有的数据出来,只各自读50bytes。
  • 在时刻t5重新注册两个事件并监听。
  • 在时刻t6时,只有fd1会返回,因为fd1里的数据没有读完,仍然处于“被触发”状态;而fd2不会被返回,因为没有新数据到达。在这里插入图片描述
    这个例子很明确的显示了水平触发和边沿触发的区别。
  • 水平触发只关心文件描述符中是否还有没完成处理的数据,如果有,不管怎样epoll_wait,总是会被返回。简单说——水平触发代表了一种“状态”。
  • 边沿触发只关心文件描述符是否有的事件产生,如果有,则返回;如果返回过一次,不管程序是否处理了,只要没有新的事件产生,epoll_wait不会再认为这个fd被“触发”了。简单说——边沿触发代表了一个“事件”。
    “那么边沿触发怎么才能迫使新事件产生呢?一般需要反复调用read/write这样的IO接口,直到得到了EAGAIN错误码,再去尝试epoll_wait才有可能得到下次事件。
    那么为什么需要边沿触发呢?
    边沿触发把如何处理数据的控制权完全交给了开发者,提供了巨大的灵活性。比如,读取一个http的请求,开发者可以决定只读取http中的headers数据就停下来,然后根据业务逻辑判断是否要继续读(比如需要调用另外一个服务来决定是否继续读)。而不是次次被socket尚有数据的状态烦扰;写入数据时也是如此。比如希望将一个资源A写入到socket。当socket的buffer充足时,epoll_wait会返回这个fd是准备好的。但是资源A此时不一定准备好。如果使用水平触发,每次经过epoll_wait也总会被打扰。在边沿触发下,开发者有机会更精细的定制这里的控制逻辑。但不好的一面时,边沿触发也大大的提高了编程的难度。一不留神,可能就会miss掉处理部分socket数据的机会。如果没有很好的根据EAGAIN来“重置”一个fd,就会造成此fd永远没有新事件产生,进而导致饿死相关的处理代码。

EPOLLET触发模式的意义

若用EPOLLLT,系统中一旦有大量无需读写的就绪文件描述符,它们每次调用epoll_wait都会返回,这大大降低处理程序检索自己关心的就绪文件描述符的效率。 而采用EPOLLET,当被监控的文件描述符上有可读写事件发生时,epoll_wait会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait时,它不会通知你,即只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。这比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。

优点

  • 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)。
  • 效率提升,不是轮询,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用。callback函数 即Epoll最大的优点就在于它只关心“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
  • 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
  • epoll通过内核和用户空间共享一块内存来实现的。
    表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
    epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现。

select和poll都只提供了一个函数——select或者poll函数。
而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,
epoll_create是创建一个epoll句柄;
epoll_ctl是注册要监听的事件类型;
epoll_wait则是等待事件的产生。

总结

select,poll,epoll都是IO多路复用机制,即可以监视多个描述符,一旦某个描述符就绪(读或写就绪),能够通知程序进行相应读写操作。 但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

  • select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
  • select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值