select、poll、epoll -IO模型

前置知识:

  1. 文件描述符

    文件描述符其实就是一个数字代表的数据结构

    另外每个进程一旦创建都有三个自己默认的文件描述符 0u(标准输入) 1u(标准输出) 2u(报错信息输出),u代表读写都可以。

    在/proc下有进程相关的信息,在/proc/进程pid/fd下有该进程正在使用的文件描述符

    每个进程都有自己的文件描述符,因为进程的隔离,所以不同进程维护的各自的文件描述符可以是重复的,也就是说不同进程的相同的文件描述符可以指向不同的文件

  2. 中断
    image

    • CPL寄存器和DPL寄存器和RPL寄存器

      为了安全考虑,用户段的空间不能随意访问内核段的空间。怎样实现这样的控制呢?

      CPL是当前进程的权限级别(Current Privilege Level),是当前正在执行的代码所在的段的特权级,存在于cs寄存器的低两位。

      RPL说明的是进程对段访问的请求权限(Request Privilege Level),是对于段选择子而言的,每个段选择子有自己的RPL,它说明的是进程对段访问的请求权限,有点像函数参数。而且RPL对每个段来说不是固定的,两次访问同一段时的RPL可以不同。RPL可能会削弱CPL的作用,例如当前CPL=0的进程要访问一个数据段,它把段选择符中的RPL设为3,这样虽然它对该段仍然只有特权为3的访问权限。

      DPL存储在段描述符中,规定访问该段的权限级别(Descriptor Privilege Level),每个段的DPL固定。

      当进程访问一个段时,需要进程特权级检查,一般要求DPL >= max {CPL, RPL}

      下面打一个比方,中国官员分为6级国家主席1、总理2、省长3、市长4、县长5、乡长6,假设我是当前进程,级别总理(CPL=2),我去聊城市(DPL=4)考察(呵呵),我用省长的级别(RPL=3 这样也能吓死他们:-))去访问,可以吧,如果我用县长的级别,人家就不理咱了(你看看电视上的微服私访,呵呵),明白了吧!为什么采用RPL,是考虑到安全的问题,就好像你明明对一个文件用有写权限,为什么用只读打开它呢,还不是为了安全!

      数字越小级别越大

      当DPL >= CPL才能访问内核段的内存空间

      内核空间的级别权限为0,用户态地址空间的权限级别为3

    • int 0x80指令

      所有的指令中,只有 int 0x80 指令 可以把CPL设置为0、DPL设置为3,然后才可以访问内核空间

      系统调用通过int 0x80中断陷入核心,int 0x80的处理函数是system_call

    • echo $$或echo $BASHPID 命令查看当前进程pid。

    • export命令可以在父子shell间通信,export是将自定义变量变成系统环境变量

      1. 执行脚本时是在一个子shell环境运行的,脚本执行完后该子shell自动退出
      2. 一个shell中的系统环境变量才会被复制到子 shell中(用export定义的变量)
      3. 一个shell中的系统环境变量只对该shell或者它的子shell有效,该shell结束时变量消失 (并不能返回到父shell中)
      4. 不用export定义的变量只对该shell有效,对子shell也是无效的。
      echo $$ #查看自己的ID号, 假设他现在是100
      x=100   # 在父进程中定义x变量
      export x # 导出X
      
      /bin/bash #在当前bash窗口里启动一个新的bash 此时新bash是刚才100进程的子进程
      echo $x  #此时子进程可以取到访问到x变量
      
  3. 网络IO

    怎么设计一个可以供多个客户端同时连接,并且能处理这些客户端传来的请求?一个简单的想法就是设置多个线程,每来一个请求就开一个线程处理,但是多线程是需要进行上下文切换的,也是非常耗时的。先来看下多线程为什么高效。

image

多线程是cpu分时进行线程执行的,那么如果不分时,单线程执行,反正总共干的事情就那么多,单线程还免去了线程上下文切换,那单线程不应该比多线程快吗?是因为线程执行时,并不总是cpu一直在工作,还有I/O的时间。

DMA(直接内存访问):

在文件读取的过程中,cpu并不直接操作硬盘,而是先向DMA下达指令,让DMA去读取,这个指令中含有磁盘设备信息和要读取的文件的位置,然后DMA将磁盘中的内容加载到内存中,然后DMA以中断的方式通知cpu,文件读取完成。而在cpu等待DMA完成时就可以将cpu分配给其他线程。

select:

源码image

上半部分主要创建了socket客户端,并创建了五个文件描述符,并放入fds数组里,然后遍历fds数组,弄一个bitmap(就是那个rset),比如这几个文件描述符分别是1 2 5 7 9,就在对应位置置1,然后调用select函数,把bitmap拷贝到内核空间,让内核判断哪个fd是否有数据来,select函数是阻塞的,当没有消息来的时候会一直阻塞,有消息后会将有消息对应的bitmap位置位,然后下面for循环遍历一遍,看看哪个文件描述符对应的有消息,然后处理。

缺点:

  • bitmap大小一般是1024,有上限,能监听的socket有限
  • 他那个bitmap不可重用,因为内核会进行置位,所以每次while循环FD_ZERO(&reset)重置
  • 有用户态到内核态的开销
  • 最后还有O(n)遍历,并不知道哪个fd有消息来

poll:

poll源码image

poll没有直接使用fds数组存放fd,而是定义了一个结构体pollfd,里面有fd(文件描述符),events(对哪个事件关心,比如图中的POLLIN),revents(置位用的),工作原理和select差不多,将polldfs数组拷贝到内核态,然后由内核判断哪个fd有消息到来,poll是阻塞的,然后对有消息到来的pollfd的revents置位,然后for循环遍历查找处理。

poll相比select的优点:

  1. 没有用bitmap,用了结构体数组,能监控的socket数量更多了
  2. 对revents置位,使得每次while循环,pollfds数组是可重用的。

epoll:

epoll源码image

上面的代码先用epoll_create()向内核申请空间,里面的参数10就是能监听的最大socket fd数,然后for循环里给epoll_event结构体里的成员赋值,然后通过epoll_ctl()函数,将这些结构体加入到内核中的红黑树里,通过对这棵黑红树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合(bitmap中的1,和pollfd中有值的events),只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。

然后epoll 使用事件驱动的机制,**内核里维护了一个链表来记录就绪事件**,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

image

然后要注意对于那个链表,并没有用共享内存,还是需要将数据拷贝到用户空间的

epoll 支持两种事件触发模式,分别是边缘触发和水平触发。

边缘触发就是如果有事件发生,系统只会通知你一次,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的(阻塞型socket),没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用

水平触发就是只要有事件需要读,系统就会一直通知你,如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。

select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。

posted @ 2022-02-10 09:40  YUKINO62  阅读(38)  评论(0编辑  收藏  举报