Linux 操作系统原理 — IO 模型

目录

基本概念

同步与异步

  • 同步是指一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成。

  • 异步是指不需要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工作,依赖的任务也立即执行,只要自己完成了整个任务就算完成了,异步一般使用状态、通知和回调。

阻塞与非阻塞

  • 阻塞是指调用结果返回之前,当前线程会被挂起,一直处于等待消息通知,不能够执行其他业务。
  • 非阻塞是指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。

Linux 的五种 IO 模型

对于一次 IO 访问,数据会先被拷贝到内核的缓冲区中,然后才会从内核的缓冲区拷贝到应用程序的地址空间。需要经历两个阶段:

  1. 准备数据。
  2. 将数据从内核缓冲区拷贝到用户态进程的地址空间。

对于一个对 Socket 的输入操作,第一步一般来说是等待数据从网络传到本地。当数据包到达的时候,数据将会从网络层拷贝到内核的缓存中;第二步是从内核中把数据拷贝到应用程序的数据区中。
在这里插入图片描述
由于存在这两个阶段,Linux 具有下面五种 I/O 模型。

  • 阻塞 I/O
  • 非阻塞 I/O
  • 信号驱动 I/O(SIGIO)
  • 异步 I/O
  • I/O 多路复用

阻塞 IO

是最普遍的 I/O 模型,大部分程序都是用的是阻塞式 I/O。Linux 中,默认情况下所有的 Socket 都是阻塞 IO 的。

对于 UDP 套接字来说,数据就绪的标志比较简单:

  • 已经收到了一整个数据报
  • 没有收到

而 TCP 则比较复杂,需要附加一些其他变量。如下图所示,当一个用户进程调用了 recvfrom(),内核就进入 IO 的第一个阶段:准备数据(内核需要等待足够多的数据再拷贝),这个过程需要等待,用户进程会被阻塞,等内核将数据准备好,然后拷贝到用户地址空间,内核返回结果,用户进程才从阻塞态进入就绪态。(如果系统调用收到一个中断信号,则它的调用会被中断)。

所以,我们称这个用户进程在调用 recvfrom() 开始,一直到从 recvfrom() 返回的这段时间内都是阻塞的。当 recvfrom() 正常返回后。用户进程才可以继续它的操作。

在这里插入图片描述

非阻塞 IO

Linux 下可以通过设置 Socket 为 non-blocking 模式。。当我们将一个套接字设置为非阻塞模式时,相当于告诉了系统内核:“当我请求的 I/O 操作不能马上完成,不要让进程休眠等待,,请马上返回一个错误给我”。

当用户进程发出 read() 调用时,如果 Kernel 中的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个 EWOULDBLOCK 的 Error。用户进程判断结果是一个 Error 时,它就知道数据还没有准备好,于是它可以再次发送 read() 调用。一旦 Kernel 中的数据准备好了,并且又再次收到了用户进程的系统调用,那么它马上就将数据拷贝到了用户内存,然后返回。

非阻塞 IO 模式下用户进程需要不断地询问内核的数据准备好了没有,如果没有准备好,那么在某些场景中,用户进程可以去做别的事情而不需要一直等待。
在这里插入图片描述
当一个应用程序使用了非阻塞模式的套接字,它就会使用一个循环来不停的测试是否一个文件描述符合有数据可读(称作 Polling,轮询)。应用程序不停地 Plling 内核来检查是否 I/O 操作已经就绪。这是对 CPU 资源的极大浪费,所以这种模式平时使用中不是很普遍。

同步 IO(信号驱动)

当我们将一个套接字设置为信号驱动 I/O 模式,让内核文件描述符就绪后,通过 Signal(信号)通知用户进程,用户进程再通过系统调用读取数据,我们将这种模式称之为信号驱动 I/O 模式。

对于信号驱动 I/O 模式,好处在于等待数据的时候不会阻塞,程序可以做自己的事情。当有数据到达的时候,系统内核会向应用程序主动发送一个 SIGIO 信号进行通知,所以应用程序就可以获得更大的灵活性,而不必为阻塞等待数据进行额外的编码。

此方式的本质属于同步 IO,因为实际读取数据到用户进程缓存的工作仍然是由用户进程自己负责的。
在这里插入图片描述

为了在一个套接字上使用信号驱动 I/O 操作,必须有下面三个步骤:

  1. 必须设定一个处理 SIGIO 信号的函数。
  2. 必须设定套接字的拥有者,一般使用 fcntl 函数的 F_SETOWN 参数来设定拥有着。
  3. 套接字必须被允许使用异步 I/O(接受 SIGIO)。一般使用 fcntl 函数的 F_SETFL 命令,O_ASYNC 为参数来实现。

异步 IO

用户进程发起 read() 调用之后,立刻就可以开始去做其它的事。内核收到一个异步 IO read 之后,会立刻返回,不会阻塞用户进程。内核会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,内核会给用户进程发送一个 Signal(信号),告诉它 read() 完成了。用户进程再从用户内存读取数据。

异步 I/O 与信号驱 动I/O 区别:

  • 信号驱动 I/O 模式下,内核在操作可以操作的时候通知给程序发送 SIGIO 消息。
  • 异步 I/O 模式下,内核在所有操作都被内核操作结束后才会通知给程序。

当进程进行 I/O 操作时,进程传递给内核它的文件描述符、缓存区指针、缓存区的大小以及一个偏移量 offset,以及在内核结束所有操作后和给进程发送通知,这种调用也是立即返回的,程序不需要阻塞来等待程序的就绪。
在这里插入图片描述

IO 多路复用

通过一种机制,一个进程可以监视多个文件描述符(套接字描述符),一旦某个文件描述符就绪(一般是读就绪,或者写就绪),能够通知程序进行相应的读写操作。这样就不需要每个用户进程不断的询问内核数据准备好了没有,也不需要内核给用户进程发送信号了。
在这里插入图片描述
在使用多路复用 I/O 技术时,会调用 select() 函数和 poll() 函数,在调用他们的时候阻塞,而不是在调用 recvfrom() 的时候阻塞。

当调用 select() 函数阻塞的时候,select() 等待数据报套接字进入读就绪状态。当 select() 返回的时候,就是套接字可以读取数据的时候。这是很好就可以调用 recvfrom() 函数来将数据拷贝到缓存区中。

和阻塞模式比较,select() 和 poll() 并没有特别的地方。而且,在阻塞模式下只需要调用一个函数:读取或发送,在使用了多路复用技术后,需要调用两个函数:select() 或 poll(),然后才能调用 recvfrom() 进行真正的读写。

但多路复用的好处在于,能同时等待多个文件描述符,而这些文件描述符其中的任意一个进入读取状态,select() 函数就可以返回。

应用场景:

  • 当一个客户端需要同时处理多个文件描述符的输入输出操作时。
  • 当程序需要同时进行多个套接字的操作时。
  • 如果一个 TCP 服务器程序同时处理正在侦听网络连接的套接字和已经连接好的套接字。
  • 如果一个服务器程序同时使用 TCP 和 UDP 协议。
  • 如果一个服务器同时使用多种服务并且每种服务使用协议不同。

select

Kernel 会监视所有 select() 负责的若干个 Socket,当任意 Socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read(),将数据从 Kernel 拷贝到用户进程。

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

形参列表:

  • 读、写、异常、集合中的文件描述符的最大值+1
  • 读集合
  • 写集合
  • 异常集合
  • 超时结构体

select() 的返回值为检测到的事件个数并且返回哪些 I/O 发生了事件,遍历这些事件,进而处理事件。

select() 监视的文件描述符分 3 类:

  1. writefds
  2. readfds
  3. exceptfds

调用后 select() 会阻塞住,直到有描述符就绪(有数据可读、可写、或 Except),或者超时(timeout 形参指定等待时间,如果希望立即返回,则设为 null)函数返回。当 select() 返回后,可以通过遍历 fdset,来找到就绪的描述符。

select() 的一个缺点在于单个进程能够监视的文件描述符数量存在限制,在 Linux 上一般为 1024 个,可以通过调整内核的参数进行修改。另外,select() 中的 fd_set 集合容量同样具有限制(FD_SETSIZE=1024)这需要重新编译内核。

poll

poll() 使用了一个 pollfd 的指针实现。

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

第一参数是指向结构体数组,每个数组元素都是一个 pollfd 结构。结构体类型参数 pollfd 包含了要监视的 Event 和发生的 Event。

struct pollfd {
	int fd; 		/* file descriptor */
	short events; 	/* requested events to watch */
	short revents;	/* returned events witnessed */
};

和 select() 一样,poll() 返回后,内核要遍历所有文件描述符,直到找到所有发生事件的 pollfd 文件描述符来获取其中就绪的描述符。区别在于 poll 没有监听的最大数量限制。

epoll

epoll 使用一个文件描述符来管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,采用监听回调的机制,这样在用户空间和内核空间的数据拷贝只需要进行一次,避免再次遍历就绪的文件描述符列表,从而提升了性能。

相比于 select() 与 poll(),epoll() 最大的好处在于它不会随着监听 fd 数目的增长而降低效率,内核中 select() 与 poll() 的实现时采用轮询来处理的,轮询的 fd 数目越多,自然耗时越多。而 epoll() 实现是基于回调的,如果 fd 有期望的事件发生就通过回调函数将其加入 epoll 就绪队列中,也就说说它只关心 “活跃” 的 fd,与 fd 的数目无关。

另外,内核空间和用户空间拷贝问题,在这个问题上 select/poll 采取的是内存拷贝的方式,而 epoll 采用的共享内存(缓冲区共享)的方式,避免了一次拷贝。

epoll 不仅会告诉应用程序有 I/O 事件到来,还会告诉应用程序相关的信息,这些信息是应用填充的,因此根据这些消息应用程序就能直接定位到事件,而不必遍历整个 fd 集合。

epoll 的操作过程需要三个接口:

  1. 创建一个 epoll 的句柄,形参 size 用来告诉内核这个监听的数目一共有多大。
int epoll_create(int size)
  1. 对指定描述符 fd 执行 op 操作。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
  • epfd:是 epoll_create() 的返回值。
  • op:表示操作,用三个宏来表示:EPOLL_CTL_ADD,EPOLL_CTL_DEL,EPOLL_CTL_MOD。分别添加、删除和修改对 fd 的监听事件。
  • fd:是需要监听的 fd(文件描述符)。
  • epoll_event:是告诉内核需要监听什么事件,struct epoll_event 结构如下:
struct epoll_event {
	__uint32_t events; /* Epoll events */ 
	epoll_data_t data; /* User data variable */
};

events 可以是以下几个宏的集合:

  • EPOLLIN :表示对应的文件描述符可以读(包括对端 Socket 正常关闭);
  • EPOLLOUT:表示对应的文件描述符可以写;
  • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
  • EPOLLERR:表示对应的文件描述符发生错误;
  • EPOLLHUP:表示对应的文件描述符被挂断;
  • EPOLLET: 将 epoll 设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的;
  • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 Socket 的话,需要再次把这个 Socket 加入到 epoll 队列里。
  1. 等待 epfd 上的 IO 事件,最多返回 maxevents 个事件。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • events:用来从内核得到事件的集合
  • maxevents:告之内核这个 events 有多大,这个 maxevents 的值不能大于创建 epoll_create() 时指定的 size
  • timeout:超时时间,单位毫秒,0 表示立即返回,-1 表示不确定,也有说法说是永久阻塞

该函数返回需要处理的事件数目,如返回 0 表示已超时。

epoll 的两种工作模式:

  1. LT(Level Trigger,水平触发)模式:当 epoll_wait 检测到描述符就绪,将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用 epoll_wait 时,会再次响应应用程序并通知此事件。LT 模式是默认的工作模式,同时支持阻塞和非阻塞 Socket。
  2. ET(Edge Trigger,边缘触发)模式:当 epoll_wait 检测到描述符就绪,将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用 epoll_wait 时,不会再次响应应用程序并通知此事件。ET 是高速工作方式,只支持非阻塞 Socket。ET 模式减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。

三者的比较

用通俗的话来讲,假如有三个老师分别是 select,poll,epoll,每次当老师要去收全班同学的作业时,也就是当老师被调用时:select 和 poll 老师就会一个一个的检查在这个班的所有的同学的作业并拿走作业,而 epoll 老师设置了一个讲台,说谁写完了就放在讲台上,那当 epoll 老师工作的时候,只需要在讲台上拿走完成的作业,而不用全部遍历。

几种 I/O 模式比较

在这里插入图片描述

可以看出,阻塞程度:阻塞 IO > 非阻塞 IO > 多路复用 IO > 信号驱动 IO > 异步 IO,效率是由低到高的。

原创文章 592 获赞 1483 访问量 197万+

猜你喜欢

转载自blog.csdn.net/Jmilk/article/details/105916184