Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

操作系统进阶

本章只讨论 Linux/Android 的进阶机制: 地址映射与文件 I/O, 多路复用, Linux 普通任务调度与死锁现场. 进程/线程/协程, 虚拟内存, IPC 和数据库基础定义请先读操作系统与数据库基础; Binder 协议细节见 Binder 与 IPC 深入.

学习目标与章节边界

完成后应能比较 read 与 mmap 的成本和故障面, 解释 LT/ET 与 select/poll/epoll 的触发语义, 理解 CFS 的 vruntime 与 EEVDF 的版本边界, 并由线程 dump 还原死锁等待环. 本文不重新讲基础 OS/SQL 定义, 避免与 56 重复.

一, read 与 mmap: 同为文件 I/O, 故障面不同

虚拟内存让每个进程看到连续独立地址空间, 由页表映射到物理页. Android 中它直接影响大文件读取, so 加载, Dex/OAT 映射, Binder 缓冲区等.

进程虚拟地址
  ↓ 页表 / MMU / TLB
物理内存页
  ↑
缺页(page fault)时由内核把文件页/匿名页调入内存
  • read: 内核将文件页数据复制到调用方提供的用户缓冲; 调用边界清晰, 可按块处理并显式控制读取大小.
  • mmap: 把文件或设备映射到进程虚拟地址, 按需加载, 访问时发生缺页; 少一次显式用户缓冲复制且随机访问方便, 但错误会延迟到内存访问点, 地址空间 / 页缓存仍有成本. Binder 驱动也利用 mmap 建立用户态可访问缓冲区.
  • page fault: 访问的虚拟页尚未在物理内存中, CPU 触发异常进入内核处理. 轻微缺页可能只建映射, 重大缺页可能要读磁盘.
  • 启动性能关联: 冷启动读取 dex, resources, so 时可能产生大量缺页; Baseline Profile, 预加载和减少冷路径大文件访问都能降低抖动.
  • 内存压力关联: 匿名页, 文件页, Ashmem / 共享内存都会参与系统回收; 低端机上大 Bitmap 和大 mmap 文件都要考虑峰值.
选择更适合常见失败证据与修复
read + 小缓冲顺序流式解析, 明确背压主线程阻塞, 缓冲过大Perfetto 看 I/O 阻塞; 移后台, 按吞吐测缓冲大小
mmap随机读取, 多个访问点共享页缓存冷页 fault 抖动, 映射后文件截断的异常访问Perfetto/page-fault 指标与 tombstone; 预热关键页, 限制映射生命周期

示例边界: 不能因为 mmap “少复制” 就用它映射全部超大文件; 32 位进程或地址空间碎片下映射可能失败. 对同一设备, 同一访问模式测量端到端耗时, 缺页和峰值 RSS 后再选择.

二, 文件描述符与多路复用: 监听集合而不是轮询所有连接

Linux 把文件, socket, pipe, eventfd 等都抽象成文件描述符 (fd). Android 的网络, 数据库, 日志, Looper 唤醒都离不开 fd.

I/O 机制特点Android 关联
阻塞 I/O调用线程等待结果主线程网络/磁盘会 ANR
非阻塞 I/O没数据立即返回需要轮询或事件通知
select/poll监听多个 fd, 但扩展性一般传统多路复用方案
epoll事件驱动, 适合大量 fdLooper, 网络框架, native event loop

Looper 为什么不忙等? MessageQueue 没消息时, 底层通过 epoll_wait 阻塞等待 fd 事件或超时; 有消息, Binder, 输入事件, 定时器到期时再被唤醒. 这也是 “死循环不等于耗 CPU” 的经典追问.

fd 常见问题:

  • 文件 / 网络流未关闭导致 fd 泄漏, 最终 Too many open files.
  • 日志, 图片, 数据库 Cursor 未及时 close.
  • 连接池过大或泄漏导致 socket fd 占用异常.

select, poll, epoll 与 LT/ET

select 用位图集合, 受 FD_SETSIZE 等实现限制, 返回后要重新设置集合; poll 用数组, 移除位图上限但每次仍线性扫描; epoll 将关注 fd 注册到内核, epoll_wait 返回就绪事件列表, 适合大量长期连接. 三者都不是自动让业务处理变快, 回调中的阻塞工作仍会卡住事件线程.

触发方式语义处理规则典型坑
LT (水平触发, 默认)只要 fd 仍可读 / 写, 就会持续通知可每次读一部分, 下一轮仍会收到未及时处理会反复唤醒, 形成忙循环
ET (边沿触发)状态从未就绪变为就绪时通知fd 设非阻塞, 并循环读 / 写到 EAGAIN只读一次就返回会遗漏剩余数据, 可能永不再通知

ET 伪 trace: socket 到达 8 KiB → 一次可读事件 → handler 用非阻塞 read 循环读取直到 EAGAIN → 等待下一次 “无数据到有数据” 边沿. 边界: EOF 是 read=0, 不是 EAGAIN, 必须关闭 / 回收 fd; 错误事件必须同时读取 errno/socket error, 而非把它当普通可读.

三, Linux 普通任务调度, vruntime 与 Android 卡顿

传统 CFS 的目标是公平分配 CPU: 它用按权重归一的虚拟运行时间 vruntime 排序, vruntime 较小的可运行任务优先获得 CPU.nice 值影响权重: 较高权重的任务消耗相同真实运行时间时 vruntime 增长较慢. 新上游 Linux 正逐步以 EEVDF (Earliest Eligible Virtual Deadline First) 取代传统 CFS 的选任务机制, 因此不能把 vruntime 的具体选择规则外推到所有新内核. Android 设备实际使用 CFS 还是 EEVDF, 以及厂商是否回移植补丁, 必须以该设备的 kernel 版本与源码/配置为准; Android 仍会叠加进程优先级, 线程 nice 值, cgroup/cpuset 与前后台策略.

  1. UI 线程不是绝对优先: 它仍要参与调度, 如果自己执行长任务或系统 CPU 被打满, 就会错过约 16.7ms 的 60 Hz 单帧周期.
  2. 线程优先级要谨慎: 后台下载 / 日志压缩不应抢 UI; 音视频, 渲染, 输入链路要避免被低价值任务干扰.
  3. 协程调度器不是魔法: Dispatchers.Default 适合 CPU, Dispatchers.IO 适合阻塞 IO, 乱用会导致线程饥饿.
  4. ANR 本质: 主线程长时间无法处理输入, 广播, 服务生命周期等消息, 可能是锁等待, IO, CPU, Binder 调用卡住.

四, 锁, 死锁与现场排查

Android 中的死锁常见于 synchronized 交叉持锁, 主线程等待后台且后台反向同步切主线程, 或 Binder 同步调用互等; 四个形成条件等基础定义见操作系统与数据库基础.

Thread-A: 持有 dbLock → 等待 networkLock
Thread-B: 持有 networkLock → 等待 dbLock
结果:循环等待,两边都无法推进

实践建议:

  • 固定锁顺序, 避免 A→B 与 B→A 混用.
  • 不在持锁期间做网络, 磁盘, Binder 或回调外部代码.
  • 优先缩小临界区, 读多写少用读写锁或不可变快照.
  • Kotlin 协程中区分 Mutex 与 JVM 锁, 不要在 synchronized 内调用可能挂起的逻辑.
  • 主线程不要等待后台锁; 后台也不要同步等待主线程回调.

排查 trace (示意, 不是实际工具输出):

"main"  BLOCKED on <0xA> (dbLock), owned by "worker-1"
"worker-1" BLOCKED on <0xB> (networkLock), owned by "main"

步骤: 1) 收集所有线程栈和锁 owner (Java kill -3/ANR traces, native 看 tombstone); 2) 从 BLOCKED 线程画 “线程 → 等待锁 → 持锁线程” 边; 3) 找环, 而不是只看第一个卡住的线程; 4) 对照锁顺序, 同步 Binder/主线程等待点; 5) 固定全局锁序或移除持锁 I/O; 6) 用回归并发测试与 trace 验证等待环消失. 超时只能避免无限等待, 不能修复共享状态一致性, 不能替代锁序设计.

五, Android/Linux 关联速记

  • Zygote: 预加载类和资源后 fork App 进程, 降低启动成本; fork 后进程拥有独立虚拟地址空间, 通过 COW 共享只读页.
  • Binder: Android 主要 IPC, 结合驱动, mmap, 线程池和引用计数, 比 Socket 更适合系统服务调用.
  • cgroup/cpuset: 系统按前后台, 任务类型限制 CPU / 资源分配, 解释为什么后台任务可能变慢.
  • LMK / 内存回收: 低内存时系统按进程重要性回收; App 要保存状态, 不能假设进程永生.
  • SELinux / 权限模型: 限制进程访问系统资源, 移动安全和文件访问都要考虑沙箱边界.

六, 可观测工具与版本边界

Android Framework 进程与生命周期见 Framework 源码与进程生命周期. 内核, LMKD, 调度和 /proc 字段可能随 Android 版本与厂商内核变化, 引用实现时注明 AOSP tag / 设备 kernel 版本.

排查优先使用可观察证据: Perfetto/atrace 看调度与 I/O, heapprofd 看 native allocation, dumpsys meminfo/procstats 看进程状态, tombstone 看 native 现场. 工具输出是某个时间点和设备的观测, 不能直接外推为所有版本机制.

高频面试题

Q1: mmap 和普通 read 有什么区别? read 把数据从内核缓冲复制到用户缓冲, mmap 把文件映射到虚拟地址空间, 访问时按页加载, 可减少拷贝和简化随机访问. 但 mmap 仍可能触发 page fault, 不是 “免费加载”.

Q2: Looper 底层为什么用 epoll? Looper 要同时等待消息队列, 输入, Binder / 管道等 fd 事件. epoll 能高效等待多个 fd, 无事件时阻塞让出 CPU, 有事件再唤醒, 避免忙等.

Q3: Android 死锁如何排查和避免? 看线程堆栈确认谁持有什么锁, 谁在等待; 避免嵌套锁顺序不一致, 持锁做耗时操作, 主线程同步等待后台. 必要时用超时, 锁顺序规范和异步化拆环.

易错点 / 追问

  • 易错: 把协程说成轻量线程; 准确说协程不是内核线程, 它运行在线程之上.
  • 追问: page fault 是否一定是坏事? 不是, 按需分页依赖它; 但冷启动大量重大缺页会带来磁盘 IO 抖动.
  • 易错: 以为 epoll 只用于服务端高并发; Android Looper 和 native 事件循环同样依赖 fd 多路复用思想.
  • 追问: 为什么主线程没有死循环占 CPU? 因为 MessageQueue 空闲时阻塞在 epoll_wait, 不是 while true 忙轮询.

主题练习与预期证据

  1. 为一个顺序读取和一个随机读取场景选择 read 或 mmap, 预期说明页 fault/复制/地址空间三项权衡及一个实际测量指标.
  2. 用 ET 的 8 KiB trace 解释为何必须读到 EAGAIN, 并说明 read=0 的不同处理.
  3. 对两线程交叉持锁的示意栈画等待图, 预期明确锁环和固定锁顺序后的验证方式.