title | tags | categories | keywords | description | cover | abbrlink | date | |||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
操作系统-IO与零拷贝 |
|
|
操作系统,IO,零拷贝 |
基本面试会问到的IO进行了详解,同时本篇文章也对面试以及平时工作中会看到的零拷贝进行了充分的解析。万字长文系列,读到就是赚到。 |
e959db2e |
2021-04-08 08:21:58 -0700 |
- 本篇文章对于IO相关内容并没有完全讲完,不过也讲的差不多了,基本面试会问到的都讲了,如果想看的更细的,推荐**《操作系统导论》**这本书。
- 同时本篇文章也讲了面试以及平时工作中会看到的零拷贝,因为和IO有比较大的关系,就在这篇文章写一下。
- 零拷贝很多开源项目都用到了,netty,kafka,rocketmq等等。所以还是比较重要的,也是面试常问
- 流程图为processOn手工画的
这应该是大家看到很多文章对IO的一种分类,这只是IO最常见的一种分类。是从是否阻塞,以及是否异步的角度来分类的
在这里,我们以一个网络IO来的read来举例,它会涉及到两个东西:一个是产生这个IO的进程,另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:
**阶段1:**等待数据准备
**阶段2:**数据从内核空间拷贝到用户进程缓冲区的过程
- 当用户进程进行recvfrom这个系统调用,内核就开始了IO的第一个阶段:等待数据准备。
- 于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的TCP包),这个时候内核就要等待足够的数据到来。
- 而在用户进程这边,整 个进程会被阻塞。当内核一直等到数据准备好了,它就会将数据从内核中拷贝到用户内存,然后内核返回果,用户进程才解除 block的状态,重新运行起来。
- 所以,blocking IO的特点就是在IO执行的两个阶段都被block了。
- 当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。
- 从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好。用户线程需要不断地发起IO请求,直到数据到达后,才真正读取到数据,继续执行。
- 虽然用户线程每次发起IO请求后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的CPU的资源。一般很少直接使用这种模型,而是在其他IO模型中使用非阻塞IO这一特性。
- 所以,用户进程第一个阶段不是阻塞的,需要不断的主动询问内核数据好了没有;第二个阶段依然总是阻塞的。
应用程序每次轮询内核的 I/O 是否准备好,感觉有点傻乎乎,因为轮询的过程中,应用程序啥也做不了,只是在循环。
为了解决这种傻乎乎轮询方式,于是 I/O 多路复用技术就出来了,如 select、poll,它是通过 I/O 事件分发,当内核数据准备好时,再以事件通知应用程序进行操作。
这个做法大大改善了应用进程对 CPU 的利用率,在没有被通知的情况下,应用进程可以使用 CPU 做其他的事情。
下面是大概的过程
- IO多路复用模型是建立在内核提供的多路分离函数select基础之上的,使用select函数可以避免同步非阻塞IO模型中轮询等待的问题。利用了新的select系统调用,由内核来负责本来是请求进程该做的轮询操作
- 它的基本原理就是select /epoll这个函数会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程,正式发起read请求。
- 从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket(也就是数据准备好了的socket),即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。
select函数
handle_events:实现事件循环
handle_event:进行读/写等操作
- 使用select函数的优点并不仅限于此。虽然上述方式允许单线程内处理多个IO请求,但是每个IO请求的过程还是阻塞的(在select函数上阻塞),平均时间甚至比同步阻塞IO模型还要长。
- 如果用户线程只注册自己感兴趣的socket或者IO请求,然后去做自己的事情,等到数据到来时再进行处理,则可以提高CPU的利用率。
- IO多路复用模型使用了Reactor设计模式实现了这一机制。
- 通过Reactor的方式,可以将用户线程轮询IO操作状态的工作统一交给handle_events事件循环进行处理。用户线程注册事件处理器之后可以继续执行做其他的工作(异步),而Reactor线程负责调用内核的select函数检查socket状态。当有socket被激活时(就是数据准备好的时候),则通知相应的用户线程(或执行用户线程的回调函数),执行handle_event进行数据读取、处理的工作。
- 由于select函数是阻塞的,因此多路IO复用模型也被称为异步阻塞IO模型。注意,这里的所说的阻塞是指select函数执行时线程被阻塞,而不是指socket。(一般在使用IO多路复用模型时,socket都是设置为NONBLOCK的,不过这并不会产生影响,因为用户发起IO请求时,数据已经到达了,用户线程一定不会被阻塞。)
- 实际上,无论是阻塞 I/O、非阻塞 I/O,还是基于非阻塞 I/O 的多路复用都是同步调用。因为它们在 read 调用时,内核将数据从内核空间拷贝到应用进程空间,这个阶段都是需要等待的。
- 而真正的异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待
- 真正”的异步IO需要操作系统更强的支持。在IO多路复用模型中,由用户线程自行读取数据、处理数据。
- 而在异步IO模型中,用户进程发起read操作之后,立刻就可以开始去做其它的事。
- 而另一方面,从内核的角度,当它受到一个异步读之后,首先它会立刻返回,所以不会对用户进程产生任何阻塞。然后,内核会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都 完成之后,内核会给用户进程发送一个信号,告诉它read操作完成了,用户线程直接使用即可。 在这整个过程中,进程完全没有被阻塞。
- 异步IO模型使用了Proactor设计模式实现了这一机制。(具体怎么搞得,看上面的文章链接)
有一篇文章以实际例子讲解的比较形象
-
磁盘 I/O 是非常慢的,所以 Linux 内核通过减少磁盘 I/O 次数来减少I/O时间,在系统调用后,会把用户数据拷贝到内核中缓存起来,这个内核缓存空间也就是【页缓存:PageCache】,只有当缓存满足某些条件的时候,才发起磁盘 I/O 的请求。
-
根据是「否利用操作系统的页缓存」,可以把文件 I/O 分为直接 I/O 与非直接 I/O:
- 直接 I/O,不会发生内核缓存和用户程序之间数据复制,跳过操作系统的页缓存,直接经过文件系统访问磁盘。
- 非直接 I/O,正相反,读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘。
- 想要实现直接I/O,需要你在系统调用中,指定 O_DIRECT 标志。如果没有设置过,默认的是非直接I/O。
在进行写操作的时候以下几种场景会触发内核缓存的数据写入磁盘:
以下摘自---《深入linux内核架构》
1、
可能因不同原因、在不同的时机触发不同的刷出数据的机制。
周期性的内核线程,将扫描脏页的链表,并根据页变脏的时间,来选择一些页写回。如果系统不是太忙于写操作,那么在脏页的数目,以及刷出页所需的硬盘访问操作对系统造成的负荷之间,有一个可接受的比例。
如果系统中的脏页过多(例如,一个大型的写操作可能造成这种情况),内核将触发进一步的机制对脏页与后备存储器进行同步,直至脏页的数目降低到一个可接受的程度。而“脏页过多”和“可接受的程度”到底意味着什么,此时尚是一个不确定的问题,将在下文讨论。
内核的各个组件可能要求数据必须在特定事件发生时同步,例如在重新装载文件系统时。
前两种机制由内核线程pdflush实现,该线程执行同步代码,而第三种机制可能由内核中的多处代码触发。
2、
可以从用户空间通过各种系统调用来启用内核同步机制,以确保内存和块设备之间(完全或部分)的数据完整性。有如下3个基本选项可用。
使用sync系统调用刷出整个缓存内容。在某些情况下,这可能非常耗时。
各个文件的内容(以及相关inode的元数据)可以被传输到底层的块设备。内核为此提供了fsync和fdatasync系统调用。尽管sync通常与上文提到的系统工具sync联合使用,但fsync和fdatasync则专用于特定的应用程序,因为刷出的文件是通过特定于进程的文件描述符(在第8章介绍)来选择的。因而,没有一个通用的用户空间工具可以回写特定的文件。
msync用于同步内存映射
1、我们先来说脏页
脏页-linux内核中的概念,因为硬盘的读写速度远赶不上内存的速度,系统就把读写比较频繁的数据事先放到内存中,以提高读写速度,这就叫高速缓存,linux是以页作为高速缓存的单位,当进程修改了高速缓存里的数据时,该页就被内核标记为脏页,内核将会在合适的时间把脏页的数据写到磁盘中去,以保持高速缓存中的数据和磁盘中的数据是一致的。
2、通过对上面的解读,我们用通俗的语言翻译以下
- 周期性的扫描脏页,如果发现脏页存在的时间过了某一时间时,也会把该脏页的数据刷到磁盘上
- 当发现脏页太多的时候,内核会把一定数量的脏页数据写到磁盘上;
- 用户主动调用
sync
,fsync
,fdatasync
,内核缓存会刷到磁盘上;
- 文件操作的标准库是可以实现数据的缓存,那么根据「是否利用标准库缓冲」,可以把文件 I/O 分为缓冲 I/O 和非缓冲 I/O:
- 缓冲 I/O,利用的是标准库的缓存实现文件的加速访问,而标准库再通过系统调用访问文件。
- 非缓冲 I/O,直接通过系统调用访问文件,不经过标准库缓存。
- 这里所说的「缓冲」特指标准库内部实现的缓冲。比方说,很多程序遇到换行时才真正输出,而换行前的内容,其实就是被标准库暂时缓存了起来,这样做的目的是,减少系统调用的次数。
- 非缓冲io,因为没有标准库提供的缓冲,只能用操作系统的缓存区,会造成很多次的系统调用,降低效率
- 带缓存IO也叫标准IO,符合ANSI C 的标准IO处理,不依赖系统内核,所以移植性强,我们使用标准IO操作很多时候是为了减少对read()和write()的系统调用次数,带缓存IO其实就是在用户层再建立一个缓存区,这个缓存区的分配和优化长度等细节都是标准IO库代你处理好了,不用去操心。
标准 I/O 库提供缓冲的目的是尽可能减少使用 read 和 write 调用的次数(见图 3-6,其中显示了在不同缓冲区长度情况下,执行 I/O 所需的 CPU 时间量)。它也对每个 I/O流自动地进行缓冲管理,从而避免了应用程序需要考虑这一点所带来的麻烦。遗憾的是,标准 I/O 库最令人迷惑的也是它的缓冲。
标准 I/O提供了以下3 种类型的缓冲。
- 全缓冲。在这种情况下,在填满标准 I/O 缓冲区后才进行实际 I/O 操作。对于驻留在磁盘上的文件通常是由标准 IO库实施全缓冲的。在一个流上执行第一次 I/O 操作时,相关标准 I/O函数通常调用 malloc (见7.8 节)获得需使用的缓冲区。 术语冲洗(fush)说明标准 UO 缓冲区的写操作。缓冲区可由标准 I/O 例程自动地冲洗(例如,当填满一个缓冲区时),或者可以调用函数 fflush 冲洗一个流。值得注意的是,在 UNTX环境中,fush有两种意思。在标准 I/O库方面,flush(冲洗)意味着将缓冲区中的内容写到磁盘上(该缓冲区可能只是部分填满的)。在终端驱动程序方面(例如,在第 18章中所述的tcflush函数),flush(刷清)表示丢弃已存储在缓冲区中的数据。
- 行缓冲。在这种情况下,当在输入和输出中遇到换行符时,标准 I/O 库执行 I/O 操作。这允许我们一次输出一个字符(用标准 I/O 函数fputc),但只有在写了一行之后才进行实际 I/O操作。当流涉及一个终端时(如标准输入和标准输出),通常使用行缓冲。 对于行缓冲有两个限制。第一,因为标准 I/O 库用来收集每一行的缓冲区的长度是固定的。所以只要填满了缓冲区,那么即使还没有写一个换行符,也进行 I/O 操作。第二,任何时候只要通过标准 I/O 库要求从(a)一个不带缓冲的流,或者(b)一个行缓冲的流(它从内核请求需要 数据)得到输入数据,那么就会冲洗所有行缓冲输出流。在(b)中带了一个在括号中的说明,其理由是,所需的数据可能已在该缓冲区中,它并不要求一定从内核读数据。很明显,从一个不带缓冲的流中输入(即(a)项)需要从内核获得数据。
- 不带缓冲。标准 I/O 库不对字符进行缓冲存储,例如,若用标准 I/O 函数 fputs 写 15个字符到不带缓冲的流中,我们就期望这 15 个字符能立即输出,很可能使用 3.8 节的write 函数将这些字符写到相关联的打开文件中。
讲零拷贝前,讲一下前置知识
- 来看一个标准设备(不是真实存在的,相当于一个逻辑上抽象的东西),通过它来帮助我们更好地理解设备交互的机制。可以看到一个包含两部分重要组件的设备。第一部分是向系统其他部分展现的硬件接口(interface)。同软件一样,硬件也需要一些接口,让系统软件来控制它的操作。因此,所有设备都有自己的特定接口以及典型交互的协议。
- 第2部分是它的内部结构(internal structure)。这部分包含设备相关的特定实现,负责具体实现设备展示给系统的抽象接口。
- 在上图中,一个(简化的)设备接口包含3个寄存器:一个状态(status)寄存器,可以读取并查看设备的当前状态;一个命令(command)寄存器,用于通知设备执行某个具体任务;一个数据(data)寄存器,将数据传给设备或从设备接收数据。通过读写这些寄存器,操作系统可以控制设备的行为
- 我们现在来描述操作系统与该设备的典型交互,以便让设备为它做某事。协议如下:
While (STATUS == BUSY);//wait until device is not busy
Write data to DATA register
Write command to COMMAND register
(Doing so starts the device and executes the command)
While (STATUS == BUSY);//wait until device is done with your request
- 该协议包含4步。
- 第1步,操作系统通过反复读取状态寄存器,等待设备进入可以接收命令的就绪状态。我们称之为轮询(polling)设备(基本上,就是问它正在做什么)。
- 第2步,操作系统下发数据到数据寄存器。例如,你可以想象如果这是一个磁盘,需要多次写入操作,将一个磁盘块(比如4KB)传递给设备。如果主CPU参与数据移动(就像这个示例协议一样),我们就称之为编程的I/O(programmedI/O,PIO)。
- 第3步,操作系统将命令写入命令寄存器;这样设备就知道数据已经准备好了,它应该开始执行命令。最后一步,操作系统再次通过不断轮询设备,等待并判断设备是否执行完成命令(有可能得到一个指示成功或失败的错误码)。
- 这个简单的协议好处是足够简单并且有效。但是难免会有一些低效和不方便。我们注意到这个协议存在的第一个问题就是轮询过程比较低效,在等待设备执行完成命令时浪费大量CPU时间,如果此时操作系统可以切换执行下一个就绪进程,就可以大大提高CPU的利用率。
关键问题:如何减少轮询开销操作系统检查设备状态时如何避免频繁轮询,从而降低管理设备的CPU开销?
**概念:**有了中断后,CPU 不再需要不断轮询设备,而是向设备发出一个请求,然后就可以让对应进程睡眠,切换执行其他任务。当设备完成了自身操作,会抛出一个硬件中断,引发CPU跳转执行操作系统预先定义好的中断服务例程(InterruptService Routine,ISR),或更为简单的中断处理程序(interrupt handler)。中断处理程序是一小段操作系统代码,它会结束之前的请求(比如从设备读取到了数据或者错误码)并且唤醒等待I/O的进程继续执行。
例子:
- 没有中断时:进程1在CPU上运行一段时间(对应CPU那一行上重复的1),然后发出一个读取数据的I/O请求给磁盘。如果没有中断,那么操作系统就会简单自旋,不断轮询设备状态,直到设备完成I/O操作(对应其中的p)。当设备完成请求的操作后,进程1又可以继续运行。
-
有了中断后:中断允许计算与I/O重叠(overlap),这是提高CPU利用率的关键。我们利用中断并允许重叠,操作系统就可以在等待磁盘操作时做其他事情。
- 在这个例子中,在磁盘处理进程1的请求时,操作系统在CPU上运行进程2。磁盘处理完成后,触发一个中断,然后操作系统唤醒进程1继续运行。这样,在这段时间,无论CPU还是磁盘都可以有效地利用。
注意,使用中断并非总是最佳方案。假如有一个非常高性能的设备,它处理请求很快:通常在CPU第一次轮询时就可以返回结果。此时如果使用中断,反而会使系统变慢:切换到其他进程,处理中断,再切换回之前的进程代价不小。因此,如果设备非常快,那么最好的办法反而是轮询。如果设备比较慢,那么采用允许发生重叠的中断更好。如果设备的速度未知,或者时快时慢,可以考虑使用混合(hybrid)策略,先尝试轮询一小段时间,如果设备没有完成操作,此时再使用中断。这种两阶段(two-phased)的办法可以实现两种方法的好处。
中断仍旧存在的缺点:
IO过程简述:
- 用户进程调用 read 方法,向cpu发出 I/O 请求
- cpu向磁盘发起IO请求给磁盘控制器,之后立马返回。返回之后cpu可以切换到其它进程执行其他任务
- 磁盘控制器收到指令后,于是就开始进行磁盘IO,磁盘IO完成后会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断
- CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据读进内核的页缓存【这个过程是可以用DMA进行优化的】。
- 接着将数据从内核页缓存拷贝到用户进程空间【这个过程想要优化,只能用到我们上面说的异步IO】
- 最后read()调用返回。
注意:
- 这里很多博客画的图是错的,讲的也是错的。使用中断减少CPU开销时,在进行磁盘IO期间,CPU可以执行其他的进程不必等待磁盘IO。【因为这是《操作系统导论》里的原话】
这里为什么要特别强调原文呢?因为可以让读者读的安心,这本经典书籍总不会出错吧
《操作系统导论》原文:
标准协议还有一点需要我们注意。具体来说,如果使用编程的I/O将一大块数据传给设备,CPU又会因为琐碎的任务而变得负载很重,浪费了时间和算力,本来更好是用于运行其他进程。下面的时间线展示了这个问题:
进程1在运行过程中需要向磁盘写一些数据,所以它开始进行I/O操作,将数据从内存拷贝到磁盘(其中标示c的过程)。拷贝结束后,磁盘上的I/O操作开始执行,此时CPU才可以处理其他请求。
也就是说在从内存拷贝到磁盘或者从磁盘拷贝到内存这个过程是可以使用DMA(Direct Memory Access)优化的,怎么优化呢?
原文:
DMA工作过程如下。为了能够将数据传送给设备,操作系统会通过编程告诉DMA引擎数据在内存的位置,要拷贝的大小以及要拷贝到哪个设备。在此之后,操作系统就可以处理其他请求了。当DMA的任务完成后,DMA控制器会抛出一个中断来告诉操作系统自己已经完成数据传输。修改后的时间线如下:
从时间线中可以看到,数据的拷贝工作都是由DMA控制器来完成的。因为CPU在此时是空闲的,所以操作系统可以让它做一些其他事情,比如此处调度进程2到CPU来运行。因此进程2在进程1再次运行之前可以使用更多的CPU。
为了更好理解,看图:
过程:
- 用户进程调用 read 方法,向cpu发出 I/O 请求
- cpu将IO请求交给DMA控制器,之后自己立马返回去执行其他进程的任务
- DMA向磁盘发起IO请求
- 磁盘控制器收到指令后,于是就开始进行磁盘IO,磁盘IO完成后会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断。
- DMA收到中断后,把磁盘控制器的缓冲区的数据读进内核的页缓存,接着抛出一个中断
- 操作系统收到中断后,调度cpu回来执行之前的进程:将数据从内核页缓存拷贝到用户进程空间【这一步还是只能用异步IO来优化】
- 最后read()调用返回。
场景:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。
-
很明显发生了4次拷贝
- 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝是通过 DMA 的。
- 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是应用程序就可以使用这部分数据了,这个拷贝是由 CPU 完成的。
- 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然由 CPU 完成的。
- 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到协议栈里,这个过程又是由 DMA 完成的。
-
发生了4次用户上下文切换,因为发生了两个系统调用read和write。一个系统调用对应两次上下文切换,所以上下文切换次数在一般情况下只可能是偶数。
想要优化文件传输的性能就两个方向
- 减少上下文切换次数
- 减少数据拷贝次数
因为这两个是最耗时的
read()
系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,为了减少这一步开销,我们可以用 mmap()
替换 read()
系统调用函数。mmap()
系统调用函数会直接把内核缓冲区里的数据映射到用户空间,这样,操作系统内核与用户空间共享缓冲区,就不需要再进行任何的数据拷贝操作。
总的来说mmap减少了一次数据拷贝,总共4次上下文切换,3次数据拷贝
Linux2.1
版本提供了 sendFile
函数,其基本原理如下:数据根本不经过用户态,直接从内核缓冲区进入到 SocketBuffer
总的来说有2次上下文切换,3次数据拷贝。
Linux在2.4
版本中,做了一些修改,避免了从内核缓冲区拷贝到 Socketbuffer
的操作,直接拷贝到协议栈,从而再一次减少了数据拷贝
前文一直提到了内核里的页缓存(PageCache),这个页缓存的作用就是用来提升小文件传输的效率
原因:
- 读写磁盘相比读写内存的速度慢太多了,这个有点基础的人应该都知道,所以我们应该想办法把读写磁盘换成读写内存。于是,我们通过 DMA 把磁盘里的数据拷贝到内存里,这样就可以用读内存替换读磁盘。读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。这点不是很类似redis和mysql的关系吗,所以说操作系统里的一些设计理念和平时工作应用息息相关,毕竟操作系统可是无数大牛的结晶
- 程序运行的时候,具有局部性原理,也就是说刚被访问的数据在短时间内再次被访问的概率很高,通常称为热点数据,于是我们用 PageCache 来缓存这些热点数据,当空间不足时有对应的缓存淘汰策略。
Q:PageCache可以用来大文件传输吗?
A:不能
Q:为什么呢?
A:
- 假设你要几G的数据要传输,用户访问这些大文件的时候,内核会把它们载入 PageCache 中, PageCache 空间很快被这些大文件占满。
- PageCache 由于长时间被大文件占据,其他热点小文件可能就无法使用到 PageCache,就频繁读写磁盘,效率低下
- 而PageCache 中的大文件数据,没有享受到缓存带来的好处,反而却耗费 DMA 多拷贝到 PageCache 一次
- 这前前后后加起来,效率低了很多,所以PageCache不适合小文件传输
而想不用到内核缓冲区,我们就想到了直接IO这个东西,直接IO不经过内核缓存,同时经过上面的讲述,我们也可以知道异步IO效率是最高的。所以大文件传输最好的解决办法应该是:异步IO+直接IO
读到这里你就会发现,我为何这样安排目录顺序了,前面讲到的,后面都会用到。
-
《操作系统导论》 强推