ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android主线程Looper死循环为何不卡死?epoll+消息队列解析

Android主线程Looper死循环为何不卡死?epoll+消息队列解析 先说个真实场景。前几年我参与团队招聘Android 岗一位简历上写着精通 Handler 机制的候选人前几个问题答得行云流水。我随手追问了一句主线程的 Looper 一直在死循环里跑为什么我们的应用没有卡死他明显愣了一下然后回了一个让我至今印象深刻的答案因为 Android 底层做了优化死循环跑得很快。我在心里默默划掉了他简历上精通两个字。说实话这个问题在 Android 开发圈子里流传很广几乎所有面试题集里都有但真正能把它讲透的人真的不多。它表面上问的是主线程的消息循环机制实际上考的是你对 Android 运行模型、Binder 通信、Linux 内核事件机制这几个层面知识的综合理解。今天我就把这个经典存疑题彻底拆开从源码到原理、从面试到实战一次性说清楚。1. 先把问题问明白死循环为什么没卡死 App1.1 我听过的最离谱的三种回答这些年面试 带人我听过的答案大概能分成三类每类都挺有代表性。第一种是底层优化论就是开头那位老哥说的死循环跑得很快。这个答案属于完全没理解问题死循环快不快跟卡不卡没有任何关系while(true)里就算只写一个空语句照样能把一个 CPU 核吃到 100%跑得越快卡得越死。第二种是消息队列轮询论大意是Looper 会不停遍历 MessageQueue如果消息队列为空就继续转圈反正循环里没任务所以不卡。这个答案比第一种接近得多但仍然有问题。如果真的是没有任何休眠机制的轮询那主线程会在后台疯狂空转CPU 占用率会持续居高不下手机照样发烫、掉电应用还是会被系统判定为无响应。一个不睡的死循环和卡死没有本质区别。第三种是分时片调度论说主线程的循环总会被系统切走给其他线程让出 CPU所以看起来没卡。这个说法有一定道理因为 Linux 是抢占式调度但问题在于高优先级或频繁被唤醒的线程会让系统负载居高不下最终 UI 的输入事件依然排不进去用户体感就是卡顿、掉帧、ANR。这三种答案都踩在同一个坑上把 Looper 的无限 for 循环理解成了纯粹的忙等轮询。实际上这个循环里有非常关键的一步——消息队列取不到消息时会主动进入休眠直到有消息到达才被唤醒。这就是整个问题最核心、也最容易被忽略的地方。1.2 两个死循环根本不是一回事我们先做一个直观的对比。// 场景A传统意义上的死循环 while (true) { // 忙等疯狂消耗CPU }// 场景BAndroid主线程的死循环 Looper.prepareMainLooper(); Looper.loop();场景 A 没有任何让出 CPU 的机制除非被系统强制抢占否则它永远霸占着处理器的执行权。场景 B 的loop()方法内部虽然也是for (;;)但它做的事情完全不同每次循环都会从 MessageQueue 里取一条消息取到就分发处理取不到就阻塞等待。这个阻塞等待是区分正常的消息泵和真正的死循环的分水岭。我们可以把主线程想象成一个只有一个窗口的银行柜台。Looper.loop()是这个柜员的工作流程排队系统里有业务就喊下一号办理排队系统里没人柜员就趴在桌子上休息而不是一直站在窗口前大喊有没有人要办业务。休息和喊号之间有人来取号时会主动敲一下柜台把柜员叫醒。这个敲柜台的动作对应的就是 Android 里向主线程发送消息、唤醒休眠线程的机制。所以严格来说Looper 的循环不是死的它是一个被外部事件驱动的、可休眠的消息泵。应用不会卡死是因为这个泵在没活儿的时候会睡着有活儿的时候才醒过来干活。2. 主线程消息循环的源码级拆解2.1 Handler、Looper、MessageQueue 的分工聊源码之前先理顺这三个类的关系。很多初学者把 Handler、Looper、MessageQueue 混为一谈其实它们的分工非常清晰。Looper负责启动和驱动消息循环每个线程有且只能有一个 Looper。MessageQueue存放消息的队列内部用链表按时间排序只提供存取能力不负责循环。Handler负责往队列里投递消息也负责处理从队列里取出来的消息。用流水线来类比MessageQueue 是传送带Handler 是工位的投料口和操作台Looper 就是不停检查传送带上有没有新物件的工人。工人只负责问一个问题传送带上有没有需要处理的货有就搬下来处理没有就原地打盹。这里有一个容易忽略的细节Looper 和线程是绑定的一个线程只能有一个 Looper这是靠 ThreadLocal 实现的。Looper.prepare()会往当前线程的 ThreadLocal 里塞一个 Looper 实例如果同一个线程调用两次prepare()会直接抛异常。主线程的 Looper 是在 ActivityThread 启动时由系统主动准备的不需要我们自己创建。2.2 loop() 里的 for(;;) 到底在干什么看一段简化但保留了关键路径的源码public static void loop() { final Looper me myLooper(); final MessageQueue queue me.mQueue; for (;;) { // 这一步很关键它可能一直阻塞直到有消息可取 Message msg queue.next(); if (msg null) { // 队列被置为退出状态时next返回null循环退出 return; } // 分发给Handler处理 msg.target.dispatchMessage(msg); // 回收消息放入对象池复用 msg.recycleUnchecked(); } }queue.next()是整个循环的心脏。如果它是一行普通的从队列里取数据代码那么当队列为空时循环确实会变成空转。但事实是当队列里没有消息时next()会进入一个非常深的沉睡状态一直等到队列里有新消息才返回。这样一来loop()里的 for 循环虽然一直在运行但它绝大多数时间都停在next()这一步等消息而不是疯狂空转。这里还有一个容易被问倒的知识点dispatchMessage()里面的耗时操作是发生在loop()的循环体内的。也就是说主线程所有任务的单线程串行执行模型就是靠这个循环把任务一个一个从队列里捞出来处理。如果一个消息处理花了 10 秒那么队列里排着的后续消息包括点击事件、绘制任务全部都要等 10 秒这才是用户感知到卡顿和 ANR 的直接原因。2.3 MessageQueue.next() 为什么会睡着next()方法内部其实也是一个 for 循环Message next() { int nextPollTimeoutMillis 0; for (;;) { // 核心阻塞点休眠到指定时间或者被唤醒 nativePollOnce(ptr, nextPollTimeoutMillis); synchronized (this) { final long now SystemClock.uptimeMillis(); Message prevMsg null; Message msg mMessages; if (msg ! null) { if (now msg.when) { // 队首消息还没到执行时间计算需要再等多久 nextPollTimeoutMillis (int) Math.min(msg.when - now, Integer.MAX_VALUE); } else { // 有可以立刻执行的消息取出并返回 mMessages msg.next; msg.next null; msg.markInUse(); return msg; } } else { // 队列为空休眠到天荒地老 nextPollTimeoutMillis -1; } } } }关键在nativePollOnce(ptr, nextPollTimeoutMillis)这一行。这是一个 native 方法它会进入 MessageQueue 的 native 层最终调用到 Linux 的epoll_wait()。nextPollTimeoutMillis是阻塞的超时时间如果是 -1表示没有消息时无限期阻塞如果是 0表示立即返回一次、也就是非阻塞地轮询一遍如果是一个正整数表示最多休眠这么久之后自动醒来检查队列。这就是为什么说 MessageQueue 不是靠 Java 层轮询来感知消息的它把等待的任务完整交给了 Linux 内核事件机制。主线程没有消息可处理时它就是一个在内核里挂起等待的线程不占用 CPU 时间不被调度器反复唤醒也就不会造成任何资源浪费。3. 核心答案epoll 让循环变成事件驱动3.1 nativePollOnce 背后发生了什么要彻底搞懂Looper 为什么不卡死必须下潜到 native 层。MessageQueue在初始化时会创建 native 对象这个 native 对象对应的是 JNI 层的 NativeMessageQueue再往里走就是 Android 系统里一个独立的 native Looper。native 层的沉睡和唤醒是围绕一个文件描述符fdesc展开的。早期 Android 用的是 pipe 管道后来换成了eventfd。这个文件描述符的两端很有意思一端负责读一端负责写。当没有消息时主线程的epoll_wait()会监听这个描述符的读端一旦有数据写入也就是敲柜台的动作发生内核就会立刻唤醒等待中的线程。这笔账可以算一下。假如主线程通过Looper.loop()一直跑在 for 循环里但它的next()阻塞在 epoll 等待上那么这个线程在 CPU 调度器眼里是一个不活跃的线程几乎不会分配到时间片。对系统来说它跟存在但没有在跑没有区别。真正会引发 ANR 的是那些在dispatchMessage()里长期占用主线程执行权的耗时任务而不是这个沉睡的循环本身。3.2 睡着的线程为什么不会拖垮系统用一个比方来理解一个 24 小时营业的便利店收银员坐在柜台后面手里拿着排队叫号器但这个叫号器是按一下才响、不按就静音的。顾客一进店按下叫号器收银员才睁眼干活没有顾客时收银员就是趴在桌上睡。他从头到尾都在岗但从来没有空耗体力地转圈。反过来说如果收银员改成每秒钟站起来喊一次欢迎光临那体力消耗会大得多虽然他确实也在等客。Looper 就是这个收银员。epoll_wait()就是那个按一下才响的叫号器。消息队列为空时它睡得死死的消息一到内核唤醒它它爬起来取消息、分发给 Handler 处理处理完继续睡。还有一个很多人没意识到的事实正因为主线程能在空闲时阻塞休眠Android 系统才能同时流畅运行多个进程。如果每个进程的主线程都在空转抢 CPU那手机早就卡成 PPT 了。移动设备的 CPU 功耗控制、后台进程的调度很大程度上都依赖这种忙时干活、闲时挂起的线程模型。3.3 从 Binder 到消息入队的完整唤醒链路每次你点击屏幕事件从产生到主线程处理会经过这样一条链路Linux 内核收到触摸屏中断把原始事件上报给 InputReader。InputDispatcher 把事件整理好通过 socket 或者 Binder 通知应用进程。应用进程的 InputEventReceiver 在主线程没有空闲的情况下会向主线程的消息队列发送一个事件消息。如果主线程正阻塞在nativePollOnce()这个入队操作会通过 native 层的wake()写入 eventfd唤醒epoll_wait()。主线程醒来从next()返回一条消息进入dispatchMessage()处理点击事件再把绘制任务排入队列。注意第 4 步唤醒操作不是 Java 层做的而是在 MessageQueue 的 nativeenqueueMessage()路径里触发。它向文件描述符写一个字节内核检测到这个 fd 可读立即把正在 époll_wait 的线程唤醒。这一整条链路下来从物理触控到 UI 响应时间被压缩到毫秒级就是因为你看到的无限循环其实是个高效的事件驱动循环。4. 真正的元凶ANR 是怎么产生的4.1 ANR 的判定标准与触发场景既然 Looper 循环本身不是 ANR 的来源那 ANR 到底怎么发生的Android 系统的 ANR 判定本质上是一套超时监控机制。场景超时时间具体条件输入事件派发5 秒主线程没有及时处理按键或触摸事件即没有及时调用 inputDispatchingTimedOut前台广播10 秒BroadcastReceiver 的 onReceive 在 10 秒内没有执行完后台广播60 秒BroadcastReceiver 后台执行超时服务20 秒Service 的 onCreate、onStartCommand 等存活相关回调超时这里要强调一个经常被误解的点系统在判定 ANR 时并不会杀掉主线程或者主动停掉 Looper 循环。它监控的是某个消息处理是否超时而消息处理发生在dispatchMessage()里处于当前这一轮循环的循环体内部。如果处理代码迟迟不返回Looper 就会被卡在同一个迭代里队列里排队的后续消息全部无法执行。系统一等再等等不到输入事件被消费于是弹出应用无响应对话框。换句话说ANR 永远是队列里的下一个消息迟迟无法被执行而不是Looper 循环本身有问题。循环是输送带输送带本身没有问题问题是某件货物在输送带上卡死了后面的货物全被堵住。4.2 主线程卡顿的第一个真实场景我见过的最典型的 ANR是有人把网络请求直接写在了 Activity 的onCreate()里Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); HttpURLConnection conn (HttpURLConnection) new URL(https://example.com/api/data).openConnection(); conn.setConnectTimeout(5000); conn.setReadTimeout(5000); BufferedReader reader new BufferedReader( new InputStreamReader(conn.getInputStream())); // 读取数据... }如果网络状况差这段代码在主线程上可能要等十几秒才返回。这十几秒里主线程的Looper.loop()正卡着这一条消息的dispatchMessage()没走完后面的绘制、点击、动画全部排队。用户点任何按钮都没反应5 秒后系统判定输入超时ANR 出现。这个案例把死循环导致卡死的误解彻底澄清了真正导致卡死的是循环体内一次执行时间过长而不是循环本身。如果把耗时任务放到子线程主线程的 Looper 就能迅速处理完这条消息继续从队列里取下一条界面自然流畅。4.3 一次点击事件完整处理链路再说一遍点击事件的旅程因为这是理解 UI 线程模型的基础。屏幕产生触摸Linux 内核把事件交给 InputReader。InputDispatcher 通过 ViewRootImpl 的 InputEventReceiver 把事件投递给应用。如果主线程空闲事件被封装成消息加入消息队列。Looper 从队列取出消息最终通过 ViewRootImpl 派发到 View 的onTouchEvent()、onClick()等回调。点击回调里如果触发了requestLayout()会往队列里再塞一个绘制消息。绘制消息最终走到measure、layout、draw把一帧画面送到屏幕上。这一条链路里的每一步都是在 Looper 循环体内串行完成的。任何一个环节超过了 16ms一帧的标准时间画面就会掉帧超过 5 秒没有消费输入事件就会触碰 ANR 红线。理解了这条链路很多性能问题其实都能迅速定位要么是某个回调太耗时要么是这条链路上排队太多。5. 进阶理解那些没问但希望你都懂的点5.1 主线程的 Looper 是什么时候创建的主线程的 Looper 不是某个 Activity 创建时才有而是在进程启动时就绪了。// ActivityThread.main() 的简化流程 public static void main(String[] args) { Looper.prepareMainLooper(); ActivityThread thread new ActivityThread(); thread.attach(false, startSeq); Looper.loop(); }注意执行顺序先 prepare 主 Looper再创建 ActivityThread 并 attach最后才进入循环。也就是说Application、Activity 的创建、启动、生命周期回调全部是通过消息机制塞进主线程队列在Looper.loop()启动之后才被逐个处理。这也是一个很经典的追问点onCreate()到底是在loop()内部执行的还是在外面执行的答案是Activity 的onCreate()是被主线程 Handler 处理一条LAUNCH_ACTIVITY消息时调用的所以它一定在loop()内部。如果onCreate()里有个耗时操作Looper 就会卡在这一条消息上后面的生命周期消息全都排队等着。这就是为什么官方一直强调onCreate()里不要做耗时初始化任务要做就拖到子线程或者延迟到空闲期执行。5.2 IdleHandler、同步屏障与消息优先级MessageQueue本身只做一件事按时间排好队。但它的内部还有一个容易忽略的机制——消息分同步和异步两类还用到了同步屏障的概念。正常情况下View 绘制相关的消息VSYNC同步信号触发的那条消息会在队列中按时间排序。为了让每一帧的绘制能及时执行Choreographer 会通过异步消息 同步屏障保证一旦发送一个同步屏障队列里所有同步消息暂时不可见只处理异步消息。这个设计用于确保绘制优先级高于普通业务消息避免因为业务消息堆积而错过 vsync 窗口。还有一个叫IdleHandler的机制在消息队列空闲时执行一个回调。它是做延迟到空闲期再处理的利器适合放一些不紧急的初始化任务比如预加载数据、首帧之后再构建复杂 View。面试时如果有人能主动聊到 IdleHandler 和同步屏障通常说明他对消息队列的理解已经超过了背答案的层面。5.3 为什么子线程不能直接更新 UI子线程更新 UI 会抛CalledFromWrongThreadException表面原因是只有创建 ViewRootImpl 的线程才能操作 View本质原因正是 Looper 机制也可以用责任链来理解View 的一次重绘需要通过主线程消息队列排队最终由 Looper 循环体里的绘制消息来执行。UI 组件不是线程安全的如果多个线程同时修改 View 状态状态就完全不可控。Android 之所以强制 UI 操作必须在主线程不是因为主线程有什么特殊权限而是因为主线程是唯一一个跑着 Looper、能串行消费消息队列的线程。其他线程没有 Looper就没有办法安全地串行处理 View 的更新请求。View.post()能确保 Runnable 被投递到主线程队列本质上也还是把这个任务转交给主线程的 Looper 去执行。6. 实战经验怎么用 Looper 机制定位卡顿6.1 最朴素的做法在主线程打印消息日志聊完原理说点能落地的排查技巧。第一个是最直接的Looper提供了setMessageLogging()可以给主线程的 Looper 挂一个 Printer打印每条消息的执行和退出时间。Looper.getMainLooper().setMessageLogging(new Printer() { Override public void println(String log) { if (log.startsWith( Dispatching)) { // 记录消息开始执行的时间 Log.i(MainLooper, start: log); } else if (log.startsWith( Finished)) { // 记录消息执行结束的时间 Log.i(MainLooper, end: log); } } });打印日志里自带了消息耗时如果某一条消息的耗时超过 50ms它大概率就是卡顿的来源。再配合主线程的堆栈信息就能基本确定是哪段代码占了主线程。这个方案其实是在监听 Looper 循环不需要插桩也不会侵入业务代码实测下来排查小应用的卡顿足够用了。6.2 BlockCanary 的思路关注 dispatchMessage 的耗时BlockCanary 这个开源库的思路和setMessageLogging一样它往主线程 Looper 挂一个 Printer监听每条消息的执行时长。一旦检测到某条消息执行超过阈值就立刻抓取主线程堆栈把卡顿现场保留下来。它的巧妙之处在于利用 Looper 循环的串行特性——主线程正在执行哪条消息堆栈里就能看到哪条消息对应的方法。只要把堆栈 dump 出来卡顿的函数基本无处遁形。我实际用下来的体会是它对于定位偶发卡顿比看Systrace更直接因为堆栈是精准到代码行的不需要在一大堆 trace 里慢慢找。后面我排查线上用户报告的应用假死基本都是这套组合拳BlockCanary 抓现场堆栈结合Logcat里的系统 ANR 信息再配合CPU Profiler确认是不是某个同步锁或 IO 卡住。绝大多数死循环导致卡死的误报最终都被定位成了某条消息里的耗时操作排空不了队列而不是 Looper 循环本身的锅。6.3 几个容易踩的坑以及我的几点体会最后说一些我踩过、也看着别人踩过的坑。第一不要试图在主线程上优雅地退出 Looper。Looper的循环一旦退出主线程的main()会返回进程直接结束所以系统不会让你随便退。设计上主线程的 Looper 就是永续运行的。第二用HandlerThread的时候也要注意。HandlerThread 内部自己也跑了一个Looper.loop()同样遵循无消息就休眠的机制所以它是安全的轮询线程模型。但它到底专不专注、要不要频繁创建销毁得靠你自己掌握毕竟每个 HandlerThread 都有独立的 Loop 和消息队列。第三写代码的时候主线程里任何看似微小的大循环都可能是元凶。比如onBindViewHolder里做了一个 100 万次的循环这段代码在dispatchMessage()里跑它的耗时等于堵死了整个消息队列。工具能帮你定位到卡顿消息但治本之道仍然是少在主线程做重活。我们团队现在做性能 review主线程代码都要求写出预估耗时超过一帧预算就必须拆到子线程。回到开头那个面试问题。如果有人再问我Looper 死循环为什么没有导致应用卡死我的标准回答是Looper 的循环本质上是一个通过epoll实现的事件驱动循环主线程没有消息时会在内核中挂起休眠不消耗 CPU真正让应用卡死的从来不是循环本身而是循环体内某一次消息处理耗时过久导致队列堵塞、后续事件无法派发。这个机制不仅是 Android 的基石也是理解 ANR、卡顿优化、线程模型的最重要的入口。把这层想透了很多面试追问和线上疑难都会豁然开朗。
返回列表