• select 从应用层到内核实现解析


      在一个应用中,如果需要读取多个设备文件,这其中有多种实现方式:

      1、使用一个进程,并采用同步查询机制,不停的去轮询每一个设备描述符,当设备描述符不可用时,进程睡眠。

      2:使用多个进程或者线程分别读取一个描述符,描述符不可用则进程或者线程睡眠。

      3、使用select或者poll机制,这是一种多路IO复用机制。

    第一种方法的缺点是,当进程在一个描述符上睡眠时,即使有其他描述符已经就绪,进程也不会醒来,这影响了程序的效率。第二种方法可以解决方法一中的问题,但是复杂性提高了,进程间切换或者同步带来复杂性的同时也会影响效率。第三种方法算是一种折中的方案,兼顾了效率和复杂性。

      select函数的原型如下:

      int select(int n,fd_set * readfds,fd_set * writefds,fd_set * exceptfds,struct timeval * timeout);

    参数readfds,writefds,exceptfds是设备描述符位映射数组,用来传入待监视的设备描述符,准备好的设备描述符也用这些数组传出。timeout为超时参数,如果这个参数为NULL,则select会一直阻塞,直到有设备准备就绪,如果timeout结构中的时间参数都为0,则select不会阻塞,相当于不停的查询,有准备就绪的设备就进行操作,没有的话就立刻返回,如果timeout结构内是正数,则在没有设备准备就绪的情况下,select最多会阻塞timeout时间,然后返回,如果在这期间有设备准备就绪了,即使没有到达timeout时间,也会立即返回。n为最大描述符加1,描述符fd是一个整形数,例如最大描述符fd=100,则n需要传入101。假设readfds是一个32位的映射数组,我们要监视的读设备描述符为2和5,则传入的readfds为 01001000 00000000 00000000 00000000。传入的n就为6。

      select的整体调用流程如下:

      select() -> core_sys_select() -> do_select() -> fop->poll()

      select进入内核后,内核会使用copy_from_user将三个位映射数组拷贝到内核中,内核中有三个比较重要的结构:

      struct poll_wqueues、struct poll_table_page、struct poll_table_entry、struct poll_table_struct。

      每一次select进入内核,进程都会创建一个poll_wqueues结构,这个结构用来辅助完成这次select调用中待监测fd的轮询工作起着一个统领的作用,具体如下:

     1 struct poll_wqueues {
     2 
     3        poll_table pt;
     4 
     5        struct poll_table_page *table;
     6 
     7        struct task_struct *polling_task; //保存当前调用select的用户进程struct task_struct结构体
     8 
     9        int triggered;         // 当前用户进程被唤醒后置成1,以免该进程接着进睡眠
    10 
    11        int error;               // 错误码
    12 
    13        int inline_index;   // 数组inline_entries的引用下标
    14 
    15        struct poll_table_entry inline_entries[N_INLINE_POLL_ENTRIES];
    16 
    17 };

    select在进入内核后会进行一系列的初始化,具体步骤如下:

    步骤1:

      创建poll_wqueues结构实例(在do_select中创建),对这个实例中的成员先做部分初始化,例如给poll_table成员中的qproc注册回调函数,这是通过下面的函数实现的:

    1 void poll_initwait(struct poll_wqueues *pwq)
    2 {
    3     init_poll_funcptr(&pwq->pt, __pollwait);
    4     pwq->polling_task = current;
    5     pwq->triggered = 0;
    6     pwq->error = 0;
    7     pwq->table = NULL;
    8     pwq->inline_index = 0;
    9 }

    其中注册的回调函数为__pollwait,如下:

     1 static void __pollwait(struct file *filp, wait_queue_head_t *wait_address,
     2                 poll_table *p)
     3 {
     4     struct poll_wqueues *pwq = container_of(p, struct poll_wqueues, pt);
     5     struct poll_table_entry *entry = poll_get_entry(pwq);
     6     if (!entry)
     7         return;
     8     get_file(filp);
     9     entry->filp = filp;
    10     entry->wait_address = wait_address;
    11     entry->key = p->key;
    12     init_waitqueue_func_entry(&entry->wait, pollwake);
    13     entry->wait.private = pwq;
    14     add_wait_queue(wait_address, &entry->wait); // 把p中的entry->wait加入到等待队列
    15 }

      

      可以看到这个函数暂时只初始化了一部分成员,struct poll_table_page *table和struct poll_table_entry inline_entries[N_INLINE_POLL_ENTRIES]还没有做任何改变,下面就会看到这两个成员也会写入相应的信息。接着往下看。

    步骤2:

      内核根据传入的n值和各个位映射数组,使用了几个循环,大概完成以下操作,调用每一个fd所对应的驱动中的poll函数,在poll函数中最终会调用到poll_wait,这个函数将当前进程加入到设备的等待队列中,加入的同时会及时检查设备的状态,并进行记录,调用完每一个设备的poll_wait之后。如果发现有设备就绪了,则可以立刻返回了,后面会讲到这个函数的具体操作,这个函数的原型如下:

    1 static inline void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p)
    2 {
    3     if (p && wait_address)
    4         p->qproc(filp, wait_address, p); //是在do_select中的poll_init_wait中为__pollwait
    5 }

    真正调用poll_wait时大概是这种形式:

    poll_wait(filp, &dev->r_wait, wait);

    dev->r_wait为所在驱动程序的读队列头,每个设备的驱动程序包含多个队列头,例如:读队列头、写队列头、异常队列头。

    poll_wait函数最终会调用到我们在步骤1中注册的回调函数__pollwait。

    这个函数有三个参数,其中filp是某个所监视的fd对应的file结构指针,dev->r_wait是这个设备描述符fd对应的驱动程序中的读等待队列,wait便是上面提到的辅助结构poll_wqueues中的poll_table成员。

      再来梳理一下,在for循环中会调用驱动程序的poll函数,然后会调用到poll_wait,然后进一步调用到__pollwait,在__pollwait函数中根据传入的poll_table指针先算出poll__wqueues地址(这在linux内核中是一个很常用的技巧),得到了poll_wqueues的地址后,就可以进行具体的操作了,目的只有一个,就是为每一个fd的每一个监视事件初始化一个poll_table_entry结构,初始化的过程中会涉及到对以下两个成员的操作:

      struct poll_table_page *table

      struct poll_table_entry inline_entries[N_INLINE_POLL_ENTRIES]

      具体有哪些操作呢? 首先填充inline_entries中的一项,每个fd的每个监视事件对应inline_entries中的一项,也就是struct poll_table_entry结构的一个实例,例如对于一个fd=5来说,监视它的读事件和写事件,这就对应两个struct poll_table_entry实例,也就是每调用一次poll_wait就会初始化一个struct poll_table_entry实例。

      但是inline_entries数组又是有限的,所以当这个数组用完后,如果还有fd的事件需要监视,就申请一个struct poll_table_page结构挂在table指针上,这个结构具体如下:

     1 struct poll_table_page { // 申请的物理页都会将起始地址强制转换成该结构体指针
     2 
     3        struct poll_table_page * next;     // 指向下一个申请的物理页
     4 
     5        struct poll_table_entry * entry; // 指向entries[]中首个待分配(空的) poll_table_entry地址
     6 
     7        struct poll_table_entry entries[0]; // 该page页后面剩余的空间都是待分配的
     8 
     9 //  poll_table_entry结构体
    10 
    11 };

      申请完struct poll_table_page后,然后申请poll_table_entry结构挂在里面,poll_table_entry结构如下:

     1 struct poll_table_entry {
     2 
     3        struct file *filp;            // 指向特定fd对应的file结构体;
     4 
     5        unsigned long key;              // 等待特定fd对应硬件设备的事件掩码,如POLLIN、
     6 
     7 //  POLLOUT、POLLERR;
     8 
     9        wait_queue_t wait;             // 代表调用select()的应用进程,等待在fd对应设备的特定事件
    10 
    11 //  (读或者写)的等待队列头上,的等待队列项;
    12 
    13        wait_queue_head_t *wait_address; // 设备驱动程序中特定事件的等待队列头;
    14 
    15 };

       poll_table_entry结构的初始化包括,将当前fd对应的file结构体填入filp成员,构造等待队列项并填入wait成员(这是通过init_waitqueue_func_entry(&entry->wait, pollwake)函数完成的,同时也注册了唤醒回调函数pollwake),将驱动程序的对应的等待队列头填入wait_address成员。

     步骤3:

      通过add_wait_queue(wait_address, &entry->wait)将上面构造的等待队列项,加入到这个fd所对应的驱动程序中的等待队列中,这个等待队列项包含当前进程的信息。

      当for循环执行完,包含当前进程的等待队列元素会加入到所有待监视的fd的驱动程序的等待队列中,这样的话,不管哪一个fd准备就绪了,都会唤醒当前进程。当前进程被唤醒后,会再执行一次驱动中的poll,检查是否有其他设备准备就绪,最后根据所有就绪设备的fd设置对应的位图,并拷贝回用户空间。例如发现fd=5的读准备就绪了,就将readfds中的对应位置位,如果fd=2的写准备就绪了,就将writefds中的相应位置位。最终返回的是准备就绪的描述符的数量。

      select返回之前,会将当前进程从设备队列中脱链。

       放一张总体的框图:

      select存在一些缺点:

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

    2、同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大,每个fd_set是1024,则三个就是3072

    3、select支持的描述符的数量比较小,默认是1024,即数据类型fd_set的大小

    4、select每次返回时,readfds和writefds都将相应的准备就绪的fd对应的位置位,下次调用时,需要重新初始化这些参数。

    5、select返回的只是准备就绪的描述符的数量,具体哪一个描述符准备好了还需要应用程序一个一个进行判断。

    6、select每次返回后都会将poll_wqueues结构销毁,下次调用时还要重新申请,并重新初始化。

    7、select只能监视读、写、异常事件,不够精细,属于粗放型的,poll系统调用可以更精细化。带外数据属于异常事件,如果是在poll中则为POLLRDBAND。

    总结:

      select的调用过程:陷入内核,构造辅助数据结构poll_wqueues,第一次执行poll_wait,为每一个待监视的设备描述符fd构造一个poll_table_entry加入到poll_w_queues中,并将当前进程加入到驱动程序的相应的等待队列中,同时记录下每个设备状态,循环完所有的fd之后,如果有设备就绪,则可以立刻返回,否则进入睡眠。被唤醒时,还会再循环一次所有设备的驱动中的poll函数,不管是设备准备就绪唤醒的还是超时时间到唤醒的都会执行这个操作,只是这次不会再有将进程加入队列的操作。这次同样记录下每一个设备的状态,并拷贝到用户空间中,然后将当前进程从队列中脱链,然后返回到用户空间。

  • 相关阅读:
    有功功率和无功功率
    变压器的一些知识点
    服创大赛_思考
    AndroidsStudio_找Bug
    服创大赛第一次讨论_2019-1-14
    AndroidStudio_TextView
    JVM 专题十三:运行时数据区(八)直接内存
    JVM 专题十二:运行时数据区(七)对象的实例化内存布局与访问定位
    JVM 专题十一:运行时数据区(六)方法区
    JVM 专题十:运行时数据区(五)堆
  • 原文地址:https://www.cnblogs.com/wanmeishenghuo/p/9286475.html
Copyright © 2020-2023  润新知