回顾Linux下的IO模型

1. IO分类

IO,即INPUT和OUTPUT。数据读入和数据输出。

说到IO,很多人就联想到读写磁盘文件,其实这只是其中一种。对Linux系统而言,所有设备都是文件,其中包括磁盘、内存、网卡、键盘、显示器等等,对所有这些文件的访问都属于IO。针对所有的IO对象,可以将IO分成三类:

  • 网络IO
  • 磁盘IO
  • 内存IO

而通常我们讨论的是前两种,具体的区分可以参考参考Linux IO解读中的解释。摘自原文:

举个最常见的涉及到IO的例子:当我们要通过浏览器访问网站某张图片,整个过程的数据流向图如下:
在这里插入图片描述
浏览器发起请求,请求首先到达服务器网卡,再到达Nginx(这里以Nginx为例),Nginx读取磁盘上的数据,读取成功后再将图片返回给浏览器。这里涉及到2次IO,分别是读IO、写IO。读IO:图片从磁盘被读取到内存;写IO,图片从内存中被写进网卡缓冲区,发回给浏览器。这里涉及到操作系统内存操作的过程,在此先简单介绍下Linux的内存管理。


2. 从Linux内存管理分析IO

每台计算机运行都需要内存,这些内存是供内核系统和用户进程一起使用。Linux在操作这些物理内存时,不会直接操作物理内存,而是建立一个虚拟地址(可以理解成跟物理内存相对应的映射),即在物理内存跟进程之间增加一个中间层。为什么不直接操作物理内存?原因如下:
1,隔离不同进程使用的内存地址空间;
2,提高内存的使用率;
3,确定程序运行时的地址;
4,扩展内存,即运行所需内层大于物理内存的程序

个人认为最重要的是第一条:Linux运行时需要内存来存放系统内核的数据,用户进程运行也需要内存存放数据;为了系统安全,Linux是防止用户进程直接操作内核空间所占空间的,免得系统被用户进程搞挂掉。为此,系统将虚拟地址分为两部分:一部分专门给系统内核使用,另一部分给用户进程使用。对于32位的系统,虚拟地址范围是0x00000000 ~ 0xFFFFFFFF,即最大虚拟内存为2^32 Bytes = 4GB,系统将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF)分配内核使用,此区域称作内核空间;另外将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。由于虚拟地址是物理内存的映射,相当于系统将物理内存分成两部分单独使用。所以,现在有两个概念:

  • 内核空间:系统内核使用的内存空间;当一个进程执行调用系统命令(例如read, write)时,会进入内核代码的执行,进程此时的状态我们称之为内核态。
  • 用户空间:用户进程使用的内存空间;当一个进程执行用户自己的代码时,该进程此时的状态为用户态。

再看回获取图片的例子,Nginx读取图片时(原始做法),图片不是一次就复制到用户空间的,结合下图看看:
在这里插入图片描述
实际上分为以下四步:
1,数据从磁盘被复制到内核空间的内存中(到了系统内存,还没到Nginx内存);
2,数据从内核空间的内存被复制到用户空间的内存(到了Nginx的内存中);
3,数据从用户空间内存被再复制到内核空间内存(Socket缓冲区);
4,再从内核空间复制到协议引擎(网卡驱动的传输队列);

其中第1、2步是读IO,3、4步是写IO,nginx输出一张图片的数据流向大致是这样。那我们平时经常提到的同步、异步、阻塞、非阻塞,其实是描述上面流程里的其中一个过程。

  • 阻塞和非阻塞:描述是上面的第1步,即在数据被拷贝到内核空间前,用户进程是否等待。如果用户进程是等待的,就是阻塞;如果用户进程是立即返回,就是非阻塞。
  • 同步和异步:描述是上面的第2步,即数据从内核空间复制到用户进程空间,这时用户进程是否处于等待状态,如果是用户进程需要等待,即为同步;否则为异步。

看回Nginx读取磁盘图片这个过程,这里可以分为两个角色:用户进程(Nginx)、系统内核。那读取图片,其实是Nginx叫系统帮他读取图片,下面我们就以两个不同的角度分析这个读取图片的过程。

用户进程的角度:用户进程是怎么将IO任务告诉系统的,并且在系统准备数据的过程中,用户进程处于什么状态?我们所说IO模式其实就是描述了这些问题。

当然还有系统内核角度的分析,不是本文主要讨论的方向,可以参考原文Linux IO解读


3. IO模型的通用解释

其中也详细讲解了5种IO模型的工作原理:
1.阻塞I/O模型
老李去火车站买票,排队三天买到一张退票。
耗费:在车站吃喝拉撒睡 3天,其他事一件没干。

2.非阻塞I/O模型
老李去火车站买票,隔12小时去火车站问有没有退票,三天后买到一张票。耗费:往返车站6次,路上6小时,其他时间做了好多事。

3.I/O复用模型
3.1.select/poll
老李去火车站买票,委托黄牛,然后每隔6小时电话黄牛询问,黄牛三天内买到票,然后老李去火车站交钱领票。
耗费:打电话
3.2.epoll
老李去火车站买票,委托黄牛,黄牛买到后即通知老李去领,然后老李去火车站交钱领票。
耗费:无需打电话

4.信号驱动I/O模型
老李去火车站买票,给售票员留下电话,有票后,售票员电话通知老李,然后老李去火车站交钱领票。
耗费:无需打电话

5.异步I/O模型
老李去火车站买票,给售票员留下电话,有票后,售票员电话通知老李并快递送票上门。
耗费:无需打电话

通俗解释来源于简书,关于5种IO模型也可以参考5种IO模型详解


4. 图解5种IO模型

在下列各图中,将整个过程分为两个阶段:wait for data和copy data from kernel to user。分别对应第一小节中Nginx获取图片中的第1步和第2步。第一步wait for data中,主要是等待内核进程从磁盘中获取数据到内核空间;第二步copy data from kernel 投user,则是将内核空间中的数据复制到用户空间下。

而对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),或者数据到达网卡后还没有读入到内核空间中,这个时候kernel就要等待足够的数据到来,对应第1阶段的wait for data。第2阶段则是将从网卡中接收、并缓存在内核空间的数据复制到用户空间去。

理解了这两个阶段,再看下面5种模型则更加清晰明了。第一阶段主要看是否阻塞,第二阶段则主要看是否同步。

4.1 同步阻塞IO模型BIO- Blocking IO

在这里插入图片描述
当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

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

4.2 同步非阻塞IO模型NIO- non-blocking IO

linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:
在这里插入图片描述

从图中可以看出,当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好。于是它可以再次发送read操作,通过这样的方式查询数据是否准备好。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,用户进程其实是需要不断的主动询问kernel数据好了没有。换句话说在第一阶段用户进程没有被阻塞。

4.3 IO多路复用 IO Multiplexing

如果一个I/O流进来,我们就开启一个进程处理这个I/O流。那么假设现在有一百万个I/O流进来,那我们就需要开启一百万个进程一一对应处理这些I/O流(——这就是传统意义下的多进程并发处理)。
进程切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的,并且进程切换是非常耗费资源的。

进程阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得了CPU资源),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。

一百万个进程,CPU占有率会非常非常高,超多进程的调度是非常浪费资源的,这样的实现方式极其的不合理。所以人们提出了I/O多路复用这个模型,一个线程通过记录I/O流的状态来同时管理多个I/O,可以提高服务器的吞吐能力。

关于文件描述符。**文件描述符(File descriptor)**是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

简而言之: I/O多路复用(multiplexing)的本质是通过一种机制(系统内核缓冲I/O数据),让单个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。

以select/epoll为例,它们的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:


当用户进程调用了select,那么整个进程会被block(阻塞),而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
这个图和blocking IO(同步阻塞模型BIO)的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。

所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

select、poll 和 epoll 都是 Linux API 提供的 IO 复用方式

4.3.1 Select

我们先分析一下select函数

int select(int maxfdp1,
                  fd_set * readset,
                  fd_set * writeset,
                  fd_set * exceptset,
                  const struct timeval * timeout);

【参数说明】
int maxfdp1 指定待测试的文件描述字个数,它的值是待测试的最大描述字加1。
fd_set *readset , fd_set *writeset , fd_set *exceptset
fd_set可以理解为一个集合,这个集合中存放的是文件描述符(file descriptor),即文件句柄。中间的三个参数指定我们要让内核测试读、写和异常条件的文件描述符集合。如果对某一个的条件不感兴趣,就可以把它设为空指针。
const struct timeval *timeout timeout告知内核等待所指定文件描述符集合中的任何一个就绪可花多少时间。其timeval结构用于指定这段时间的秒数和微秒数。
【返回值】
int 若有就绪描述符返回其数目,若超时则为0,若出错则为-1
select运行机制
select()的机制中提供一种fd_set的数据结构,实际上是一个long类型的数组,每一个数组元素都能与一打开的文件句柄(不管是Socket句柄,还是其他文件或命名管道或设备句柄)建立联系,建立联系的工作由程序员完成,当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪一Socket或文件可读。
从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
select机制的问题

每次调用select,都需要把fd_set集合从用户态拷贝到内核态,如果fd_set集合很大时,那这个开销也很大
同时每次调用select都需要在内核遍历传递进来的所有fd_set,如果fd_set集合很大时,那这个开销也很大
为了减少数据拷贝带来的性能损坏,内核对被监控的fd_set集合大小做了限制,并且这个是通过宏控制的,大小不可改变(限制为1024)

4.3.2 Poll

poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。也就是说,poll只解决了上面的问题3,并没有解决问题1,2的性能开销问题。
下面是pll的函数原型:

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

typedef struct pollfd {
        int fd;                         // 需要被检测或选择的文件描述符
        short events;                   // 对文件描述符fd上感兴趣的事件
        short revents;                  // 文件描述符fd上当前实际发生的事件
} pollfd_t;

poll改变了文件描述符集合的描述方式,使用了pollfd结构而不是select的fd_set结构,使得poll支持的文件描述符集合限制远大于select的1024
【参数说明】
struct pollfd *fds fds是一个struct pollfd类型的数组,用于存放需要检测其状态的socket描述符,并且调用poll函数之后fds数组不会被清空;一个pollfd结构体表示一个被监视的文件描述符,通过传递fds指示 poll() 监视多个文件描述符。其中,结构体的events域是监视该文件描述符的事件掩码,由用户来设置这个域,结构体的revents域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域
nfds_t nfds 记录数组fds中描述符的总数量
【返回值】
int 函数返回fds集合中就绪的读、写,或出错的描述符数量,返回0表示超时,返回-1表示出错;

4.3.3 Epoll

epoll在Linux2.6内核正式提出,是基于事件驱动的I/O方式,相对于select来说,epoll没有描述符个数限制,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
Linux中提供的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). epoll_create 函数创建一个epoll句柄,参数size表明内核要监听的描述符数量。调用成功时返回一个epoll句柄描述符,失败时返回-1。
(2). epoll_ctl 函数注册要监听的事件类型。四个参数解释如下:

epfd 表示epoll句柄

op 表示fd操作类型,有如下3种

EPOLL_CTL_ADD   注册新的fd到epfd中
EPOLL_CTL_MOD 修改已注册的fd的监听事件
EPOLL_CTL_DEL 从epfd中删除一个fd

fd 是要监听的描述符

event表示要监听的事件

epoll_event 结构体定义如下:

struct epoll_event {
    __uint32_t events;  /* Epoll events */
    epoll_data_t data;  /* User data variable */
};

typedef union epoll_data {
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;

(3). epoll_wait 函数等待事件的就绪,成功时返回就绪的事件数目,调用失败时返回 -1,等待超时返回 0。

epfd 是epoll句柄

events 表示从内核得到的就绪事件集合

maxevents 告诉内核events的大小

timeout 表示等待的超时事件

epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。

epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。

水平触发(LT):默认工作模式,即当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;下次调用epoll_wait时,会再次通知此事件

边缘触发(ET): 当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次通知此事件。(直到你做了某些操作导致该描述符变成未就绪状态了,也就是说边缘触发只在状态由未就绪变为就绪时只通知一次)。

LT和ET原本应该是用于脉冲信号的,可能用它来解释更加形象。Level和Edge指的就是触发点,Level为只要处于水平,那么就一直触发,而Edge则为上升沿和下降沿的时候触发。比如:0->1 就是Edge,1->1 就是Level。
ET模式很大程度上减少了epoll事件的触发次数,因此效率比LT模式下高。

IO多路复用参考资料:https://www.jianshu.com/p/397449cadc9a

4.4 异步IO模型AIO- Asynchronous I/O

Asynchronous I/O

linux下的asynchronous IO其实用得很少。先看一下它的流程:
在这里插入图片描述
用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

该模型与前三种不同,主要体现在第二阶段copy data from kernel to user,将数据从内核空间拷贝到用户空间时,用户进程可以干自己的事情,不用被阻塞。所以称之为异步IO。

4.5 信号驱动IO - signal driven IO

在这里插入图片描述
信号驱动IO相当于同步非阻塞NIO的升级版。用户进程调用read命令,系统在准备数据时,用户进程不会被阻塞,可以同时做其他任务,不需要循环去问系统数据是否准备就绪;当数据准备好之后,系统通知用户进程。但在数据从内核空间复制到用户空间这阶段,此时用户进程处于阻塞状态。

4.6 五种IO模型总结

阻塞 vs 非阻塞
区别在于IO操作的第一阶段,调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回,用户进程不会因此而阻塞。

同步 vs 异步
在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。Stevens给出的定义(其实是POSIX的定义)是这样子的:

A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;

An asynchronous I/O operation does not cause the requesting process to be blocked;

两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blockingIO,non-blocking IO,IO multiplexing都属于synchronous IO。

有人可能会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。
在这里插入图片描述

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值