为Linux开发板开发AIChat 客户端“踩坑”实录:从死锁到静音的排查之旅

这篇文档记录了我在开发 AIChat 客户端时遇到的三个Bug。为了方便理解,我让ai把枯燥的代码逻辑比作生活中的场景,并标注了背后的硬核技术点和具体的代码实现。


第一关:TTS End 死锁 —— “电话亭与送货员”的故事

🛠️ 涉及技术点

  • 并发编程:多线程 (std::thread)、互斥锁 (std::mutex)、原子变量 (std::atomic)
  • 网络编程:WebSocket 协议 (websocketpp)、异步回调 (Async Callback)

1. 场景设定

  • WebSocket 连接锁 = 电话亭的钥匙
  • 采集线程 = 送货员
  • WebSocket 回调线程 = 老板

2. 案发现场(死锁是怎么发生的?)

老板(WS 回调线程)拿着钥匙进电话亭接电话,想等送货员(采集线程)下班。送货员拿着货想进电话亭发货,不给钥匙就不下班。两人互相等待,导致死锁。

3. 破局之道(异步停止)

我们规定:老板接电话时,绝对不能在电话亭里等送货员!

💡 形象比喻

老板接到“TTS 结束”电话,只在门口贴张条子:“送货员,一会儿下班啊”,然后立刻交出钥匙走人。主循环看到条子后,在外面等着送货员下班。

💻 代码实现

“条子”的定义 (Application.h)

// 这是一个原子变量,保证多线程读写的安全性(不需要额外的锁)
std::atomic<bool> pending_force_idle_{false}; 

“贴条子” (Application.cc)

void Application::HandleTtsStreamEnd() {
    // 不直接调用 Stop(),因为那会死锁
    // 而是设置原子标志位(贴条子)
    pending_force_idle_ = true; 
}

“看条子” (Application.cc)

void Application::MainLoop() {
    while (running_) {
        // 主循环每轮检查一次条子
        if (pending_force_idle_) {
            pending_force_idle_ = false; // 撕掉条子
            StopListeningAudio();        // 安全地停止录音(此时没拿 WS 锁)
            state_machine_->ChangeState(GetIdleState());
        }
        // ...
    }
}

第二关:Listening 超时死锁 —— “叫不醒的装睡人”

🛠️ 涉及技术点

  • 音频开发:PortAudio 库、ALSA 驱动交互
  • I/O 模型:阻塞 I/O (Blocking I/O) vs 非阻塞轮询 (Non-blocking Polling)
  • 线程同步std::thread::join()

1. 场景设定

  • Pa_ReadStream (阻塞读) = 进仓库等货
  • Pa_AbortStream (终止流) = 老板的大喇叭

2. 案发现场

送货员进仓库(阻塞读)等货,但因为驱动 Bug,老板在外面喊破喉咙(Abort)他也听不见。老板在门口死等(join),导致死锁。

3. 破局之道(从“轮询”到“混合模式”的演进)

我们经历了两个阶段才彻底解决这个问题。

第一阶段:纯轮询 (Pure Polling) —— “礼貌的送货员”

我们规定:送货员绝对不能进仓库死等,必须先问问有没有货。

  • 做法:送货员每 5 毫秒问一声看门大爷(Pa_GetStreamReadAvailable):“有货吗?”
    • 有货 -> 进仓库搬。
    • 没货 -> 玩手机。
  • 结果死锁解除了!(因为送货员一直在门口玩手机,老板一叫就能应)。
  • 新问题饿死(Starvation)。因为看门大爷(驱动)撒谎,一直说“没货”,送货员就一直玩手机,结果一点声音都没录到。
第二阶段:混合模式 (Hybrid Mode) —— “急眼的送货员”

我们规定:送货员不能太老实,如果等太久了,不管大爷说什么,都要冲进去看看。

  • 做法:引入一个计数器。如果连续 20 次(约 100ms)大爷都说没货,送货员就强制冲进仓库搬一次。
  • 结果:既避免了死锁(大部分时间在玩手机),又保证了能录到声音(定期强制搬货)。
💻 代码实现

关键变量定义 (AudioProcess.h)

PaStream *stream_;              // PortAudio 流句柄(仓库)
std::atomic<bool> capturing_;   // 录音控制标志(老板的命令)

“混合模式”逻辑 (AudioProcess.cc)

void AudioProcess::CaptureLoop() {
    int poll_count = 0;
    while (capturing_) {
        // 1. 问大爷:有货吗?
        long available = Pa_GetStreamReadAvailable(stream_);
        
        // 2. 没货(或者货不够)
        if (available < frame_samples_) {
            poll_count++;
            // 如果还没等到急眼(< 20次)
            if (poll_count < 20) {
                std::this_thread::sleep_for(std::chrono::milliseconds(5)); // 玩手机
                continue;
            }
            // 否则:急眼了!强制往下走(去搬货)!
        } else {
            poll_count = 0; // 有货,计数器清零
        }

        // 3. 进仓库搬货(可能是因为有货,也可能是因为急眼了)
        Pa_ReadStream(stream_, ...);
    }
}

第三关:录音静音 —— “撒谎的侦察兵”与“爆仓”

🛠️ 涉及技术点

  • 缓冲区管理:缓冲区溢出 (Buffer Overflow)、饿死 (Starvation)
  • API 调试:错误码分析 (Error Handling)

1. 场景设定

  • Pa_GetStreamReadAvailable = 看门大爷(侦察兵)
  • paInputOverflowed = 爆仓警告单

2. 案发现场

大爷(驱动)老眼昏花,一直说“没货”,导致送货员一直玩手机。等我们强制送货员进去搬货时,发现货堆满了(Overflow),送货员吓得把货全扔了(continue),导致服务器收不到声音。

3. 破局之道(容忍溢出)

我们告诉送货员:“爆仓警告单”不是“报废单”!

💡 形象比喻

只要搬出来的货是好的,不管有没有收到“爆仓警告单”,都要发给服务器。

💻 代码实现

“无视警告单” (AudioProcess.cc)

// 强制去搬货后...
PaError err = Pa_ReadStream(stream_, ...);

if (err != paNoError) {
    // 以前:看到错误直接扔货 (continue)
    // 现在:如果是爆仓警告,仅仅打印个日志,货照样发!
    if (err == paInputOverflowed) {
        std::cerr << "爆仓了,但货还要发!" << std::endl;
        // 不写 continue,让代码继续往下走
    } else {
        // 其他严重错误才扔货
        continue;
    }
}

// 继续编码、发送...
EncodeAndSend(buffer);

总结

  1. 电话亭死锁:用 std::atomic<bool> 实现了无锁的异步通信。
  2. 仓库死锁:用 Pa_GetStreamReadAvailable + 混合模式 实现了既防死锁又防饿死的平衡。
  3. 静音乌龙:用 paInputOverflowed 的特殊处理挽救了数据。

这一路走来,我们不仅修好了 Bug,更深刻理解了多线程(电话亭)、驱动交互(仓库)和错误处理(警告单)的奥义。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐