# Local Responding Worker 持久化:内存下降问题原因、证据与修复说明 ## 1. 文档目的 本文说明以下提交解决的问题、源码根因、现场证据、修改方式和剩余边界: ```text 4e7fadd fix(multimodal): keep local responding worker persistent ``` 该提交针对的是多轮语音对话过程中 internal heap 和 DMA-capable heap 持续下降的问题。修改后,负责发送 `LocalRespondingStarted` 和 `LocalRespondingEnded` 的任务不再每次空闲 5 秒后退出,而是在当前多模态客户端生命周期内保持常驻。 ## 2. Local Responding Worker 是什么 服务端下发 TTS 音频后,设备需要向服务端报告两个本地播放事件: ```text LocalRespondingStarted LocalRespondingEnded ``` 它们分别表示: - 设备已经开始播放当前云端响应; - 设备已经播放完成或停止当前云端响应。 这些事件不能直接在音频播放回调中完成所有网络发送,原因包括: - WebSocket send 可能等待锁或发生网络阻塞; - 音频回调需要尽快返回; - 在音频任务上下文中直接进行复杂网络操作会放大卡顿风险; - 回调、会话状态锁和 WebSocket 锁之间可能形成不安全的锁顺序。 因此项目使用一个独立 worker: ```text 音频播放事件 ↓ 写入 local responding event queue ↓ mm_local_evt worker 读取事件 ↓ 核对 task_id 和会话状态 ↓ 发送 LocalRespondingStarted / LocalRespondingEnded ``` 该 worker 的主要参数: ```text 任务名:mm_local_evt 栈大小:4096 bytes 优先级:5 队列长度:4 运行核心:CONFIG_TQ_LOCAL_TASK_CORE ``` ## 3. 修复前的生命周期 修复前,worker 不是在多模态客户端启动时创建,而是在第一次需要发送 responding 事件时按需创建: ```text TTS 开始或结束 ↓ queue_local_responding_event() ↓ ensure_local_responding_event_worker() ↓ 如果任务不存在,则创建 mm_local_evt ``` worker 等待队列时设置了 5 秒超时: ```c #define CONTROL_PLANE_LOCAL_RESPONDING_EVENT_IDLE_EXIT_MS 5000U if (xQueueReceive(queue, &msg, idle_ticks) != pdTRUE) { s_local_responding_event_task = NULL; vTaskDelete(NULL); return; } ``` 因此每轮对话之间只要超过 5 秒没有新 responding 事件,就会发生: ```text worker 空闲 5 秒 ↓ 将任务句柄清空 ↓ 任务调用 vTaskDelete(NULL) 自删除 ↓ 下一轮 TTS 再次创建 worker ``` 实际运行模式变成了频繁的: ```text 创建 → 工作 → 空闲 → 删除 → 创建 → 工作 → 空闲 → 删除 ``` ## 4. 真正的内存泄漏原因 ### 4.1 worker 使用 WithCaps API 创建 当前配置允许把任务栈放到外部 PSRAM: ```text CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM=y ``` 因此 `CONTROL_PLANE_LOCAL_RESPONDING_TASK_WITH_CAPS=1`,worker 实际通过以下 API 创建: ```c xTaskCreatePinnedToCoreWithCaps( local_responding_event_worker, "mm_local_evt", 4096, ..., MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); ``` 其内存分布大致为: | 对象 | 主要内存区域 | | --- | --- | | 4096-byte 任务栈 | PSRAM;失败时回退到 internal RAM | | FreeRTOS TCB | internal RAM | | 调度和任务管理数据 | internal/DMA-capable heap 所在内存池 | | responding event queue | 优先放到 PSRAM | ### 4.2 WithCaps 任务不能用普通 vTaskDelete 删除 ESP-IDF v5.5.4 对 `xTaskCreatePinnedToCoreWithCaps()` 有明确要求: ```text A task created using this function must only be deleted using vTaskDeleteWithCaps(). ``` 原因是 WithCaps API 自己分配 stack 和 TCB,再以静态任务的形式交给 FreeRTOS。普通 `vTaskDelete()` 只完成 FreeRTOS 任务删除,Idle task 不知道如何释放 WithCaps API 额外管理的 stack 和 TCB。 ESP-IDF 自己的实现注释也直接说明: ```text the idle task will not free the task TCB and stack memories we created statically during xTaskCreateWithCaps() or xTaskCreatePinnedToCoreWithCaps(). Therefore, it will leak memory. ``` 修复前,worker 恰好使用了错误组合: ```text xTaskCreatePinnedToCoreWithCaps(...) ↓ vTaskDelete(NULL) ``` 这不是单纯的 heap 碎片,而是 ESP-IDF API 生命周期不匹配导致的真实任务内存泄漏。 ## 5. 为什么主要表现为 internal/DMA 水位下降 虽然 worker 的 4096-byte stack 优先放到 PSRAM,但 TCB 必须位于 internal RAM。ESP-IDF 文档明确指出,WithCaps 的 capability 只作用于 stack,不作用于 TCB。 ESP32-S3 的许多 internal RAM 块同时带有 DMA capability,因此同一块内存没有释放时,可能同时表现为: ```text free_internal 下降 free_dma 下降 largest_dma 下降或无法恢复 ``` 这也是为什么当时看起来像“DMA 内存持续泄漏”,但真正泄漏对象不一定是 I2S DMA descriptor 或音频 DMA buffer,而可能是任务 TCB 和相关管理内存。 ## 6. 现场日志证据 ### 6.1 修复前:每轮对话 internal heap 继续下降 `monitor-20260812-153303` 中,多轮对话开始前的内存值为: | 时间 | free_internal | largest_dma | | ---: | ---: | ---: | | 26947 ms | 54775 | 31744 | | 47037 ms | 53807 | 31744 | | 61507 ms | 53323 | 31744 | | 75057 ms | 52959 | 31744 | | 87847 ms | 52475 | 31744 | `free_internal` 的变化: ```text 54775 ↓ -968 53807 ↓ -484 53323 ↓ -364 52959 ↓ -484 52475 ``` 它不是只在第一次初始化时下降,而是随着多轮对话继续降低。这与 worker 每隔 5 秒退出、下一轮重新创建的节奏一致。 `largest_dma` 在这段日志中保持 31744,说明当时最大的连续块暂时没有变小,但总 internal/DMA 可用量正在减少。持续足够多轮后,最大连续块仍可能受到影响。 ### 6.2 修复后:worker 只启动一次 `monitor-20260812-154459` 中出现: ```text I (19727) cp_mm_session: Local responding event worker started (persistent) ``` 在随后大量对话中没有重复出现该启动日志,说明 worker 没有因为 5 秒空闲而反复退出和重建。 ### 6.3 修复后:水位进入稳定平台 同一份日志中的多轮上行前内存值: | 时间 | free_internal | largest_dma | | ---: | ---: | ---: | | 26997 ms | 54399 | 31744 | | 44127 ms | 54131 | 31744 | | 62017 ms | 54131 | 31744 | | 78857 ms | 53995 | 31744 | | 96607 ms | 53995 | 31744 | | 116377 ms | 53995 | 31744 | | 134727 ms | 53995 | 31744 | | 152147 ms | 53867 | 31744 | | 171657 ms | 53867 | 31744 | | 213507 ms | 53867 | 31744 | | 237227 ms | 53867 | 31744 | | 258507 ms | 53867 | 31744 | | 279227 ms | 53867 | 31744 | 初始化和前几轮存在少量正常波动,之后长期稳定在约 53867~53995 bytes,`largest_dma` 一直为 31744 bytes。 这证明持久化 worker 对“每轮对话后继续下降”的现象具有直接效果。 ### 6.4 另一份长时间日志也进入稳定平台 `monitor-20260812-155603` 中,系统因为整体配置和当次启动状态不同,internal/DMA 基线更低,但稳定后多轮数据基本保持: ```text free_internal ≈ 21083 largest_dma = 10240 ``` 即使继续进行大量对话,也没有恢复到修复前那种每轮固定下降的趋势。 这进一步说明判断重点应是“是否持续下降”,而不是不同固件启动后的绝对水位是否完全相同。 ## 7. 本次提交的修改方案 ### 7.1 删除 5 秒空闲退出机制 移除了: ```text CONTROL_PLANE_LOCAL_RESPONDING_EVENT_IDLE_EXIT_MS ``` worker 改为永久等待队列: ```c xQueueReceive(queue, &msg, portMAX_DELAY) ``` 没有事件时任务处于 Blocked 状态,不占用 CPU 时间,也不会周期性唤醒轮询。 ### 7.2 在多模态客户端启动时创建 worker 修复前是在每个 responding 事件到来时才调用 `ensure_local_responding_event_worker()`。 修复后,在 `control_plane_multimodal_start()` 中完成鉴权准备后、注册播放回调和启动 transport 前创建 worker: ```text 多模态 start ↓ 准备鉴权 ↓ 创建 persistent local responding worker ↓ 注册下行播放 callback ↓ 启动 WebSocket transport ``` 好处包括: - 资源不足会在启动阶段明确失败; - 音频播放回调不再临时创建任务; - 事件到来时 worker 已经就绪; - responding 事件发送延迟更加确定。 ### 7.3 事件入队路径不再负责创建任务 `queue_local_responding_event()` 现在只获取已存在的 queue 和 task: ```c queue = s_local_responding_event_task != NULL ? s_local_responding_event_queue : NULL; ``` 如果 worker 不可用,会清除 pending 标志并记录警告,而不会在音频事件路径中分配新任务资源。 ### 7.4 只在明确停止多模态客户端时退出 worker 仍支持 `LOCAL_RESPONDING_EVENT_QUIT`,但只在以下情况主动停止: - 多模态启动后注册下行回调失败; - transport 启动失败; - 调用 `control_plane_multimodal_stop()` 正式停止多模态客户端。 因此“persistent”表示: > worker 在一次多模态客户端生命周期内持续存在,而不是从设备启动到关机永远不退出。 ## 8. 修改前后的完整链条 ### 8.1 修改前 ```text TTS 开始 ↓ 临时创建 WithCaps worker ↓ 发送 LocalRespondingStarted ↓ TTS 结束 ↓ 发送 LocalRespondingEnded ↓ 空闲 5 秒 ↓ worker 使用普通 vTaskDelete 自删除 ↓ WithCaps stack/TCB 没有被正确释放 ↓ 下一轮重新创建 worker ↓ internal/DMA/PSRAM 再损失一部分 ↓ 多轮对话后可用内存持续下降 ``` ### 8.2 修改后 ```text 多模态客户端启动 ↓ 创建一次 WithCaps worker ↓ worker 阻塞等待事件 ↓ TTS 开始 → 发送 LocalRespondingStarted ↓ 继续阻塞等待 ↓ TTS 结束 → 发送 LocalRespondingEnded ↓ 继续阻塞等待 ↓ 后续多轮复用同一个任务和队列 ↓ 不再产生每轮 create/delete 泄漏 ``` ## 9. 为什么常驻任务不会持续消耗 CPU worker 使用: ```c xQueueReceive(queue, &msg, portMAX_DELAY) ``` 队列为空时,FreeRTOS 会将任务置为 Blocked: - 不参与可运行任务竞争; - 不会不断轮询; - 不会每隔固定时间唤醒; - CPU 可以运行其他任务或进入 idle。 因此持久化增加的是固定内存占用,不是持续 CPU 占用。 ## 10. 资源影响 ### 10.1 固定占用增加 多模态客户端存活期间固定保留: - 一个 4096-byte worker stack,优先位于 PSRAM; - 一个 internal RAM 中的 FreeRTOS TCB; - 一个长度为 4 的事件队列,优先位于 PSRAM; - 少量同步和队列管理数据。 与旧设计相比,这些资源不会在空闲 5 秒后尝试释放。 ### 10.2 总体长期占用反而下降 旧设计表面上会删除空闲任务,但因为删除 API 与创建 API不匹配,实际没有完整回收任务内存。每轮重新创建都会继续累积。 新设计只保留一份固定资源: ```text 旧设计:固定资源 × 反复泄漏次数 新设计:固定资源 × 1 ``` 因此设备运行时间越长、对话轮数越多,持久化方案的资源优势越明显。 ### 10.3 响应延迟略有改善 事件产生时不再创建任务,`LocalRespondingStarted/Ended` 可以直接入队。虽然差异通常很小,但减少了任务分配和调度的不确定性。 ## 11. 对业务行为的影响 ### 不变的行为 - `LocalRespondingStarted` 和 `LocalRespondingEnded` 的协议内容不变; - 仍通过独立 worker 异步发送; - 仍使用 task_id 过滤迟到或旧会话事件; - 仍使用 pending 标志防止重复入队; - 队列满时仍会记录警告并清除 pending 状态; - WebSocket、鉴权、ASR、TTS 和音频编解码流程不变。 ### 改变的行为 - worker 不再因空闲 5 秒自动退出; - worker 在多模态 start 阶段创建; - 如果 worker 创建失败,多模态启动会明确失败; - responding 回调路径不再临时分配任务资源; - worker 只在完整停止多模态客户端时退出。 ## 12. 原提交的边界与后续补充修复 `4e7fadd` 解决了最频繁的泄漏来源,但该提交完成时仍有一个生命周期边界: worker 收到 `LOCAL_RESPONDING_EVENT_QUIT` 后仍调用: ```c vTaskDelete(NULL); ``` 而该任务在当前配置下由 `xTaskCreatePinnedToCoreWithCaps()` 创建。按照 ESP-IDF API 要求,理论上应该使用: ```c vTaskDeleteWithCaps(NULL); ``` 因此仅看 `4e7fadd` 时,实际效果是: ```text 修复前:每次空闲超过 5 秒都可能泄漏一次 修复后:正常多轮对话不再泄漏;完整停止/重建多模态客户端时仍可能泄漏一次 ``` 这解释了为什么现场长时间正常对话已经趋于稳定,但如果设备频繁发生 WebSocket 退休、multimodal stop/start,仍应继续关注 internal/DMA 水位。 当前分支已经追加补齐删除路径,新增统一封装: ```c static void delete_local_responding_event_task(TaskHandle_t task) { #if CONTROL_PLANE_LOCAL_RESPONDING_TASK_WITH_CAPS vTaskDeleteWithCaps(task); #else vTaskDelete(task); #endif } ``` worker 收到 `LOCAL_RESPONDING_EVENT_QUIT` 后改为调用: ```c delete_local_responding_event_task(NULL); ``` 这样创建和删除方式保持严格配对: | 创建方式 | 删除方式 | | --- | --- | | `xTaskCreatePinnedToCoreWithCaps()` | `vTaskDeleteWithCaps()` | | `xTaskCreatePinnedToCore()` | `vTaskDelete()` | 补充修复后,正常多轮复用和完整 stop/start 两种生命周期都不再依赖错误的普通任务删除路径。需要注意,这部分是 `4e7fadd` 之后的补充修复,不应被描述为原提交本身已经包含的代码。 ## 13. 与其他内存修改的关系 ### 与 Opus Decoder reset/reuse 无关 现场关闭 MultiNet、关闭唤醒提示音、使用按键对话后,内存仍会下降;而持久化 responding worker 后趋势明显稳定。这说明该问题与 Opus Decoder 是否复用不是同一个原因。 ### 与内存水位监测的关系 内存水位监测没有修复泄漏,它提供了定位证据: - `free_internal` 是否逐轮下降; - `largest_dma` 是否缩小; - 是否发生 allocation failure; - 当时处于 PTT、listening、responding 还是 uplink 状态。 ### 与 local wake 无关 按键对话也会触发云端 TTS 和 local responding 事件,因此旧 worker 生命周期问题在按键流程中同样存在。它不是 WakeNet 或 MultiNet 专属问题。 ## 14. 建议验证方法 ### 14.1 正常多轮对话测试 1. 重启设备并保存完整 monitor 日志; 2. 等待 WebSocket 连接成功; 3. 确认只出现一次: ```text Local responding event worker started (persistent) ``` 4. 连续完成至少 50 轮对话; 5. 每轮之间等待超过 5 秒,覆盖旧逻辑的 idle exit 条件; 6. 比较每轮 `Heap before_uplink_start`; 7. 确认 `free_internal` 和 `largest_dma` 在初始化后进入稳定区间。 ### 14.2 本地唤醒和按键分别测试 - 使用“喵掌柜”完成至少 20 轮; - 使用 PTT 按键完成至少 20 轮; - 确认两种入口都不会引起 worker 重建或逐轮下降。 ### 14.3 transport 重建压力测试 删除路径补齐后,仍建议通过 transport 重建验证完整生命周期: 1. 人为制造 WebSocket error/disconnected; 2. 等待旧 transport 回收和 fresh-auth 重连; 3. 重复至少 20 次; 4. 统计每次 `Local responding event worker started (persistent)`; 5. 对比每次重连前后的 `free_internal`、`free_spiram` 和 `largest_dma`。 如果每次完整重建仍产生固定下降,则说明还有其他 stop/start 生命周期资源没有释放,应根据水位变化继续定位;此时 local responding worker 已经使用正确的 WithCaps 删除路径。 ## 15. 验收标准 该提交本身的验收标准: - 一次多模态客户端生命周期内 worker 只创建一次; - 对话间隔超过 5 秒后 worker 不退出; - 多轮 TTS 的 Started/Ended 事件正常发送; - 正常多轮对话不再出现 internal/DMA 逐轮下降; - worker 阻塞等待期间没有额外 CPU 负载; - multimodal start 失败时 worker 能进入受控清理路径。 完整生命周期进一步完善后的标准: - 正常多轮对话水位稳定; - 反复 stop/start 和 transport 重建后水位也稳定; - 所有 WithCaps 任务都通过 `vTaskDeleteWithCaps()` 或匹配的统一封装删除。 ## 16. 涉及文件 该提交只修改: ```text components/control_plane/src/multimodal/control_plane_multimodal_session_service.c ``` 主要涉及函数: ```text local_responding_event_worker() ensure_local_responding_event_worker() queue_local_responding_event() control_plane_multimodal_start() stop_local_responding_event_worker() control_plane_multimodal_stop() ```