【Android进阶】Android I/O 模型

【Android进阶】Android I/O 模型

本文介绍了Android,Linux,Java相结合的一些IO模型和底层原理

Java的I/O模型

Java 的 I/O 模型 指的是 Java 如何处理输入和输出(Input/Output)操作的方式,尤其是在涉及文件、网络、控制台等数据流时的读写机制。不同的 I/O 模型在性能、阻塞行为、线程使用等方面有显著差异。

Java 的 I/O 模型经历了几个发展阶段:传统的阻塞式 I/O(java.io)、NIO(java.nio)和 NIO.2(java.nio.file)。

阻塞 I/O (Blocking I/O - BIO)

Java 传统的 I/O 基于流的概念,将数据视为连续的字节序列或字符序列。

字节流InputStreamOutputStream 是所有字节流的抽象基类,用于处理原始字节数据(例如图片、音频、视频文件)。常见的实现类有 FileInputStream, FileOutputStream, BufferedInputStream, BufferedOutputStream, DataInputStream, DataOutputStream, ObjectInputStream, ObjectOutputStream 等。

字符流ReaderWriter 是所有字符流的抽象基类,用于处理字符数据(例如文本文件)。它们处理字符编码,能够将字节流转换为字符流。常见的实现类有 FileReader, FileWriter, BufferedReader, BufferedWriter, InputStreamReader, OutputStreamWriter 等。

传统的 I/O 是 阻塞式 (Blocking) 的。当一个线程执行 I/O 操作(如读取文件或网络数据)时,它会 一直等待直到操作完成 ,期间该线程无法执行其他任务。这在并发场景下会导致性能问题,因为每个连接可能需要一个独立的线程来处理。适用于客户端数量较少、连接时间较短的场景,例如简单的文件读写。

NIO (Non-blocking I/O - NIO)

NIO 在 Java 1.4 中引入,旨在解决传统 I/O 的阻塞问题,提供更高效、可伸缩的 I/O 操作。

基于 通道 (Channel),表示到实体(如文件、网络套接字)的连接,通过通道读写数据。它与传统 I/O 的流不同,通道是双向的,可以同时进行读写操作。

常见实现类有 FileChannel, SocketChannel, ServerSocketChannel, DatagramChannel 等。所有数据都 通过缓冲区读写 。数据从通道读取到缓冲区,或者从缓冲区写入通道。缓冲区提供了对数据的结构化访问,支持在内存中对数据进行高效操作。最常用的是 ByteBuffer,此外还有 CharBuffer, IntBuffer 等。

NIO模型还允许 单个线程管理多个通道 。通过选择器,一个线程可以监控多个通道上的 I/O 事件(如连接就绪、读就绪、写就绪),从而实现非阻塞 I/O。这大大减少了线程创建和切换的开销,提高了服务器的并发处理能力。

NIO 是 非阻塞式 (Non-blocking) 的。当 I/O 操作无法立即完成时,线程不会被阻塞,而是可以去执行其他任务。当 I/O 准备就绪时,选择器会通知线程。相较于传统I/O,其特点有:

  • 数据读写都通过缓冲区。
  • 非阻塞,I/O 操作不阻塞线程。
  • 多路复用,单个线程可以处理多个 I/O 通道,提高并发性能。
  • 复杂性,相对于传统 I/O,NIO 的编程模型更复杂,需要管理缓冲区、通道和选择器。

适用于高并发、大量连接的场景,如网络服务器。

AIO (Asynchronous I/O)

AIO,也叫 NIO.2 在 Java 7 中引入,主要增加了异步 I/O (Asynchronous I/O) 功能,以及对文件系统操作的增强 (java.nio.file 包)。

异步通道允许在 I/O 操作完成后,通过回调函数或 Future 对象来处理结果,而不需要显式地等待。例如 AsynchronousFileChannel, AsynchronousSocketChannel, AsynchronousServerSocketChannel。还定义了 CompletionHandler 作为I/O 操作完成时调用的回调接口,可以处理成功或失败的情况。

Path/Files,提供了更现代、功能更丰富的文件系统 API,解决了 java.io.File 类的一些局限性,例如更好的异常处理、符号链接支持、元数据访问等。

AIO 是异步式 (Asynchronous) 的。I/O 操作由操作系统执行,当操作完成时,操作系统会通知应用,应用可以注册回调函数来处理结果。这进一步减少了应用层面的线程管理负担。

适用于需要极致性能和扩展性的大型应用,例如高并发网络服务器、大数据处理。

常见分类标准

阻塞和非阻塞

阻塞IO是指在执行IO操作时,如果没有数据可用或者IO操作还没有完成,那么当前线程会被挂起,直到数据准备好或者IO操作完成。这种方式会导致线程阻塞,无法执行其他任务,适用于需要等待IO操作完成的场景。

非阻塞IO是指在执行IO操作时,如果没有数据可用或者IO操作还没有完成,当前线程不会被阻塞,而是会立即返回一个状态码或者错误码,告诉调用者当前IO操作还没有完成。这种方式不会导致线程阻塞,适用于需要同时处理多个IO操作的场景。

Java 中的 NIO 是非阻塞 IO,当用户发起读写的时候,线程不会阻塞,之后,用户可以通过轮询或者接受通知的方式,获取当前 IO 调度的结果。

缓冲IO和直接IO

非直接IO也称为缓冲IO,是大多数操作系统默认的文件访问方式。数据先被 复制到内核缓冲区 ,然后再 从内核缓冲区复制到用户空间 。可以减少实际磁盘操作次数,利用预读(read-ahead)和延迟写(write-behind)优化性能。但是数据需要在内核和用户空间之间 多次拷贝 ,在某些场景下可能增加延迟。适合小文件或随机访问

直接IO绕过内核缓冲区,直接在用户空间和存储设备之间传输数据。数据直接在用户空间和设备间传输,可以 减少数据拷贝次数 。这种方式,每次IO操作都是实际的设备操作。同时要求IO大小和内存对齐符合设备要求。适合大文件传输或已知访问模式的应用

缓冲是针对标准库的

Linux 标准库定义了很多操作系统的基础服务,比如输入/输出、字符串处理等等。Android 操作系统的标准库是 Bionic ,它可是应用层联系内核的桥梁,我们也可以通过 NDK 访问 Bionic。

使用标准库进行 IO 我们称为缓冲 IO,我们读文件的时候,经常遇到,读完一行才会让输出,在 Android 内部也做了类似的处理。

直接是针对内核的

使用 Binder 跨进程传递数据的时候,需要 将数据从用户空间传递到内核空间 ,非直接 IO 也这样,内核空间会多做一层页缓存,如果做直接 IO,应用程序会直接调用文件系统。

Android 的 Binder 通信 既不是 直接 I/O,也不是 非直接 I/O ,而是一种 进程间通信 机制。它主要依赖 内存映射(mmap)内核缓冲区 来实现高效的数据传输,而不是传统的文件 I/O 方式。Binder 使用 mmap() 在内核和用户空间之间建立 共享内存区域,避免数据在用户态和内核态之间的多次拷贝。发送方(Client)和接收方(Server)通过这块共享内存进行数据交换。Binder 驱动维护一个 内核缓冲区,用于临时存储跨进程传递的数据。数据先写入内核缓冲区,再通过共享内存传递给目标进程。Binder 通过 mmap 实现 一次拷贝(用户空间 → 内核缓冲区 → 目标进程用户空间),提高效率。Binder 不属于传统IO,因为它 不涉及磁盘读写,而是纯内存操作 (进程间通信)。

缓冲和非直接 IO 就像 IO 调度的一级和二级缓存,为什么要做这么多缓存呢?因为操作磁盘本身就是消耗资源的,不加缓存频繁 IO 不仅会耗费资源也会耗时。

Android平台的 IO

在 Android 平台上,IO(输入/输出)操作是应用与外部世界(如文件系统、网络、硬件设备)进行交互的基础。

首先确定一个原则,Android 平台的任何耗时操作,都应该在后台线程中进行,主线程需要尽量保持不出现耗时大于16ms的非UI任务,避免出现卡顿现象。主线程的事件循环如果被卡顿5s以上,还会出现ANR的问题。

以下是 Android 平台上常见的 IO 模型及其特点:

阻塞 IO (Blocking I/O)

这是最简单、最直观的 IO 模型。当一个线程执行 IO 操作时(例如,从文件中读取数据,或者向网络发送数据),该线程会被阻塞,直到 IO 操作完成。编程模型简单,易于理解和实现。

需要注意的是,如果在 Android 的主线程(UI 线程)上执行阻塞 IO 操作,会导致应用无响应(ANR - Application Not Responding)错误,给用户带来糟糕的体验。在高并发场景下,每个连接都需要一个线程,线程切换的开销很大。

所以,仅适用于 非常小的、不频繁的 IO 操作,或者在专门的后台线程中进行。例如应用启动时,需要从 res/raw 或 assets 目录读取一个非常小的、固定的配置字符串,比如一个 API Key 或者一个版本号,并且这个读取操作是在一个 独立的后台线程中 进行的。

非阻塞 IO (Non-blocking I/O)

线程在进行 IO 操作时,如果数据尚未准备好,IO 调用会立即返回一个状态码,而不是阻塞线程。开发者需要通过轮询(polling)来检查 IO 操作是否完成。避免了线程阻塞,提高了线程的利用率。

这种非阻塞的模式,需要不断轮询,增加了编程的复杂性。而且频繁的轮询会消耗大量的 CPU 资源。

在 Android 开发中,直接使用纯非阻塞 IO 的场景相对较少。例如要自定义一个 网络服务器框架的底层 ,网络通信库或服务器框架(例如,一个模拟 Netty 行为的 Android 本地服务器),你可能会利用 Java NIO 的 SocketChannel 在非阻塞模式下读写数据。这种模式的编程难度很高,需要开发者手动管理缓冲区、检查返回状态码,并且处理各种边缘情况。

多路复用 IO (I/O Multiplexing)

这种模型允许单个线程同时监听多个 IO 流(或文件描述符)的事件。当任何一个 IO 流准备好读写时,系统会通知该线程,然后线程可以对相应的 IO 流进行操作。常见的实现包括 selectpollepoll(在 Linux 内核中)。

一个线程可以处理多个连接,避免了大量线程创建和切换的开销。可以避免不必要的阻塞,线程只在有 IO 事件发生时才被唤醒。同样的,编程模型相对复杂,需要对底层系统调用有一定了解。

例如在 Android 应用中,如果需要实现一个轻量级的 本地服务器或者 P2P 连接 ,多路复用 IO 是一种高效的选择。

还有 异步网络请求库的底层 ,许多流行的网络请求库(如 OkHttp)底层都可能利用了多路复用 IO 的思想来管理并发网络连接。

异步 IO (Asynchronous I/O - AIO)

线程发起 IO 操作后,立即返回,无需等待 IO 完成。当 IO 操作真正完成时,系统会通知发起者(通常通过回调函数、事件或 Future/Promise 模式)。

线程无需等待 IO 完成,可以立即执行其他任务,进一步提高了并发性。相对于轮询, 回调函数 的方式可以简化编程逻辑。如果是 RxJava 这种框架,当嵌套的回调过多,可能导致代码难以阅读和维护(Callback Hell)。

使用上,例如 Retrofit + OkHttp + Coroutines 推荐组合。Retrofit 定义接口,OkHttp 执行实际的网络请求,Coroutines (suspend 函数和 Dispatchers.IO) 负责在后台线程执行请求并在请求完成后通知 UI 线程。

还有像大文件和频繁的读写。

suspend fun saveLargeFile(data: ByteArray, fileName: String) {
    withContext(Dispatchers.IO) {
        val file = File(context.filesDir, fileName)
        FileOutputStream(file).use { fos ->
            fos.write(data)
        }
    }
}

扩展:Android 阻塞IO 调用流程

一个系统调用的大致工作流程:

  • 用户程序发起请求: 用户程序通过调用库函数(这些库函数内部会封装系统调用)或直接使用 syscall 指令,将系统调用号和参数传递给操作系统。
  • 切换到内核态: CPU从用户态(用户程序运行的权限较低的状态)切换到内核态(操作系统核心运行的权限较高的状态)。这个切换通常通过软中断实现。
  • 内核处理请求: 操作系统内核根据系统调用号找到对应的内核函数,并执行相应的操作。
  • 返回结果: 内核完成操作后,将结果(成功或失败,以及相关数据)返回给用户程序。
  • 切换回用户态: CPU切换回用户态,用户程序继续执行。

绝大部分的非直接IO都会通过系统调用这层桥梁来进行,应用进程不会直接联系内核。

VFS

在 Android 平台上,虚拟文件系统 (Virtual File System, VFS) 是一个至关重要的概念,它为应用程序提供了一个统一的文件访问接口,而无需关心底层存储的实际物理结构和文件系统类型。VFS 位于文件系统之上,抽象了不同文件系统之间的差异,使得应用程序可以以相同的方式处理各种文件和目录。

常常有下列的对象(C语言中的结构体)构成:

blogs_linux_vfs

  • 超级块 (Superblock): 每个文件系统都有一个超级块,它包含了文件系统的元数据,如文件系统类型、块大小、总块数等。
  • 索引节点 (Inode): 每个文件或目录都有一个索引节点,它包含了文件的元数据,如文件大小、权限、创建时间等。
  • 文件系统操作 (File System Operations): 每个文件系统都有一组文件系统操作函数,用于执行文件系统的各种操作,如打开文件、读取文件、写入文件等。
  • 目录项 (Directory Entry): 目录是一种特殊的文件,它包含了指向其他文件或目录的指针。
  • 文件 (File): 文件是存储在磁盘上的数据,可以是文本、图像、音频等。

不过,光有这些对象可不行,VFS 还得知道如何操作它们,所以,每个对象中还存在对应的操作对象:

  • super_operation 对象:内核针对超级块所能调用的方法
  • inode_operation 对象:内核针对索引结点所能调用的方法
  • dentry_operation 对象:内核针对目录项所能操作的方法
  • file_operation 对象:内核针对进程中打开的文件所能操作的方法

大伙最熟悉的应该是文件,这是我们能够在进程中实实在在能够操作的,比如,在文件的 file_operation 中,就有我们熟悉的读、写、拷贝、打开、写入磁盘等方法。

超级块和索引节点存在于内存和磁盘,而目录项和文件只存在于内存。

我的理解是对于磁盘,索引节点已经足够记录文件信息,并不需要目录项再来记录层级关系;而对于内存来说,为了节省内存,只会把需要用到的文件和目录项所用到的索引节点加入内存,文件系统只有被挂载的时候超级块才会被加入到内存中。

VFS中的缓存

结合本文中的第一张图,我们会发现,VFS 有目录项缓存、索引节点缓存和页缓存,目录项和索引节点我们都知道什么意思,那页缓存呢?

页缓存是由 RAM 中的物理页组成的,对应着 ROM 上的物理地址。我们都知道,现在主流 Android 的 RAM 访问速度高达是 8.5 GB/S,而 ROM 的访问速度最高只有 6400 MB/S,所以访问 RAM 的速度要远远快于 ROM,页缓存的目的也在于此。

当发起一个读操作的时候,内核会首先检查需要的数据是否在页缓存,如果在,直接从内存中读取,我们称之为缓存命中;如果不在,那么内核在读取数据的时候,将读到的数据放入页缓存,需要注意的是,页缓存可以存入全部文件内容,也可以仅仅存几页。

文件系统

VFS 定义了文件系统的统一接口,具体的实现了交给了文件系统,超级块里面的数据如何组织、目录和索引结构如何设计、怎么分配和清理数据,这都是设计一个文件系统必须考虑的!

说白了,文件系统就是用来管理磁盘里的持久化的数据的,对于 Android 来说,最常见的就是 ext4 和 f2fs。

Ext2 (Second Extended File System) 是 Linux 内核最初使用的文件系统之一,由法国软件开发者 Rémy Card 设计。它取代了早期的 Ext 和 Minix 文件系统,解决了它们在文件大小、文件名长度等方面的限制。尽管现在更常用的是 Ext3 和 Ext4(它们在 Ext2 的基础上增加了日志功能),但理解 Ext2 的基本结构对于理解现代 Linux 文件系统仍然非常重要。

Ext2 文件系统结构

Ext2 文件系统将磁盘空间划分为逻辑块 (Blocks),然后将这些块进一步组织成 块组 (Block Groups)。这种设计旨在减少磁盘碎片,并最小化磁头移动,从而提高性能。

每个块组通常包含以下几个部分:

  1. 引导块 (Boot Block):位于磁盘的起始位置,包含用于系统引导的信息。Ext2 文件系统本身并不使用这部分空间。

  2. 超级块 (Superblock):
    • 它是文件系统的核心,包含了文件系统的全局元数据。
    • 重要信息:文件系统的大小、块大小、每个块组的块数量、空闲块和空闲 inode 的数量、文件系统状态(如是否干净卸载)、上次挂载时间、上次写入时间、魔数(标识文件系统类型)等。
    • 冗余备份:为了容错,Ext2 会在每个块组的开始部分(或至少一些块组)存储超级块的副本。如果主超级块损坏,可以使用副本进行恢复。
  3. 块组描述符表 (Group Descriptor Table):
    • 紧跟在超级块之后,它包含了每个块组的描述信息。
    • 重要信息:每个块组中空闲块位图的位置、inode 位图的位置、inode 表的起始位置以及该块组中空闲块和空闲 inode 的数量。
  4. 块位图 (Block Bitmap):
    • 一个位图,用于记录该块组中数据块的分配情况。每个位对应一个数据块,如果位是 1,表示该块已被占用;如果是 0,表示空闲。
    • 操作系统利用块位图来查找空闲块,以分配给文件数据。
  5. Inode 位图 (Inode Bitmap):
    • 类似于块位图,用于记录该块组中 inode 的分配情况。每个位对应一个 inode,1 表示已分配,0 表示空闲。
  6. Inode 表 (Inode Table):
    • 存储该块组中所有 inode 结构体的数组。
    • Inode (Index Node) 是 Ext2 文件系统的核心概念之一,它代表了文件系统中的一个对象(文件、目录、符号链接等)。
    • Inode 包含的信息:
      • 文件类型(普通文件、目录、符号链接等)
      • 访问权限(读、写、执行权限)
      • 文件所有者和所属组
      • 文件大小
      • 创建时间、修改时间、上次访问时间
      • 硬链接计数
      • 指向数据块的指针:这是 Inode 最重要的部分。Inode 本身不存储文件内容,而是存储指向文件实际数据块的指针。Ext2 的 inode 结构通常包含:
        • 直接指针:通常有 12 个,直接指向文件的前 12 个数据块。
        • 一级间接指针:指向一个块,该块中存储了更多的数据块指针。
        • 二级间接指针:指向一个块,该块中存储了一级间接指针的地址。
        • 三级间接指针:指向一个块,该块中存储了二级间接指针的地址。
        • 这种多级索引方式使得 Ext2 能够支持非常大的文件。
  7. 数据块 (Data Blocks):
    • 存储文件实际内容(数据)的区域。这些块由 inode 指向。
    • 目录在 Ext2 中也是一种特殊的文件,它的数据块中存储的是目录项(文件名到 inode 号的映射)。

Ext2 的 I/O 流程 (以读取文件为例)

当应用程序请求读取一个文件时,Ext2 文件系统通常会经历以下 I/O 流程:

  1. VFS 层解析路径名:
    • 应用程序通过系统调用(如 open())传入文件路径名。
    • 操作系统内核的虚拟文件系统 (VFS) 层接收到请求。
    • VFS 层会从根目录开始,逐级解析路径名。对于每个目录,VFS 会:
      • 查找该目录的 inode(通过其父目录的 inode 指向的数据块中的目录项)。
      • 读取该目录的数据块,查找与路径名中下一个组件匹配的目录项。
      • 从目录项中获取对应文件或子目录的 inode 号。
  2. 获取目标文件的 Inode:
    • 一旦 VFS 找到目标文件的 inode 号,它会根据 inode 号计算出该 inode 所在的块组以及在该块组的 Inode 表中的偏移量。
    • 操作系统会从磁盘上读取包含该 inode 的块,并将其加载到内存中。这个 inode 包含了文件的大小、权限和最重要的数据块指针。
  3. 确定数据块位置:
    • 假设应用程序请求读取文件内容的某个偏移量。
    • 文件系统会根据文件偏移量和文件系统的块大小,计算出需要读取的逻辑块号。
    • 然后,文件系统会使用 Inode 中存储的指针来查找该逻辑块对应的物理块地址:
      • 如果是前 12 个块,直接使用直接指针。
      • 如果是后续的块,需要通过一级、二级或三级间接指针来查找实际的数据块地址。这可能涉及到多次磁盘读取,以获取间接块中的指针。
  4. 读取数据块:
    • 文件系统通过计算出的物理块地址,向磁盘控制器发出读取请求。
    • 磁盘控制器将数据从磁盘读取到内核缓冲区。
  5. 数据返回给用户:
    • 内核将从磁盘读取的数据从内核缓冲区复制到应用程序指定的内存缓冲区中。
    • read() 系统调用返回,应用程序可以处理文件数据。

虽然大部分的文件系统也都有超级块、索引节点和数据块,但是各个文件系统的实现却大不相同,这就导致了他们的侧重点也不一样。拿 ext4 和 f2fs 来讲,ext4连续读取大文件更强,占用的空间更小;而f2fs随机 IO 更快

说白了,也就是它们对于空闲空间分配和已有的数据管理方式不一致,不同的数据结构和算法导致了不同的结果。

块IO层

Linux 下面有两大基本设备类型:

  • 块设备:能够随机访问固定大小数据片的硬件设备,硬盘和闪存(下面介绍)就是常见的块设备
  • 字符设备:字符设备只能按照字符流的方式被有序访问,比如键盘和串口

这两个设备的区别就是是否能够随机访问。拿属于字符设备的键盘来说,当我们输入 Hello World 的时候,系统肯定不可以先得到得到 eholl wrodl,这样的话,输出就乱套了。而对于闪存来说,常常是看完这个这些数据库组成的图片,又要读间隔很远的数组块的小说内容,所以读取的块在磁盘上肯定不是连续的。

因为内核管理块设备实在太复杂了,所以就出现了管理块设备的子系统,就是上面说的文件系统。

块设备结构

块设备中常用的数据管理单位:

  • 扇区:设备的最小寻址单元
  • 块:文件系统的最小寻址单元,数倍大于扇区
  • 片段:由数百至数千的块组成

因为 Linux 中常常用的硬盘,这里我有点疑问,这里的管理单位是否和下面闪存管理单位一致?

IO过程

如果当前有 IO 操作,内核会建立一个 bio 结构体的基本容器,它是由多个片段组成,每一个片段都是一小块连续的内存缓冲区。

之后,内核会将这些 IO 请求保存在一个 request_queue 的请求队列中。

如果按照 IO 请求产生的顺序发向块设备,性能肯定难以接受,所以内核会按照磁盘地址对进入队列之前提交的 IO 请求做合并与排序的预操作。

磁盘

移动设备中常用的持久化存储是 Nand 闪存,UFS 又是 Nand 闪存中的佼佼者,其特点是速度更快、体积小和更省电。当今 Android 旗舰机基本上标配 UFS 3.1,它们只是一块儿很小的芯片。

闪存是一种非易失性存储器,即使掉电了,数据也不会丢。闪存的存储单元从小到大有:

  • Cell(单元):是闪存存储的最小单位,根据存储的数量可以分为SLC(1bit/Cell)、MLC(2bit/Cell)、TLC(3bit/Cell)和QLC(4bit/Cell)
  • Page(页):由大量的 Cell 构成,每个 Page 的大小通常是 16 kb,它是闪存能够读取的和写入的最小单位
  • Block(块):每个块由数百至数千的 Page 组成
  • Plane(面):Plane 由数百至数千的 Black 组成
  • Die(逻辑单元):每个 Die 由一个至多个 Plane,是闪存中可以执行命令或者回报状态的最小单元

到Cell这一层,再往下就是MOS管了,通过电压控制电子是否进入存储单元。

blogs_io_all.jpeg