ARTICLE DETAIL

资讯详情

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

高通camx线程调度内核:ThreadManager设计解析与实战排障

高通camx线程调度内核:ThreadManager设计解析与实战排障 做高通平台相机开发的兄弟应该都有过这种经历pipeline配好了request也发给HAL了一跑起来线程就乱套——图像数据还没到buffer就先还了某个功能模块的线程优先级设得太高把别的核都挤爆了多路摄像头同时跑几个node互相等消息直接卡死在那边。最后查来查去问题多半出在camx里那套线程调度机制上也就是ThreadManager。很多从mm-camera转过来的人第一个不适应就是camx里你几乎看不到裸的pthread_create所有需要并发执行的逻辑都会被塞进ThreadManager这套框架里。这篇文章就围绕高通camx的ThreadManager把它的设计思路、核心调度链路、实战封装和排障方法一次讲透适合正在做高通平台camera HAL、pipeline开发或性能优化的工程师参考。1. ThreadManager要解决的问题camx为什么不能裸开pthread1.1 camera请求模型的并发压力Android camera HAL3的对外模型是严格的一request一result强时序模型但内部pipeline天然需要大量并发。一颗主摄sensor出帧的同时IPE要做噪声处理stats模块要算3A统计JPEG编码要跑后台任务可能还有一路虚化副摄同时在出图。如果这些工作全塞在一个线程里帧率直接崩如果每个模块都自己开一个pthread线程数很快就失控。更要命的是多session并发。今天一台手机至少是三摄四摄双景录像、前后摄同开、多路视频流同时跑每个session都有自己的pipeline。底层camera provider进程里的线程数量可能轻松上几百个如果没有统一的线程管理和调度策略系统负载、优先级、实时性全都不可控。1.2 mm-camera时代的教训旧架构mm-camera的思路是每个feature模块一个线程模块之间用消息队列通信。单摄时代问题不大到了多摄时代就暴露出两个硬伤第一线程各自为政同一时刻可能有十几个线程在抢锁访问sensor设备节点序列化全靠一堆乱七八糟的mutex第二线程生命周期没人统一管异常退出后feature模块还在往队列里丢消息直接就崩了。camx重新设计执行模型时把“线程”这个概念抽象成可注册、可调度、可统一debug的资源。ThreadManager就是这套执行模型的核心底座。1.3 ThreadManager在camx架构中的位置camx从上层往下大致是CHI、HAL Device、Pipeline/Session/Node最底层才是ThreadManager这类基础模块。凡是需要响应异步事件的组件基本都会跟ThreadManager挂钩。我列几个最常见的每个pipeline nodeCamxNode内部会创建自己的ThreadManager实例node业务回调在独立线程执行避免阻塞HAL的request分发线程。KernelSession、CSLSession处理底层request分发和结果回收会用到ThreadManager做请求串行化。SensorManager有自己的工作线程负责sensor配置、状态轮询这类耗时操作。StatsProcessor处理统计数据的线程也是基于ThreadManager封装的。理解这一点对定位问题很重要你在trace里看到的那些“CAMX_xxx”命名的线程大概率就是某个组件用ThreadManager注册出来的执行体。2. 核心调度链路拆解从PostRequest到ExecuteRequest2.1 关键类与它们的关系ThreadManager不是一个孤零零的类而是一组配套机制。最核心的四个角色ThreadManager管理入口负责创建线程节点、投递请求、flush等对外接口。CamxThreadManagerThreadManager的主要实现内部维护多个执行节点并有一个调度线程负责任务派发。ThreadNode执行节点的内部类封装线程回调、请求队列、等待信号量。一个ThreadNode对应一个可执行线程。CamxThread真正的pthread封装负责线程创建、调度策略设置优先级、cpuset、实时策略、线程命名。它们的关系可以理解为ThreadManager像一个车间主任手里管着若干条产线ThreadNode每条产线有一台机器CamxThread。外部把订单request投进来车间主任决定哪条产线开工机器负责真正把活儿干完。2.2 请求投递与执行的主链路一条请求从头走到尾大概是下面这条路径业务模块调用ThreadManager::PostRequest传入nodeId、requestId、param和paramSize。PostRequest把请求封装成ThreadData挂到对应ThreadNode的请求队列尾部。内部调度线程发现某个节点有pending请求且当前没有在执行就把这个节点标记为running并唤醒节点线程。节点的CamxThread从WaitForRequest返回取出请求调用注册时传入的ThreadFunc回调。回调执行完毕线程回到WaitForRequest继续等待下一个请求。这里有个容易被忽略的关键点同一个ThreadNode内的请求严格串行执行不同ThreadNode之间并发执行。所以如果你的多个消息都发到同一个nodeId它们不会并行加速只会排队。需要并行处理的业务要么注册多个ThreadNode要么把业务拆到不同ThreadManager里。2.3 两种ThreadManager的执行策略差异camx里ThreadManager其实有两个分支CamxThreadManager和SimpleThreadManager。前者就是上面说的带调度线程的完整实现适合实时性要求高、请求交错复杂的场景后者更轻量会在PostRequest时尽量把请求同步执行掉省掉跨线程切换开销适合请求量少、对延迟不敏感的路径。选哪个不是随意的。我曾经见过有同事在一个高频率metadata回调里用了SimpleThreadManager本以为能异步化结果它内部在部分路径上是同步执行的相当于白做了异步还带来了一次没必要的封装开销。所以选型时要先搞明白这条路到底需不需要真正跨线程。2.4 优先级、实时线程与绑核RegisterThread时可以指定线程优先级、时间片、cpuset、isRealTime。camx的线程优先级分为几档从低到高大致是Low、Normal、High、Critical再往上就是实时线程。实际使用中优先级不是越高越好。把关键node线程设成High甚至Critical后一旦这个线程内部有某个等待操作没写对占着CPU又不出结果整个camera进程的其它线程都会被它拖死。真正的实时线程isRealTimeTRUE走的是SCHED_FIFO优先级比普通线程高得多稍不留神就把系统UI、显示合成这些线程饿死了。我一般只在sensor出帧这种最高时敏路径上开实时线程其它一律用Normal到High之间配合绑核来保证时延。绑核是用cpuset参数控制的比如把实时出帧线程绑到大核簇把后台统计线程绑到小核簇。这样一方面保证关键路径不被小核拖累另一方面避免后台线程跟关键线程抢同一个核的cache多路并发场景下效果非常明显。2.5 线程命名是免费的调试资产注册线程时传入的pThreadName会体现在/proc/pid/task/tid/comm里。排查问题第一步永远是先看清楚现场有哪些线程、每个线程在干什么。# 找到camera provider进程pid adb shell ps -A | grep camera # 列出该进程所有线程重点是线程名 adb shell ps -T -p pid # 查看某个tid的内核栈确认它卡在哪个内核函数 adb shell cat /proc/pid/task/tid/stack我在实际排障时习惯把业务线程命名成“MyNode_Worker”这种一眼能认出来的格式。真出了死锁或者ANR直接ps -T扫一眼哪个线程卡住、哪个线程在等锁基本就有数了。3. 实战演示基于ThreadManager封装一个pipeline工作线程3.1 场景与选型理由假设你要在自己的node里做一个后台处理线程接收上层下发的配置消息执行耗时操作比如读写sensor寄存器、做一段算法初始化并且不能阻塞HAL的request分发线程。你可以选择pthread_create裸开一个线程但那样你必须自己处理线程退出、消息队列、等待唤醒和错误恢复。用ThreadManager线程生命周期、消息排队、flush清理全都自带还能直接用camx的线程命名和优先级体系调试时完全融入camera进程的现有框架。这笔账怎么算都划算。3.2 线程创建与注册先创建ThreadManager并注册工作线程ThreadManager* pThreadMgr CamxThreadManager::Create(MyNodeThreadMgr); if (NULL pThreadMgr) { return CamxResultEFailed; } CamxResult result pThreadMgr-RegisterThread( MyNodeWorker, // 线程名会出现在ps -T里 MyWorkerFunc, // 线程回调函数 NULL, // 线程私有数据 CamxThreadPriorityNormal, 0, // timeSlice0表示使用默认 CAMX_CPUSET_DEFAULT, FALSE); // 非实时线程注册成功后这个名为“MyNodeWorker”的线程就挂到了ThreadManager下面开始等待请求。注意RegisterThread本身不会执行任何业务逻辑真正的执行发生在PostRequest之后。3.3 投递请求业务代码往线程里投递消息时要封装一个参数结构体。比如struct MyPayload { UINT command; UINT value; VOID* pCallbackData; }; // 投递前把payload存在堆上由工作线程处理完后负责释放 MyPayload* pPayload static_castMyPayload*(CAMX_CALLOC(sizeof(MyPayload))); pPayload-command kMyCommandConfig; pPayload-value 100; CamxResult result pThreadMgr-PostRequest( 0, // nodeId单线程场景填0 kMyRequestConfig, // requestId业务自定义 pPayload, // 参数指针 sizeof(MyPayload), // 参数大小 NULL, // 完成标记 NULL); // 请求句柄不需要可以传NULL这里有个非常重要的点ThreadManager在传参时做的是Value Copy还是指针保留取决于具体用法。我这个例子传的是指针那么payload的释放必须放在工作线程里不能在PostRequest返回后马上free否则工作线程拿到的是野指针。3.4 工作线程回调实现线程回调的写法是这样的static VOID* MyWorkerFunc(VOID* pData) { // pData实际上指向的是ThreadData结构不是直接指向我们传入的payload ThreadData* pThreadData static_castThreadData*(pData); MyPayload* pPayload static_castMyPayload*(pThreadData-pParam); CAMX_LOG(CamxLogGroupCore, CamxLogInfo, MyNodeWorker got requestId%u command%u value%u, pThreadData-requestId, pPayload-command, pPayload-value); // 在这里执行耗时操作... // payload在工作线程里释放 CAMX_FREE(pPayload); return NULL; }注意回调函数返回后ThreadManager会继续等待下一个请求。这个回调里千万别做会导致线程永久卡死的等待操作比如在一个永远不会被唤醒的信号量上无限等待。那样的话ThreadManager的这个ThreadNode就彻底废了后面所有发给它的请求全部排队积压。3.5 清理与退出当组件销毁时需要把ThreadManager安全释放。不同版本的camx对Flush和Destroy语义有差异但大原则一致Flush会丢弃队列中还没开始执行的请求并阻塞等待正在执行的那个请求跑完。释放ThreadManager前保证没有外部模块再往它PostRequest。工作线程回调里已经在执行的业务要能响应退出信号不能无限等。实际项目中我见过因为退出时序写错导致的use-after-free上层模块已经析构了ThreadManager某个后台线程还拿着老的thread指针在PostRequest一跑就crash。所以封装ThreadManager的组件析构时一定要先置空对外暴露的指针再Flush和Destroy保证不会有新的请求进来。3.6 实测效果与场景延展按上面这套封装完以后业务线程和HAL分发线程就彻底解耦了。上层下发配置消息后可以立即返回工作线程慢慢处理。在camera进程的线程列表里也能清楚看到“MyNodeWorker”这个线程的存在和状态。这套模式不只在node场景用。sensor校准数据读取、算法库异步初始化、多路camx扩展节点的串口命令处理我都是这么封的基本一套代码到处复用。4. 死锁、卡顿与请求丢失高频问题的排查链路4.1 场景一业务回调把线程堵死整个session卡住现象是preview突然卡住几秒后报ANR或camera timeout。第一步永远是先看线程状态adb shell ps -T -p camera_provider_pid如果看到某个camx工作线程的状态变成D不可中断睡眠或者长时间占用CPU再往下看它在干什么adb shell cat /proc/pid/task/tid/stack我自己踩过的一个典型坑在某个基于ThreadManager的node回调里直接调用了I2C读写函数而这条I2C总线上恰好挂了一个响应很慢的外设。结果这个线程在I2C驱动里一睡就是几百毫秒整个pipeline的buffer都在等它直接把帧率拖到个位数。这类问题的最佳解法不是去优化I2C而是调整架构把慢速设备访问放到独立ThreadManager线程里跟pipeline主路径解耦。ThreadManager本身解决的是调度和解耦问题它不负责替你处理慢IO。4.2 场景二两个线程互相等待的隐性死锁比单个线程卡死更难查的是死锁。线程A在回调里等线程B的请求结果线程B又在等线程A持有的锁。ThreadManager这个框架本身不会产生死锁死锁一定是你业务回调里的等待关系形成了环。比如线程A业务逻辑里持有锁L然后PostRequest到线程B并同步等待B的执行结果。线程B的回调里需要获取锁L但锁L被A持有。结果就是A等BB等LL等A释放闭环形成。排查这种问题靠看内核栈还不够因为两个线程可能都在用户态等锁。正确做法是抓取ANR trace# 找到camera provider的pid adb shell kill -3 pid # 在/data/anr/下拿到trace adb shell ls /data/anr/ adb pull /data/anr/trace_filetrace里会列出每个线程的用户态栈能看到线程具体卡在哪个锁上。再根据锁的地址反查持有者基本就能把环找出来。我的经验是ThreadManager回调里尽量不要“同步等待”别的ThreadManager线程的结果。如果业务上做不到就一定要核对等待方向确保A只等B、B不反向等A。破坏环的方法其实很多最简单的就是回调里绝不拿锁去等别的线程。4.3 场景三Flush之后还在等旧请求永远等不到回调这是并发编程常见的“过期请求”问题。ThreadManager的Flush语义是清理排队中的请求并等待正在执行的那个跑完。但如果你在flush之后业务层还拿着旧requestId在等回调很可能等不到。原因在于flush过程中清掉的请求不会像正常流程那样一个接一个地回调。如果你在业务逻辑里把“回调是否会发生”当成了必然事件就会在这里卡死。对策是代码里必须有requestId的过期判断。所有异步请求都带上单调递增的requestId回调回来以后先比对当前flush的版本号如果发现这是旧请求直接丢弃不做任何业务处理。这个习惯在ThreadManager场景下特别重要因为你永远不知道上层什么时候会触发一次flush。另外PostThreadManager的请求回调如果不设置pRequestDone执行结果是不通知的。如果业务层需要知道请求到底执行成功没有要在业务参数里自己带一个同步标记而不是假设PostRequest返回CamxResultSuccess就万事大吉。4.4 场景四实时线程优先级设置不当导致系统其它模块被饿死我见过一个典型案例有人把某个node线程设成了isRealTimeTRUE还在回调里做了带锁的打印日志。结果这个线程用SCHED_FIFO优先级抢占了CPU又在日志锁上等待而持有日志锁的线程优先级低一直得不到调度。最后整个系统的UI线程、显示合成线程全部出现周期性卡顿。所以设实时线程之前先问自己三个问题这条路径是不是真的需要微秒级响应线程内部有没有可能发生阻塞等待如果它被更高优先级的线程抢占后果是什么如果有一个答案让你犹豫就不应该开实时线程。camx里绝大多数场景用High优先级加上绑核已经足够。真正需要SCHED_FIFO的通常只有sensor出帧、vblank同步这类硬实时路径。4.5 善用camx日志和schedstat做量化分析除了死锁和卡死线程调度的性能问题也要会查。比如怀疑某个ThreadManager线程被频繁抢占可以用schedstat看它的调度统计adb shell cat /proc/pid/task/tid/schedstat三个数字分别代表累计运行时间、等待调度时间、调度次数。重点是第二个数如果它跟第一个数差不多甚至更大说明这个线程大量时间在等CPU优先级或绑核可能有问题。camx日志里每个线程的tid都是真实线程ID配合你注册的线程名可以在日志里精确过滤某个线程的整个生命周期adb shell logcat -s CamX | grep MyNodeWorker如果嫌日志太多可以加camxoverridesettings里的调试开关把指定模块的日志级别调高只打核心路径别全量开否则日志本身就会变成性能瓶颈。4.6 多路并发与车载场景的映射现在的高通平台已经不只是手机智能座舱和车载摄像头方案非常常见热词里也总能看到AIS、车载NPU这类东西。车载场景对ThreadManager的使用提出了更高要求五六路摄像头同时工作每一路都有自己的pipeline node可能还要叠加AI分析线程。这种局面下光靠ThreadManager本身的调度显然不够必须要配合cpuset做全局的CPU资源划分。我的通常做法是把pipeline出帧线程绑到中高性能核把3A统计线程绑到小核把AI相关的大计算线程绑到独立的大核簇再通过ThreadManager的优先级参数让关键路径的线程能够在绑核范围内抢占CPU。这套组合拳打下来多路并发时各线程互相干扰的问题能明显减少。写在最后的个人体会如果你刚开始接触camx我的建议是先别急着啃pipeline那套复杂的node图花一个下午把ThreadManager几个核心API和线程模型吃透后面看任何node代码都会顺畅很多。camx里那些看似神秘的回调、状态机、请求流转本质都是线程之间的协作而协作的规则就写在ThreadManager这套机制里。真遇到疑难问题先打开/proc看线程再想调度关系最后才看业务逻辑这个顺序能帮你少走很多弯路。
返回列表