578 lines
17 KiB
Markdown
578 lines
17 KiB
Markdown
# 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()
|
||
```
|