侧边栏壁纸
博主头像
林雷博主等级

斜月沉沉藏海雾,碣石潇湘无限路

  • 累计撰写 132 篇文章
  • 累计创建 47 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

1、I/O模型

林雷
2023-08-04 / 0 评论 / 1 点赞 / 226 阅读 / 8,889 字

I20200910

一 I/O模型

I/O(Input/Output, 输入/输出),即数据的读取(接收)获写入(发送)操作,通常用户进程中的一个完整I/O分为两阶段:

  • 用户进程空间 --> 内核空间
  • 内核空间 --> 设备空间(磁盘、网络等)。

I/O有内存I/O、网络I/O和磁盘I/O三种,通常我们说的I/O指的是后两者。
Linux中普通进程是无法直接操作I/O设备,其必须通过系统调用请求kernel来协助完成I/O动作,内核会为每个I/O设备维护一个缓冲区。 对于一个输入操作而言,进程I/O系统调用后,内核会先看缓冲区中有没有相应的缓存数据,没有的话再到设备中读取,因为设备I/O一般速度比较慢,需要等待;内核缓冲区有数据则直接复制到进程空间。所以对于一个网络输入操作通常包括两个阶段:

  • 等待网络数据到达网卡 --> 读取到内核缓冲区,数据准备好
  • 从内核缓冲区复制数据到进程空间

在Unix或类Unix(如Linux)系统上又五种I/O模型:

  • 阻塞式I/O模型
  • 非阻塞式I/O模型
  • I/O复用模型
  • 信号驱动式I/O模型
  • 异步I/O模型

1.1 阻塞式I/O模型

最常用的I/O模型是阻塞式I/O(Blocking I/O)模型,如下图所示:
IOModel-1.1-1

进程发起I/O系统调用后被阻塞等待,直到内核准备好数据,并将数据从内核空间复制到用户的进程空间,整个I/O操作完毕返回进程。操作成功进程就会获取到数据。
在一次完整的I/O过程中,用户的进程都是阻塞的,直到发生错误或者数据正常处理完成用户进程才会被唤醒。在Java中,典型的应用场景式阻塞Socket、BIO等,阻塞I/O有如下特点:
进程阻塞挂起不消耗CPU资源,及时响应每个操作
实现难度低,开发应用容易
适用于并发量小的网络应用开发
不适用并发量大的应用,因为一个请求I/O会阻塞进程,所以得为每个请求分配一个处理进程/线程及时响应,系统开销大

1.2 非阻塞式I/O模型

在上述的阻塞式I/O模型中,应用进程发起系统调用转为阻塞状态,而非阻塞式I/O模型是将这段时间的阻塞转为非阻塞,如下图所示:
IOModel-1.2-1

应用进程在发起系统调用这段时间是非阻塞的,也就是内核不管有没有数据报用户进程都可以处理自己的事情,而不需要阻塞。但是应用进程会一直轮询内核是否已经有数据准备好,当内核没有准备好数据会立即返回EWOULDBLOCK错误给进程,如上图,当第四次调用recvfrom时已有一个数据报准备好,它被复制到应用进程缓冲区,recvfrom成功返回。这种轮询方式将压力都集中到了内核上,内核本身已经很繁忙了,所以这种模型是比较低效的。
这种模型的特点有:

  • 进程轮询调用,消耗CPU资源
  • 实现难度低,开发应用简单
  • 适用于并发量较小,且不需要及时响应的网络应用开发

1.3 I/O复用模型

有了I/O复用,我们就可以调用select或poll,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的I/O系统调用上,如下图所示:
IOModel-1.3-1

在select调用时是阻塞的,等待数据报套接字变为可读,当select返回套接字可读条件时,我们调用recvfrom把所读数据报复制到应用进程缓冲区。
比较阻塞式I/O模型,I/O复用模型看上去优势并没有太明显,事实上在套接字量大的情况下就有明显的差别了。

我们以select为例,其实现模型如下:
IOModel-1.3-2

I/O复用模型,用户线程不是直接处理I/O读写的,而是交给了Selector选择器,Selector选择器维护了一个文件描述符的数组,当内核告知Selector有任何一个可读时,Selector会告知用户线程可读的数量(Selector只返回可读的文件描述符的个数),用户线程遍历所有的可读Socket进而处理数据。
阻塞式I/O模型,是一个线程处理一个请求,线程资源相对较为昂贵,所以在并发量较高的情况下I/O复用模型就有了更大的优势。
稍后我们对select、poll和epoll详解。I/O复用模型的特点有:

  • 专一进程解决多个进程I/O阻塞问题,性能好;Reactor模式
  • 实现、开发应用难度大
  • 使用高并发服务应用开发:一个进程/线程响应多个请求

1.4 信号驱动式I/O模型

让内核在文件描述符就绪时发送SIGIO信号通知应用进程,这种我们称为信号驱动式I/O模型。如下图所示:
IOModel-1.4-1

首先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,我们的应用进程继续处理其他事务,也就是说没有被阻塞。当数据报准备好读取时,内核就会为该进程产生一个SIGIO信号,随后可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已经准备好。
无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不会被阻塞,主循环可以继续工作。

1.5 异步I/O模型

异步I/O右POSIX规范定义,一般来说,这种函数的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到进程缓冲区)完成后通知用户进程。这种模式与上文介绍的信号驱动模式主要区别在于:信号驱动式I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成,如下图所示:
IOModel-1.5-1

应用进程调用aio_read函数,给内核传递文件描述符、缓冲区指针、缓冲区大小和文件偏移,并告诉内核当整个操作完成时如何通知我们,该系统调用立即返回,而且在等待I/O完成期间,我们的应用进程不被阻塞。其特点有:

  • 不阻塞,数据一步到位,Proactor模式
  • 需要操作系统底层支持
  • 实现、开发应用难度大
  • 非常适合高性能并发应用

二 I/O复用模型的实现

服务器要接收客户端的数据,要建立Socket内核结构,主要包含两个重要的数据结构:进程等待队列和数据接收队列,Socket在进程中作为一个文件,可以用文件描述符fd来表示,为了方便理解,下文中 socket内核对象 ≈ fd文件描述符 ≈ TCP连接。
在Unix或者类Unix操作系统中I/O多路复用有三种实现:select、poll和epoll。

问了下文方便理解,我们先解释一些名词:
文件描述符,File Descriptor(fd),是一个非负数,从0开始,进程使用文件描述符来标识一个打开的文件。Linux中一切皆文件。系统为每一个进程维护一个文件描述符表,表示该进程打开文件的记录表,而文件描述符实际上就是这张表的索引。
Socket:Socket可以用于同一台主机的不同进程的通信,也可以用于不同主机间的通信。操作系统将socket映射到进程的一个文件描述符上,进程就可以通过读写这个文件描述符来和远程主机通信。socket是进程通信规则的高层抽象,而fd提供的是底层的具体实现,socket与fd是一一对应的。通过socket通信,实际上就是通过文件描述符fd读写文件

2.1 select实现原理

Linux提供的select函数定义如下:

int select (
    int nfds,                   //监控的文件描述符集里最大文件描述符+1
    fd_set *readfds,            //监控有读数据到达文件描述符集合
    fd_set *writefds,           //兼有写数据到达文件描述符集合
    fd_set *exceptfds,          //监控异常发生文件描述符集合
    struct timeval *timeout     //定时阻塞监控时间
);
  • timeout:它告知内核等待所指定描述符中的任何一个就绪可花多长时间,也就是调用select时的阻塞时长。如果fd文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪或阻塞超过设置的timeout后返回
  • readfds、writefds、exceptfds:select会遍历每个集合的前 nfds 个描述符,分别找到可以读取、可以写入、发生错误的描述符,这些统称为就绪的描述符,函数返回的是就绪的描述符的数量

2.1.1 select的执行过程

我们以这样一个示例说明:在服务器进程A启动的时候 ,要监听的连接的socket文件描述符是3、4、5,并且此时三个连接均没有数据到达网卡,如下图所示:
IOModel-2.1.1-1

3、4和5三个连接都没有数据到达网卡,那么进程A让出CPU时间,进入阻塞状态,同时会将进程A的进程描述符和被唤醒时用到的回调函数组成等待队列项加入到socket对象3、4、5的进程等待队列中,注意,这里select调用时,fd_set文件描述符集合会从用户空间复制到内核空间。

当网卡接收到数据,假设连接3、5有数据到达网卡,如下图所示:
IOModel-2.1.1-2

此时网卡通过中断信号通知CPU有数据到达,执行中断程序,中断程序主要做了两件事:

  • 将网络数据写入到对应socket的数据接收队列里面
  • 唤醒队列中的等待进程A,重新将进程A放入CPU的运行队列中

此时fd_set文件描述符集合会从内核空间拷贝到用户空间。
从上述分析来看,select实现的I/O多路复用有以下缺点:

  • 调用select函数时会陷入内核,这时需要将参数中的fd_set从用户空间拷贝到内核空间,select执行完成后,还需要将fd_set从内核空间复制回用户空间,高并发的场景下这样的拷贝会消耗极大
  • 进程被唤醒后,不知道哪些连接已就绪即收到了数据,需要遍历传递进来的所有fd_set的每一位(fd_set是一个二进制的位图,0表示没有就绪,1表示已就绪),不管它们是否已就绪,遍历过程消耗CPU
  • select只返回就绪文件的个数,具体哪个文件可读、可写、异常还需要遍历
  • 同时能够监听的文件描述符数量太少,受限于sizeof(fd_set)的大小,一般是1024。内部使用的是数组,所以在数据结构上大小比较固定。

2.2 poll实现原理

poll的实现与select几乎一样,只是描述fd集合的方式不同,poll使用的pollfd结构而非select的fd_set结构:

struct pollfd {
    int fd;          //要监听的文件描述符
    short events;    //要监听的事件
    short revents;   //文件描述符fd上实际发生的事件
}

管理多个文件描述符也是进行轮询,根据描述符的状态进行处理,但是poll无最大文件描述符数量的限制,因其基于链表存储。poll并没有从根本上解决select存在的问题。

2.3 epoll实现原理

由于epoll实现机制与上述的select/poll机制完全不同,所以select的缺点在epoll上不复存在。

例如如下场景:有100万个客户端同时与一个服务器进程保持TCP连接,而每一时刻,通常只有几百上千个TCP连接是活跃的,如何实现这样的高并发场景?

  • select/poll的实现:服务器进程每次都把这100万个连接告诉内核(即从用户内存空间复制文件描述符到内核空间),让内核去查询这些文件描述符是否有事件发生,轮询完成后,内核再将这100万个文件描述符的数据复制到用户空间,服务器应用进程只知道就绪的连接数量(select函数返回就绪的文件描述符数量),所以服务器进程再次遍历这100万个连接找到就绪的文件描述符,这一过程资源消耗较大,而且是大多数是无效的消耗。因此,select/poll一般只能处理几千的并发连接
  • epoll的实现:epoll与select实现不同,epoll通过在Linux内核中申请一个红黑树的结构的文件系统,把原先的select/poll调用分成三个部分:
    • 调用epoll_create函数建立一个epoll对象
    • 调用epoll_ctl向epoll对象中添加这100万个连接的套接字
    • 调用epoll_wait收集发生的事件的连接

如此一来,要实现上面说的场景,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或删除连接。同时epoll_wait的效率也比较高,因为调用epoll_wait时,并没有一次性将100万个连接的句柄数据全部复制给内核,内核也不需要去遍历全部的连接。所以相对于select/poll,epoll在并发量较大时更为高效。

2.3.1 epoll_create

epoll_create函数表示创建epoll句柄,其函数原型如下:

int epoll_create(int size)

参数size用来告诉内核这个监听的数目一共多大,返回值表示创建的epoll的句柄。当创建好epoll句柄后,它会占用一个fd值,在Linux下可以查看 /proc/进程id/fd/ ,在这个目录下可以查看这个fd。所以用完epoll后,必须调用close() 关闭,否则可能导致系统fd被耗尽。
epoll_create函数会创建一个 struct eventpoll 的内核对象,类似socket,把它关联到当前进程的已打开文件列表中,在eventpoll中主要包含三个字段:

struct eventpoll {
    wait_queue_head_t wq;        //等待队列链表, 存放阻塞的进程
    struct list_head rdllist;    //数据就绪的文件描述符都会放到这里
    struct rb_root rbr           //红黑树, 管理用户进程下添加进来的所有socket连接
};
  • wq:等待队列,如果当前进程没有数据要处理,会将当前进程句柄和回调函数default_wake_function 构造一个等待队列项,放入当前wq等待队列中
  • rbr:红黑树,管理用户进程下添加进来的所有socket连接
  • rdllist:已经就绪的文件描述符的链表,当有新的连接数据就绪的时候,内核会把就绪的连接放入到这个链表里,这样应用进程只需要操作链表就能找出就绪的进程,而不用遍历整棵树寻找

eventpoll的结构如下图所示:
IOModel-2.3.1-1

2.3.2 epoll_ctl

epoll_ctl函数主要负责把服务端和客户端建立的socket连接注册到eventpoll对象上,其函数原型如下:

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • epfd:epoll_create() 的返回值,也就是epoll对象的句柄
  • op:表示进行的操作,其值分别为:
    • EPOLL_CTL_ADD:注册新的fd到epfd中
    • EPOLL_CTL_MOD:修改已经注册的fd的监听事件
    • EPOLL_CTL_DEL:从epfd中删除一个fd
  • fd:需要操作/监听的文件句柄
  • event:是告诉内核需要监听什么事件,可以是以下几个宏:
    • EPOLLIN:表示对应的文件描述符上有可读数据(包括对端socket正常关闭)
    • EPOLLOUT:表示对应的文件描述符上可以写数据
    • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示带外数据到来)
    • EPOLLERR:表示对应的文件描述符发生错误
    • EPOLLHUP:表示对应文件描述符被挂断
    • EPOLLET:将EPOLL设置为边缘触发(Edge Triggered)模式,这是相对水平触发(Level Triggered)而言的
    • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要监听这个socket,需要再次把socket加入到EPOLL队列里。

epoll_ctl主要做了三件事,如下图所示:
IOModel-2.3.2-1

  1. 创建一个epitem对象,主要包含两个字段,分别存放socket fd(连接的文件描述符)和所属的eventpoll对象的指针
  2. 将一个数据到达时用到的回调函数ep_poll_callback 添加到socket的进程等待队列中,注意,这里不会将进程fd添加进来,只是将回调函数添加进来,因为在epoll中,进程是存放在eventpoll的等待队列中,等待被epoll_wait函数唤醒,而不是在socket的进程等待队列中
  3. 将创建的epitem对象插入到红黑树。

2.3.3 epoll_wait

epoll_wait函数的动作比较简单,检查eventpoll对象的就绪连接rdllist上是否有数据到达,如果没有就把当前的进程描述符添加到一个等待队列项中,然后阻塞当前进程,等待数据到达时通过回调函数被唤醒。其函数原型如下:

int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
  • epfd:epoll_create() 的返回值,也就是epoll对象的句柄
  • events:用于回传待处理事件的数组
  • maxevents:每次能处理的最大事件数
  • timeout:等待I/O事件发生的超时毫秒数,-1表示阻塞,0表示非阻塞

epoll_wait执行流程如下图所示:
IOModel-2.3.3-1

  1. socket的数据接收队列有数据到达,会通过进程等待队列的回调函数ep_poll_callback 唤醒红黑树中的epitem
  2. ep_poll_callback函数将有数据到达的epitem添加到eventpoll对象的就绪队列rdllist中
  3. ep_poll_callback函数检查eventpoll对象的进程等待队列上是否有等待项,通过回调函数 default_wake_function 唤醒这个进程,进行数据的处理
  4. 当进程唤醒后,继续从 epoll_wait 时暂停的代码继续运行,把rdllist中就绪的事件返回给用户进程,让用户进程调用recv 把已经到达内核socket等待队列的数据拷贝到用户空间使用

2.4 epoll的工作模式

epoll有两种工作模式:

  • ET:Edge Triggered:高速工作模式,只支持no_block。在此模式下,当文件描述符从未就绪变为就绪时,内核通过epoll告知,然后它会假设用户知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到某些操作导致这个文件描述符不再为就绪状态了。也就是说只在数据就绪时通知一次,若数据没有读完,下次不会通知,直到有新的就绪数据
  • LE:Level Triggered,默认的工作模式,支持block和no_block,在LT模式下内核会告知一个文件描述符是否就绪,然后可以对这个就绪的fd进行I/O操作。如果不作任何操作,内核还是会继续通知;若数据没有读完,内核也会继续通知,直到设备数据为空为止

2.5 小结

I/O多路复用有三种实现技术,分别是select、poll和epoll:

  • select机制:当有socket需要建立连接时,需要调用系统函数select,调用此函数会陷入内核态,当前用户进程阻塞;内核会遍历select函数中readfds、writefds和exceptfds的文件描述符集合(用户进程空间复制到内核空间),当有数据到达时,会遍历这些集合并找到已经就绪的文件描述符,并返回已经就绪的文件描述符的数量,唤醒用户进程。select有以下缺点:
    • 调用select函数时会将所有的socket文件描述符从用户空间复制到内核空间;内核找到就绪的文件描述符后,会将所有的(用户进程传递过来的)文件描述符回传给用户进程,即从内核空间复制到用户进程空间,并发量大的情况下极为耗时
    • 进程被唤醒后,只知道就绪的文件描述符的数量(select函数返回的是就绪文件描述符的数量),所以需要遍历所有的fd_set,并发量大的情况下极为耗时
    • 存储文件描述符的fd_set结构体内部使用的是数组结构,Linux默认为1024大小,所以每一个select最大只能关联1024个连接
  • poll机制:poll机制与select几乎一样,只是将数组变为链表,取消了1024的限制。select和poll机制通常几千个并发已经很多了
  • epoll机制:epoll与select/poll完全不同。epoll主要分为三个阶段:epoll_create、epoll_ctl和epoll_wait:
    • epoll_create:创建eventpoll对象,对象中主要有wq表示等待的进程队列,并关联回调函数default_wake_function,表示进程被唤醒时的回调函数;rdr红黑树,epitem对象,表示所有的注册进来的文件描述符;rdllist链表,表示已经就绪的socket文件描述符。同时返回当前进程的句柄(也就是文件描述符)
    • epoll_ctl:表示对于与客户端连接的socket文件描述符的操作,如添加、删除、修改。当有客户端需要连接时,会将对应的socket文件描述符添加进来,也就是添加到rdr红黑树中,即epitem结构体注册到eventpoll的rdr上;当有断开操作时则从eventpoll的rdr上删除
    • epoll_wait:表示用户进程等待客户端的连接,当有客户端连接时,内核将红黑树上的对应的epitem信息存放到已经就绪的rdllist链表上,用户进程只需要遍历已经就绪的rdllist链表即可,而不需要遍历整棵红黑树获取
  • epoll机制解决了select调用的大部分问题:
    • select机制会将所有的文件描述符从用户空间复制到内核空间,又将所有的文件描述符从内核空间复制回用户空间;但是epoll只需要在第一次连接进来的时候添加一次,将已经就绪的文件描述符返回,第一阶段从用户空间复制到内核空间与select类似,但是第二阶段从内核空间只复制已经就绪的文件描述符到用户空间,这样在高并发的场景下大大降低了内存复制的消耗
    • 在select中,用户进程并不知道哪些文件描述符已经就绪,所以需要遍历整个文件描述符查找已经就绪的文件描述符;而epoll在epoll_wait被唤醒后就知道已经就绪的rdllist了,所以遍历的只是已经就绪的文件描述符,而不是遍历所有的连接。在类似有1000个连接,但是只有10个连接就绪的场景下高效很多
    • epoll内部使用红黑树存储用户添加进来的描述符,所有没有1024的限制
    • 纵观epoll的代码,没有发现mmap机制,所有还是存在内存复制的消耗
  • epoll有两种工作模式:
    • ET,Edge Triggered:高效模式,就绪的文件描述符内核只会通知用户进程一次,即使数据没有读完下次也不会通知
    • LT,Level Triggered:默认模式,就绪的文件描述符内核会一直通知
1

评论区