IO多路复用

[TOC]

定义

对于socket流而言,数据的流向经历两个阶段:

  • 第一步通常涉及等待网络上的数据分组到达,然后被复制到内核的某个缓冲区。
  • 第二步把数据从内核缓冲区复制到应用进程缓冲区。

recvfrom() 用于接收 Socket 传来的数据,并复制到应用进程的缓冲区 buf 中

阻塞IO

应用进程被阻塞,直到数据从内核缓冲区复制到应用进程缓冲区中才返回。

blocking IO的特点就是在IO执行的两个阶段都被block了。

非阻塞式 I/O

应用进程可以继续执行,但是需要不断的执行系统调用来获知I/O 是否完成,这种方式称为轮询(polling)。

此时的非阻塞IO只是应用到等待数据上,当真正有数据到达执行recvfrom的时候,还是同步阻塞IO来的

image-20201211193546070

I/O 复用

主要有 三种机制,分别是select poll epoll ,让它们等待数据,当某一个套接字可读时返回,之后再使用 recvfrom 把数据从内核复制到进程中。

好处:
如果一个 Web 服务器没有 I/O 复用,那么每一个 Socket 连接都需要创建一个线程去处理。如果同时有几万个连接,
那么就需要创建相同数量的线程。相比于多进程和多线程技术,I/O 复用不需要进程线程创建和切换的开销,系统开
销更小。
img

select/poll/epoll 都是 I/O 多路复用的具体实现,select 出现的最早,之后是 poll,再是 epoll。

select

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

有三种类型的描述符类型:readfds、writefds、exceptdfs,分别对应读、写、异常条件的描述符集合。fd_set 使用
数组实现,数组大小使用 FD_SETSIZE 定义。

timeout 为超时参数,调用 select 会一直阻塞直到有描述符的事件到达或者等待的时间超过 timeout。

成功调用返回结果大于 0,出错返回结果为 -1,超时返回结果为 0。

poll

int poll(struct pollfd *fds, unsigned int nfds, int timeout);

poll 的机制与 select 类似,与 select 在本质上没有多大差别,但是 poll 没有最大文件描述符数量的限制。poll改变了fds集合的描述方式,使用了pollfd结构而不是select的fd_set结构 .pollfd 使用链表实现。

epoll

1
2
3
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

epoll_ctl() 用于向内核注册新的描述符或者是改变某个文件描述符的状态。已注册的描述符在内核中会被维护在一棵
红黑树上,通过回调函数内核会将 I/O 准备好的描述符加入到一个链表中管理,进程调用 epoll_wait() 便可以得到事
件完成的描述符。
从上面的描述可以看出,epoll 只需要将描述符从进程缓冲区向内核缓冲区拷贝一次,并且进程不需要通过轮询来获
得事件完成的描述符。

epoll 仅适用于 Linux OS。
epoll 比 select 和 poll 更加灵活而且没有描述符数量限制。

epoll 对多线程编程更有友好,一个线程调用了 epoll_wait() 另一个线程关闭了同一个描述符也不会产生像 select 和
poll 的不确定情况。

工作模式

epoll 的描述符事件有两种触发模式:LT(level trigger)和 ET(edge trigger)。

水平触发:如果文件描述符已经就绪可以非阻塞的执行IO操作了,此时会触发通知.允许在任意时刻重复检测IO的状态.select,poll就属于水平触发.

边缘触发:如果文件描述符自上次状态改变后有新的IO活动到来,此时会触发通知.在收到一个IO事件通知后要尽可能多的执行IO操作,因为如果在一次通知中没有执行完IO那么就需要等到下一次新的IO活动到来才能获取到就绪的描述符.信号驱动式IO就属于边缘触发.

应用场景

IO复用?
select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
select、poll和epoll ·select、poll、epoll都可以监听多个文件描述符,等待指定的超时时间,直到一个或者多个文件描述符上有事件发生时返回。返回的值就是文件描述符的数量。返回0表示没有事件发生。
·差别:

  • 事件集和工作原理 : select有三种类型的描述符类型:readfds、writefds、exceptfds,分别对应读、写、异常条件的描述符集合。因此,select不能处理这三种事件以外的事件类型。并且,每一次select会使得内核直接对fd_set进行修改,再下一次使用select的时候需要重置fd_set。每次select返回的都是注册了的事件集合,包括了就绪和没有就绪的,程序检索就绪事件的时间复杂度为O(n)
    poll对select进行了改进,poll的参数是一个结构体pollfd。poll不会修改描述符,因此每次使用不需要重置pollfd。但是,poll仍然是返回注册了的事件集合,包括了就绪和没有就绪的,程序检索就绪事件的时间复杂度为O(n)。
    ·epoll_ctl() 用于向内核注册新的描述符或者是改变某个描述符的状态。已注册的描述符在内核中会被维护在一棵 红黑树上,通过回调函数内核会将 I/O 准备好的描述符加入到一个链表中管理,进程调用 epoll_wait() 便可以得到事 件完成的描述符。使得检索的时间复杂度达到O(1)
  • 支持最大的文件描述符 : select:受到系统的限制,由<sys/select.h>头文件中的FD_SETSIZE宏决定,通常是1024 ·poll和epoll一般为65535
  • 工作模式 ·select和poll只能工作在相对来说低效的水平触发模式(LT) ·epoll可以工作在高效的边缘触发模式(ET),也可以工作在水平触发模式

概念

 Linux 系统中,把一切都看做是文件,当进程打开现有文件或创建新文件时,内核向进程返回一个文件描述符,文件描述符就是内核为了高效管理已被打开的文件所创建的索引,用来指向被打开的文件,所有执行I/O操作的系统调用都会通过文件描述符。

信号驱动 I/O

应用进程使用 sigaction 系统调用,内核立即返回,应用进程可以继续执行,也就是说等待数据阶段应用进程是非阻
塞的。内核在数据到达时向应用进程发送 SIGIO 信号,应用进程收到之后在信号处理程序中调用 recvfrom 将数据从
内核复制到应用进程中。

image-20201211193643656

异步 I/O

应用进程执行 aio_read 系统调用会立即返回,应用进程可以继续执行,不会被阻塞,内核会在所有操作完成之后向
应用进程发送信号。

image-20201211193658657

水平触发和边缘触发

一、LT、ET模式介绍

水平触发模式(LT)

  • LT (Level Trigger,水平触发)模式
  • LT模式是epoll的默认的工作模式,这种模式下epoll 相当于一个效率较高的poll。当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符,ET模式是epoll的高效工作模式。对于采用LT工作模式的文件描述符,当 epoll_wait 检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件,这样,当应用程序下一次调用epoll_wait时,epoll_wait会再次向应用程序通告此事件,直到该事件被处理

边沿触发模式(ET)

  • ET (Edge Trigger,边缘触发)模式
  • 对于采用ET工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序\必须立即处理该事件,因为后续的epoll_wait用将不再向应用程序通知这一事件**,可见,ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此效率要比LT模式高
  • 可以用下面一张图理解:
    • 0代表无事件,1代表有事件。当epoll监听的套接字从无事件变为有事件就代表1次触发
    • 边缘触发:事件只触发一次,在后面的时间线就消失了
    • 水平触发:事件触发之后,如果不处理那么该事件依然存在,随着时间线往后延长,直至你处理完为止

img

  • 一些相关使用场景:
    • 大数据处理:因为大数据的数据量比较多,因此一次可能处理不完,可以使用水平触发,来多次处理数据
    • 小数据处理:小数据调用边缘触发即可,一次处理完就行
    • 服务器的监听套接字:使用水平触发。当有客户端连接时如果这次不处理,可以放到下一次来处理。但是如果使用边缘触发,本次不处理,下次再处理就消失了,从而失去了这个客户端的连接
  • epoll处理套接字在LT、ET不同模式下的数据演示案例:https://blog.csdn.net/qq_41453285/article/details/103141158

二、ET在实际应用中使用的注意事项

读事件

  • 因为ET的事件只能触发一次,所以如果不想要接收的数据丢失,那么就需要在\这次事件触发的时候一次性把数据读完**(一般是读到EAGAIN)
  • 如果读取的时候,接收缓冲区满了,那就需要应用层自行标记,解决OS不再通知可读的问题(一般是读到EAGAIN)

写事件

  • 因为ET的事件只能触发一次,所以如果想要不想让发送的数据没有全部发送出去,那么就需要在\这次事件触发的时候一次性把数据写完**(一般是写到EAGAIN)
  • 如果发送的时候,发送缓冲区满了,那就需要应用层自行标记,解决OS不再通知可写的问题

accept()事件

  • ET模式下的accept()应该是非阻塞的:
    • 如果套接口被设置成阻 塞模式,服务器就会一直阻塞在 accept 调用上,直到其他某个客户建立一个新的连接 为止。但是在此期间,服务器单纯地阻塞在 accept 调用上,就绪队列中的其他描述符 都得不到处理
    • 解决办法是把监听套接口设置为非阻塞,当客户在服务器调用 accept 之前中止某个连 接时,accept 调用可以立即返回-1,这时源自 Berkeley 的实现会在内核中处理该事件,并不会将该事件通知给epoll,而其他实现把 errno 设置为 ECONNABORTED 或者 EPROTO 错误,我们应该忽略这两个错误
  • ET模式下使用while处理accept():
    • 考虑这种情况:多个连接同时到达,服务器的 TCP 就绪队列瞬间积累多个就绪连接,由 于是边缘触发模式,epoll 只会通知一次,accept 只处理一个连接,导致 TCP 就绪队列 中剩下的连接都得不到处理。
    • 解决办法是用 while 循环抱住 accept 调用,处理完 TCP 就绪队列中的所有连接后再退出循环。如何知道是否处理完就绪队列中的所有连接呢?accept 返回-1 并且 errno 设置为 EAGAIN 就表示所有连接都处理完
  • 综上所述:服务器应该使用非阻塞的accept,accept 在ET模式下的正确使用方式如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
while ((conn_sock = accept(listenfd,(struct sockaddr *) &remote,(size_t *)&addrlen)) > 0) 



{



handle_client(conn_sock);



}







if (conn_sock == -1)



{



if (errno != EAGAIN && errno != ECONNABORTED &&errno != EPROTO && errno != EINTR)



perror("accept");



}

三、LT在实际应用中使用的注意事项

读事件

  • 因为LT的事件可以一直触发,所以对读事件的处理可以根据自己的需求,自己决定是一次性读取完,还是分段的读

  • 如果接收缓冲区满了,那么其就不能接收数据了,

    但是读事件还是会一直触发(因为你没有去接收数据)。解决方法如下:

    • 修改fd的注册事件类型
    • 或者把fd移出事件表

写事件

  • 因为LT的事件可以一直触发,所以对写事件的处理可以根据自己的需求,自己决定是一次性写完,还是分段的写

  • 如果发送缓冲区满了,那么其就不能发送数据了,

    但是写事件还是会一直触发(因为你没有把数据写出去)。解决方法如下:

    • 修改fd的注册事件类型
    • 或者把fd移出事件表
  • 从上面可以看出,LT模式相比于ET模式:
    • 优点在于:事件循环处理比较简单,无需关注应用层是否有缓冲或缓冲区是 否满,只管上报事件
    • 缺点在于:可能经常上报,可能影响性能。