做 Android 性能优化这几年CPU 调度这块我一直觉得是最值得啃、但也是最容易被误解的方向。很多人一卡顿就盯 CPU 占用率可我看到的大量案例是占用率不高甚至还有核心闲着但关键线程就是抢不到执行时间画面照样掉帧。问题的根源往往不在业务代码跑得慢而在调度器怎么安排这些线程。这篇文章分享的是我自己沉淀下来的一套Android CPU调度优化可落地模板从调度器原理、问题定位到六级优化手段、灰度回归的完整流程都给你整理好。适合正在做应用性能优化、系统级功耗优化的开发者也能给刚入门的同学一个清晰的排查框架。1. 调度器如何决定“谁先跑”从CFS到EAS的底层逻辑想优化 CPU 调度先得知道调度器到底在干什么。Android 上层跑的是 Linux 内核默认调度器叫 CFS完全公平调度器后来为了省电引入了 EAS能耗感知调度。不了解这些机制的话后面所有优化手段都容易变成“玄学调参”。1.1 线程排队、运行与抢占的基本过程CFS 的核心思路是“公平排队”。每个线程都有一个虚拟运行时间 vruntime调度器维护一棵红黑树每次选 vruntime 最小的那个线程去运行。你可以把 CPU 想象成一个只有一条服务通道的窗口线程是排队的人vruntime 是每个人已经服务过的累计时长。谁累计被服务得少谁就排到前面去。所谓“优先级”本质上是改变 vruntime 的增长速度高优先级线程的 vruntime 涨得慢所以它总是能插到前面低优先级线程的 vruntime 涨得快很快就轮到别人。真正的调度过程比排队复杂因为还有抢占。高优先级线程一旦就绪可以立刻打断正在运行的低优先级线程这叫抢占式调度。对性能分析来说频繁抢占就意味着上下文切换开销变大而且被抢的线程如果正好是渲染线程那一帧就可能超时。在大小核架构上同一个时刻不只一个核心可以运行线程。于是调度器又多了一件事决定把线程放到哪个核上。如果只看性能当然是大核越快越好但大核功耗也高手机电池扛不住。所以 EAS 出现后选核不再只看性能还会把“在这个核上跑要花多少电”算进去。1.2 EAS选核机制省电和性能的拉扯EAS 的核心是负载跟踪。内核通过 PELTPer-Entity Load Tracking记录每个调度实体在过去一段时间内的负载历史调度器综合各核的负载、频率、功耗模型挑选“性价比最高”的核心去放置任务。低负载的小任务被放到小核既能省电又不会影响流畅度高负载的大任务放小核会被拖慢调度器才会把它挪到大核。这里有个关键点EAS 判断负载是动态变化的而且有滞后性。负载是历史滑动平均不是瞬间值。如果一个任务刚被唤醒负载还没被拉起来调度器可能先把它放到小核等负载涨上来再迁移到大核。这中间就会出现性能损失。用 Perfetto 抓 trace 时经常能看到线程在核之间跳来跳去每次迁移都有缓存丢失和调度开销频繁迁移比“待在次优核”更亏。理解这些之后应用层能干预的入口就很清晰了线程优先级nice 值 - 影响 vruntime 增长速度影响抢占CPU 亲和性affinity - 限制线程只能在哪些核上跑cpuset 分组 - 由进程优先级决定线程集合可用的核范围频率请求 / 性能提示 - 影响调频器的升频策略中断亲和性 - 影响哪些核会被硬件事件打断要注意的是普通 App 能改的主要是线程优先级和属于自己的 cpuset/affinity系统开发者可以改调度策略、中断、内核参数。下面的手段我会按“从合法温和到激进”的顺序讲而且每一项都说明风险和适用前提。2. 定位调度瓶颈抓Trace、抠指标、还原丢失的时间片优化之前必须先把“病根”找出来。我见过太多人上来就给线程加优先级结果卡顿反而加剧。CPU 调度问题有个特点看起来一样的两段卡顿病因可能完全不同。要区分必须回到现场。2.1 perfetto抓一次不带噪音的现场Perfetto 是现在 Android 性能分析的主力工具替代了老的 systrace。抓 trace 不是随便点一下就行要尽量控制变量不然拿到的数据根本没法分析。常用命令长这样# 抓设备端trace15秒包含调度、频率、空闲、CPU频率变化等事件 adb shell perfetto -o /data/misc/perfetto/trace.perfetto-trace \ -b 32mb -t 15s \ sched freq idle cpufreq cpu adb pull /data/misc/perfetto/trace.perfetto-trace ./抓之前建议先把后台通知、同步任务清掉开启飞行模式更好。让用户或者你自己在抓 trace 的同时复现卡顿场景并且在关键操作点打一个标记。在代码里可以用 Trace APITrace.beginSection(openMainPage); // 业务操作 Trace.endSection();在 trace 里定位到这个 section就能找到对应的时间窗口看那一小段时间内线程状态和 CPU 分配情况。直接看全量 trace 容易眼花我习惯先找“卡顿帧对应的窗口”再放大分析。2.2 判断线程“没分到CPU”还是“在等结果”打开 Perfetto 后找到主线程的 track你会看到线程状态分几种颜色绿色是 Running正在跑蓝色是 Runnable可运行但没分到核紫色是 Sleeping睡眠等待橙色是 Uninterruptible Sleep不可中断睡眠。真正反映调度瓶颈的是 Runnable 时间。Runnable 线程的意思是它已经就绪想立刻执行但调度器暂时没把核给它。这段时间持续越长说明 CPU 资源越紧张或者这个线程的优先级在竞争里处于劣势。如果线程长期是 Sleeping通常是在等锁、等Binder回复、等网络/IO这属于“业务阻塞”不是纯调度问题。看 trace 时要分清两种卡顿主线程 Runnable 时间很长说明“有活想干但抢不到核”这是调度问题。主线程大量时间在 Sleeping等一个异步结果那要查的是为什么异步结果那么慢而不是动调度参数。另一个指标是线程迁移数量。在 trace 里搜索线程的 sched_switch 事件看它从哪个 CPU 切到哪个 CPU。如果每个 CPU 都在跳说明调度器不断重新平衡负载缓存命中率会下降。高频迁移本身就是一种开销迁移次数太多时要考虑用 affinity 把线程“粘住”。还有个现象很常见主线程明明在 Running但一帧还是超了。这通常是主线程被高优先级线程抢占。例如音频线程、某些系统服务线程都在抢同一个大核。这类问题要配合查看sched_wakeup是谁唤醒的、抢占事件发生在哪。2.3 线上长期监控不能一直靠抓Trace抓 Trace 适合精确定位但没法 7x24 小时盯着。线上要建立一批调度相关指标可比对、可告警。比较实用的几项关键线程的 Runnable 时长 / 单帧内累计线程运行核分布评估迁移次数进程 CPU 占用和线程 CPU 占用 TopCPU 频率分布和温度曲线卡顿率、ANR 率等上层业务指标Android 上可以通过dumpsys cpuinfo拿到进程级别的 CPU 时间分布但线上更常用的是把线程状态周期采样后上报。比如应用自己记录关键线程每一帧的ThreadMXBean.getThreadCpuTime()和getThreadUserTime()再加上getThreadInfo().getBlockedTime()、getWaitedTime()基本能拼出线程是“在算”还是“在等”。定位阶段不要贪多。先抓一次完整 trace把主线程、渲染线程、Binder 线程、SurfaceFlinger 这几个对象的调度关系理清楚问题基本就暴露了。接下来才轮到优化手段上场。3. 六大优化手段按收益和风险排好序再去动代码调度优化不是“全部拉满”每项手段都有副作用。我按风险和收益做了排序建议你按这个顺序逐项尝试每一项都要单独做 AB 验证。3.1 线程优先级最轻量的调度杠杆调线程优先级是门槛最低的调度优化。Android 上层通过android.os.Process.setThreadPriority(int tid, int priority)设置 nice 值范围是 -20 到 19数值越小优先级越高。普通 App 默认主线程是 0后台线程可以调到 10渲染线程和输入相关线程可以提到 -2 左右。实际用法// 当前线程注意Android将高优先级表示为负值 Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO); // -19音频 Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY); // -4显示相关 Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); // 10后台提升优先级的直接效果是该线程在竞争 CPU 时更容易被选中、更不容易被低优先级抢占。我通常只在主线程、渲染线程和必要的输入处理线程上做小幅提升比如主线程提 2-3 档即可。但这里有两个必须警惕的坑。第一优先级反转。高优先级线程在等待一个低优先级线程持有的锁而低优先级线程又拿不到 CPU于是高优先级线程也被拖住。线程越多、锁层级越深这个问题越致命。第二全局公平性。一旦你把某个进程里大量线程都提成高优先级系统里的其他进程都会受影响甚至可能导致系统服务线程饥饿。优化不是“把每个线程都调到 -10”而是“只调真正决定流畅度的线程”。我的原则是能不动优先级就不动。如果一定要调先只调一个问题线程不要同时开多个高优先级。3.2 cpuset与绑核把线程安放到合适的核心比优先级更强硬的优化是 constraining 线程运行的 CPU 范围。Android 的 cpuset 机制把 CPU 分成几个集合最典型的是top-app、foreground、background。系统根据进程的前后台状态把进程放进不同集合从而限制它可用的核。对普通应用来说操作系统已经自动帮前台进程放到了性能最好的核上。我们能做的是对关键线程做亲和性绑定sched_setaffinity让线程只能跑在某几个核上。// 假设CPU 4、5是性能核绑定线程到4、5 cpu_set_t set; CPU_ZERO(set); CPU_SET(4, set); CPU_SET(5, set); sched_setaffinity(0, sizeof(set), set);好处很直接线程的缓存局部性变好不会被调度器迁移到小核去关键线程的执行时间更稳定。坏处同样明显线程空闲时也占着大核大核的空闲功耗明显高于小核。如果一帧里该线程真正干活的时间只有 3ms你却让它一直待在大核上接下来的几百毫秒都在浪费电。所以我给绑核定的规矩是只绑真实在计算密集又要求稳定的线程绑定后要验证温度、功耗曲线不能只看帧率要提供“解绑”开关方便灰度对比和回滚系统级开发者还可以用内核参数isolcpus把某些核从通用调度中隔离出来专门给特定线程使用。不过这种方式会影响系统整体负载平衡不适合大规模默认开启通常只在专项场景比如游戏性能模式里用。3.3 中断与软中断亲和性别再让高频中断打断关键线程很多调度问题看着是线程优先级不够其实是中断抢占。CPU 被硬件中断打断后会暂停当前线程去执行中断处理如果触摸、网络、音频的中断都集中在某个核上而你的渲染线程恰好也在那个核上哪怕它是高优先级也会被反复打断。排查方法是在 trace 里看 IRQ 事件sched_switch中出现大量swapper和中断处理线程之间的切换说明中断频率很高。也可以在设备端看cat /proc/interrupts这个文件会列出每个中断在每个 CPU 上的触发次数。如果某个中断(比如触摸的irq/xxx-touch)次数集中在一个核上那你需要把关键线程挪到别的核或者把中断的亲和性改掉。修改中断亲和性需要 root/系统权限# 查看中断号然后调整亲和性例如把123号中断放到CPU2 echo 4 /proc/irq/123/smp_affinity # 或用list形式 echo 2 /proc/irq/123/smp_affinity_list注意一点可写/proc/irq不代表可以乱写。把中断统统挪到 cpu0会让 cpu0 成为新的瓶颈。更合理的做法是让中断和关键线程“分核错峰”触摸这样的高频中断留在小核渲染线程绑到大核两者互不打扰。系统级方案里还有irqbalance服务它可以自动把中断分散到多个 CPU。但对实时性要求很高的场景自动平衡可能把中断平摊到关键线程所在核上反而帮倒忙。所以这个领域我更倾向手动调整。3.4 渲染链路里的帧调度把主线程的杂事清出去这一节严格说不是直接的 CPU 调度但从观察到的调度问题里有相当比例是“主线程做了太多不该它做的事”造成的。渲染链路由 Choreographer 驱动主线程在每一帧里要做 Input、Measure、Layout、Draw 的准备然后交给 RenderThread 去执行 GPU 命令。如果主线程被乱七八糟的任务占满它的虚运行时间蹭蹭上涨调度器再怎么优化 CPU 分配也只是在“烂摊子”上做修修补补。优先做的几件事用FrameMetrics或者Trace看主线程每帧的耗时分布把非 UI 的耗时操作文件读取、JSON 解析、数据库查询移出主线程在滚动和动画期间暂停低优先级的后台任务避免每帧都做不必要的对象创建和 layout 重排// 用FrameMetrics统计每帧耗时 Window.OnFrameMetricsAvailableListener listener new Window.OnFrameMetricsAvailableListener() { Override public void onFrameMetricsAvailable( Window window, FrameMetrics frameMetrics, int dropCount) { long animDuration frameMetrics.getMetric(FrameMetrics.ANIMATION_DURATION); long inputHandling frameMetrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION); long layout frameMetrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION); long draw frameMetrics.getMetric(FrameMetrics.DRAW_DURATION); } }; window.addOnFrameMetricsAvailableListener(listener, new Handler(Looper.getMainLooper()));如果实际瓶颈在 RenderThread那就重点看硬件加速的绘制命令是否太重、是否频繁触发 shader 编译、是否存在过度绘制。主线程和 RenderThread 之间还有一个容易忽略的问题它们会把同一帧的数据通过同步屏障送到渲染线程如果主线程跑得慢RenderThread 就一直在等Perfetto 里你会看到 RenderThread 是 Running 状态却像是在空转。帧调度优化的总原则是让主线程的活儿尽量少让渲染线程的优先级匹配它的重要程度。主线程清干净了调度器压力会骤降。3.5 频率与功耗的平衡从“锁全核”走向“按需给能”有些场景下调度器并不是没给线程分到核而是核的频率太低。EAS 会根据负载调节 CPU 频率但频率爬升有延迟。用户触摸屏幕时如果频率还在最低档即便线程立刻运行单核算力也不够照样卡。传统做法是“触摸时锁大核高频率”。这种做法短期有效但长期锁定会让温度快速上升触发 thermal throttling 后频率断崖式下降反而更卡。比较好的做法是用系统提供的性能提示接口按需拉高算力。Android 中可以用PerfHintManagerint perfHintId PerfHintManager.getPerfHintId(); PerfHintManager.HintSession hintSession PerfHintManager.createHintSession(perfHintId, targetThreads, targetDurationNanos); // 开始帧时更新目标时长让系统知道要在这个时间内完成 hintSession.updateTargetWorkDuration(targetDurationNanos); hintSession.reportActualWorkDuration(actualDurationNanos);让系统自己去决定怎么调频、怎么选核你只告诉它“这一帧目标要在 16.6ms 内完成”。这样既不会盲目锁核也能避免温度失控。对于游戏这类固定循环场景这个接口比手动调 nice 值更科学。功耗温度这块不能等到上线后再看。开发阶段就要跑连续压力测试把 CPU 频率曲线、电池温度曲线和帧时间曲线放在同一张图里观察。如果频繁出现“帧时间上升伴随温度升高”那基本就是功耗策略的问题。4. 优化方案模板问题定义、灰度投放与回归设计优化手段再多没有流程保障也容易翻车。我自己用的模板整理成了一套流程适合直接抄进项目里。4.1 用一张表把问题和收益量化清楚每次优化开始前先填下面这张表。填不清楚后面所有判断都会失真。项目内容目标场景例如视频Feed滑动、游戏战斗场景、冷启动进入首页用户可感知问题例如滑动掉帧、点击响应延迟、发热当前基线数据帧率、掉帧率、线程可运行时长、频率曲线、温度优化方向线程优先级 / 绑核 / 中断 / 渲染减负 / 频率策略预期收益必须写具体数值例如 P95 掉帧数下降30%风险项功耗增加、其他进程受影响、特定平台失效数据来源Perfetto Trace、线上性能监控、用户反馈我特别看重“用户可感知问题”这一栏。很多优化做完了指标统计上帧率是好看了一点但用户根本没感觉。如果这条写不出来你就得思考这次优化值不值得做。4.2 方案排序与灰度节奏即使定位清楚了也别一次把所有方案都叠上去。一次只上一个变量否则出了问题你不知道是谁引起的。我推荐的顺序是先做“减负”再做“调度”先砍业务层多余的工作量主线程减负再调关键线程优先级小额、可回滚然后做 CPU 亲和性绑定同时看功耗如果还有问题处理中断亲和性最后考虑频率与功耗策略性能提示、调频器灰度节奏也要定死先实验室单机验证再内部小流量再逐步放量。每一步都要留足观察时间不要上午发版下午就全量。实验室验证要用同一型号设备、同一系统版本、同一测试场景。小流量灰度时要按设备分桶避免把不兼容设备混在一起。灰度期间每天看一次关键指标曲线尤其是温度变化。调度优化往往不是“立刻坏”而是用十多分钟后性能衰减所以至少要观察一个完整的持续使用周期。4.3 回归指标与回滚条件回归指标分成三层性能层掉帧率、ANR、关键线程延时时长功耗层单位时间耗电、电池温度、CPU频率分布系统层其他进程的 CPU 使用情况、系统服务稳定性调度优化有个特有的风险你改了某个线程的亲和性影响可能波及其他进程。所以不能只看自家应用指标还要监控系统整体 CPU 占用和 Android 系统服务是否有异常。我设定的回滚条件一般是功耗上升超过 3%或者温度升高超过 2 度或者目标帧率指标不降反升。只要触发任意一条立刻关闭开关回滚不要恋战。宁可没有优化成功也不能带着发热问题上线。建议给每个优化方案加一个远程配置开关{ sched_opt: { thread_priority: true, affinity_bind: false, affinity_cpus: [4, 5] } }这样发布后即使出现线上问题也能快速拉掉不用等下一次发版。5. 真实项目里的“翻车”复盘平台差异与过度优化最后聊几个我亲身经历或者说反复看到同行踩过的坑。很多优化方案在实验室里好看到了真实机型上却完全不是一个故事。5.1 同一条优化策略在另一颗芯片上完全失效某次线上优化我们在 A 平台的 demo 机上验证好了“绑定大核 提升渲染线程优先级”的方案效果明显帧率提升接近 8%温度也稳。结果灰度到一批采用不同芯片的设备后掉帧率不但没降反而涨了 5%。原因在于不同平台的 CPU 拓扑差异很大。A 平台的大小核之间容量差异没那么悬殊绑定大核能稳定获得算力而 B 平台是“1 个超大核 3 个大核 4 个中核”架构调度器默认就会把渲染线程放到超大核上你再绑定到大核反而把它从超大核拉下来了。EAS 的选核逻辑也取决于厂商的功耗模型同样是“大核”不同芯片的大核算力和能耗比完全不同。从那以后我在方案设计里强制要求必须按 CPU 拓扑分平台回归。至少要覆盖三种典型配置44、134、224。绑定核心的编号也不能写死要动态读取/sys/devices/system/cpu/下的 cluster 信息再决定绑哪些核。5.2 优化过头之后的温度反噬见过最典型的“反噬”案例是某团队为了让游戏场景满帧把主要线程、渲染线程、输入线程全部绑定到性能核并且把优先级提到 -10 以上。前 5 分钟帧率确实稳到一条直线第 7 分钟开始温度冲破阈值系统触发 thermal throttling频率从最高档直接跌到中低档。结果后面 3 分钟帧率比优化前还差而且续航明显变差。这类问题的核心不是“绑核错了”而是没有给系统留呼吸空间。CPU 的瞬时高性能是可以有的但不能用“长时间高占用”去换。后来我们改成短时性能爆发 空闲回碎的策略只在真正需要计算的那几百毫秒里把线程放到大核其余时间让它回到小核。配合 PerfHintManager 的按需调频效果反而更好。还有个容易忽略的现象热是累积的。调度优化如果导致平均功耗只增加 0.3W看起来不大但持续 10 分钟后温度会慢慢爬到一个临界点触发降频后性能断崖。所以小流量测试时一定要跑足够长时间至少连续使用 20-30 分钟不能只看刚开始的性能数据。调度优化里我反复跟团队同学强调一句话与其追求“把线程都塞到最快的核上”不如追求“让线程在正确的时间出现在正确的核上”。后者更难需要数据支撑和灰度验证但这条路才走得远。你拿到的每一张 trace、每一次线上指标波动都是在帮这个项目把调度策略打磨得更细。希望这套模板能让你少走几个我走过的弯路。