Linux下io多路复用api系统调用实例总结

 
Category: Linux-Shell

写在前面

select: 选择

用途: 在一段指定时间内, 监听用户感兴趣的文件描述符上的可读可写和异常事件.

#include <sys/select.h>

int select(int nfds, fd_set* readfds, fd_set* write_fds, fd_set* exceptfds, struct timeval* timeout);

poll: 轮询

select和poll的缺点

epoll: 基于事件的轮询

水平触发和边缘触发

  • 水平触发通知(LT, Level Trigger): 如果文件描述符上可以非阻塞地执行I/O系统调用, 此时认为它已经就绪.
  • 边缘触发通知(ET, Edge Trigger): 如果文件描述符自上次状态检查依赖有了新的I/O活动( 比如新的输入), 则需要触发通知.

其中, LT是epoll默认的工作模式, 这种模式下epoll相当于一个效率较高的poll.

当向epoll内核事件表中注册一个文件描述符上的EPOLLET事件时, epoll将以ET模式操作文件描述符, 这是高效工作模式.

关于读事件,如果业务可以保证每次都可以读完,那就可以使用ET,否则使用LT。对于写事件,如果一次性可以写完那就可以使用LT,写完删除写事件就可以了;但是如果写的数据很大也不在意延迟,那么就可以使用ET,因为ET可以保证在发送缓冲区变为空时才再次通知(而LT则是发送缓冲区空了就可以通知就绪,这样就每次触发就只能写一点点数据,内核切换开销以及内存拷贝开销过大)

作者:心痕 链接:https://www.zhihu.com/question/272447529/answer/1414142223 来源:知乎 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

优点

  1. 当检查大量文件描述符时, epoll性能延展性比select和poll高很多
  2. epoll() 既支持水平触发又支持边缘触发, 而select/poll仅支持水平触发, 信号驱动I/O只支持边缘触发
  3. 性能方面: epoll表现与信号驱动I/O类似, 但是epoll有一些胜过信号驱动I/O的点:
    • 避免复杂的信号处理流程, 例如信号队列溢出处理
    • 灵活性高, 可以指定希望检查的事件类型, 例如, 检查套接字文件描述符的读就绪, 写就绪或同时指定.

epoll实例

与一个打开的文件描述符关联, 文件描述符不是做I/O操作的, 而是内核数据结构的句柄(handle), 两个作用:

  • 记录了在进程中声明过得感兴趣的文件描述符列表: interest list
  • 维护处于I/O就绪状态的文件描述符列表: ready list(就绪列表中的成员是兴趣列表的子集)

epoll主要系统调用

#include <sys/epoll.h>
  • epoll_create: 创建一个epoll实例, 返回代表该实例的文件描述符.
    int epoll_create(int size);//success: fd, error:-1
    
  • epoll_ctl: 操作同epoll实例相关联的兴趣列表, 具体来说:

    • 增加新的描述符到列表中
    • 将已有的文件描述符从该列表中删除
    • 修改代表文件描述符上事件类型的位掩码
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev); // 0:success, -1:error
    
  • epoll_wait: 返回与epoll实例相关的就绪列表中的成员
    int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
    //ready fd:success, 0:timeout, -1: error
    

深入探究epoll语义

文件描述符和打开的文件之间的关系

  1. 在进程A中,文件描述符1和20都指向同一个打开的文件句柄(标号为23)。这可能是通过调用dup()、dup2()或fcntl()而形成的
  2. 进程A的文件描述符2和进程B的文件描述符2都指向同一个打开的文件句柄(标号为73)。这种情形可能在调用fork()后出现(即,进程A与进程B之间是父子关系),或者当某进程通过UNIX域套接字将一个打开的文件描述符传递给另一进程时,也会发生。
  3. 进程A的描述符0和进程B的描述符3分别指向不同的打开文件句柄,但这些句柄均指向i-node表中的相同条目(1976),换言之,指向同一文件。发生这种情况是因为每个进程各自对同一文件发起了open()调用。同一个进程两次打开同一文件,也会发生类似情况.

总结:

  • 两个不同的文件描述符若指向同一个打开的文件句柄, 将共享同一个文件偏移量. 因此, 如果通过其中一个文件描述符来修改文件偏移量, 那么从另一个文件描述符中也会观察到这一变化. 无论这两个文件描述符分属不同进程还是同属一个进程, 都是如此
  • 要获取和修改打开的文件标志, 可执行fcntl的F_GETFL, 和F_SETFL操作.
  • 相比而言, 文件描述符标志(close-on-exec标志)为进程和文件描述符私有, 对这一标志的修改不会影响同一进程或不同进程中的其他文件描述符.

分析: 创建epoll实例(epoll_create)

当通过epoll_create创建一个epoll实例时, 内核在内存中创建了一个新的i-node并且打开文件描述(并不是文件描述符, 文件描述指的是内核中对同一文件的数据结构信息, 而一个文件的文件描述符可能有多个, 是用户级的), 随后在调用进程中为打开的这个文件描述分配一个新的文件描述符, 同epoll实例的兴趣列表相关联的是打开的文件描述(内核级), 而不是epoll文件描述符(epfd), 于是:

  • 如果使用dup()复制一个epoll文件描述符, 那么被复制的描述符所指代的epoll兴趣列表同原始的epoll文件描述符相同. 若要修改兴趣列表, 需要在epoll_ctl()的参数epfd上设定文件描述符可以是原始的也可以是复制的.
  • 上述观点同样适用于fork()调用后的情况, 此时子进程通过继承复制了父进程的epoll文件描述符, 而这个复制的描述符所指向的epoll数据结构同原始的描述符相同.

分析: 添加元素(epoll_ctl, EPOLL_CTL_ADD)

当执行epoll_ctl()EPOLL_CTL_ADD操作时, 内核在epoll兴趣列表中添加了一个元素, 这个元素同时记录了需要检查的文件描述符数量以及对应的打开文件描述符的引用.

epoll_wait()调用的目的是: 让内核负责监视打开的文件描述. 一旦所有指向打开的文件描述文件描述符都被关闭后, 这个打开的文件描述将从epoll的兴趣列表中移除.

即, 如果通过fork()或者dup()未打开的文件创建了描述符副本, 那么这个打开的文件只会在原始的描述符以及所有其他的副本都被关闭时才会移除.

与select/poll相比,为什么epoll性能很好?

  • 每次调用select()和poll(), 内核必须检查所有在调用中指定的文件描述符.

    与之相反, 当通过epoll_ctl()指定了需要监视的文件描述符时, 内核会在与打开的文件描述上下文相关联的列表中记录该描述符. 之后每当执行I/O操作使文件描述符成为就绪态时, 内核才在epoll描述符的就绪列表中添加一个元素. (单个打开的文件描述上下文中的一次I/O事件可能导致与之相关的多个文件描述符成为就绪态, 由于fork, dup等) 之后的epoll_wait()调用从就绪列表中简单地取出这些元素.

  • 每次调用select或poll时, 传递了一个标记了所有待监视的文件描述符的数据结构给内核, 调用返回时, 内核将所有标记为就绪泰德文件描述符的数据结构回传.

    而epoll使用epoll_ctl()在内核空间中建立一个数据结构, 该数据结构会将待监视的文件描述符都记录下来. 这个数据结构建立完成之后, 稍后每次调用epoll_wait()时就不需要在传递任何与文件描述符有关的信息给内核了, 而调用返回的信息中只包含哪些已经处于就绪态的描述符.

  • 最后一点, 对于select来说, 必须每次调用之前先初始化输入数据, 并且无论是select函数poll, 都需要对返回的数据结构做检查, 以此找出 N 个文件描述符中有哪些处于就绪态. 这也是select和poll比epoll慢的一个原因.

采用边缘触发通知(ET)

struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN | EPOLLET;

assert(epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev) != -1);