最近在看《Linux内核设计与实现》的时候,就想着要不把知识串联一下吧。聊什么呢?今天先来聊聊 Android IO 的调用链路。说起 IO,这可真是一个很复杂的过程,里面涉及了很多内容,先是软件,最后到硬件,用一张图来表示一下吧:
本文打算简单得和大伙讨论一下 IO 的流程。
作为应用开发者,我们通常是 IO 发起点,比如用户说这本小说很好看,我要下载到本地,或者,这张图拍的不错,分享给你看一下。
虽然这些都是常见的 IO 场景,但是你知道有哪些 IO 吗?
通常去使用 IO 的时候,我们会有很多种选择,常见的有:
大家平时可能也就听过缓冲 IO 和 阻塞 IO,这些可能是我们平时开发可能涉及到的。
前两种分类都是使用缓存的。
缓冲是针对标准库的。
Linux 标准库定义了很多操作系统的基础服务,比如输入/输出、字符串处理等等。Android 操作系统的标准库是 Bionic,它可是应用层联系内核的桥梁,我们也可以通过 NDK 访问 Bionic。
使用标准库进行 IO 我们称为缓冲 IO,我们读文件的时候,经常遇到,读完一行才会让输出,在 Android 内部也做了类似的处理。
直接是针对内核的。
使用 Binder 跨进程传递数据的时候,需要将数据从用户空间传递到内核空间,非直接 IO 也这样,内核空间会多做一层页缓存,如果做直接 IO,应用程序会直接调用文件系统。
缓冲和非直接 IO 就像 IO 调度的一级和二级缓存,为什么要做这么多缓存呢?因为操作磁盘本身就是消耗资源的,不加缓存频繁 IO 不仅会耗费资源也会耗时。
同步和异步我想大家都了解什么意思。
阻塞 IO指的是当用户执行读写的时候,线程会一直阻塞,数据准备和将数据拷贝到用户进程都是阻塞的:
Java 中的 NIO 是非阻塞 IO,当用户发起读写的时候,线程不会阻塞,之后,用户可以通过轮询或者接受通知的方式,获取当前 IO 调度的结果:
即使是非阻塞 IO,对于读数据来说,也只有准备数据的过程是异步,将数据从内核拷贝到用户进程这个过程还是同步的。所以非阻塞 IO 不能算是真正意义上的异步 IO。
真正的异步 IO 应该是这样的:
准备数据和将数据拷贝从内核到用户进程都应该是异步的,当收到通知的的时候,我们已经可以在应用进程使用数据了。
作为应用层开发,大家做 IO 的场景并不多,最多也就是使用 BufferedInputStream
和 BufferedOutputStream
读写文件,至于 NIO ,那就更少见了。
我们了解一下阻塞 IO 的读调用流程。
应用层调完了,下面会直接进入内核吗?
除去直接 IO,大部分都不会!用户空间和内核之间隔着一个系统调用(sysCall),它的作用如下:
毕竟内核很复杂,抽象出通用的接口,可以防止用户空间的进程僭越,获取到它不该获取的内容。
为了能够让应用进程联系上内核,它会通过一个软中断,通知内核,我想调用内核中 sysCall 中的读接口。
对于读 IO,系统调用中有一个 sys_read
方法与之对应,内核收到通知执行该方法的时候,就会执行虚拟文件系统的 read
方法。
文件系统实在是太多了,比如我手机用户空间的文件系统是 f2fs,系统空间的文件系统是 ext4。对于应用程序来说,它就想调用个读方法,不想管你手机的底层文件系统是什么!
虚拟文件系统就是来干这活的,它可以屏蔽具体的文件系统,定义了一组所有文件系统都支持的数据结构和标准接口。这样,应用层的程序员只需了解 VFS 提供的统一接口就行。
虚拟文件系统常被称为 VFS(Virtual File System),下称 VFS。
VFS 采用的是面向对象的设计思路,它常常有下列的对象(C语言中的结构体)构成:
这些对象构成了基本的虚拟文件系统。
不过,光有这些对象可不行,VFS 还得知道如何操作它们,所以,每个对象中还存在对应的操作对象:
super_operation
对象:内核针对超级块所能调用的方法inode_operation
对象:内核针对索引结点所能调用的方法dentry_operation
对象:内核针对目录项所能操作的方法file_operation
对象:内核针对进程中打开的文件所能操作的方法大伙最熟悉的应该是文件,这是我们能够在进程中实实在在能够操作的,比如,在文件的 file_operation
中,就有我们熟悉的读、写、拷贝、打开、写入磁盘等方法。
不知道大伙儿有没注意到,我特意标注了超级块和索引节点存在于内存和磁盘,而目录项和文件只存在于内存。
我的理解是对于磁盘,索引节点已经足够记录文件信息,并不需要目录项再来记录层级关系;而对于内存来说,为了节省内存,只会把需要用到的文件和目录项所用到的索引节点加入内存,文件系统只有被挂载的时候超级块才会被加入到内存中。
目录项、索引节点、文件和超级块结构图:
上面的结构图还有几点要注意一下:
结合本文中的第一张图,我们会发现,VFS 有目录项缓存、索引节点缓存和页缓存,目录项和索引节点我们都知道什么意思,那页缓存呢?
页缓存是由 RAM 中的物理页组成的,对应着 ROM 上的物理地址。我们都知道,现在主流 Android 的 RAM 访问速度高达是 8.5 GB/S,而 ROM 的访问速度最高只有 6400 MB/S,所以访问 RAM 的速度要远远快于 ROM,页缓存的目的也在于此。
当发起一个读操作的时候,内核会首先检查需要的数据是否在页缓存,如果在,直接从内存中读取,我们称之为缓存命中;如果不在,那么内核在读取数据的时候,将读到的数据放入页缓存,需要注意的是,页缓存可以存入全部文件内容,也可以仅仅存几页。
经过系统调用,读 IO 进入了 VFS。
就去找文件对象(VFS 中的),通过文件对象的 file_operation
对象,调用 read
方法,传入读取的数据量。不过 read
方法也是找到文件对象对应的目录项,目录项又找到索引节点,毕竟,只有索引节点知道文件存在哪儿?
通过索引节点,内核就能唯一确定一个文件,然后在页缓存中寻找是否有自己需要的数据,找到就直接返回。没找到就去进行下一步的操作。
VFS 定义了文件系统的统一接口,具体的实现了交给了文件系统,超级块里面的数据如何组织、目录和索引结构如何设计、怎么分配和清理数据,这都是设计一个文件系统必须考虑的!
说白了,文件系统就是用来管理磁盘里的持久化的数据的,对于 Android 来说,最常见的就是 ext4 和 f2fs。
因为文件系统是 VFS 的具体实现,所以同样有目录项、索引节点和超级块,上面的图片用来描述文件系统也同样适合。
拿早起 ext2 的系统结构来讲:
每一个 ext2 都由大量的块组组成,每个块组的结构就跟上面的目录项和索引节点中的图一样。可以看到,在 inode 列表,存在着很多数据块,块是内存中最小的寻址单元,见于磁盘中的章节,一般可以设置带大小为 2kb - 64kb 之间。
虽然大部分的文件系统也都有超级块、索引节点和数据块,但是各个文件系统的实现却大不相同,这就导致了他们的侧重点也不一样。
拿 ext4 和 f2fs 来讲:
说白了,也就是它们对于空闲空间分配和已有的数据管理方式不一致,不同的数据结构和算法导致了不同的结果。
这里的 IO 流程其实跟 VFS 差不多,毕竟文件系统是 VFS 的具体实现。
Linux 下面有两大基本设备类型:
这两个设备的区别就是是否能够随机访问。拿属于字符设备的键盘来说,当我们输入 Hello World
的时候,系统肯定不可以先得到得到 eholl wrodl
,这样的话,输出就乱套了。而对于闪存来说,常常是看完这个这些数据库组成的图片,又要读间隔很远的数组块的小说内容,所以读取的块在磁盘上肯定不是连续的。
因为内核管理块设备实在太复杂了,所以就出现了管理块设备的子系统,就是上面说的文件系统。
块设备中常用的数据管理单位:
因为 Linux 中常常用的硬盘,这里我有点疑问,这里的管理单位是否和下面闪存管理单位一致?
如果当前有 IO 操作,内核会建立一个 bio 结构体的基本容器,它是由多个片段组成,每一个片段都是一小块连续的内存缓冲区。
之后,内核会将这些 IO 请求保存在一个 request_queue 的请求队列中。
如果按照 IO 请求产生的顺序发向块设备,性能肯定难以接受,所以内核会按照磁盘地址对进入队列之前提交的 IO 请求做合并与排序的预操作。
移动设备中常用的持久化存储是 Nand 闪存,UFS 又是 Nand 闪存中的佼佼者,其特点是速度更快、体积小和更省电。
当今 Android 旗舰机基本上标配 UFS 3.1,它们只是一块儿很小的芯片:
闪存是一种非易失性存储器,即使掉电了,数据也不会丢。闪存的存储单元从小到大有:
对于每个 Cell 来说,是由一种类 NMOS 的双层浮栅 MOS 管组成,大概是这样:
对于 SLC(存储1bit)来说:
这就构成了数据存储的最小单位,0和1!
整个流程简要的用一张图来表示:
因为我对内核也不是特别熟,文中难免有不对的地方,欢迎在评论区指正,如果觉得本文不错,「点赞」是最好的肯定!