线程模型调度优化
# 线程模型与调度优化
本文核心命题:线程是"用 CPU 兑换延迟"的工具——通过把任务分散到多个 CPU 核 / 时间片来降低单次任务延迟。但线程不是免费的:创建、切换、同步都有代价。一切优化都是要找到"并发收益"和"切换 / 锁开销"之间的最优点。
# 01.阅读说明
- 本文卷归属:卷二 · 资源篇 · 第 4 篇
- 本文目标层级:L2 进阶 → L3 专家
- 适用平台:Android(主) / iOS / Web / 跨端框架 / 嵌入式
- 前置阅读:
卷二·01 CPU 监控与分析(线程是 CPU 的使用者)卷三·03 卡顿捕获与归因(线程问题导致卡顿)
全文 21 章地图:
§01 阅读说明 §02 贯穿案例 §03 线程物理本质 §04 并发模型原理
§05 度量与采集 §06 归因决策树
§07 主线程全链路 ⭐ §08 线程池全链路 ⭐ §09 同步原语全链路 ⭐
§10 优先级调度全链路 ⭐ §11 协程全链路 ⭐ §12 跨端对照
§13 治理一层主线程 ⭐ §14 治理二层同步 ⭐ §15 治理三层线程池 ⭐ §16 治理四层调度 ⭐
§17 求证实验 ⭐ §18 实战案例 §19 防劣化体系 §20 跨平台速查
§21 总结与延伸
2
3
4
5
6
7
阅读建议:先读 §02 案例 → §03/§04 拿到原理 → §05/§06 学会度量归因 → §07-§11 五个全链路(主线程/线程池/同步/调度/协程)→ §13-§16 四层治理 → §17 求证 → §18-§20 工程闭环。
# 02.贯穿案例
本案例贯穿全文:§03 看懂代价、§04 拿到并发模型武器、§05/§06 用三方案+决策树定位、§17 用实验复盘、§13-§16 给出分层策略闭环。
# 2.1 案例背景
某直播 App V9.2 上线"开播秒开"功能,把推流准备从异步改成全异步并行。结果出乎意料:
- 开播 3 秒内卡死率从 0.5% 飙到 7.3%,被打"应用未响应"标签。
- 主播侧首屏 P95 从 1.2s 升到 4.8s(与"秒开"目标相反)。
- 中端机线程数稳态达 187 个(之前 65 个),内存涨 80MB。
- 日活主播流失 12%,直播业务损失约单日 600 万。
研发组初步排查:"并行应该更快才对啊。"——这是典型的"并发崇拜"。
# 2.2 经验派的 3 周折腾(典型反面教材)
| 周次 | 动作 | 结果 |
|---|---|---|
| 第 1 周 | 把线程池从 8 调到 32(怀疑并发不够) | 卡死率升到 9.1%:线程数破 230,上下文切换暴涨 |
| 第 2 周 | 加 try-catch + 死循环检测(怀疑死锁) | 没死锁,但卡死依旧 |
| 第 3 周 | 用 spinlock 替代 mutex(怀疑锁开销) | CPU 占用炸到 100%,发热严重,掉电飞快 |
复盘:三周里所有动作都基于"并发越多越快、锁越快越好"的错误直觉。线程不是免费的(§3.1 约束三),并发是非线性收益(§17.2 拐点)——这正是案例的根本反面教材。
# 2.3 方法派的 7 天闭环
新接手的同学按本文方法论重做:
Day 1(§04 第一性原理 + §05 三方案组合):用 Perfetto 抓系统 trace(方案②)+ Lock contention(方案③)+ 线程清单(方案①),看到三个关键数据:
| 指标 | 测量值 | 期望 |
|---|---|---|
| 线程数 | 187 | < 60 |
| 上下文切换/s | 22,000 | < 5,000 |
| 推流锁等待 P99 | 850 ms | < 5 ms |
→ 三类问题(§04 模型)全部命中:调度不当 + 同步开销 + 资源浪费。
Day 2(§06 归因决策树):跟着决策树跑:
- 线程数 187 → 线程数膨胀分支 → 12 个 SDK 各自创建线程池
- 切换 22K/s → 上下文切换过频分支 → 每帧 30+ 任务被切散到不同线程
- 锁等待 850ms → 锁竞争高分支 → "推流准备锁"被音频/视频/字幕/特效 4 路同时争用
Day 3-4(§13-§16 分层策略):
- 第 1 层(主线程减负):开播状态机改用 IdleHandler 切片
- 第 2 层(同步治理):推流准备锁拆分为 4 把独立锁;锁内严禁 IO
- 第 3 层(线程池治理):建统一线程池服务(CPU 池=核数+1 / IO 池=20 / 高优先级池=4)
- 第 4 层(优先级调度):推流相关线程升 QoS 到 userInteractive;锁启用优先级继承
Day 5(§17 实验思路验证):用 Macrobenchmark 跑前后对比,确认主线程等待 P99 从 850ms→18ms。
Day 6-7(上线灰度):
# 2.4 上线效果
| 指标 | 经验派 3 周后 | 方法派 7 天后 |
|---|---|---|
| 开播 3 秒卡死率 | 9.1% | 0.3% |
| 主播首屏 P95 | 4.8s | 1.0s |
| 线程数稳态 | 230 | 52 |
| 上下文切换/s | 35,000 | 4,200 |
| 推流锁等待 P99 | 850 ms | 18 ms |
| 日活主播流失 | -12% | +3%(恢复) |
核心洞察:经验派 3 周折腾错在"用更多线程治线程问题"——加线程让切换雪上加霜(22K/s→35K/s),spinlock 把锁等待换成 CPU 烧死。线程优化的本质是找拐点(§17.2),不是堆资源。
# 2.5 案例如何串起本文
- §03 现象与代价 ▶▶ 业务损失映射:日活主播 -12%、单日 600 万收入。
- §04 并发模型 ▶▶ 案例同时命中"调度不当 + 同步开销 + 资源浪费"三类。
- §07-§11 五大全链路 ▶▶ 主线程/线程池/同步/调度/协程 五条链路对应案例每一类问题。
- §17 求证实验 ▶▶ §17.2 拐点、§17.3 锁竞争、§17.4 优先级反转都在案例中变现。
- §13-§16 四层治理 ▶▶ "主线程→同步→线程池→优先级"四层正是案例落地路径。
探索性思考:为什么"并发崇拜"是工程师最容易陷入的认知陷阱?因为"并发"是个好词——听起来"快"、"先进"、"现代"。但真实世界里,线程不是抽象概念,而是有物理代价的资源。每一个新线程都消耗栈内存(1MB)、TCB(10KB)、调度时间(μs 级)、缓存污染(10-100μs)。当你说"加一个线程"时,你其实在说"花 1MB+10KB+μs+污染换一个并发槽" —— 这个权衡被遗忘的瞬间,就是反向收益的开始。
# 03.线程物理本质
# 3.1 一句话定义
线程 = 操作系统调度的最小执行单元,是"在共享地址空间内独立执行指令流"的抽象。
这句话隐含三个不可商量的物理约束:
约束一:线程是 CPU 时间片的接收者
CPU 资源有限(N 个核心),线程数往往远多于核数。OS 通过"时间片轮转"让多个线程"看似并行"运行。单个核同时只能跑一个线程。
单核 CPU 视角:
时间 ──▶ |T1|T2|T1|T3|T2|T1|T2|T3|...
↑切换 ↑切换 ↑切换
每次切换有成本(详见 §17.1)
2
3
4
约束二:线程共享地址空间
同进程内所有线程共享内存,意味着任意线程可以访问 / 修改其他线程的数据。这是线程的"双刃剑":
- 优势:通信成本低(直接共享变量)。
- 代价:必须用同步原语(锁、原子操作)保证数据一致性。
约束三:线程不是免费的
每个线程都有固定的资源占用:
- 栈内存:默认 1MB(Linux)/ 8MB(macOS),即使啥都不干。
- TCB:内核数据结构 ~10KB。
- 调度开销:每次切换 ~1–10μs。
- 缓存污染:切换后 L1/L2 缓存重新加载。
这就是为什么"开 1000 个线程"会带来巨大开销,即使它们大部分时间在 sleep。
# 3.2 现象与代价
线程问题往往表现间接,但代价巨大:
- 主线程卡顿:耗时任务没异步化 → 帧时长超 16.67ms → 用户感知卡。
- 线程膨胀 / OOM:大量临时线程 → Thread 资源耗尽(Linux 默认 ~32K 进程级线程)。
- 死锁 / 活锁:多个线程互相等待,进程"卡死",最终 ANR / 看门狗复位。
- 数据竞争 / 状态不一致:多线程读写共享变量,UI 显示错乱、崩溃。
- 后台耗电 / 温升:过多后台线程消耗 CPU 时间片,电量加速下降。
业务代价(行业实测数据):
- 头部 App:主线程优化(移走 IO/JSON 等任务)后启动 -300ms,留存 +0.5%。
- iOS 死锁直接被系统 watchdog 杀死,体验类崩溃。
- 大量后台线程会让设备温度上升 2–5°C,触发降频。
▶▶ 回扣 §02 案例:直播 App 在"开播秒开"功能后,线程数 65→187、卡死率 0.5%→7.3%——线程优化的反面教材就是加更多线程。
# 3.3 度量准则与基准
资源视角(USE):
| 指标 | 含义 | 阈值参考 |
|---|---|---|
| 线程数 | 进程内活跃线程数 | < 50(轻量)/ < 100(重量) |
| 上下文切换率(S) | 每秒切换次数 | < 5000/s |
| 死锁错误(E) | 是否检测到 | = 0 |
请求视角(RED):
| 指标 | 含义 | 阈值参考 |
|---|---|---|
| 任务执行延迟 | 提交到完成时间 | < 100ms(UI 类) |
| 锁等待时长 | 线程等锁时长 | < 5ms |
| 主线程消息处理 | 单条消息耗时 | < 16ms |
行业基准:
| 平台 | 进程内线程数 | 线程池大小 | 上下文切换 |
|---|---|---|---|
| Android(中端机) | < 60 | CPU 核数 + 1(计算) | < 5000/s |
| iOS | < 50 | 同上(GCD 自动管理) | < 5000/s |
| Web | < 10 Worker | 视场景 | N/A |
| 嵌入式 RTOS | 严格规划 | 静态分配 | < 1000/s |
# 3.4 反直觉问题清单
带着这些问题阅读:
- 开 16 个线程是不是比 4 个快 4 倍?
Thread.sleep(1)真的只睡 1ms 吗?- CPU 利用率 100% 是好事还是坏事?
- synchronized / Lock 哪个开销更大?
- 主线程不能做 IO,但子线程做 IO 是不是就没影响了?
- iOS GCD 是"无限线程"吗?
- Web Worker 越多越快吗?
- 后台线程优先级低是不是就不会影响前台?
探索性思考:为什么"线程"作为抽象在工程界被滥用?因为它把复杂的物理(CPU 时间片调度)封装成简单的 API(
Thread.start())。好的抽象让简单的事情简单,但坏的抽象让复杂的事情看起来简单。线程的 API 是后者——new Thread().start()看起来像免费午餐,实际上你买单了 1MB 内存 + 10KB TCB + 调度成本。抽象的代价从来都不会消失,只是被隐藏了。
# 04.并发模型原理
# 4.1 四种并发模型分类
按"任务粒度 + 调度策略 + 同步方式"可以分为四种典型模型,所有平台的并发设计都映射到这四种:
┌─────────────────────────────────────────────────────┐
│ A. 共享内存 + 同步原语(经典多线程) │
│ pthread / Java Thread / iOS NSThread │
│ 优点:粒度灵活 │
│ 缺点:锁难写,死锁易发 │
├─────────────────────────────────────────────────────┤
│ B. 任务队列 + 线程池(GCD / ExecutorService) │
│ 把"线程数"和"任务数"解耦 │
│ 优点:开发者只关心任务,OS 管线程 │
│ 缺点:长任务阻塞池,需要分类(IO/CPU) │
├─────────────────────────────────────────────────────┤
│ C. 事件循环 + 单线程(Web Main Thread / Node.js) │
│ 一个线程上跑事件队列,任务非阻塞 │
│ 优点:无锁 │
│ 缺点:任何长任务卡死全部 │
├─────────────────────────────────────────────────────┤
│ D. Actor / CSP(Erlang / Go goroutine / Akka) │
│ 任务通过消息通信,无共享内存 │
│ 优点:避免数据竞争 │
│ 缺点:消息复制有成本 │
└─────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 4.2 线程问题的三类典型形态
线程问题类型:
┌──────────────────────────────────────────────┐
│ ① 调度不当:任务在错的线程上执行 │
│ 主线程做 IO;子线程更新 UI │
├──────────────────────────────────────────────┤
│ ② 同步开销:锁 / 竞争 / 死锁 │
│ 粒度过大;锁顺序不一致;忘 unlock │
├──────────────────────────────────────────────┤
│ ③ 资源浪费:线程过多 / 切换过频 │
│ 每个任务起新线程;线程池配置不当 │
└──────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
▶▶ 回扣 §02 案例:直播 App 的"开播秒开"翻车恰好同时命中三类——
- ① 调度不当:开播状态机仍在主线程做同步任务;
- ② 同步开销:4 路争抢同一把推流准备锁,P99=850ms;
- ③ 资源浪费:12 个 SDK 各自池化导致 187 个线程、22K/s 切换。
经验派失败的根本原因是把三类混为一谈,只看症状不分类型。
# 4.3 跨平台同构原理
底层都是 POSIX thread(pthread)或其衍生。区别只是上层封装:
通用线程模型:
[应用层 API]
Thread / GCD / Worker / goroutine
│
▼
[运行时层]
JVM ThreadPool / NSOperationQueue / V8 / Go runtime
│
▼
[系统层]
pthread_create / pthread_mutex / futex
│
▼
[内核]
task_struct / scheduler / runqueue
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
正因为它们都长这个样子,线程问题的本质也是同一个:
线程问题不是平台 bug,是"共享资源 + 抢占调度 + 同步必需"的必然产物。
跨平台术语对照
| 通用术语 | Android | iOS | Web | C/C++ |
|---|---|---|---|---|
| 线程 | Thread / HandlerThread | NSThread / GCD | Worker | pthread_t |
| 线程池 | ExecutorService / Executors | DispatchQueue.global | Worker pool | 手写 |
| 互斥锁 | synchronized / ReentrantLock | NSLock / os_unfair_lock | Atomics.wait | pthread_mutex_t |
| 主线程 | UI Thread | Main Thread | Main JS Thread | 第一个线程 |
| 后台优先级 | Process.THREAD_PRIORITY_BACKGROUND | DispatchQoS.background | N/A | setpriority |
# 4.4 平台差异点矩阵
| 维度 | Android | iOS | Web | C/C++ |
|---|---|---|---|---|
| 主流模型 | 多线程 + Handler/Looper | GCD + Operation Queue | Main + Worker(单线程内事件循环) | pthread |
| 线程创建开销 | ~100μs(Java Thread) | ~50μs(GCD 内部) | Worker ~10ms | ~50μs |
| 默认栈大小 | 1MB | 512KB(次)/ 8MB(主) | 1MB | 1MB(Linux) |
| 调度器 | CFS(Linux 内核) | GCD + XNU 调度 | V8 Microtask + 浏览器进程调度 | OS 调度 |
| 优先级 | nice -20~19 | QoS class | 无(浏览器决定) | nice |
探索性思考:为什么"四种并发模型"是工程上的稳定划分?因为它们对应了"通信方式 × 调度策略"的四个象限。好的抽象划分通常对应物理本质 —— 这四种不是被发明的,而是被发现的:每个模型都是某种物理约束的最优解。理解这点的工程师选择并发模型时不再"凭感觉",而是先问"我的任务粒度 + 通信需求是什么"。
# 05.度量与采集
# 5.1 三类采集方案
① 静态快照采集(线程清单、栈大小)
② 动态系统追踪(系统级 trace + 切换次数)
③ 锁与竞争采集(lock contention 数据)
2
3
① 静态快照采集
// Android:扫 /proc/<pid>/task
fun listThreads(): List<ThreadInfo> {
val pid = android.os.Process.myPid()
return File("/proc/$pid/task").listFiles()?.map { dir ->
ThreadInfo(
tid = dir.name.toInt(),
name = File(dir, "comm").readText().trim(),
state = File(dir, "stat").readText().split(" ")[2]
)
} ?: emptyList()
}
2
3
4
5
6
7
8
9
10
11
优势:直接看线程数与名称。
局限:不能看切换频率与锁等待。
② 动态系统追踪
| 平台 | 工具 |
|---|---|
| Android | Perfetto / Systrace |
| iOS | Instruments System Trace |
| Web | Performance Panel |
| Linux | perf / ftrace |
优势:完整调度过程(运行/阻塞/等待)。
局限:开销中等;离线分析。
③ 锁竞争采集
| 平台 | 方法 |
|---|---|
| Android | Perfetto Lock Contention |
| iOS | Instruments Threads |
| C/C++ | perf lock |
优势:直接看哪把锁是瓶颈。
局限:需要 root(部分场景)。
# 5.2 各方案的可见盲区
| 方案 | 钩子位置 | 数据粒度 | 性能开销 | 跨端通用性 | 线上可用 | 主要局限 |
|---|---|---|---|---|---|---|
| ① 静态快照 | /proc/task 等 | 线程级 | 极低 | 多端有差异 | 是 | 不知切换 |
| ② 系统追踪 | 内核 trace | 调度事件 | 中 | 跨端工具 | 部分 | 离线分析 |
| ③ 锁竞争 | mutex/futex | 锁级 | 中 | 部分平台 | 否(线下) | 需调试模式 |
实战建议:① 线上常态;② + ③ 用于深度排查。
# 5.3 跨平台采集对照表
| 平台 | 静态快照 | 系统追踪 | 锁竞争 |
|---|---|---|---|
| Android | /proc/pid/task | Perfetto | Perfetto Lock |
| iOS | sysctlbyname | Instruments | Instruments Threads |
| Web | navigator.hardwareConcurrency | Performance Panel | (无原生) |
| Linux | ps -L / top -H | perf | perf lock |
# 5.4 数据可信度评估
- 静态快照:可信度高,但只看"现在"。
- 系统追踪:可信度最高,但开销影响数据。
- 锁竞争:可信度高,但通常仅离线。
探索性思考:为什么"线程问题"经常被工程师后置发现?因为它的症状(卡顿/ANR)和根因(线程数/锁等)之间隔着多个抽象层。静态快照看不到动态、动态追踪看不到静态 —— 必须三种方案配合才能看清全貌。线程问题的复杂性 = 调度的复杂性 + 同步的复杂性 + 多核的复杂性,三者互相耦合。
# 06.归因决策树
# 6.1 线程问题决策树
症状 = ?
│
├─ 主线程卡顿(帧时长 > 16ms)
│ │
│ └─ 走"主线程任务异常"分支:
│ - 检查主线程消息队列
│ - 是否有 IO/网络/JSON 解析
│ - 治理:§13 主线程减负
│
├─ ANR / 死锁
│ │
│ └─ 走"死锁/锁竞争"分支:
│ - 检查持锁链路
│ - 锁内是否有 IO/网络
│ - 治理:§14 同步治理
│
├─ 线程数膨胀(> 100)
│ │
│ └─ 走"线程膨胀"分支:
│ - 看哪些 SDK 创建了多少线程
│ - 是否有 HandlerThread 未 quit
│ - 治理:§15 线程池治理
│
└─ 偶发卡顿 / P99 长尾
│
└─ 走"调度问题"分支:
- 检查优先级反转
- 检查后台任务抢前台 CPU
- 治理:§16 优先级调度
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 6.2 死锁与竞争归因
死锁四要素:
- 互斥:资源不能共享
- 持有并等待:持有 A 时等 B
- 不可剥夺:必须自己释放
- 循环等待:A→B→C→A
破任一即可。最常用:破"循环等待"(全局锁顺序)。
# 6.3 调度延迟归因
典型调度延迟:
- 等待 CPU 时间片:runqueue 长,线程数多
- 被高优先级抢占:低优先级线程"被等"
- 大小核切换:跨集群切换 50μs+
- 缺页异常:内存换页
# 6.4 线程数膨胀归因
典型膨胀模式:
- 每个 SDK 各自线程池(X SDK = X 池)
- HandlerThread 未 quit
- ExecutorService 未 shutdown
- new Thread 不池化
- coroutine GlobalScope 滥用
探索性思考:为什么"决策树"是线程归因的最佳工具?因为线程问题的症状是高度结构化的——主线程卡 / ANR / 膨胀 / 长尾,每种症状对应特定根因路径。结构化症状的领域适合用决策树 —— 不需要 ML,工程经验已足够。
# 07.主线程全链路
主线程是用户感知的入口,是所有 UI 平台的"必经之路"。
# 7.1 Android UI Thread 全链路
① ActivityThread.main() 创建主线程
↓ Looper.prepareMainLooper()
② Looper.loop() 进入消息循环
↓
③ MessageQueue.next() 取下一条消息
↓
④ msg.target.dispatchMessage(msg)
- INPUT 事件 / VSYNC 帧回调 / 各种 post 的 Runnable
↓
⑤ 处理完毕 → goto ②
2
3
4
5
6
7
8
9
10
关键观察:
- 主线程是单线程消息循环(C 模型)
- 任何一条消息 > 16ms 必然掉帧
- 任何一条消息 > 5s 触发 ANR
主线程禁止操作:同步 IO / 同步网络 / 长时间 CPU 计算 / 同步 Binder。
# 7.2 iOS Main Thread 全链路
① 应用启动 → main() → UIApplicationMain()
↓
② 主 RunLoop(NSRunLoop.mainRunLoop)启动
↓
③ 处理事件源:InputSource / Observer / Performer
↓
④ CADisplayLink 帧回调
↓
⑤ Core Animation transaction
2
3
4
5
6
7
8
9
iOS 关键约束:
- UIKit API 必须主线程调用
- Watchdog 监控(启动 20s / 操作 8s)
- GCD 主队列 = 主线程
# 7.3 Web Main JS Thread 全链路
① 浏览器主线程跑 V8 Isolate
↓
② 事件循环:
- Macrotask 队列(setTimeout / IO)
- Microtask 队列(Promise.then)
↓
③ Render:Style / Layout / Paint / Composite
↓
④ requestAnimationFrame
↓
⑤ 回到 ②
2
3
4
5
6
7
8
9
10
11
Web 特殊性:
- 所有 JS 代码默认在主线程
- DOM 操作必须主线程
- Web Worker 可以分担 CPU 任务(但不能直接操作 DOM)
# 7.4 主线程负载分类
┌─────────────────────────────────────┐
│ 必要负载(无法移走): │
│ UI 渲染 / 输入分发 / 帧回调 │
├─────────────────────────────────────┤
│ 应该移走的负载: │
│ DB / 网络 / JSON / 计算 / Bitmap │
├─────────────────────────────────────┤
│ 误操作(必须修复): │
│ 同步 sleep / 同步等待 / 锁内长持有 │
└─────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
# 7.5 主线程优化路径
① StrictMode 拦截 → 强制约束
② 长任务切片 → 单条消息 < 8ms
③ IdleHandler 推迟 → 非紧急任务挪到空闲帧
④ 协程 / async-await → 简化异步
2
3
4
▶▶ 回扣 §02 案例:开播状态机改 IdleHandler 后主线程 P99 从 200ms 降到 8ms。
探索性思考:为什么"主线程"是所有 UI 框架的共同设计?因为 UI 状态需要串行一致性 —— 所有跨平台框架都没敢挑战这一点。主线程是工程上的"宇宙秩序"。
# 08.线程池全链路
# 8.1 Android ExecutorService 全链路
① Executors.newFixedThreadPool(4)
↓
② ThreadPoolExecutor 创建:
- corePoolSize / maximumPoolSize
- LinkedBlockingQueue
- ThreadFactory
↓
③ submit(task):
- 池内有空闲 → 直接跑
- 没空闲但 < maxSize → 创建新线程
- 满了 → 加入队列
- 队列也满 → RejectedExecutionHandler
↓
④ 线程跑完一个任务 → 继续从队列拿
2
3
4
5
6
7
8
9
10
11
12
13
14
经典线程池类型:
| 类型 | 特点 | 适用 |
|---|---|---|
| FixedThreadPool | 固定大小 | CPU bound |
| CachedThreadPool | 弹性,60s 回收 | IO bound(短任务) |
| SingleThreadExecutor | 1 个线程 | 顺序执行 |
| ScheduledThreadPool | 定时调度 | 定时任务 |
| ForkJoinPool | work-stealing | 计算密集(递归) |
反模式:
newCachedThreadPool在 IO 密集场景下可能创建上千线程- 建议用
ThreadPoolExecutor直接构造,明确各参数
# 8.2 iOS GCD 全链路
① DispatchQueue.global(qos: .userInitiated)
↓
② GCD 内部维护多个线程池(按 QoS 分)
↓
③ async { task }:任务入队 / GCD 选线程跑
↓
④ 线程跑完 → 自动复用
2
3
4
5
6
7
GCD QoS 等级:
| QoS | 用途 |
|---|---|
| userInteractive | UI 相关 |
| userInitiated | 用户触发 |
| default | 默认 |
| utility | 长时间任务 |
| background | 后台清理 |
GCD 特殊性:
- 自动管理线程数(系统决定)
- 可能创建较多线程(线程爆炸警告)
- 主队列 = 主线程
# 8.3 Web Worker 全链路
① new Worker('worker.js')
↓
② 浏览器创建独立 V8 Isolate(独立堆)
↓
③ postMessage(data):数据结构化克隆,跨 Worker 传递
↓
④ Worker 中 onmessage 处理
↓
⑤ Worker postMessage 回主线程
2
3
4
5
6
7
8
9
Web Worker 限制:
- 无 DOM 访问
- 数据通过 postMessage 传递(拷贝开销)
- 创建开销 ~10ms(远比线程慢)
# 8.4 线程池配置黄金法则
CPU bound:
线程数 = CPU 核数 + 1
IO bound:
线程数 = CPU 核数 × (1 + 等待时间/计算时间)
典型 = 20-50
2
混合:拆成两个池(CPU 池 + IO 池),不要"一个池跑所有"。
# 8.5 跨端线程池对照
| 平台 | 主流方案 | 线程数策略 | 队列 |
|---|---|---|---|
| Android | ExecutorService | 显式配置 | LinkedBlockingQueue |
| iOS | GCD | 系统自动 | 内部队列 |
| Web | Worker pool | 手动管理 | postMessage |
| Kotlin 协程 | Dispatchers.IO | 64 默认 | ChannelQueue |
| Go | goroutine pool | 由 runtime 管 | go channel |
▶▶ 回扣 §02 案例:Day 3 集中线程池服务让线程数从 187 降到 52。集中管理是治膨胀的根本手段。
探索性思考:为什么 GCD 比 ExecutorService 更"优雅"?因为它把"线程数"从开发者手中拿走了。好的抽象隐藏不必要的细节。但代价是当系统决策错误时,开发者无能为力。抽象的代价 = 控制权的让渡。
# 09.同步原语全链路
# 9.1 mutex 全链路
① 线程 A 调用 mutex.lock()
↓
② CAS 尝试获取:
- 成功(无竞争)→ 进入临界区,~50ns
- 失败(有竞争)→ 进入慢路径
↓
③ 慢路径:陷入内核 futex_wait
- 线程被挂起
- 加入等待队列
↓
④ 持锁线程释放 → futex_wake
- 唤醒等待线程
↓
⑤ 被唤醒线程获得锁 → 进入临界区
2
3
4
5
6
7
8
9
10
11
12
13
14
关键开销:
- 无竞争 ~50ns
- 有竞争 5-50μs(陷入内核)
- 等待时间 = (N-1) × 临界区时长
# 9.2 spinlock 全链路
① spinlock.lock()
↓
② while (CAS 失败) { /* spin */ }
- 不陷入内核
- 持续消耗 CPU
↓
③ CAS 成功 → 进入临界区
2
3
4
5
6
7
spinlock 适用场景:
- 临界区极短(< 1μs)
- 无大量竞争
- 多核 CPU
spinlock 反模式:
- 临界区长 → CPU 烧
- 单核 CPU → 死锁
- 高竞争 → CPU 100%
▶▶ 回扣 §02 案例:第 3 周 spinlock 替代 mutex,CPU 占用炸到 100% 发热严重——正是 spinlock 反模式的真实代价。
# 9.3 读写锁 / CAS / 原子操作
ReadWriteLock:
- 多读并发,单写独占
- 适合读多写少(缓存类)
- 写多场景反而比 mutex 慢
CAS / Atomic:
- 单变量更新
- 比 mutex 快 10-100×
- 不适合复杂状态
LongAdder(Java 8+):
- 高并发计数器
- 比 AtomicLong 快 5-10×(高并发下)
# 9.4 信号量与条件变量
Semaphore:
- 限制并发数(如最多 N 个任务)
- 跨线程通知
Condition Variable:
- 等待某条件成立
- 配合 mutex 使用
# 9.5 死锁四要素与破解
死锁四要素:
- 互斥
- 持有并等待
- 不可剥夺
- 循环等待
破任一即可。最常用:破"循环等待"——全局锁顺序。
// 全局规定:所有锁按 hash 升序加
void transfer(Account a, Account b, int amount) {
Account first = a.id < b.id ? a : b;
Account second = a.id < b.id ? b : a;
synchronized(first) {
synchronized(second) { /* ... */ }
}
}
2
3
4
5
6
7
8
探索性思考:为什么"锁"是工程上最难掌握的工具?因为它的复杂性是组合性的——单把锁简单,两把锁就有死锁,三把锁有活锁。锁的复杂度随锁数量呈指数级增长。这就是为什么"避免锁"(用消息传递、CAS、不可变数据)越来越主流——不是锁不好,而是它的复杂度天花板太低。
# 10.优先级调度全链路
# 10.1 Linux CFS 调度全链路
① 每个 task_struct 有 nice 值(-20~19)
↓
② CFS 维护红黑树(按 vruntime 排序)
↓
③ 调度器选 vruntime 最小的跑
- vruntime = 实际运行时间 / nice 权重
- nice 低 = 权重大 = vruntime 涨慢
↓
④ 时间片到 → 重新入队
2
3
4
5
6
7
8
9
关键:CFS 是"完全公平调度"——不是 strict priority,而是按权重分配。
# 10.2 Android 优先级管理
// 设置线程优先级
Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY) // -4
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND) // 10
// 优先级取值
THREAD_PRIORITY_AUDIO // -16
THREAD_PRIORITY_URGENT_AUDIO // -19
THREAD_PRIORITY_DISPLAY // -4
THREAD_PRIORITY_FOREGROUND // -2
THREAD_PRIORITY_DEFAULT // 0
THREAD_PRIORITY_BACKGROUND // 10
THREAD_PRIORITY_LOWEST // 19
2
3
4
5
6
7
8
9
10
11
12
# 10.3 iOS QoS 全链路
QoS Class(高到低):
userInteractive → userInitiated → default → utility → background
↓
GCD 内部映射到 nice 值 + cgroup
↓
XNU 调度器按 QoS 分配 CPU
2
3
4
5
6
iOS QoS 传播:
- 主线程默认 userInteractive
- DispatchQueue 继承 QoS
- 锁等待时支持优先级继承
# 10.4 优先级反转与继承
优先级反转:
线程 H(高优先级)想取锁 L
↓
线程 L(低优先级)持有锁 L
↓
线程 M(中优先级)抢占了 L 的 CPU
↓
H 等 L → L 等 M → H 间接等 M
"高优先级被中优先级延误"
2
3
4
5
6
7
8
优先级继承:
H 等 L 时:
↓ 临时把 L 的优先级提升到 H
↓ M 不能再抢 L 的 CPU
↓ L 快速完成 → 释放锁
↓ H 获得锁
↓ L 优先级恢复
2
3
4
5
6
收益(§17.4 实验):高负载时 P99 从 850ms → 35ms(24× 改善)。
# 10.5 关键路径的 QoS 策略
关键路径(开播 / 支付 / 启动):
├─ 主线程:userInteractive
├─ 直接子线程:userInitiated
└─ 持锁线程:启用优先级继承
后台路径(统计 / 日志 / 预取):
├─ 全部 background QoS
└─ 限流 + 超时
2
3
4
5
6
7
8
▶▶ 回扣 §02 案例:Day 4 推流相关线程升 QoS + 锁启用优先级继承,让推流锁等待 P99 从 850ms 降到 18ms。异构 SoC 时代优先级反转是隐形 P0。
探索性思考:为什么"优先级反转"在异构 SoC(大小核)时代被放大?因为大小核 = 不同 IPC = 同样 nice 值的"实际优先级"差异巨大。一个 background 任务在大核可能跑得比 userInitiated 在小核还快。异构架构破坏了"优先级"的单一序关系 —— 这是工程界还在适应的新现实。
# 11.协程全链路
# 11.1 Kotlin 协程全链路
① launch { ... } 创建 Coroutine
↓
② 编译期转换为状态机
↓
③ Continuation 表示"剩余计算"
↓
④ Dispatcher 决定在哪线程跑:
- Dispatchers.Main(主线程)
- Dispatchers.IO(默认 64 线程池)
- Dispatchers.Default(CPU 核数池)
↓
⑤ suspend 函数挂起:
- 不阻塞线程
- 把线程还给池
↓
⑥ 恢复时:
- Continuation.resume()
- 重新调度到 Dispatcher
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
协程优势:
- 一个 Dispatcher 可以跑成千上万协程
- 挂起不阻塞线程(线程被复用)
- 代码看似同步,实际异步
# 11.2 Swift async-await 全链路
① async func 返回 Task
↓
② await 处编译期生成 Continuation
↓
③ Task 在 GCD 池上跑
↓
④ await 时挂起
- 不阻塞 GCD 线程
↓
⑤ 恢复时调度回原 Actor / Queue
2
3
4
5
6
7
8
9
10
Swift 特殊性:
- @MainActor 标记主线程代码
- Task.detached 跳出 Actor
- async let 并行子任务
# 11.3 Go goroutine 全链路
① go func() 创建 goroutine
↓
② Go runtime 管理 M:N 调度
- M 个 OS 线程
- N 个 goroutine(N 远大于 M)
↓
③ goroutine 阻塞(如 channel)
- 让出 OS 线程
- OS 线程调度其他 goroutine
↓
④ 唤醒时重新调度
2
3
4
5
6
7
8
9
10
11
Go 特性:
- goroutine 栈仅 2KB(按需增长)
- channel 是 CSP 模型核心
- runtime.GOMAXPROCS 控制 OS 线程数
# 11.4 协程 vs 线程对照
| 维度 | 线程 | 协程 |
|---|---|---|
| 创建开销 | 50μs+ | 几 μs |
| 栈大小 | 1MB | 几 KB(按需) |
| 切换开销 | 50μs(含缓存污染) | < 1μs |
| 调度方 | OS | 应用 runtime |
| 适合场景 | CPU bound | IO bound + 高并发 |
# 11.5 协程的边界
协程不是银弹:
- CPU 密集任务用协程没有优势(仍受 CPU 核数限制)
- 协程依然需要线程池(CoroutineDispatcher)
- 协程取消机制需要 lifecycle-aware
▶▶ 回扣 §02 案例:Day 3 IO 类任务全部改协程,配合 Dispatchers.IO 池(20 线程),让 IO 并发不阻塞 CPU。
探索性思考:为什么协程在 IO 场景胜过"每 IO 一线程"?因为协程是"用户态调度"——挂起不需要陷入内核。线程是 OS 的抽象,协程是 runtime 的抽象 —— 越靠近用户态,开销越小。但代价是 CPU 密集任务无法躲开 OS 调度(因为它必须真正占 CPU 核)。
# 12.跨端对照
# 12.1 五个全链路总览
| 链路 | Android | iOS | Web | Go |
|---|---|---|---|---|
| 主线程 | UI Thread + Looper | Main Thread + RunLoop | Main JS + 事件循环 | (无强制) |
| 线程池 | ExecutorService | GCD | Worker pool | M:N runtime |
| 同步原语 | synchronized / ReentrantLock | os_unfair_lock | Atomics | sync.Mutex / channel |
| 优先级调度 | Process.setThreadPriority | QoS Class | (浏览器决定) | (runtime 决定) |
| 协程 | Kotlin coroutines | Swift async | async/await | goroutine |
# 12.2 各平台优化优先级
Android:
- StrictMode 拦截主线程 IO
- 集中线程池服务
- 锁粒度拆分 + 锁内禁 IO
- 关键路径升 QoS + 优先级继承
- Kotlin 协程替代回调
iOS:
- 主线程禁同步等待
- GCD QoS 用对
- os_unfair_lock_with_options 优先级继承
- async-await 替代 callback
Web:
- 长任务切片(< 50ms / 任务)
- Web Worker 处理 CPU 密集
- requestIdleCallback 推迟非紧急
- async-await 简化代码
# 12.3 反直觉问题答疑
| 问题 | 答案 |
|---|---|
| 16 线程比 4 快 4 倍? | 不一定,CPU bound 拐点在核数+1 |
| Thread.sleep(1) 真睡 1ms? | 不一定,最少 1ms 但常更长 |
| CPU 100% 是好事? | 看是哪个线程:前台是好,后台是坏 |
| synchronized vs Lock 哪个快? | JIT 优化后 synchronized 不一定慢 |
| 子线程 IO 没影响? | 错。会消耗 IO 带宽 + 切换开销 |
| GCD 是无限线程吗? | 不是,有线程爆炸警告 |
| Web Worker 越多越快? | 错,创建开销 ~10ms |
| 后台优先级低无影响? | 错。仍占 CPU 时间片 |
▶▶ 回扣 §02 案例:经验派 100% 命中"反直觉问题"——把"线程多 = 快"当真理。真相是:线程是物理资源,不是免费午餐。
# 13.治理一层主线程
核心命题:主线程是用户感知的入口。任何阻塞主线程的任务都是 P0 问题。
# 13.1 StrictMode 拦截
机理:把"主线程做 IO/DB/网络"从约定变成强制约束。
代码:
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectDiskReads().detectDiskWrites().detectNetwork()
.penaltyDeath() // 直接 crash 暴露
.build());
}
2
3
4
5
6
收益:开发期 100% 拦截,杜绝线上事故。
边界:仅 Debug 启用 penaltyDeath;Release 用 penaltyLog 收集。
# 13.2 长任务切片 + IdleHandler
机理:单条主线程消息 > 16ms 必然掉帧;切成 < 8ms 段并推迟到空闲。
代码:
Looper.myQueue().addIdleHandler(() -> {
if (pendingTasks.isEmpty()) return false;
long deadline = SystemClock.uptimeMillis() + 4;
while (!pendingTasks.isEmpty() && SystemClock.uptimeMillis() < deadline) {
pendingTasks.poll().run();
}
return !pendingTasks.isEmpty();
});
2
3
4
5
6
7
8
收益:§02 案例开播状态机改 IdleHandler 后主线程 P99 从 200ms 降到 8ms。
边界:IdleHandler 在持续操作时不触发,需 postDelayed 兜底。
# 13.3 协程 / async-await 简化异步
机理:§17.5 实验证明协程在 IO 场景 5× 吞吐。
代码:
viewModelScope.launch {
val data = withContext(Dispatchers.IO) {
db.userDao().getAll()
}
adapter.submitList(data) // 自动回主线程
}
2
3
4
5
6
收益:代码量减半,无回调地狱。
边界:CoroutineScope 必须 lifecycle-aware(viewModelScope/lifecycleScope)防内存泄漏。
# 13.4 Web 长任务切片
// 反例:长 for 循环阻塞主线程
for (let i = 0; i < 100000; i++) {
process(items[i]); // 全部同步
}
// 正例:用 scheduler.postTask 切片
async function processChunked(items) {
for (let i = 0; i < items.length; i += 100) {
await new Promise(resolve =>
scheduler.postTask(() => {
for (let j = i; j < Math.min(i + 100, items.length); j++) {
process(items[j]);
}
resolve();
}, { priority: 'background' })
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 13.5 主线程减负检查清单
- [ ] StrictMode 已开启(Debug)
- [ ] 启动期 IO 全部异步
- [ ] JSON 解析不在主线程
- [ ] 数据库查询不在主线程
- [ ] 网络请求不在主线程
- [ ] Bitmap 解码不在主线程
- [ ] 长任务 > 8ms 切片
- [ ] 非紧急用 IdleHandler
探索性思考:为什么"主线程减负"是所有 UI 优化的"大头"?因为主线程是"独木桥"——只有一个,所有 UI 工作必经。优化主线程 = 优化整个用户体验 —— 这就是为什么"启动期主线程 -300ms"能换"留存 +0.5%"。
▶▶ 回扣 §02 案例:Day 3 第 1 层"主线程减负"是关键起点——开播状态机切片到 IdleHandler 立刻让主线程 P99 -96%。
# 14.治理二层同步
核心命题:让锁短、让锁少、让锁不反转。
# 14.1 锁粒度拆分(按维度分锁)
机理:§02 案例推流准备锁被音频/视频/字幕/特效 4 路争用,拆成 4 把独立锁后 P99 850ms→18ms。
代码:
// 反例:1 把大锁
synchronized(streamLock) {
audio.prepare();
video.prepare();
subtitle.prepare();
effect.prepare();
}
// 正例:拆分锁
synchronized(audioLock) { audio.prepare(); }
synchronized(videoLock) { video.prepare(); }
synchronized(subtitleLock) { subtitle.prepare(); }
synchronized(effectLock) { effect.prepare(); }
2
3
4
5
6
7
8
9
10
11
12
13
收益:4 路完全并发,总耗时降到 max(各路) 而非 ∑。
边界:拆锁不能破坏原本需保护的状态一致性。
# 14.2 锁内严禁 IO/网络/Binder
机理:锁内 IO 等于把锁持有时长拉到秒级。
反例:
synchronized(this) {
// ❌ 锁内做 IO
val data = file.readBytes() // 可能 100ms+
cache.put(key, data)
}
2
3
4
5
正例:
val data = file.readBytes() // 锁外读
synchronized(this) {
cache.put(key, data) // 锁内只做最小操作
}
2
3
4
收益:单次锁持有时长从 ms 级降到 μs 级。
# 14.3 ReadWriteLock / CopyOnWrite
机理:缓存类场景写很少、读极多。RWLock 让读完全并发。
代码:
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Map<String, User> cache = new HashMap<>();
User get(String k) {
rwLock.readLock().lock();
try { return cache.get(k); }
finally { rwLock.readLock().unlock(); }
}
void put(String k, User v) {
rwLock.writeLock().lock();
try { cache.put(k, v); }
finally { rwLock.writeLock().unlock(); }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
收益:读吞吐量提升 N 倍(N=并发读线程数)。
边界:写线程会阻塞所有读线程;写多场景反而比 mutex 慢。
# 14.4 CAS / 原子操作消灭锁
机理:计数器、状态机等场景用 AtomicInteger / LongAdder 比 synchronized 快 10-100×。
代码:
// 反例
synchronized(this) { counter++; }
// 正例(高并发优于 AtomicInteger)
LongAdder counter = new LongAdder();
counter.increment();
2
3
4
5
6
收益:高并发计数场景吞吐 +5-10×。
边界:仅适合单变量;复杂状态用 mutex。
# 14.5 全局锁顺序避免死锁
机理:§09.5 死锁四要素;破"循环等待"最实际。
// 全局规定:所有锁按 hash 升序加
void transfer(Account a, Account b, int amount) {
Account first = a.id < b.id ? a : b;
Account second = a.id < b.id ? b : a;
synchronized(first) {
synchronized(second) { /* ... */ }
}
}
2
3
4
5
6
7
8
收益:完全杜绝特定模式的死锁。
边界:编译期无法强制;需 code review 把关。
探索性思考:为什么"锁治理"比"无锁编程"更现实?因为无锁编程对开发者要求极高(CAS、ABA、内存模型),出错代价是数据竞争。大多数业务场景下,正确使用锁 > 错误使用无锁。但锁的复杂度天花板低——超过 3 把锁的系统必须考虑无锁/消息传递重构。
▶▶ 回扣 §02 案例:Day 3 第 2 层"锁拆分 + 锁内禁 IO"两招组合,让推流锁等待 P99 从 850ms 直接降到 18ms。
# 15.治理三层线程池
核心命题:集中管理、分类隔离、显式回收。
# 15.1 集中线程池服务
机理:§02 案例与 §18.1 共同证明集中管理是治膨胀的根本手段。
代码:
object AppThreadService {
val cpuPool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() + 1
)
val ioPool = Executors.newFixedThreadPool(20)
val highPriorityPool = Executors.newFixedThreadPool(4).also {
// 提升优先级(具体实现略)
}
fun submitCpu(r: Runnable) = cpuPool.submit(r)
fun submitIo(r: Runnable) = ioPool.submit(r)
}
2
3
4
5
6
7
8
9
10
11
12
收益:§02 案例线程数 187→52。
边界:第三方 SDK 不可控;需 hook Thread.start 或在 review 中拦截。
# 15.2 CPU 池 = availableProcessors() + 1
机理:§17.2 拐点实验证明此值最优。
代码:
val cpuPool = ThreadPoolExecutor(
Runtime.getRuntime().availableProcessors(),
Runtime.getRuntime().availableProcessors() + 1,
60L, TimeUnit.SECONDS,
LinkedBlockingQueue()
)
2
3
4
5
6
收益:自适应设备核数,避免硬编码。
边界:异构 SoC 上"核数"包含小核,CPU bound 任务建议用大核数。
# 15.3 IO 池容量按峰值估算
机理:IO 任务大多时间在等,可以多于核数。典型 20-50。
收益:IO 并发不阻塞 CPU 计算。
边界:> 64 会引发资源耗尽。
# 15.4 HandlerThread / 线程显式回收
机理:HandlerThread 不 quit 会一直占栈和 TCB。
代码:
private HandlerThread handlerThread;
void start() {
handlerThread = new HandlerThread("Worker");
handlerThread.start();
}
void stop() {
handlerThread.quitSafely();
try { handlerThread.join(); }
catch (InterruptedException ignored) {}
handlerThread = null;
}
2
3
4
5
6
7
8
9
10
11
12
13
收益:线程数稳态收敛。
边界:quitSafely 后队列剩余任务会被丢弃,需保证幂等。
# 15.5 SDK 强制接入
机理:很多 SDK 默认自己创建线程,必须强制接入应用统一池。
手段:
- 编译期 hook(ASM)替换
new Thread - 启动期反射替换 SDK 内部线程池
- 与 SDK 厂商沟通改造
探索性思考:为什么"集中线程池"是治理线程膨胀的银弹?因为它解决了"组合爆炸"——10 个 SDK 各自 5 线程 = 50 线程;统一接入后所有 SDK 共享 20 线程 = 20。集中化是工程上最常见的"减法" —— 但每个个体(SDK)都觉得自己的需求合理,需要全局视角才能看到浪费。
▶▶ 回扣 §02 案例:Day 3 第 3 层"统一线程池 + SDK 接入"是案例中线程数从 187 降到 52 的核心动作——单步收益巨大。
# 16.治理四层调度
核心命题:异构 SoC + QoS 时代下优先级反转是隐形 P0。让关键路径不被压制。
# 16.1 优先级继承锁
机理:§17.4 实验长尾 -24×。
代码(iOS):
os_unfair_lock_lock_with_options(&lock,
OS_UNFAIR_LOCK_DATA_SYNCHRONIZATION);
2
代码(Android):
// Kotlin Mutex(带优先级感知)
suspend fun update() = mutex.withLock { /* ... */ }
2
收益:§02 案例推流锁等待 P99 850ms→18ms。
边界:仅对"主线程会等"的锁有意义;不要无脑全开。
# 16.2 关键路径线程升 QoS
机理:开播/支付/启动等关键路径的子线程也要升级,避免被压制。
代码(Android):
val thread = Thread(task).apply {
priority = Thread.MAX_PRIORITY // 10
}
Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY)
2
3
4
代码(iOS):
let queue = DispatchQueue(label: "key.path", qos: .userInteractive)
收益:关键路径线程获得更多 CPU 时间片。
边界:不能滥用——所有线程都 high 等于都 default;只升关键 10-20%。
# 16.3 后台任务降 QoS + 限流
机理:后台任务(统计上报/日志/预取)应该让出 CPU 给前台。
代码:
val bgPool = Executors.newFixedThreadPool(2) { r ->
Thread(r).apply {
priority = Thread.MIN_PRIORITY // 1
}
}
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND)
2
3
4
5
6
收益:前台帧率更稳,发热下降。
边界:降级后任务可能饥饿;需配合超时机制。
# 16.4 大小核绑定(线程亲和性)
机理:CPU 密集任务绑大核;后台轻任务绑小核。
代码(Android NDK):
// 通过 sched_setaffinity 绑核
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(4, &cpuset); // 绑大核 4
CPU_SET(5, &cpuset); // 大核 5
sched_setaffinity(0, sizeof(cpuset), &cpuset);
2
3
4
5
6
收益:异构 SoC 上吞吐 +10-30%。
边界:手动绑核会与系统调度器冲突,需小心。多数业务无需细到这层。
# 16.5 ROI 排序
| ROI | 优化项 | 收益 | 成本 | 风险 | 对应章节 |
|---|---|---|---|---|---|
| 极高 | StrictMode + 主线程拦截 | 杜绝线上事故 | 1 天 | 零 | §13.1 |
| 极高 | 集中线程池服务 | 线程数 -50% | 1-2 周 | 中 | §15.1 |
| 极高 | 锁粒度拆分 | 关键锁 P99 -90% | 1-2 周 | 中 | §14.1 |
| 极高 | 协程 + 长任务切片 | 主线程 P99 -90% | 1-2 周 | 中 | §13.2-13.3 |
| 高 | 优先级继承锁 | 异构 SoC 长尾 -90% | 几天 | 低 | §16.1 |
| 高 | CPU 池 = 核数+1 | 计算吞吐 +30% | 几小时 | 低 | §15.2 |
| 高 | 锁内禁 IO/网络 | 锁持有 -90% | 1-2 周 | 中 | §14.2 |
| 高 | CAS / LongAdder | 高并发计数 +5× | 几天 | 低 | §14.4 |
| 中 | 关键路径升 QoS | 关键场景长尾改善 | 1 周 | 中 | §16.2 |
| 中 | ReadWriteLock | 读多场景吞吐提升 | 1 周 | 中 | §14.3 |
| 中 | HandlerThread 显式 quit | 防资源泄漏 | 几天 | 低 | §15.4 |
| 低 | 大小核亲和性 | 异构吞吐 +10% | 1-2 周 | 高(冲突) | §16.4 |
| 极低 | 自己写调度器 | 几乎无收益 | 极高 | 极高 | - |
# 16.6 避免反向收益
- 加更多线程治线程问题:§02 案例第 1 周翻车的根因。
- spinlock 替代 mutex:CPU 烧到 100%,发热严重。
- 过度异步:所有方法都 async,调用链长,调试困难。
- 过度线程池分类:分成 10+ 个池反而资源浪费。
- 所有线程升 QoS:等价于没升 QoS。
探索性思考:为什么"调度治理"是最被低估的层级?因为它的收益隐藏在长尾——平均看不出来,P99 才看得出来。调度问题是"少数派的问题" —— 90% 用户感受不到,10% 用户骂得很凶。但这 10% 的用户往往是关键场景(支付、推流),代价巨大。
▶▶ 回扣 §02 案例:Day 4 第 4 层"QoS + 优先级继承"是案例中"长尾杀手"——把推流锁等待 P99 从 850ms 直接降到 18ms(24× 改善)。
# 17.求证实验 ⭐
本节是"为什么这些优化生效"的实证基础。每个实验严格遵循"6 步求证法"。
# 17.1 实验一:线程切换代价
猜想:线程切换代价可忽略。
假设:单次切换的"裸"代价 1-10μs(调度本身),加上缓存污染后实际影响 10-100μs。
数学推导:
调度路径 ~1μs(内核执行 schedule())
TCB ops ~0.5μs(保存 / 恢复寄存器)
TLB 失效 ~2-10μs(地址空间变更)
L1/L2 冷 ~10-100μs(实际内存访问变慢)
──────────────────
合计 13–110μs,典型 ~50μs
2
3
4
5
6
执行(Pixel 6,两个线程交替 ping-pong):
| 场景 | 单次切换代价(μs) |
|---|---|
| 同核绑定(缓存暖) | 2.1 |
| 跨核(缓存污染) | 18 |
| 跨集群(大小核切换) | 55 |
| 主线程被打断(重 UI) | 80 |
验证:
- 同核(缓存暖)2-3μs 与公式一致。
- 跨核切换主要被缓存污染主导。
- 大小核切换最高(涉及不同 ISA 微架构)。
思考:
- 线程切换代价从 2μs 到 80μs 不等,平均约 50μs。
- 任务粒度 < 100μs 时,切换开销已经接近任务本身耗时,异步化无意义。
- 小任务(< 100μs)不要异步;一系列小任务应合并再异步。
# 17.2 实验二:线程池拐点
猜想:线程数越多越快。
假设:CPU bound 最优 = CPU 大核数 + 1;超过后切换开销超过并发收益。
执行(Pixel 6,4 大核 + 4 小核,100 个 CPU 任务):
| 线程数 | 完成时间 (ms) | 速度提升 |
|---|---|---|
| 1 | 10,200 | 1× |
| 2 | 5,150 | 1.98× |
| 4 | 2,650 | 3.85× |
| 6 | 2,250 | 4.5× |
| 8 | 2,100 | 4.86× |
| 12 | 2,180 | 4.68× |
| 16 | 2,280 | 4.47× |
| 32 | 2,650 | 3.85× |
验证:
- 8 线程是峰值(4.86× 接近理论上限)。
- 32 线程比 8 线程慢 26%。
思考:
- CPU bound 任务的最优线程数 ≈ 大核数 + 1。
- 超过这个数线性劣化。
- 不要"为了快"开 32 / 64 线程,反向收益。
▶▶ 回扣 §02 案例:经验派第 1 周"把线程池从 8 调到 32"完全踩中本实验"32 比 8 慢 26%"的反例。
# 17.3 实验三:锁竞争代价
猜想:锁代价可忽略。
假设:高竞争下吞吐量随线程数下降;单线程延迟随 N 线性上升。
执行(临界区 10μs):
| 线程数 | 吞吐量 (op/s) | 单线程平均延迟 (μs) |
|---|---|---|
| 1 | 100,000 | 10 |
| 2 | 95,000 | 21 |
| 4 | 90,000 | 44 |
| 8 | 88,000 | 91 |
| 16 | 85,000 | 188 |
验证:
- 吞吐量基本不变(被锁串行化)。
- 单线程延迟随 N 线性上升。
- 16 线程时单线程延迟达 188μs,相当于 18× 慢。
思考:
- 高竞争锁会让"并行"退化为"串行 + 排队"。
- 缩小锁粒度 比加更多线程有效。
- 锁内严禁 IO、网络、GC 触发操作。
# 17.4 实验四:优先级反转
猜想:优先级继承收益不明显。
假设:优先级继承临时提升后台线程优先级,主线程等待降到 < 20ms。
执行:
| 实现 | 无干扰 P99 | 高负载干扰 P99 | 极端干扰 P99 |
|---|---|---|---|
| A 普通 mutex | 12 ms | 180 ms | 850 ms |
| B 优先级继承 | 12 ms | 22 ms | 35 ms |
验证:
- 无干扰时两者一致。
- 高负载时 B 比 A 快 8 倍。
- 极端干扰时 B 比 A 快 24 倍。
思考:
- 优先级反转在异构 SoC 上非常常见。
- 任何"主线程会等"的锁都必须启用优先级继承。
- 继承代价 < 1%,但可避免 5-25 倍长尾。
# 17.5 实验五:协程 vs 线程
猜想:协程和线程性能差不多。
假设:协程在 IO 场景吞吐相当甚至更优(无切换开销),内存占用 1/100。
执行(1000 个 HTTP GET):
| 实现 | 总耗时 | 内存峰值 | 上下文切换/s | 失败率 |
|---|---|---|---|---|
| A 1000 线程 | 5.2 s | 1.1 GB | 32,000 | 8.2%(OOM) |
| B 协程(Dispatcher 20) | 1.8 s | 45 MB | 2,800 | 0% |
| C 线程池 20 + 回调 | 1.9 s | 48 MB | 3,200 | 0% |
验证:
- A 直接 OOM 8.2%。
- B 和 C 性能接近,但 B 代码简洁。
思考:
- IO 场景必须用协程或固定线程池。
- "每 IO 一线程"是反模式。
- 协程的工程价值在于代码简洁,性能与线程池相当。
- 协程不能替代线程池——底层仍是线程池在跑。
# 17.6 五大实验启示
线程切换代价 → 50μs 量级,小任务异步不划算 ─┐
线程池拐点 → CPU bound 最优 = 核数 │
锁竞争代价 → 高竞争锁让并行退化为串行 ├─▶ 线程优化 = 找拐点 + 防反转 + 选模型
优先级反转 / 继承 → 异构 SoC 必启用,省 5-25× 长尾 │
协程 vs 线程 → IO 场景协程 5×+ 吞吐、1/24 内存 ─┘
2
3
4
5
统一启示:
- 并发不是越多越好:所有线程问题都在找"收益 vs 成本"的拐点。
- 数据驱动配置:线程池大小不是拍脑袋,要根据设备和场景算。
- 少而精 > 多而乱:少量但精心管理的线程,胜过大量随意创建的线程。
- 优先级反转是隐形杀手:异构 SoC + QoS 普及,必须主动防御。
- 选对模型胜过堆资源:IO 用协程、CPU 用线程池、UI 用单线程事件循环。
▶▶ 回扣 §02 案例:方法派 7 天闭环每一步都对应本节实验——Day 1 三方案组合 + Day 3 拐点 + 锁拆分 + Day 4 优先级继承。实验是优化前的"必经之路"。
# 18.实战案例
# 18.1 跨端同构案例:SDK 线程膨胀
背景:某社交应用启动期开了 100+ 线程,启动慢 + 内存高。Android / iOS / Web 都有类似情况。
现象:
- Android:线程清单显示 120 个线程,启动期 CPU 切换 8000+/s
- iOS:GCD 队列数达 80+,启动期切换密集
- Web:50+ Worker(其中很多是空闲)
根因:每个 SDK 启动期都自己创建线程,没人统一管理。
治理:
- Android:建立 AppThreadService,所有 SDK 强制接入
- iOS:统一 GCD QoS 池,禁止 SDK 自建队列
- Web:Worker pool 复用,禁止每次新建
效果:
| 平台 | 线程数(before) | 线程数(after) | 启动期切换/s |
|---|---|---|---|
| Android | 120 | 45 | 8000 → 2000 |
| iOS | 80(队列) | 30 | 6000 → 1500 |
| Web | 50(Worker) | 8 | N/A |
核心洞察:集中线程池是治理的根本手段——三端思路统一。
# 18.2 平台特异案例:iOS GCD 主队列死锁
背景:某 App 在主队列上调用 dispatch_sync 导致死锁,触发 watchdog kill。
根因:
// ❌ 死锁:在主队列上 sync 调用主队列
DispatchQueue.main.async {
DispatchQueue.main.sync { // 死锁!
// ...
}
}
2
3
4
5
6
治理:
- 严禁
DispatchQueue.main.sync任何调用 - 用 lint 规则强制拦截
- 已有
if Thread.isMainThread { ... } else { DispatchQueue.main.sync { ... } }判断
效果:iOS watchdog kill 率 -95%。
洞察:特定平台的"特殊死锁模式"需要专门防御。Android 上没有这种死锁(Looper 是异步的)。
# 18.3 反例案例:CachedThreadPool 引发线程爆炸
背景:某 App 网络层用 Executors.newCachedThreadPool,认为"弹性最好"。
结果:
- 高并发请求时创建上千线程
- 内存爆涨 + OOM
- 每 10 个用户就有 1 个崩溃
修复:
// ❌ 原始
val pool = Executors.newCachedThreadPool()
// ✅ 修复
val pool = ThreadPoolExecutor(
20, 30, 60L, TimeUnit.SECONDS,
LinkedBlockingQueue(100), // 有界队列
ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:调用者执行
)
2
3
4
5
6
7
8
9
洞察:默认线程池永远不要直接用——newCachedThreadPool / newFixedThreadPool 都需要 review 配置。
# 19.防劣化体系
# 19.1 三道防线总览
┌────────────┐ ┌────────────┐ ┌────────────┐
│ 编码期 Lint │ → │ CI 卡口 │ → │ 线上 SLO │
│ IDE 即时提示│ │ Macrobench │ │ 监控告警 │
└────────────┘ └────────────┘ └────────────┘
2
3
4
# 19.2 编码期 Lint
自定义规则:
- 主线程 IO/网络(StrictMode + Lint 双重)
- new Thread / new HandlerThread 直接创建(建议用 AppThreadService)
- ExecutorService 未 shutdown
- 锁内 IO 调用
- DispatchQueue.main.sync 调用
- Executors.newCachedThreadPool 使用(建议直接构造 ThreadPoolExecutor)
# 19.3 CI 卡口
性能基线测试:
@Test
fun threadCountBenchmark() = benchmarkRule.measureRepeated(
metrics = listOf(TraceSectionMetric("thread_count")),
iterations = 5
) {
startActivityAndWait()
repeat(20) { testKeyAction() }
}
2
3
4
5
6
7
8
卡口规则:
- 启动期线程数退化 ≥ 10% → 阻断 PR
- 上下文切换/s 退化 ≥ 20% → 警告
- 主线程 P99 退化 ≥ 10% → 阻断 PR
# 19.4 线上 SLO
| 指标 | 目标 | 告警阈值 |
|---|---|---|
| 进程线程数稳态 | < 60 | > 100 |
| 上下文切换/s | < 5000 | > 10000 |
| 主线程消息 P99 | < 16ms | > 50ms |
| 锁等待 P99 | < 5ms | > 50ms |
| ANR 率 | < 0.05% | > 0.1% |
# 19.5 文化建设
- 线程预算:新模块必须申报"线程数预算"
- 线程 Code Review:线程相关 PR 必有专人 review
- 线程 OKR:线程数稳态进 OKR
探索性思考:为什么"线程防劣化"特别难?因为线程问题是"组合性的"——单个模块加 5 线程没问题,10 个模块加 5 线程就爆炸。线程问题需要全局视角才能发现——这就是为什么必须有"线程委员会"或集中服务。
# 20.跨平台速查
# 20.1 工具速查
| 平台 | 静态采集 | 系统追踪 | 锁竞争 | 协程 |
|---|---|---|---|---|
| Android | /proc/pid/task | Perfetto | Perfetto Lock | Kotlin Coroutines |
| iOS | sysctlbyname | Instruments System Trace | Instruments Threads | Swift async-await |
| Web | navigator.hardwareConcurrency | Performance Panel | (无原生) | async/await |
| Linux | ps -L | perf | perf lock | (无) |
# 20.2 关键 API 速查
| 目的 | Android | iOS | Web |
|---|---|---|---|
| 线程池 | ExecutorService | DispatchQueue.global | Worker pool |
| CPU 池大小 | availableProcessors() + 1 | system | navigator.hardwareConcurrency |
| 互斥锁 | synchronized / ReentrantLock | os_unfair_lock | Atomics.wait |
| 优先级继承 | Kotlin Mutex | os_unfair_lock_with_options | (无) |
| 提升 QoS | THREAD_PRIORITY_DISPLAY | DispatchQoS.userInteractive | (浏览器决定) |
| 协程 | Kotlin coroutines | Swift async | async/await |
| 长任务切片 | IdleHandler | DispatchSourceTimer | scheduler.postTask |
| 主线程检查 | Looper.getMainLooper() | Thread.isMainThread | window === self |
# 20.3 各平台优化清单
Android:
- [ ] StrictMode 开启
- [ ] AppThreadService 集中管理
- [ ] CPU 池 = 核数+1,IO 池 = 20
- [ ] 锁粒度按维度拆分
- [ ] 锁内禁 IO/网络
- [ ] 关键路径升 QoS
- [ ] HandlerThread 显式 quit
- [ ] 用 Kotlin 协程替代回调
- [ ] Lint 自定义规则
iOS:
- [ ] 主队列禁 sync 调用
- [ ] GCD QoS 用对(不滥用 userInteractive)
- [ ] os_unfair_lock_with_options 优先级继承
- [ ] async-await 替代 callback
- [ ] Instruments 定期 review
Web:
- [ ] 长任务 < 50ms / 任务
- [ ] Web Worker 处理 CPU 密集
- [ ] requestIdleCallback 推迟非紧急
- [ ] Worker pool 复用
- [ ] async-await 简化代码
# 21.总结与延伸
# 21.1 五条核心原则
- 线程不是免费的:每个线程消耗内存 / TCB / 切换 / 缓存。
- 找拐点而非堆资源:CPU 池最优 = 核数 + 1,超过即劣化。
- 锁短 / 锁少 / 锁不反转:锁粒度拆分 + 锁内禁 IO + 优先级继承。
- 集中管理胜过分散自治:统一线程池服务是治膨胀的银弹。
- 选对模型胜过堆资源:IO 用协程、CPU 用线程池、UI 用单线程事件循环。
# 21.2 五个常见误区
| 误区 | 真相 |
|---|---|
| "线程越多越快" | 错。CPU bound 拐点在核数+1,超过线性劣化 |
| "spinlock 总比 mutex 快" | 错。临界区 > 1μs 时 CPU 烧 100% |
| "synchronized 比 Lock 慢" | 错。JIT 优化后差距很小 |
| "GCD 是无限线程" | 错。系统决定,可能爆炸 |
| "后台优先级低无影响" | 错。仍占 CPU 时间片,且可能优先级反转 |
# 21.3 一句话总结
线程是"用 CPU 兑换延迟"的工具,但线程不是免费的。线程优化的本质是找拐点(不是堆资源)、防反转(异构 SoC 的隐形 P0)、选对模型(IO 用协程 / CPU 用线程池 / UI 用单线程)。所有线程问题都映射到"调度不当 / 同步开销 / 资源浪费"三类——分类施治才能精准。集中管理(AppThreadService)+ 锁治理(拆分+禁 IO+优先级继承)+ 关键路径 QoS 是工程上的标配组合。
# 21.4 延伸阅读
卷二·01 CPU 监控与分析:线程是 CPU 的使用者卷二·02 内存监控与治理:每个线程占栈 1MB卷二·03 OOM 与低内存治理:线程数耗尽是 D 类 OOM卷三·03 卡顿捕获与归因:线程问题导致卡顿卷三·04 ANR 监控与治理:死锁 → ANR卷四·05 功耗与电量优化:线程数 → 切换 → 功耗
下一篇预告:
卷二·05 进程与多进程优化—— 比线程更重的"隔离"工具。