Linux 存储系统的 I/O 软件分层,分为三个层次,分别是文件系统层、通用块层、设备层。
常见的IO分为三类:
缓冲与非缓冲 I/O
直接与非直接 I/O
阻塞与非阻塞 I/O VS 同步与异步 I/O
阻塞 I/O、非阻塞 I/O,还是基于非阻塞 I/O 的多路复用都是同步调用。因为它们在 read 调用时,内核将数据从内核空间拷贝到应用程序空间,过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间。
1.缓冲与非缓冲IO
根据是否利用标准库缓冲:
比如linux中输入密码经常看不见,是因为直到遇见换行指令,才进行IO,之前的内容都是先被标准库暂时缓存起来,这样做可以减少系统调用次数。
2.直接与非直接IO
Linux 内核为了减少磁盘 I/O 次数,在系统调用后,会把用户数据拷贝到内核中缓存起来,这个内核缓存空间也就是「页缓存」,只有当缓存满足某些条件的时候,才发起磁盘 I/O 的请求。
根据是否利用操作系统缓存:
触发内核缓存写磁盘的操作
3.阻塞与非阻塞IO 与 同步与异步IO
阻塞IO:
执行read,线程先被阻塞,一直到内核数据准备好,并把数据从内核拷贝到应用程序的缓冲区中,拷贝完成read返回。
阻塞得等1.内核准备好数据 2.数据从内核态拷贝到用户态 两个过程
非阻塞IO
read请求在数据尚未准备好就立即返回,此时应用程序不断轮询,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read获取到结果。
即read发起系统调用一直轮询返回,直到数据准备好,而后从内核拷贝到应用进程。
最后一次read,获取数据是一个同步的过程,需要等待,同步指内核态数据拷贝到用户态程序缓存区的过程。
访问管道或者socket ,设置O_NONBLOCK,就表示非阻塞。
socket:IP+端口;
为了解决非阻塞IO傻乎乎的轮询,通过IO事件分发select /poll,等内核数据准备好,再以事件通知应用程序操作。
这个做法大大改善了应用进程对 CPU 的利用率,在没有被通知的情况下,应用进程可以使用 CPU 做其他的事情。
多进程模型:
如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型,也就是为每个客户端分配一个进程来处理请求。
服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」,这时就通过 fork() 函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。
又因为子进程复制了父进程的文件描述符,可以直接用socket。可以发现,子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」;父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」。
子进程再使用结束推出时,会变成僵尸进程,需要用wait() 和waitpid()函数回收资源。
多线程模型:
线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源的,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时是不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。
当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。
使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出已连接 Socket 进程处理。
这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁。
1.select:
select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。
2.poll
poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
3.epoll
epoll 通过两个方面,很好解决了 select/poll 的问题。
指 内核准备数据 以及 数据从内核态拷贝到用户态的应用进程 都不需要等待。
在前面我们知道了,I/O 是分为两个过程的:
1.数据准备的过程
2.数据从内核空间拷贝到用户进程缓冲区的过程
阻塞 I/O 会阻塞在「过程 1 」和「过程 2」,而非阻塞 I/O 和基于非阻塞 I/O 的多路复用只会阻塞在「过程 2」,所以这三个都可以认为是同步 I/O。
本质时linux内核管理的内存区域,应用程序通过mmap以及vfs通过Buffer io将文件读取到内存空间,实际上读到了page cache
Page Cache 用于缓存文件的页数据,buffer cache 用于缓存块设备(如
磁盘)的块数据。
页是逻辑上的概念,因此 Page Cache 是与文件系统同级的;
块是物理上的概念,因此 buffer cache 是与块设备驱动程序同级的。
主要优点:1.缓存最近被访问的数据(根据时空局部性)2.预读功能
Page Cache 与 buffer cache 的共同目的都是加速数据 I/O过程:
写数据时首先写到缓存,将写入的页标记为 dirty,然后向外部存储 flush,也就是缓存写机制中的 write-back(另一种是 write-through,Linux 默认情况下不采用);
读数据时首先读取缓存,如果未命中,再去外部存储读取,并且将读取来的数据也加入缓存。操作系统总是积极地将所有空闲内存都用作 Page Cache 和 buffer cache,当内存不够用时也会用 LRU 等算法淘汰缓存页。
预读功能:
读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,PageCache 使用了「预读功能」。
假设 read 方法每次只会读 32 KB 的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取到 PageCache,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就非常大。
任何系统引入缓存,就会引发一致性问题:内存中的数据与磁盘中的数据不一致
文件 = 数据 + 元数据。元数据用来描述文件的各种属性,也必须存储在磁盘上。因此,我们说保证文件一致性其实包含了两个方面:数据一致+元数据一致。
如果发生写操作并且对应的数据在 Page Cache 中,那么写操作就会直接作用于 Page Cache 中,此时如果数据还没刷新到磁盘,那么内存中的数据就领先于磁盘,此时对应 page 就被称为 Dirty page
linux 写穿和写回实现文件一致性(内存和磁盘一致)
依赖于以下三种系统调用:
不会,执行write系统调用,实际上会写到内核的page cache中,其作为一块缓冲区,即使进程崩溃,文件仍会保留。而后我们再次尝试读的时候,也是从page cache读的,因此数据没有丢。
内核会找个合适的时机,将 page cache 中的数据持久化到磁盘。但是如果 page cache 里的文件数据,在持久化到磁盘化到磁盘之前,系统发生了崩溃,那这部分数据就会丢失了。
解决方法:
程序里调用 fsync 函数,在写文文件的时候,立刻将文件数据持久化到磁盘,这样就可以解决系统崩溃导致的文件数据丢失的问题。
作用:屏蔽每个设备用法功能的不同,通过操作系统把IO设备统一管理。
流程:设备 到 控制器 到 接口(内部有三种寄存器,分别管状态,命令和数据) 经过总线 直接到 CPU/内存
通过写入这些寄存器,操作系统可以命令设备发送数据、接收数据、开启或关闭,或者执行某些其他操作。
通过读取这些寄存器,操作系统可以了解设备的状态,是否准备好接收一个新的命令等。
块设备与字符设备
CPU如何与设备控制器和数据缓冲区通信
频繁的中断对磁盘类的设备不友好,会让CPU经常被打断。
DMA:直接内存访问 使设备再CPU不参与的情况下,自行完成设备IO数据到内存。简单理解就是,在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。
具体过程:
传输文件,需要read 一次,write一次,正常4次用户态和内核态切换,4次数据拷贝
第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。
存储系统的 I/O 是整个系统最慢的一个环节,所以 Linux 提供了不少缓存机制来提高 I/O 的效率:
要想减少上下文切换的次数,就要减少系统调用。
大文件:
在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O(不等数据就返回,等内核准备好了通知) + 直接 I/O(不使用page cache)」来替代零拷贝技术。
总结:大文件:异步IO+直接IO。 小文件:零拷贝
定义:没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。
零拷贝技术实现的方式通常有 2 种:
我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。但仍然需要 4 次上下文切换,因为系统调用还是 2 次。
sendfile
#include
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
文件传输函数,可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:
但这仍然有3次拷贝,如果网卡支持 SG-DMA,sendfile() 系统调用的过程发生了点变化,具体过程如下:
第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;
只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。零拷贝技术可以把文件传输的性能提高至少一倍以上。
架构:CPU的内存接口通过系统总线接入IO桥接器,桥接器另一端通过内存总线连接内存。这样CPU和内存可以通信。
从IO桥接器拉出到IO总线,IO总线通过设备控制器连接外设。
发生了什么:
注:显示的过程显卡处于图形模式(如果计算机刚刚加电启动,显卡最开始处于文本模式),屏幕分辨率为 1024x768,即把屏幕分成 768 行,每行 1024 个像素点,但每个像素点占用显存的 32 位数据(4 字节,红、绿、蓝、透明各占 8 位)。我们只要往对应的显存地址写入相应的像素数据,屏幕对应的位置就能显示了。