17 KiB
Local Responding Worker 持久化:内存下降问题原因、证据与修复说明
1. 文档目的
本文说明以下提交解决的问题、源码根因、现场证据、修改方式和剩余边界:
4e7fadd fix(multimodal): keep local responding worker persistent
该提交针对的是多轮语音对话过程中 internal heap 和 DMA-capable heap 持续下降的问题。修改后,负责发送 LocalRespondingStarted 和 LocalRespondingEnded 的任务不再每次空闲 5 秒后退出,而是在当前多模态客户端生命周期内保持常驻。
2. Local Responding Worker 是什么
服务端下发 TTS 音频后,设备需要向服务端报告两个本地播放事件:
LocalRespondingStarted
LocalRespondingEnded
它们分别表示:
- 设备已经开始播放当前云端响应;
- 设备已经播放完成或停止当前云端响应。
这些事件不能直接在音频播放回调中完成所有网络发送,原因包括:
- WebSocket send 可能等待锁或发生网络阻塞;
- 音频回调需要尽快返回;
- 在音频任务上下文中直接进行复杂网络操作会放大卡顿风险;
- 回调、会话状态锁和 WebSocket 锁之间可能形成不安全的锁顺序。
因此项目使用一个独立 worker:
音频播放事件
↓
写入 local responding event queue
↓
mm_local_evt worker 读取事件
↓
核对 task_id 和会话状态
↓
发送 LocalRespondingStarted / LocalRespondingEnded
该 worker 的主要参数:
任务名:mm_local_evt
栈大小:4096 bytes
优先级:5
队列长度:4
运行核心:CONFIG_TQ_LOCAL_TASK_CORE
3. 修复前的生命周期
修复前,worker 不是在多模态客户端启动时创建,而是在第一次需要发送 responding 事件时按需创建:
TTS 开始或结束
↓
queue_local_responding_event()
↓
ensure_local_responding_event_worker()
↓
如果任务不存在,则创建 mm_local_evt
worker 等待队列时设置了 5 秒超时:
#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 事件,就会发生:
worker 空闲 5 秒
↓
将任务句柄清空
↓
任务调用 vTaskDelete(NULL) 自删除
↓
下一轮 TTS 再次创建 worker
实际运行模式变成了频繁的:
创建 → 工作 → 空闲 → 删除 → 创建 → 工作 → 空闲 → 删除
4. 真正的内存泄漏原因
4.1 worker 使用 WithCaps API 创建
当前配置允许把任务栈放到外部 PSRAM:
CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM=y
因此 CONTROL_PLANE_LOCAL_RESPONDING_TASK_WITH_CAPS=1,worker 实际通过以下 API 创建:
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() 有明确要求:
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 自己的实现注释也直接说明:
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 恰好使用了错误组合:
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,因此同一块内存没有释放时,可能同时表现为:
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 的变化:
54775
↓ -968
53807
↓ -484
53323
↓ -364
52959
↓ -484
52475
它不是只在第一次初始化时下降,而是随着多轮对话继续降低。这与 worker 每隔 5 秒退出、下一轮重新创建的节奏一致。
largest_dma 在这段日志中保持 31744,说明当时最大的连续块暂时没有变小,但总 internal/DMA 可用量正在减少。持续足够多轮后,最大连续块仍可能受到影响。
6.2 修复后:worker 只启动一次
monitor-20260812-154459 中出现:
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 基线更低,但稳定后多轮数据基本保持:
free_internal ≈ 21083
largest_dma = 10240
即使继续进行大量对话,也没有恢复到修复前那种每轮固定下降的趋势。
这进一步说明判断重点应是“是否持续下降”,而不是不同固件启动后的绝对水位是否完全相同。
7. 本次提交的修改方案
7.1 删除 5 秒空闲退出机制
移除了:
CONTROL_PLANE_LOCAL_RESPONDING_EVENT_IDLE_EXIT_MS
worker 改为永久等待队列:
xQueueReceive(queue, &msg, portMAX_DELAY)
没有事件时任务处于 Blocked 状态,不占用 CPU 时间,也不会周期性唤醒轮询。
7.2 在多模态客户端启动时创建 worker
修复前是在每个 responding 事件到来时才调用 ensure_local_responding_event_worker()。
修复后,在 control_plane_multimodal_start() 中完成鉴权准备后、注册播放回调和启动 transport 前创建 worker:
多模态 start
↓
准备鉴权
↓
创建 persistent local responding worker
↓
注册下行播放 callback
↓
启动 WebSocket transport
好处包括:
- 资源不足会在启动阶段明确失败;
- 音频播放回调不再临时创建任务;
- 事件到来时 worker 已经就绪;
- responding 事件发送延迟更加确定。
7.3 事件入队路径不再负责创建任务
queue_local_responding_event() 现在只获取已存在的 queue 和 task:
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 修改前
TTS 开始
↓
临时创建 WithCaps worker
↓
发送 LocalRespondingStarted
↓
TTS 结束
↓
发送 LocalRespondingEnded
↓
空闲 5 秒
↓
worker 使用普通 vTaskDelete 自删除
↓
WithCaps stack/TCB 没有被正确释放
↓
下一轮重新创建 worker
↓
internal/DMA/PSRAM 再损失一部分
↓
多轮对话后可用内存持续下降
8.2 修改后
多模态客户端启动
↓
创建一次 WithCaps worker
↓
worker 阻塞等待事件
↓
TTS 开始 → 发送 LocalRespondingStarted
↓
继续阻塞等待
↓
TTS 结束 → 发送 LocalRespondingEnded
↓
继续阻塞等待
↓
后续多轮复用同一个任务和队列
↓
不再产生每轮 create/delete 泄漏
9. 为什么常驻任务不会持续消耗 CPU
worker 使用:
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不匹配,实际没有完整回收任务内存。每轮重新创建都会继续累积。
新设计只保留一份固定资源:
旧设计:固定资源 × 反复泄漏次数
新设计:固定资源 × 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 后仍调用:
vTaskDelete(NULL);
而该任务在当前配置下由 xTaskCreatePinnedToCoreWithCaps() 创建。按照 ESP-IDF API 要求,理论上应该使用:
vTaskDeleteWithCaps(NULL);
因此仅看 4e7fadd 时,实际效果是:
修复前:每次空闲超过 5 秒都可能泄漏一次
修复后:正常多轮对话不再泄漏;完整停止/重建多模态客户端时仍可能泄漏一次
这解释了为什么现场长时间正常对话已经趋于稳定,但如果设备频繁发生 WebSocket 退休、multimodal stop/start,仍应继续关注 internal/DMA 水位。
当前分支已经追加补齐删除路径,新增统一封装:
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 后改为调用:
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 正常多轮对话测试
- 重启设备并保存完整 monitor 日志;
- 等待 WebSocket 连接成功;
- 确认只出现一次:
Local responding event worker started (persistent)
- 连续完成至少 50 轮对话;
- 每轮之间等待超过 5 秒,覆盖旧逻辑的 idle exit 条件;
- 比较每轮
Heap before_uplink_start; - 确认
free_internal和largest_dma在初始化后进入稳定区间。
14.2 本地唤醒和按键分别测试
- 使用“喵掌柜”完成至少 20 轮;
- 使用 PTT 按键完成至少 20 轮;
- 确认两种入口都不会引起 worker 重建或逐轮下降。
14.3 transport 重建压力测试
删除路径补齐后,仍建议通过 transport 重建验证完整生命周期:
- 人为制造 WebSocket error/disconnected;
- 等待旧 transport 回收和 fresh-auth 重连;
- 重复至少 20 次;
- 统计每次
Local responding event worker started (persistent); - 对比每次重连前后的
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. 涉及文件
该提交只修改:
components/control_plane/src/multimodal/control_plane_multimodal_session_service.c
主要涉及函数:
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()