Linux IO模型与Reactor、Proactor模式

本文总结了Linux I/O模型、Proactor和Reactor模型等相关概念与核心流程,参考自相关教学资源和网络博客,并附上自身理解。如有遗漏或错误,还望海涵并指出。谢谢!

一.基本概念

1.用户空间与内核空间

Linux内核给每个进程都提供了一个独立的虚拟空间,并且这个地址空间是连续的,进程就可以通过这个地址空间很方便地访问虚拟内存。

用户进程所能访问到的地址空间被称为用户空间,而被Linux内核系统调用或使用的地址被称为内核空间。

一个32位的Linux系统,其地址空间为2的32次方,即为4GB;而64位的Linux系统,其寻址空间为2的48次方,即256TB。

其中,32位系统将低位的3GB用于充当用户空间,高位1GB用于充当内核空间;64位系统将低位128TB充当用户空间,中间的2的16次方充当空洞,不会使用,而高128TB充当内核空间。

在这里插入图片描述

一般虚拟内存空间还可划分为一下几个区域:

  • 只读段: 代码和常量
  • 数据段:全局变量
  • 堆:动态分配内存,从低地址开始向上增长
  • 文件映射段: 动态库、共享内存
  • 栈: 局部变量,函数调用的上下文,栈大小一般固定是8MB

2.进程切换与进程阻塞

1.进程切换

进程由CPU来进行调度和分配,通常可以有时间片轮转、优先级队列、多级反馈队列等方式来进行调度,在一个进程运行的过程中,内核有能力挂起进程(通常将进程控制块等数据换入磁盘,需要时再换出),在需要的时候在将其重新调用,这个过程被称为进程切换。进程切换通常需要以下过程:

  1. 保存要切换的进程上下文,包括程序计数器和一些寄存器
  2. 更新PCB信息
  3. 把进程的PCB移入相应的队列,如就绪或阻塞队列
  4. 选择另一个进程执行,并更新其PCB
  5. 更新内存管理的数据结构
  6. 如果要换回原先的进程,则重复1~5这个过程

由于进程切换需要进行许多的步骤与操作,所以这是一个很消耗CPU资源的过程。

2.进程阻塞

进程阻塞指的是进程由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。

进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU时间片),才可能将其转为阻塞状态。当进程进入阻塞状态,不占用CPU资源。

3.文件描述符

文件描述符(File Descriptor,fd)是一个用于表述指向文件的引用的抽象化概念,实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。进程就可以通过文件描述符表(fd_set)来找到所需的文件。

在这里插入图片描述

4.Buffer I/O过程

一般Linux系统的I/O过程都是具有缓冲区(Buffer)支持的,以减少读取的次数和提升读取效率。

在进程请求读取数据时(例如请求读取Socket连接数据),内核会将数据先拷贝到内核缓冲区(Kernel Buffer)中,当缓冲区被打满后,会将其拷贝到相应进程的地址空间。

在这里插入图片描述

二.Linux I/O模型

由于Linux存在Buffer I/O的过程,需要将数据拷贝到内核缓冲区,然后再拷贝到相映的进程地址空间中,所以这个拷贝的过程存在的多样性就构成了Linux的I/O模型的多样性,这也是IO模型存在的基本原因。

我们可以将数据获取的过程分为两个阶段:

1.数据准备阶段(打满内核缓冲区)

2.内核数据复制到进程用户空间

Linux系统由此产生了五种网络模式的方案。

  • 阻塞 I/O(Blocking IO)
  • 非阻塞 I/O(Nonblocking IO)
  • I/O 多路复用( IO Multiplexing)
  • 信号驱动 I/O( Signal Driven IO)
  • 异步 I/O(Asynchronous IO)

下面来简单地看看每一种网络模型的工作过程:

1.阻塞I/O

在这里插入图片描述

在阻塞I/O中,应用进程相内核发出接收调用(recvfrom),内核会在数据缓存区打满后,将数据拷贝到用户进程空间,然后返回一个成功的标示,应用进程获取到数据。

在上述过程中,发起recvfrom的应用进程是完全阻塞在该条请求链路中的,直到内核将数据准备好返回给它。

所以可以看出,阻塞I/O有性能上的不足,一个进程在一段时间内都需要阻塞在一条请求链路之中,如果产生了大量的请求那么就需要开启大量的进程去完成数据读取的过程。

2.非阻塞I/O

在这里插入图片描述

非阻塞I/O与阻塞I/O最大的不同在于,用户进程需要不断的主动轮询内核数据准备好了没有,如果数据没有准备好,那么内核会立即返回错误,此时用户进程无需在链路中阻塞,而是做其他的操作,在一定时间后继续发起轮训。当内核准备好了数据时,用户进程才开始正式地阻塞在这条请求链路之中,直到数据拷贝到用户空间完成,响应成功。

可以看出,非阻塞I/O相对于阻塞I/O来说,对于应用进程的利用效率更高了,因为应用进程不需要每次发送read请求后就阻塞,而是可以继续做自己的事情,直到内核准备好了数据才开始阻塞,处理数据。

但是需要注意的是,非阻塞I/O仍不能解决大量请求的问题,因为在发生数据拷贝的过程中,进程仍然是需要阻塞的。

3.I/O多路复用

在这里插入图片描述

I/O多路复用有三个特别的系统调用select、poll、epoll函数。select调用是内核级别的,select轮询相对非阻塞I/O的轮询的区别在于:

1.select可以等待多个socket文件描述符,只需要一个进程就能实现同时对多个IO端口进行监听,当其中任何一个socket的数据准好了,就能返回进行可读,然后调用其他进程再进行recv系统调用,将数据由内核拷贝到用户进程。

2.select或poll调用之后,会阻塞当前进程,但与阻塞I/O、非阻塞I/O不同在于,此时的select不是等到内核数据全部到达再处理, 而是有了一部分数据就会调用用户进程来处理,这个过程看起来就像是“非阻塞的”(实在发生recv时还是阻塞的)。

I/O多路复用技术通过把多个I/O的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程/多进程模型比,I/O多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降底了系统的维护工作量,节省了系统资源,I/O多路复用的主要应用场景如下:

  1. 服务器需要同时处理多个处于监听状态或者多个连接状态的套接字。

  2. 服务器需要同时处理多种网络协议的套接字。

4.事件通知I/O

在这里插入图片描述

允许Socket进行信号驱动IO,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。

5.异步非阻塞I/O

在这里插入图片描述

相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到socket数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。在I/O的两个阶段,进程都是非阻塞的。

6.5种I/O模型总结

在这里插入图片描述

总体上看,阻塞I/O、非阻塞I/O、I/O多路复用、事件事件通知I/O都属于同步式I/O,只有异步I/O属于非同步式I/O。

因为区分同步或非同步的重要一点在与,整个进程对于数据的I/O过程是否需要阻塞,也就是在内核将数据拷贝到用户进程空间时需不需要阻塞。

三.select、poll与epoll

select、poll和epoll都是操作系统为了支持I/O多路复用所提供的内核调用,它们使得单个进程监听多个连接Socket成为现实,极大地减少了进程的创建数量。

1.select

select函数的定义为:

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

select 函数监视的文件描述符分3类,分别是

  1. readfds:(可选)指针,指向一组等待可读性检查的Socket。
  2. writefds:(可选)指针,指向一组等待可写性检查的Socket。
  3. exceptfds:(可选)指针,指向一组等待错误检查的Socket。

其中,timeout为select()最多等待时间,对阻塞操作则为NULL。

select大致的实现过程:

  1. 从用户空间拷贝fd_set到内核空间
  2. 对fd_set中的fd进行遍历操作,获取到fd的状态(可读、可写、异常)
  3. 只要有事件触发,系统调用返回,将fd_set从内核空间拷贝到用户空间,回到用户态,用户就可以对相关的fd作进一步的读或者写操作
  4. 如果没有发生事件触发,经过短暂等待后,继续执行上述遍历过程

在这里插入图片描述

可以看出,select的实现是基于对fd_set中的fd遍历,并且依次判断该fd是否具备可读、可写或异常的情况发生。

select有以下缺点:

1.监听的fd有限:当系统为32位时,只可监听1024个fd;当系统为64位时,最多可监听2048个fd。

2.效率低下:由于select采用的是轮询fd_set的方式来判断fd的状态,所以效率低下。

3.需要维护fd_set,当触发事件时,需要将其从内核地址空间拷贝到用户地址空间以让相应的进程得以使用,消耗资源。

当然,select也有优点,譬如几乎所有主流操作系统都实现了select函数,所以select可以作为IO多路复用的最基础实现函数。

2.poll

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

poll的本质和select一样,都是对fd_set中的fd进行遍历然后获取到触发的事件,但是poll是基于链表来描述fd的,所以对于监听的fd数量没有限制。

poll的水平(LT)触发:

当poll遍历到的fd发生了触发事件时,如果该次没有进行处理,那么下次遍历到时还会有提示请求处理。

3.epoll

通过select和poll的缺点发现,如果能给套接字注册某个回调函数,当他们活跃时(可读、可写或异常),发起通知完成相关操作,那就避免了轮询,也可以提升效率。这就是epoll的本质改变:通知机制。

epoll的操作过程涉及到三个接口:

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);

在这里插入图片描述

1.int epoll_create(int size)

创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。

2.int epoll_ctl(int epfd,
int op,
int fd,
struct epoll_event event)

epoll_ctl函数是epoll体系中的核心,它使得epoll可以基于事件通知的机制,而不需要通过轮询fd,大大地提高了效率。

  • epfd:是epoll_create()的返回值
  • op:表示操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件
  • fd:是需要监听的fd
  • epoll_event:是告诉内核需要监听什么事,当发生了该事件则进行回调

3.int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)

epoll_wait函数用于获取到epoll_ctl返回的fd触发回调,然后执行相应的操作。

TIP:epoll的水平触发与边缘触发:

  • 水平触发模式(Level Trigger,LT):当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。(默认)
  • 边缘触发模式(Edge Trigger,ET):当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。(效率高)

四.Reactor与Proactor模式

说完了I/O模型和IO多路复用、select、poll和epoll,现在来看看两种网络设计中常使用的模式:Reactor模式和Proactor模式;

通常,同步I/O模型通常用于实现Reactor模式,异步I/O模型则用于实现Proactor模式。

1.Reactor模式

Reactor模式要求主线程(I/O处理单元)只负责监听fd上是否有事件发生,有的话就立即将该事件通知工作线程来对这个fd进行处理。除此之外,主线程不做任何其他实质性的工作。读写数据,接受新的连接,以及处理客户请求均在工作线程中完成。

在这里插入图片描述

例如,使用epoll实现的I/O多路复用基于Reactor模式实现如下:

  1. 主线程通过epoll_create创建对socket fd列表上的监听
  2. 主线程调用epoll_wait等待socket fd上的事件触发
  3. 当socket fd上有事件触发时,epoll_wait接收到回调并通知主线程将socket fd上的事件放入请求队列
  4. 睡眠在请求队列上的某个工作线程被唤醒,它从socket读取数据,并处理客户请求
  5. 工作线程将事件处理完成之后,通知epoll_wait
  6. 不断执行上述过程

许多高性能网络应用都应用了Reactor模式,譬如Redis、Netty、Nginx等。Reactor模式只需要一个线程/进程来处理所有的socket fd,并且将其注册到epoll_wait中,当socket fd中有事件触发时即通知epoll_wait,然后主线程/进程将事件放入请求队列,唤醒工作线程/进程来对事件进行处理。

2.Proactor模式

与Reactor模式不同,Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。这是一种典型的异步I/O模型,因为I/O操作都交给主线程和内核来处理,这就要求I/O处理必须是非阻塞的。

在这里插入图片描述

使用了异步I/O的Proactor模式经典的工作流程:

  1. 主线程调用aio_read函数向内核注册socket上的读事件,并通知内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序
  2. 主线程继续处理其他业务逻辑
  3. 当socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,已通知应用程序数据已经可以使用了
  4. 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求
  5. 主线程继续处理I/O操作
  6. 不断执行上述流程

3.总结

简单地讲,Reactor模式适用于I/O多路复用模式(select、poll、epoll等),主线程只负责监听fd并且在fd触发时将其分发给工作线程处理,是一种经典的同步模式;Proactor模式适用于异步I/O模型下,主线程需要监听fd并且完成I/O读取操作,然后通知工作线程来完成业务逻辑即可。

发布了309 篇原创文章 · 获赞 205 · 访问量 30万+

猜你喜欢

转载自blog.csdn.net/pbrlovejava/article/details/105212836