APP下载

面试官:聊聊Linux 下五种IO模型是怎么工作的?

消息来源:baojiabao.com 作者: 发布时间:2024-05-09

报价宝综合消息面试官:聊聊Linux 下五种IO模型是怎么工作的?

在正式开始讲Linux IO模型前,比如:同步IO和异步IO,阻塞IO和非阻塞IO分别是什么,到底有什么区别?不同的人在不同的上下文下给出的答案是不同的。所以先限定一下本文的上下文。

1 概念说明

在进行解释之前,首先要说明几个概念:

使用者空间和核心空间

程序切换

程序的阻塞

档案描述符

快取 IO

1.1 使用者空间与核心空间

现在操作系统都是采用虚拟储存器,那么对32位操作系统而言,它的定址空间(虚拟储存空间)为4G(2的32次方)。操作系统的核心是核心,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件装置的所有许可权。为了保证使用者程序不能直接操作核心(kernel),保证核心的安全,操作系统将虚拟空间划分为两部分,一部分为核心空间,一部分为使用者空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供核心使用,称为核心空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个程序使用,称为使用者空间。

1.2 程序切换##

为了控制程序的执行,核心必须有能力挂起正在CPU上执行的程序,并恢复以前挂起的某个程序的执行。这种行为被称为程序切换。因此可以说,任何程序都是在操作系统核心的支援下执行的,是与核心紧密相关的。

从一个程序的执行转到另一个程序上执行,这个过程中经过下面这些变化:

储存处理机上下文,包括程式计数器和其他暂存器。更新PCB资讯。把程序的PCB移入相应的伫列,如就绪、在某事件阻塞等伫列。选择另一个程序执行,并更新其PCB。更新内存管理的资料结构。恢复处理机上下文。注:总而言之就是很耗资源,具体的可以参考这篇文章:程序切换。

1.3 程序的阻塞##

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

1.4 档案描述符fd##

档案描述符(File descriptor)是电脑科学中的一个术语,是一个用于表述指向档案的引用的抽象化概念。

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

1.5 快取 IO##

快取 IO 又被称作标准 IO,大多数档案系统的预设 IO 操作都是快取 IO。在 Linux 的快取 IO 机制中,操作系统会将 IO 的资料快取在档案系统的页快取( page cache )中,也就是说,资料会先被拷贝到操作系统核心的缓冲区中,然后才会从操作系统核心的缓冲区拷贝到应用程序的地址空间。

快取 IO 的缺点:

资料在传输过程中需要在应用程序地址空间和核心进行多次资料拷贝操作,这些资料拷贝操作所带来的 CPU 以及内存开销是非常大的。

2 Linux IO模型#

网络IO的本质是socket的读取,socket在linux系统被抽象为流,IO可以理解为对流的操作。刚才说了,对于一次IO访问(以read举例),资料会先被拷贝到操作系统核心的缓冲区中,然后才会从操作系统核心的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:

第一阶段:等待资料准备 (Waiting for the data to be ready)。第二阶段:将资料从核心拷贝到程序中 (Copying the data from the kernel to the process)。对于socket流而言,

第一步:通常涉及等待网络上的资料分组到达,然后被复制到核心的某个缓冲区。第二步:把资料从核心缓冲区复制到应用程序缓冲区。网络应用需要处理的无非就是两大类问题,网络IO,资料计算。相对于后者,网络IO的延迟,给应用带来的效能瓶颈大于后者。网络IO的模型大致有如下几种:

同步模型(synchronous IO)阻塞IO(bloking IO)非阻塞IO(non-blocking IO)多路复用IO(multiplexing IO)讯号驱动式IO(signal-driven IO)异步IO(asynchronous IO)注:由于signal driven IO在实际中并不常用,所以我这只提及剩下的四种IO Model。

在深入介绍Linux IO各种模型之前,让我们先来探索一下基本 Linux IO 模型的简单矩阵。如下图所示:

输入图片说明

每个 IO 模型都有自己的使用模式,它们对于特定的应用程序都有自己的优点。本节将简要对其一一进行介绍。常见的IO模型有阻塞、非阻塞、IO多路复用,异步。以一个生动形象的例子来说明这四个概念。周末我和女友去逛街,中午饿了,我们准备去吃饭。周末人多,吃饭需要排队,我和女友有以下几种方案。

2.1 同步阻塞 IO(blocking IO)##

2.1.1 场景描述###

我和女友点完餐后,不知道什么时候能做好,只好坐在餐厅里面等,直到做好,然后吃完才离开。女友本想还和我一起逛街的,但是不知道饭能什么时候做好,只好和我一起在餐厅等,而不能去逛街,直到吃完饭才能去逛街,中间等待做饭的时间浪费掉了。这就是典型的阻塞。

2.1.2 网络模型###

同步阻塞 IO 模型是最常用的一个模型,也是最简单的模型。在linux中,预设情况下所有的socket都是blocking。它符合人们最常见的思考逻辑。阻塞就是程序 "被" 休息, CPU处理其它程序去了。

在这个IO模型中,使用者空间的应用程序执行一个系统呼叫(recvform),这会导致应用程序阻塞,什么也不干,直到资料准备好,并且将资料从核心复制到使用者程序,最后程序再处理资料,在等待资料到处理资料的两个阶段,整个程序都被阻塞。不能处理别的网络IO。呼叫应用程序处于一种不再消费 CPU 而只是简单等待响应的状态,因此从处理的角度来看,这是非常有效的。在呼叫recv()/recvfrom()函式时,发生在核心中等待资料和复制资料的过程,大致如下图:

输入图片说明

2.1.3 流程描述###

当用户程序呼叫了recv()/recvfrom()这个系统呼叫,kernel就开始了IO的第一个阶段:准备资料(对于网络IO来说,很多时候资料在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的资料到来)。这个过程需要等待,也就是说资料被拷贝到操作系统核心的缓冲区中是需要一个过程的。而在使用者程序这边,整个程序会被阻塞(当然,是程序自己选择的阻塞)。第二个阶段:当kernel一直等到资料准备好了,它就会将资料从kernel中拷贝到使用者内存,然后kernel返回结果,使用者程序才解除block的状态,重新执行起来。

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

优点:

能够及时返回资料,无延迟;对核心开发者来说这是省事了;缺点:

对使用者来说处于等待就要付出效能的代价了;2.2 同步非阻塞 IO(nonblocking IO)##

2.2.1 场景描述###

我女友不甘心白白在这等,又想去逛商场,又担心饭好了。所以我们逛一会,回来询问服务员饭好了没有,来来回回好多次,饭都还没吃都快累死了啦。这就是非阻塞。需要不断的询问,是否准备好了。

2.2.2 网络模型###

同步非阻塞就是 “每隔一会儿瞄一眼进度条” 的轮询(polling)方式。在这种模型中,装置是以非阻塞的形式开启的。这意味着 IO 操作不会立即完成,read 操作可能会返回一个错误程式码,说明这个命令不能立即满足(EAGAIN 或 EWOULDBLOCK)。

在网络IO时候,非阻塞IO也会进行recvform系统呼叫,检查资料是否准备好,与阻塞IO不一样,"非阻塞将大的整片时间的阻塞分成N多的小的阻塞, 所以程序不断地有机会 '被' CPU光顾"。

也就是说非阻塞的recvform系统呼叫呼叫之后,程序并没有被阻塞,核心马上返回给程序,如果资料还没准备好,此时会返回一个error。程序在返回之后,可以干点别的事情,然后再发起recvform系统呼叫。重复上面的过程,循环往复的进行recvform系统呼叫。这个过程通常被称之为轮询。轮询检查核心资料,直到资料准备好,再拷贝资料到程序,进行资料处理。需要注意,拷贝资料整个过程,程序仍然是属于阻塞的状态

在linux下,可以通过设定socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程如图所示:

输入图片说明

2.2.3 流程描述###

当用户程序发出read操作时,如果kernel中的资料还没有准备好,那么它并不会block使用者程序,而是立刻返回一个error。从使用者程序角度讲,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。使用者程序判断结果是一个error时,它就知道资料还没有准备好,于是它可以再次传送read操作。一旦kernel中的资料准备好了,并且又再次收到了使用者程序的system call,那么它马上就将资料拷贝到了使用者内存,然后返回。

所以,nonblocking IO的特点是使用者程序需要不断的主动询问kernel资料好了没有。

同步非阻塞方式相比同步阻塞方式:

优点:能够在等待任务完成的时间里干其他活了(包括提交其他任务,也就是 “后台” 可以有多个任务在同时执行)。

缺点:任务完成的响应延迟增大了,因为每过一段时间才去轮询一次read操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体资料吞吐量的降低。

2.3 IO 多路复用( IO multiplexing)##

2.3.1 场景描述###

与第二个方案差不多,餐厅安装了电子屏幕用来显示点餐的状态,这样我和女友逛街一会,回来就不用去询问服务员了,直接看电子屏幕就可以了。这样每个人的餐是否好了,都直接看电子屏幕就可以了,这就是典型的IO多路复用。

2.3.2 网络模型###

由于同步非阻塞方式需要不断主动轮询,轮询占据了很大一部分过程,轮询会消耗大量的CPU时间,而 “后台” 可能有多个任务在同时进行,人们就想到了循环查询多个任务的完成状态,只要有任何一个任务完成,就去处理它。如果轮询不是程序的使用者态,而是有人帮忙就好了。那么这就是所谓的 “IO 多路复用”。UNIX/Linux 下的 select、poll、epoll 就是干这个的(epoll 比 poll、select 效率高,做的事情是一样的)。

IO多路复用有两个特别的系统呼叫select、poll、epoll函式。select呼叫是核心级别的,select轮询相对非阻塞的轮询的区别在于---前者可以等待多个socket,能实现同时对多个IO埠进行监听,当其中任何一个socket的资料准好了,就能返回进行可读,然后程序再进行recvform系统呼叫,将资料由核心拷贝到使用者程序,当然这个过程是阻塞的。select或poll呼叫之后,会阻塞程序,与blocking IO阻塞不同在于,此时的select不是等到socket资料全部到达再处理, 而是有了一部分资料就会呼叫使用者程序来处理。如何知道有一部分资料到达了呢?监视的事情交给了核心,核心负责资料到达的处理。也可以理解为"非阻塞"吧。

I/O复用模型会用到select、poll、epoll函式,这几个函式也会使程序阻塞,但是和阻塞I/O所不同的的,这两个函式可以同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函式进行检测,直到有资料可读或可写时(注意不是全部资料可读或可写),才真正呼叫I/O操作函式。

对于多路复用,也就是轮询多个socket。多路复用既然可以处理多个IO,也就带来了新的问题,多个IO之间的顺序变得不确定了,当然也可以针对不同的编号。具体流程,如下图所示:

输入图片说明

2.3.3 流程描述###

IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连线的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有资料到达了,就通知使用者程序。

当用户程序呼叫了select,那么整个程序会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的资料准备好了,select就会返回。这个时候使用者程序再呼叫read操作,将资料从kernel拷贝到使用者程序。

多路复用的特点是通过一种机制一个程序能同时等待IO档案描述符,核心监视这些档案描述符(套接字描述符),其中的任意一个进入读就绪状态,select, poll,epoll函式就可以返回。对于监视的方式,又可以分为 select, poll, epoll三种方式。

上面的图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个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。所以IO多路复用是阻塞在select,epoll这样的系统呼叫之上,而没有阻塞在真正的I/O系统呼叫如recvfrom之上。

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

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

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

了解了前面三种IO模式,在使用者程序进行系统呼叫的时候,他们在等待资料到来的时候,处理的方式不一样,直接等待,轮询,select或poll轮询,两个阶段过程:

第一个阶段有的阻塞,有的不阻塞,有的可以阻塞又可以不阻塞。

第二个阶段都是阻塞的。

从整个IO过程来看,他们都是顺序执行的,因此可以归为同步模型(synchronous)。都是程序主动等待且向核心检查状态。【此句很重要!!!】

高并发的程式一般使用同步非阻塞方式而非多执行绪 + 同步阻塞方式。要理解这一点,首先要扯到并发和并行的区别。比如去某部门办事需要依次去几个视窗,办事大厅里的人数就是并发数,而视窗个数就是并行度。也就是说并发数是指同时进行的任务数(如同时服务的 HTTP 请求),而并行数是可以同时工作的物理资源数量(如 CPU 核数)。通过合理排程任务的不同阶段,并发数可以远远大于并行度,这就是区区几个 CPU 可以支援上万个使用者并发请求的奥秘。在这种高并发的情况下,为每个任务(使用者请求)建立一个程序或执行绪的开销非常大。而同步非阻塞方式可以把多个 IO 请求丢到后台去,这就可以在一个程序里服务大量的并发 IO 请求。

注意:IO多路复用是同步阻塞模型还是异步阻塞模型,在此给大家分析下:

此处仍然不太清楚的,强烈建议大家在细究《聊聊同步、异步、阻塞与非阻塞》中讲同步与异步的根本性区别,同步是需要主动等待讯息通知,而异步则是被动接收讯息通知,通过回拨、通知、状态等方式来被动获取讯息。IO多路复用在阻塞到select阶段时,使用者程序是主动等待并呼叫select函式获取资料就绪状态讯息,并且其程序状态为阻塞。所以,把IO多路复用归为同步阻塞模式。

2.4 讯号驱动式IO(signal-driven IO)##

讯号驱动式I/O:首先我们允许Socket进行讯号驱动IO,并安装一个讯号处理函式,程序继续执行并不阻塞。当资料准备好时,程序会收到一个SIGIO讯号,可以在讯号处理函式中呼叫I/O操作函式处理资料。过程如下图所示:

输入图片说明

2.5 异步非阻塞 IO(asynchronous IO)##

2.5.1 场景描述###

女友不想逛街,又餐厅太吵了,回家好好休息一下。于是我们叫外卖,打个电话点餐,然后我和女友可以在家好好休息一下,饭好了送货员送到家里来。这就是典型的异步,只需要打个电话说一下,然后可以做自己的事情,饭好了就送来了。

2.5.2 网络模型###

相对于同步IO,异步IO不是顺序执行。使用者程序进行aio_read系统呼叫之后,无论核心资料是否准备好,都会直接返回给使用者程序,然后使用者态程序可以去做别的事情。等到socket资料准备好了,核心直接复制资料给程序,然后从核心向程序传送通知。IO两个阶段,程序都是非阻塞的。

Linux提供了AIO库函式实现异步,但是用的很少。目前有很多开源的异步IO库,例如libevent、libev、libuv。异步过程如下图所示:

输入图片说明

2.5.3 流程描述###

使用者程序发起aio_read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对使用者程序产生任何block。然后,kernel会等待资料准备完成,然后将资料拷贝到使用者内存,当这一切都完成之后,kernel会给使用者程序传送一个signal或执行一个基于执行绪的回拨函式来完成这次 IO 处理过程,告诉它read操作完成了。

在 Linux 中,通知的方式是 “讯号”:

如果这个程序正在使用者态忙着做别的事(例如在计算两个矩阵的乘积),那就强行打断之,呼叫事先注册的讯号处理函式,这个函式可以决定何时以及如何处理这个异步任务。由于讯号处理函式是突然闯进来的,因此跟中断处理程式一样,有很多事情是不能做的,因此保险起见,一般是把事件 “登记” 一下放进伫列,然后返回该程序原来在做的事。

如果这个程序正在核心态忙着做别的事,例如以同步阻塞方式读写磁盘,那就只好把这个通知挂起来了,等到核心态的事情忙完了,快要回到使用者态的时候,再触发讯号通知。

如果这个程序现在被挂起了,例如无事可做 sleep 了,那就把这个程序唤醒,下次有 CPU 空闲的时候,就会排程到这个程序,触发讯号通知。

异步 API 说来轻巧,做来难,这主要是对 API 的实现者而言的。Linux 的异步 IO(AIO)支援是 2.6.22 才引入的,还有很多系统呼叫不支援异步 IO。Linux 的异步 IO 最初是为数据库设计的,因此通过异步 IO 的读写操作不会被快取或缓冲,这就无法利用操作系统的快取与缓冲机制。

很多人把 Linux 的 O_NONBLOCK 认为是异步方式,但事实上这是前面讲的同步非阻塞方式。需要指出的是,虽然 Linux 上的 IO API 略显粗糙,但每种程式设计框架都有封装好的异步 IO 实现。操作系统少做事,把更多的自由留给使用者,正是 UNIX 的设计哲学,也是 Linux 上程式设计框架百花齐放的一个原因。

从前面 IO 模型的分类中,我们可以看出 AIO 的动机:

同步阻塞模型需要在 IO 操作开始时阻塞应用程序。这意味着不可能同时重叠进行处理和 IO 操作。

同步非阻塞模型允许处理和 IO 操作重叠进行,但是这需要应用程序根据重现的规则来检查 IO 操作的状态。

这样就剩下异步非阻塞 IO 了,它允许处理和 IO 操作重叠进行,包括 IO 操作完成的通知。

IO多路复用除了需要阻塞之外,select 函式所提供的功能(异步阻塞 IO)与 AIO 类似。不过,它是对通知事件进行阻塞,而不是对 IO 呼叫进行阻塞。

2.6 关于异步阻塞##

有时我们的 API 只提供异步通知方式,例如在 node.js 里,但业务逻辑需要的是做完一件事后做另一件事,例如数据库连线初始化后才能开始接受使用者的 HTTP 请求。这样的业务逻辑就需要呼叫者是以阻塞方式来工作。

为了在异步环境里模拟 “顺序执行” 的效果,就需要把同步程式码转换成异步形式,这称为 CPS(Continuation Passing Style)变换。BYVoid 大神的 continuation.js 库就是一个 CPS 变换的工具。使用者只需用比较符合人类常理的同步方式书写程式码,CPS 变换器会把它转换成层层巢状的异步回拨形式。

输入图片说明

输入图片说明

另外一种使用阻塞方式的理由是降低响应延迟。如果采用非阻塞方式,一个任务 A 被提交到后台,就开始做另一件事 B,但 B 还没做完,A 就完成了,这时要想让 A 的完成事件被尽快处理(比如 A 是个紧急事务),要么丢弃做到一半的 B,要么储存 B 的中间状态并切换回 A,任务的切换是需要时间的(不管是从磁盘载入到内存,还是从内存载入到快取内存),这势必降低 A 的响应速度。因此,对实时系统或者延迟敏感的事务,有时采用阻塞方式比非阻塞方式更好。

3 五种IO模型总结#

3.1 blocking和non-blocking区别##

呼叫blocking IO会一直block住对应的程序直到操作完成,而non-blocking IO在kernel还准备资料的情况下会立刻返回。

3.2 synchronous IO和asynchronous IO区别##

在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。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阻塞。按照这个定义,之前所述的blocking IO,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。

各个IO Model的比较如图所示:

输入图片说明

通过上面的图片,可以发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然程序大部分时间都不会被block,但是它仍然要求程序去主动的check,并且当资料准备完成以后,也需要程序主动的再次呼叫recvfrom来将资料拷贝到使用者内存。而asynchronous IO则完全不同。它就像是使用者程序将整个IO操作交给了他人(kernel)完成,然后他人做完后发讯号通知。在此期间,使用者程序不需要去检查IO操作的状态,也不需要主动的去拷贝资料。

作者:猿码道

连结:https://www.jianshu.com/p/486b0965c296

2019-10-26 09:57:00

相关文章