epoll源码剖析(Linux Kernel 2.6.11)

本篇文章是基于Linux Kernel 2.6.11的源码来展开的。


epoll简介

之前我的博客里也写到过,epoll是Linux特有的I/O复用函数。它在实现上与select、poll有很大差异。它的提出是为了弥补select和poll对于描述符过多处理时时间效率过高的问题。

可以参考https://mp.csdn.net/postedit/89601608

三种IO函数中:

  • select和poll都需要将文件描述符和事件每次传给内核,epoll则只需要传一次。
  • 内核实现中,select和poll都采用的是轮询的方式,时间复杂度O(n);而epoll采用回调函数实现,时间复杂度O(1)。

IO方法返回后,通知应用程序哪些事件就绪时:

  • select和poll需要遍历所有的描述符然后找到就绪的那一个,时间复杂度O(n);
  • epoll可以直接给应用程序,告诉它就绪的是哪一个文件描述符,时间复杂度O(1)。

但是epoll也并不总优于select和poll,epoll适用于并发连接的客户端很多,但是就绪的文件描述符不多的情况下;而select则适用于当前并发连接的客户端不多,但是就绪描述符较多的情况下。

select、poll只能工作于LT模式,epoll既可以工作于LT模式,还可以工作于ET模式。当有事件就绪时,LT模式会反复提醒你,ET方式当有事件就绪后,无论用户此次是否将数据读取完毕,只提醒用户一次,这就使得用户空间程序有可能缓存IO状态,减少函数t的调用,提高应用程序的效率。

在源码中可以很清楚的将ET和LT分开,即你当前到底是工作于哪种方式下。如果是LT模式下,给应用程序返回之后,会将其再次加入rdllist即就绪队列中;而如果是ET模式,那就只提醒一次。


epoll源码实现简要概述

我们在使用epoll函数时,都知道它有一组函数来实现,这也是区别于select和poll的一点。它的一组函数包括:

#include

int epoll_create(int size);//创建内核事件表
int epoll_ctl();//向内核事件表中添加描述符和事件的增加,修改或删除
int epoll_wait();//获取有就绪事件的描述符

在源码中这三个函数也是重点,在每个函数中又调用了诸多的函数来实现整个epoll的功能,这三个函数密不可分,相互依存。

epoll_create()

epoll_create()函数中定义了fd,用其来标志内核事件表,但是它只是一个整型数字怎么能找到相应的内核事件表,这就很重要了。还定义了一个文件节点*inode,和一个如果打开文件就一定会创建的的结构体struct file *file的结构体变量。

epoll_create()函数中主要调用了两个函数:ep_getfd()和ep_file_init()。ep_getfd()用来给fd、inode、file这三个变量获取到值, ep_file_init()则是通过传入的file参数来获取一个更重要的数据结构,并使得file与其建立关系。这些都是整个实现过程中非常重要的数据结构,因为他们之间的关系密不可分。

最终epoll_create()函数返回fd,即后续函数可以用fd来找到内核事件表并进行相应操作。

epoll_ctl()

epoll_ctl()函数中就要用到epoll_create的返回值了,用户并且要传入要操作的文件描述符,操作类型和事件。同样,在epoll_ctl函数中也定义了很多的数据结构和变量,传入epfd的目的就是通过它来找到一系列的东西。

而它里面最重要的函数就是通过查找当前的文件描述符是否在内核事件表中创建而执行不同的操作。如果没有添加,则执行insert,否则执行修改或删除。也是在insert中注册了最重要的回调函数。

epoll_wait()

epoll_wait()在此函数内,将所有的就绪事件拷贝给用户,返回的是就绪文件描述符的个数。在epoll_wait函数中还有很重要的一步就是会判断是ET还LT模式,然后来执行不同的操作。


eventpoll_init ()

其实可以把epoll中的内核事件表看做是一个特殊的文件系统,在eventpoll_init()中有一步就是要来注册它

首先当系统启动时,epoll进行初始化:

  • 调用 ep_poll_safewake_init() 函数初始化 poll_safewake 结构。
  • 使用kmem_cache_create创建了两个内核cache,为 struct epitem 和 struct eppoll_entry 创建一个“内存池”,来存放很多已经分配好的小块内存。用于在用户向内核申请时不用去为其特别分配,而是可以直接拿给用户用,当然在不用的时候返还就行。
  • 调用 register_filesystem() 函数为注册一个名为 eventpollfs(在eventpoll_fs_type中) 新的文件系统,然后挂载此文件系统。

这也就是为什么epoll_create会返回一个新的fd,这是因为在eventpolls这个文件系统里创建了一个新文件。


epoll_create()

在使用时,epoll_create()函数被用来创建内核事件表,它调用成功时返回的就是内核事件表的描述符。

epoll_create()函数源码的原型如下:

asmlinkage long sys_epoll_create(int size)

epoll_create函数执行过程: 

  • 首先定义了error,fd变量,struct  inode *inode文件节点指针,struct file *file文件结构体
  • 对size进行判断,只要不小于0就OK。因为在源码中size的值根本就没有用到,只是做了一个判断。所以用户在传参时,size的大小并没有什么实际意义。
  • 调用ep_getfd(),来给fd,inode,file中写入值。
  • 调用ep_file_intit(),来将file->private_data与struct eventpoll *ep;关联起来。
  • 返回文件描述符fd,后续函数可以用fd来找到内核事件表并进行相应操作。

1、strtuct inode结构体

源码中对其描述为: 内核用该结构在内部表示一个文件。它与file不同。file表示的是文件描述符。对单个文件,可能会有许多个表示打开的文件描述符的filep结构,但是它们都指向单个inode结构。

epoll源码剖析(Linux Kernel 2.6.11)_第1张图片

2、struct file结构体

epoll源码剖析(Linux Kernel 2.6.11)_第2张图片

struct file是epoll源码中非常重要的一个结构体,它与很多的结构体都会产生关联。它里面重要的成员有:

3、struct eventpoll 结构体 

  • f_op:代表与文件相关的操作,其内部靠函数指针实现。为什么在众多的实现方法中选择了函数指针呢?因为我们用户需要操作的文件类型多种多样,内核不可能给每种类型都实现一套相应的方法供其需要时使用。所以就采用函数指针实现,它依据用户传入的参数类型来选择不同的函数。所以这些函数指针就相当于系统给用户保留的接口。
  • f_pos:得到文件的偏移量。在与我文件相关的函数中,经常需要依靠它并移动指针来得到文件的大小。
  • f_maxcount:一次最多读写的字节数
  • void *private_data:这是struct file结构体中非常重要的一个成员,因为就是依靠它与存放内核事件表和就绪队列的结构产生关联关系。源码对其功能描述为:open系统调用在调用驱动程序的open方法前将这个指针置为NULL。驱动程序可以将这个字段用于任何目的或者忽略这个字段。  驱动程序可以用这个字段指向已分配的数据,但是一定要在内核销毁file结构前在release方法中释放内存它是跨系统调用时保存状态的非常有用的资源。

epoll源码剖析(Linux Kernel 2.6.11)_第3张图片 

这个结构体中重要的成员就是rdllist和rbr:

  • rdllist:就绪队列,底层实现是一个双向链表,其中保存着通过sys_epoll_wait()返回给用户的、满足条件的就绪事件。具体来说,调用epoll_wait的时候,将rdllist中的epitem出列,将触发的事件拷贝到用户空间,之后判断是ET模式还是LT模式从而决定epitem是否需要重新添加回rdllist。
  • rbr:内核事件表,底层是红黑树实现,rbr表示红黑树的根节点,随着每次增加会申请节点。这棵树中存储着所有添加到epoll中的事件,也就是这个epoll监控的事件。具体来说,一个 fd 通过EPOLL_ADD 添加进 epoll 后,内核会为它分配一个对应的 epitem 结构体对象,即红黑树的结点。epitem被添加到rbr中,该结构保存了epoll监视的文件描述符。

4、ep_getfd()

ep_getfd()函数的主要功能是为fd,inode节点,file赋予值。函数原型如下:

error = ep_getfd(&fd, &inode, &file);//调用处
static int ep_getfd(int *efd, struct inode **einode, struct file **efile);//函数实现处

可以看见在调用该函数的时候参数全传的是地址,那么可以猜想在该函数内部肯定是要对这三个变量的值进行改变的。

5、 ep_file_init()

ep_file_init()函数的主要功能是将struct file结构体与另外一个很重要的结构体建立关系,并创建内核事件表。

  • 同样在函数内主要定义了fd,inode,file变量。
  • 调用file = get_empty_filp();函数。源码对其功能描述为:get_empty_filp为pipefs文件系统中的管道分配一个索引节点对象并对其进行初始化。也就是获得一个未使用的文件缓存空间即file结构体。在get_empty_filp()函数内,采用专用高速缓存为struct file分配空间。
  • inode = ep_eventpoll_inode(); 为其分配一个文件节点
  • error = get_unused_fd(); 用来获取一个未使用过的文件描述符。获取就是需要查找到最小的未被使用的文件描述符。
  • 接下来进行一系列的初始化
  • fd_install(fd, file); 将fd加入文件表,也就是以文件描述符fd为索引,将当前文件描述符和上述的struct file结构体关联在一起。
  • 将fd,inode,file分别赋值给传进来的参数,所以最终对这三个变量都进行了修改。
error = ep_file_init(file);//函数调用处
static int ep_file_init(struct file *file);//函数实现
  • 在此函数中只新定义了一个结构体变量struct eventpoll *ep
  • 调用kamlloc通用高速缓存为ep分配内存空间,分配大小为sizeof(struct eventpoll)。
  • 接下来执行一系列的初始化
  • file->private_data = ep; 就是这句代码将struct file结构体和struct eventpoll结构体建立关系

6、总结

上述步骤是sys_epoll_create()中比较重要的步骤,执行成功后返回fd,后续函数可以用fd来找到内核事件表并进行相应操作。

其各结构体之间的关系可以先表示为:

epoll源码剖析(Linux Kernel 2.6.11)_第4张图片

 


epoll_ctl()

epoll_ctl用来操作epoll的内核事件表,可以对内核事件表进行添加、修改和删除。函数源码中的原型如下:

sys_epoll_ctl(int epfd, int op, int fd, struct epoll_event __user *event);

1、参数:

  • epfd:就是用来找到内核事件表的文件描述符,即epoll_creat的返回值。
  • op:用户要进行的操作,操作类型有三种。

       ①EPOLL_CTL_ADD,往事件表中注册fd上的事件

       ②EPOLL_CTL_MOD,修改fd上的注册事件

       ③EPOLL_CTL_DEL,删除fd上的注册事件

  • fd:用户要求执行操作的文件描述符
  • event:用户描述符上感兴趣的事件

2、epoll_ctl函数执行过程:

  • 首先定义了struct file的两个结构体变量*file(用于内核事件表),*tfile(用于用户fd);struct eventpoll *ep;struct epitem *epi(红黑树的结点); struct epoll_event epds;
  • 调用file = fget(epfd),得到epfd的struct file结构体;调用tfile = fget(fd),得到用户fd的struct file结构体
  • ep = file->private_data,通过ep来可以指向struct file结构体
  • epi = ep_find(ep, tfile, fd),在内核事件表中查重,判断用户要操作的文件描述符是否已经被添加到内核事件表。
  • 根据传入参数op的不同进入不同的case语句。

3、ep_insert()

 ep_insert()函数原型如下:

error = ep_insert(ep, &epds, tfile, fd);//函数调用处
static int ep_insert(struct eventpoll *ep, struct epoll_event *event,
		     struct file *tfile, int fd);//实现
  • 在op为EPOLL_CTL_ADD并且判断fd没有被添加至内核事件表时,才进入ep_insert()。
  • 进入ep_insert()之后,定义了节点epi,和struct ep_pqueue epq。ep_pqueue内部有一个指向节点的指针,另外一个则是一个函数指针,用于后面回调函数。所以ep_pqueue主要完成epitem和callback函数的关联,然后通过目标文件的poll函数调用回调函数ep_ptable_queue_proc。poll函数则一般由设备驱动提供。

epoll源码剖析(Linux Kernel 2.6.11)_第5张图片

  • 给epi分配空间,并初始化epi的各成员。然后将epq中的epi指针置为当前节点epi通过 调用EP_SET_FFD() 函数将目标文件和 epitem 关联
  • 注册设备驱动poll的回调函数ep_ptable_queue_proc:init_poll_funcptr(&epq.pt, ep_ptable_queue_proc)。当调用f_op->poll()时,最终会调用该回调函数ep_ptable_queue_proc()。

       ①可以看到在init_poll_funcptr函数中,将ep_ptable_queue_proc函数赋值给了epq中的函数指针,

           即接下来调用ep_ptable_queue_proc()函数。

       ②在ep_ptable_queue_proc回调函数中,注册回调函数ep_poll_callbackep_poll_callback表示当描述符fd上相应的事件发生时该               如何通知该进程。在ep_ptable_queue_proc回调函数中,检测文件描述符fd对应的设备的epoll_event事件是否发生,如果发生则把            fd 及其epoll_event加入上面提到的就绪队列rdlist中。

       ③在ep_poll_callback()中,当事件就绪后,执行该回调函数,把描述符添加到rdllist。

       ④在整个回调函数实现中,还有另外一个很重要的结构体struct eppoll_entry *pwq。eppoll_entry主要用于处理 epitem 和 epitem上 事            件发生时的回调函数之间的关联。

epoll源码剖析(Linux Kernel 2.6.11)_第6张图片

 

     它的实现步骤如下:

          a.首先将eppoll_entry的whead指向fd的设备等待队列。

          b.然后初始化eppoll_entry的base变量指向epitem。

          c.通过add_wait_queue()函数将epoll_entry挂载到目标文件fd的设备等待队列waitlist上。  

          d.最后通过list_add_tail()函数将eppoll_entry挂载到epitem的pwqlist上面。
 

  • 再回到insert中,revents = tfile->f_op->poll(tfile, &epq.pt)。调用被监控文件的poll方法,而这个poll其实就是调用poll_wait(每个支持poll的设备驱动程序都要调用的),最后就是再来调用ep_ptable_queue_proc函数。接下来就是将epitem 的 fllink 链接到目标文件的 f_ep_links上,这部分工作将在poll函数返回后在ep_insert()中完成。
  • 调用ep_rbtree_insert(ep, epi),将描述符和事件添加到内核事件表中。

  • 完成以上调用后,将还会判断刚刚添加的的event是否刚好发生,如果是,那么做一个ready动作,将epitem加入到就绪队列rdlist中,并唤醒epoll上的等待队列

  • 至此,添加完成。


epoll_wait()

      epoll_wait() 用来获取就绪描述符,函数成功时返回就绪的文件描述符的个数。函数原型如下:

asmlinkage long sys_epoll_wait(int epfd, struct epoll_event __user *events,
			       int maxevents, int timeout)

1、参数:

  • epfd:就是用来找到内核事件表的文件描述符,即epoll_creat的返回值。
  • events:指定要监听的事件
  • maxevents:指定最多监听事件的个数,和epoll_create()函数中的size一样,大于0即可。因为在wait中也会对其进行判断。
  • timeout:超时时间

2、epoll_wait函数执行过程:

  • 首先检测参数的合法性,接下来通过epfd找到相对应的struct file结构体,然后再建立struct file和ep的关系
  • 调用ep_poll()函数实现最终的所有操作。

3、ep_poll()

函数原型如下:

error = ep_poll(ep, events, maxevents, timeout);//调用处
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
		   int maxevents, long timeout);//实现
  • 进入该函数后,首先会判断是否超时。接着判断就绪队列是否为空。
  • 如果为空,那就要等到有事件就绪时,才能被回调函数唤醒。执行调用 init_waitqueue_entry() 函数将current 进程与wait关联。然后调用 add_wait_queue() 函数将 current 进程加入到 eventpoll 的 waitlist(wq) 等待队列中。接着在一个死循环中循环检测rdllist上是否有就绪或者是否超时,如果有则break出循环,设置状态为TASK_RUNNING,并将current进程通过remove_wait_queue()移除出等待队列。同时在这个循环中进行状态设置、数据检测等工作,通过set_current_state()将task的状态设置为TASK_INTERRUPTIBLE,表示可中断的,并通过schedule_timeout让出处理器。
  • 非空的情况,在源码中并没有实现。但是越过if语句往下执行,有可能是一开始有就绪事件,也有可能是刚开始为空,然后在上面的for循环中因为超时跳出循环了。所以接下来又调用了eavail = !list_empty(&ep->rdllist),来判断此时是否有事件就绪。
  • if (!res && eavail && !(res = ep_events_transfer(ep, events, maxevents)) && jtimeout),这句话在源码的实现中写的非常巧妙。因为它通过多个值和表达式的判断来决定到底是继续执行上面的代码还是返回给用户就绪描述符的个数。
  • ep_events_transfer()函数,将数据拷贝到用户空间,最后返回拷贝到用户空间文件描述符的数量。

ep_events_transfer()函数原型:

static int ep_events_transfer(struct eventpoll *ep,
			      struct epoll_event __user *events, int maxevents);

ep_events_transfer函数将rdllist中的就绪事件拷贝到它定义的另外一个结构体变量txlist中,并将rdllist清空。

①首先调用 ep_collect_ready_items()函数从rdllist中收集最多maxevent个元素到txlist中。每向txlist拷贝一个元素,从rdllist中将其删除,最终rdllist被清空。如果ep_collect_ready_items()函数返回为真时,则调用ep_send_events()函数和ep_reinject_items()函数。
②调用 ep_send_events()函数,变量txlist,将有事件就绪的描述符拷贝到用户空间,实际返回的是实际拷贝的数目。
③调用 ep_reinject_items()函数,在该函数中就要判断当前事件到底是工作于ET还是LT模式。如果是LT模式,就将描述符返还给rdlist,以便于再下一次有事件就绪时可以继续提醒用户。

 

 

 

 

 

 

 

 

你可能感兴趣的:(Linux)