三者均为I/O多路复用,即通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪,能够通知程序进行相应的读写操作。
但三者本质上均为同步IO,都需要在读写事件就绪后自己负责读写;
异步IO无需自己负责读写,其负责将数据从内核拷贝至用户空间

select

本质上通过【设置或检查存放fd的数据结构(数组)】来进行下一步操作。

select poll epoll区别 - 图1

工作流程详解

  1. 从用户空间拷贝fd_set到内核空间
  2. 注册回调函数__pollwait
  3. 遍历fd_set调用其对应poll方法(对于socket来说,为sock_poll,会根据情况调用tcp_poll,udp_polldatagram_poll)

    select poll epoll区别 - 图2 当进程A创建socket时,操作系统会创建一个由文件系统管理的socket对象,包含了发送缓冲区、接受缓冲区及等待队列;

    • 等待队列:指向所有需要等待该socket事件的进程

    ps:操作系统添加等待队列,只是添加了对这个“等待中”进程的引用

    1. __pollwait工作:把current挂到设备(对应socket)的等待队列中,不同的设备有不同的等待队列,对于tcp_poll来说,其等待队列是sk->sk_sleep(注意:把进程挂到等待队列并不代表进程睡眠!)。在设备收到一条消息(网络设备)或文件数据写入(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,此时current被唤醒;
  4. poll方法返回一个描述符就绪的mask掩码,给fd_set赋值
  5. 若遍历完fd,未返回mask掩码,则将调用select进程暂挂(进入睡眠)。若超过一定时间未唤醒,则该进程会被从新唤醒获得CPU,重复上述过程;
  6. 4步骤之后,把设置好的fd_set由内核态拷贝至用户态

    结论(缺点)

  • 每次调用select,都需要用户态和内核态之间的两次拷贝!
  • 每次调用select,都需要在内核态遍历所有fd

    Poll

    将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。

Epoll

select poll epoll区别 - 图3

讲概念

epoll是IO多路复用的一种,其他包括select和poll。IO可以分为三种,分别是阻塞IO、非阻塞IO、IO多路复用。
IO多路复用指的是可以同时阻塞多个IO操作,而且可以同时对多个读操作和写操作进行检测。

如何使用epoll

主要有如下3个函数服务epoll机制;

  • 调用epoll_create,其会在epoll文件系统中创建file对象,并在内核中开辟cache用于存储红黑树和双向链表。

select poll epoll区别 - 图4
左上角的图修正:实为双向链表(rdlist(就绪列表))!

  • 调用epoll_ctl,会将socket添加epoll文件系统创建的file对象的红黑树上,还会给内核中断处理程序注册一个回调函数,当该句柄的中断事件发生,则将其添加至file对象的双向链表上。
  • 调用epoll_wait,当有事件发生时,则返回事件发生的次数,事件的句柄存储在双向链表上;若epoll_wait无事件发生,则一直阻塞或等待一段时间后返回0,或被中断。

    说说epoll的触发模式

    epoll触发模式主要分为LT和ET,LT为水平触发模式,是默认模式,ET为边沿触发模式。
    LT和ET的区别便在触发机制上,LT模式很简单,我来先讲讲LT模式把。

    LT模式下的触发方式

    LT水平触发,顾名思义,只要缓冲区里有数据,就回触发epoll_wait返回。主要的步骤逻辑是:
    对于读事件,若缓冲区第一次没读完,LT模式下内核会频繁检测该句柄状态,若检测到该句柄未读完,则将其从新加入EPOLL的双向链表中,状态为读就绪。
    对于写事件,若缓冲区一次没写完,即缓冲区中还剩下数据,则将其新加入EPOLL的双向链表中,状态为写就绪。

    ET模式下的触发方式

    ET模式,确实比较难理解,linux 自带的manual中也没有将ET模式的触发问题叙述清楚。一般来说,ET的触发模式如下:
    对于读事件,当缓冲区由空到非空时,读事件触发;
    对于写事件,当缓冲区由满到不满时,写事件触发;
    但经过我的代码测试,还有如下两种情况会触发ET模式下的事件,我的代码时在内核版本为4.18的机器上运行的。
    对于读事件,当缓冲区数据变多时,读事件触发;
    对于写事件,当缓冲区数据变少时,写事件触发;
    我简单描述一下我的代码实现:

    • 创建一个客户端和一个服务器
    • 客户端往缓冲区中写入8个字节数据,比如aaaabbbb
    • 服务器从缓冲区每次拿4个字节数据,服务器通过epoll实现,注册了客户端的连接描述符

从我的测试结果来看,客户端每次写时,服务器才拿数据,说明了,
对于读事件,当缓冲区数据增多时,读事件会触发。

epoll为什么高效

  • 减少了用户态和内核态的文件句柄拷贝(共享内存实现)
  • 减少了对可读、可写句柄的遍历(双向链表)

mmap加速了内核与用户空间的信息传递,epoll通过内核与用户mmap同一块内存,避免了无谓的内存拷贝。IO性能不会随着监听的文件描述符数量增加而下降
使用红黑树来存储注册的句柄,增删的性能都非常好

mmap深度剖析

【深入浅出Linux】关于mmap的解析

mmap:在进程虚拟地址空间分配地址空间,创建和物理内存的映射关系。

  • 映射关系
    • 文件映射:磁盘文件映射进程的虚拟空间,使文件内容初始化物理内存。
    • 匿名映射:初始化全为0的内存空间
  • 映射关系是否共享
    • 私有映射:多进程数据共享,修改不反映到磁盘文件【写时复制】
    • 共享映射:多进程数据共享,修改反映到磁盘文件。
  • 总结(四种组合)
    • 私有文件映射:多个进程共享相同的物理页,但各个进程对内存文件的修改不会共享,也不会反映到磁盘文件
    • 私有匿名映射:mmap会创建一个新的映射(虚拟地址到物理地址),各个进程不共享,这种使用主要用于分配内存(malloc分配大内存会调用mmap)。例如开辟新进程时,会为每个进程分配虚拟的地址空间,这些虚拟地址映射的物理内存空间各个进程间【读共享、写复制】
    • 共享文件映射:多个进程共享同样的物理内存空间,对内存空间的修改反映到实际文件中,【IPC的一种】
    • 共享匿名映射:这种机制在fork时不会【写时复制】,父子进程完全共享相同的物理内存也,实现了父子进程间通信【IPC】
  • 注意:

    • mmap只是在虚拟内存分配了空间,只有在第一次访问虚拟内存时才分配物理页

      在mmap之后,并没有将文件内容加载到物理页上,仅在虚拟内存中分配了地址空间。当进程在访问这段虚拟地址映射的物理地址时,通过查找页表,发现虚拟地址对应的页没有在物理内存中缓存,触发“缺页中断”,由内核的缺页异常处理程序处理,将该文件内容,以页为单位(4K)加载到物理内存。

  • mmap在write和read时的情况

    • write
      • 进程(用户态)将需要写入的数据copy到对应的mmap地址(内存copy)
      • 若mmap未对应物理内存,则产生缺页异常,由内核处理
      • 若对应,则直接copy到对应内存
      • 由操作系统调用,将脏页写回磁盘(一般异步)
    • readselect poll epoll区别 - 图5
      • 图1,由于进程无法直接访问内核,内核需要将数据copy给用户态的buffer
      • 图2,mmap之后,进程可以直接访问mmap的数据

参考:
【深入浅出Linux】关于mmap的解析

总结(对比)

epoll在select和poll(poll和select基本一样,有少量改进)的基础引入了eventpoll作为中间层,使用了先进的数据结构,是一种高效的多路复用技术。

select poll epoll
性能 O(N) O(N) O(1)
连接数 1024,可修改FD_SETSIZE修改 底层为链表,无限制 无限制
内在处理机制 线性轮询(无差别轮询) 线性轮询 回调callback
开发复杂度
消息传递 内核态将消息传递至用户态(每次调用2次拷贝,切换) 同左 mmap:内核和用户空间共享一块内存实现
缺点
- 打开文件描述符限制1024
- 轮询效率低
- 维护存放大量fd的数据结构,用户空间和内核空间在传递该结构时复制开销大

- 大量fd被整体复制于用户态于内核态之间
- 水平触发
- 轮询效率低

- fd数量少且活跃时性能不好(回调函数拉低性能)
- 不支持跨平台