Files
lucky-cat/docs/knowledge vault/bugfix-persistent-local-responding-worker.zh-CN.md
T

17 KiB
Raw Blame History

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 正常多轮对话测试

  1. 重启设备并保存完整 monitor 日志;
  2. 等待 WebSocket 连接成功;
  3. 确认只出现一次:
Local responding event worker started (persistent)
  1. 连续完成至少 50 轮对话;
  2. 每轮之间等待超过 5 秒,覆盖旧逻辑的 idle exit 条件;
  3. 比较每轮 Heap before_uplink_start;
  4. 确认 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. 涉及文件

该提交只修改:

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()