IO复用

来源:互联网 发布:儿童票卧铺半价算法 编辑:程序博客网 时间:2024/06/06 00:30

I/O复用:

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

 

sys_select:

sys_select(int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
       struct timeval *timeout);
用于等待fd集合中的对应的文件改变状态。注意timeout参数是timeval结构体而不是timespec,三个fd集合分别用于等待文件可读、可写、例外。对这些集合的操作非常方便,内核提供了以FD_开头的宏。第一个参数n应该等于三个集合中最大的fd值加1。timeout为0时,select立即返回不会阻塞,这在轮询(polling)时很有用;若要一直阻塞直到有fd可用,timeout应设为NULL。

1.用户层应用程序调用select(),底层调用poll())

2.核心层调用sys_select() ------> do_select()

最终调用文件描述符fd对应的struct file类型变量的struct file_operations *f_op的poll函数。

struct file_operations *f_op = NULL;

poll指向的函数返回当前可否读写的信息。

if (file) {

f_op = file->f_op;

mask = DEFAULT_POLLMASK;

if (f_op && f_op->poll)

mask = (*f_op->poll)(file, retval ? NULL : wait);

fput(file);

if ((mask & POLLIN_SET) && (in & bit)) {

res_in |= bit;

retval++;

}

if ((mask & POLLOUT_SET) && (out & bit)) {

res_out |= bit;

retval++;

}

if ((mask & POLLEX_SET) && (ex & bit)) {

res_ex |= bit;

retval++;

}

}

1)如果当前可读写,返回读写信息。

2)如果当前不可读写,则阻塞进程,并等待驱动程序唤醒,重新调用poll函数,或超时返回。

3.驱动需要实现poll函数。

当驱动发现有数据可以读写时,通知核心层,核心层重新调用poll指向的函数查询信息。

poll_wait(filp,&wait_q,wait) // 此处将当前进程加入到等待队列中,但并不阻塞

在中断中使用wake_up_interruptible(&wait_q)唤醒等待队列

 

select系统调用过程如下:
    select <-> sys_select <-> do_select <-> *fop->poll
sys_select作一些准备工作和检查,比如检查传递进来的timeout参数,用rcu更新max_fdset的值,为in/out/ex分配bitmap并为fds赋值,

max_fdset = current->files->max_fdset;

if (n > max_fdset)

n = max_fdset;

让其为最大的fd值,最后再加一。

然后调用do_select:
    ret = do_select(n, &fds, &timeout);
进入do_select之后,首先初始化poll_wqueues类型的table,再将poll_table赋给wait,poll_table是构成实际数据结构的一个简单封装:
    struct poll_wqueues table;
    poll_table *wait;
     poll_initwait(&table);
    wait = &table.pt;

struct poll_wqueues {

poll_table pt;

struct poll_table_page * table;

int error;

};
下面就是真正干活的代码了,这是一个for的无限循环,下面是核心代码:
    for (j = 0; j < __NFDBITS; ++j, ++i, bit <<= 1) {
        ......
        if (file) {
            f_op = file->f_op;
            mask = DEFAULT_POLLMASK;
            if (f_op && f_op->poll)
                mask = (*f_op->poll)(file, retval ? NULL : wait);
            ......
       }
       cond_resched();
    }

poll方法,它做两件事:
    1. 在一个或多个可指示poll状态变化的等待队列上调用poll_wait;
    2. 返回一个用来描述操作是否可以立即无阻塞执行的位掩码。
现在终于知道f_op->poll中干的两件事是用来做什么的了,给mask赋值,是为了传递给do_select里的mask,而调用poll_wait就是为了加入设备的等待队列然后给cond_resched()用,如果没有一个被唤醒,那么cond_resched()就是立刻切换其他进程,用户空间select休眠(timeout非0时)。注意到poll方法的第二个参数有两种可能,这是为什么呢?先别急,等会看看retval是干嘛的就知道了。
    代码中有两个地方可以跳出这个无限循环:
    if (retval || !__timeout || signal_pending(current))
         break;
    if(table.error) {
         retval = table.error;
         break;
    }
retval非零,timeout为0(即立刻跳出),休眠过程中收到信号以及出错。后面三个很好理解,第一个是怎么回事呢?我们来看看有关retval赋值的代码:
    retval = max_select_fd(n, fds);
    n = retval;
    retval = 0;
n保存最大的文件描述符,retval在进入for无限循环前被置为0。通过比较相关的位,一旦发现可以进行I/O,retval的值就会加1,变成非零,这个时候(*f_op->poll)(file, retval ? NULL : wait)的第二个参数变成NULL,原因显而易见,因为内核知道此时不会发生任何等待,因此也不需要构造等待队列。另外,当timeout为0时,wait会被设为NULL,因此这种情况下即使retval为0,即没有可用I/O,poll的第二个参数还是NULL,系统不需要处理等待队列。跳出循环之后将进程状态设置为TASK_RUNNING,并对poll_table进行清空,最后将retval返回给sys_select做一些“善后”工作。

__set_current_state(TASK_RUNNING);

 

poll_freewait(&table);

sys_poll:

sys_poll->do_poll

我们调用select函数时候有个时间限制,实际上在调用驱动程序里的poll之前内核调用了VFS相关的POLL接口sys_poll(),阻塞、等待时间都是在那里面实现的。 

如果当前不可读,那么在sys_poll->do_poll中当前进程就会睡眠在等待队列上,这个等待队列是由驱动程序提供的(就是poll_wait中传入的那个)。

后来如果驱动程序有一部分代码运行了(比如驱动的中断服务程序),那么在这部分代码中,就会唤醒等待队列上的进程,也就是之前睡眠的那个,当那个进程被唤醒后do_poll会再一次的调用驱动程序的poll函数,这个时候应用程序就知道是可读的了。

相比于select机制,poll只是取消了最大监控文件描述符数限制,并没有从根本上解决select存在的问题。

 

epoll:

.long sys_epoll_create

.long sys_epoll_ctl /* 255 */

.long sys_epoll_wait

 epoll精巧的使用了3个方法来实现select方法要做的事:

 (1)新建epoll描述符==epoll_create()

 (2)epoll_ctl(epoll描述符,添加或者删除所有待监控的连接)

 (3)返回的活跃连接 ==epoll_wait( epoll描述符 )

     select相比,epoll分清了频繁调用和不频繁调用的操作。例如,        epoll_ctl是不太频繁调用的,而epoll_wait是非常频繁调用的。这时,epoll_wait却几乎没有入参,这比select的效率高出一大截,而且,它也不会随着并发连接的增加使得入参越发多起来,导致内核执行效率下降。

    要深刻理解epoll,首先得了解epoll的三大关键要素:mmap、红黑树、链表

  epoll是通过内核与用户空间mmap同一块内存实现的mmap将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换。内核可以直接看到epoll监听的句柄,效率高。

  红黑树将存储epoll所监听的套接字。上面mmap出来的内存如何保存epoll所监听的套接字,必然也得有一套数据结构,epoll在实现上采用红黑树去存储所有套接字,当添加或者删除一个套接字时(epoll_ctl),都在红黑树上去处理,红黑树本身插入和删除性能比较好,时间复杂度O(logN)。

   通过epoll_ctl函数添加进来的事件都会被放在红黑树的某个节点内,所以,重复添加是没有用的。当把事件添加进来的时候时候会完成关键的一步,那就是该事件都会与相应的设备(网卡)驱动程序建立回调关系,当相应的事件发生后,就会调用这个回调函数,该回调函数在内核中被称为:ep_poll_callback,这个回调函数其实就所把这个事件添加到rdllist这个双向链表中。一旦有事件发生,epoll就会将该事件添加到双向链表中。那么当我们调用epoll_wait时,epoll_wait只需要检查rdlist双向链表中是否有存在注册的事件,效率非常可观。这里也需要将发生了的事件复制到用户态内存中即可。

struct epoll_event {

__u32 events;

__u64 data;

} EPOLL_PACKED;

 

 

epoll_ctl();

该函数首先在eventpoll中查找操作的fd对应的epitem对象是否存在,然后根据用户指定的命令参数,作相应的处理。每个添加到epoll的文件都会附加到一个epitem对象中。epoll的删除文件和修改文件命令,分别有ep_remove()和ep_modify()来完成,这两个函数比较简单,不作过多分析。主要关心的是epoll的添加命令对应的函数ep_insert().

ep_insert()函数首先分配fd要附加到的epitem实例,初始化后会添加到eventpoll中存储文件的红黑树、监视文件的f_ep_links链表中以及监视文件的唤醒队列中。在加入到监视文件的唤醒队列时,如果用户关心的事件发生时,会将epitem实例添加到eventpoll的就绪队列中。revents = tfile->f_op->poll(tfile, &epq.pt);  就是将epitem实例添加到文件的唤醒队列中,真正添加的操作是ep_ptable_queue_proc()函数。

 

ep_ptable_queue_proc()函数

从上面的函数可以看出,注册在监视文件的唤醒队列上的回调方法是ep_poll_callback()函数。也就是当有事件发生时,会唤醒监视文件上等待的进程。在tcp_prequeue()函数中当有数据达到时唤醒等待队列sk_sleep上的进程

wake_up_interruptible_poll()函数会调用注册到sk_sleep中的回调函数,如果是eventpoll注册的话,该回调函数就是ep_poll_callback()。

 

ep_poll_callback()函数该函数主要的功能是将被监视文件的等待事件就绪时,将文件对应的epitem实例添加到就绪队列中,当用户调用epoll_wait()时,内核会将就绪队列中的事件报告给用户

 

 epoll_wait的工作流程:

(1)epoll_wait调用ep_poll,当rdlist为空(无就绪fd)时挂起当前进程,直到rdlist不空时进程才被唤醒。

(2)文件fd状态改变(buffer由不可读变为可读或由不可写变为可写),导致相应fd上的回调函数ep_poll_callback()被调用。

(3)ep_poll_callback将相应fd对应epitem加入rdlist,导致rdlist不空,进程被唤醒,epoll_wait得以继续执行。

(4)ep_events_transfer函数将rdlist中的epitem拷贝到txlist中,并将rdlist清空。

(5)ep_send_events函数(很关键),它扫描txlist中的每个epitem,调用其关联fd对用的poll方法。此时对poll的调用仅仅是取得fd上较新的events(防止之前events被更新),之后将取得的events和相应的fd发送到用户空间(封装在struct epoll_event,从epoll_wait返回)。

 

ET和LT模式:    

ET模式仅当状态发生变化的时候才获得通知,这里所谓的状态的变化并不包括缓冲区中还有未处理的数据,也就是说,如果要采用ET模式,需要一直read/write直到出错为止,很多人反映为什么采用ET模式只接收了一部分数据就再也得不到通知了,大多因为这样;LT模式是只要有数据没有处理就会一直通知下去的.

 

小结

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从内核空间拷贝到用户空间。

select的几大缺点:

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

2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大

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

poll:

poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是selectfd_set结构,其他的都差不多。

 

epoll:

epoll既然是对selectpoll的改进,就应该能避免上述的三个缺点。那epoll都是怎么解决的呢?在此之前,我们先看一下epollselectpoll的调用接口上的不同,selectpoll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctlepoll_waitepoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。

对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。

对于第二个缺点,epoll的解决方案不像selectpoll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。

对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

 

总结:

1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。

2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销

 

LT和ET:

LT:水平触发,效率会低于ET触发,尤其在大并发,大流量的情况下。但是LT对代码编写要求比较低,不容易出现问题。LT模式服务编写上的表现是:只要有数据没有被获取,内核就不断通知你,因此不用担心事件丢失的情况。

ET:边缘触发,效率非常高,在并发,大流量的情况下,会比LT少很多epoll的系统调用,因此效率高。但是对编程要求高,需要细致的处理每个请求,否则容易发生丢失事件的情况。

从本质上讲:与LT相比,ET模型是通过减少系统调用来达到提高并行效率的。

 

(1)ET模式下的accept问题

 请思考以下一种场景:在某一时刻,有多个连接同时到达,服务器的 TCP 就绪队列瞬间积累多个就绪连接,由于是边缘触发模式,epoll 只会通知一次,accept 只处理一个连接,导致 TCP 就绪队列中剩下的连接都得不到处理。在这种情形下,我们应该如何有效的处理呢?

  解决的方法是: while 循环抱住 accept 调用,处理完 TCP 就绪队列中的所有连接后再退出循环。如何知道是否处理完就绪队列中的所有连接呢? accept返回 -1并且errno设置为EAGAIN就表示所有连接都处理完。 

(2)ET模式为什么要设置在非阻塞模式下工作

  因为ET模式下的读写需要一直读或写直到出错(对于读,当读到的实际字节数小于请求字节数时就可以停止),而如果你的文件描述符如果不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞。这样就不能在阻塞在epoll_wait上了,造成其他文件描述符的任务饥饿。

原创粉丝点击