在我的PySide6应用中,需要一个后台线程来并发处理成百上千个TTS请求。
asyncio
似乎是为此而生的完美工具。于是我自信地写下了 async def
,启动了线程,然后当我关闭UI窗口时,一切都不正常了。
应用主进程卡死一段时间后退出了,但日志里充满了各种看似无关的错误,而那个本应退出的 asyncio
线程,像个幽灵一样在后台拒绝死亡仍在不断抽搐。接下来的一段时间,我陷入了一场与 asyncio
、threading
和 edge-tts
库的"死亡螺旋"式的搏斗。
这篇文章,就是这场搏斗的忠实记录。它没有光鲜的成功案例,只有一次次失败的尝试、错误的假设、以及在绝望中对问题本质的层层剖析。如果你也正被 asyncio
和 threading
的混合编程所困扰,希望我踩过的这些坑,能为你照亮前行的路。
第一幕:失控的开端
我的初始架构非常"标准":一个继承自 threading.Thread
的 EdgeTTS
类,它的 run
方法通过 asyncio.run()
启动整个异步世界。
_base.py
中的 run
方法:
python
# videotrans/tts/_base.py
class BaseTTS(threading.Thread):
def run(self) -> None:
# ... 一些初始化 ...
try:
if inspect.iscoroutinefunction(self._exec):
asyncio.run(self._exec())
else:
self._exec()
except Exception:
# 当时的我认为异常处理很简单
raise
_edgetts.py
中的异步逻辑:
python
# videotrans/tts/_edgetts.py
@dataclass
class EdgeTTS(BaseTTS):
async def _exec(self) -> None:
semaphore = asyncio.Semaphore(5) # 控制并发为5
tasks = [
self._create_audio_with_retry(item, semaphore)
for item in self.queue_tts
]
await asyncio.gather(*tasks)
async def _create_audio_with_retry(self, item, semaphore):
async with semaphore:
if self._exit(): # self._exit() 检查一个全局退出标志
return
for attempt in range(RETRY_NUMS):
try:
# ... aiohttp/edge-tts 网络请求 ...
await communicate.save("audio.mp3")
return # 成功后退出
except Exception as e:
# 失败后等待3秒重试
await asyncio.sleep(3)
UI关闭时,主线程会设置一个全局标志 config.exit_soft = True
,然后调用 thread.join(timeout=5)
等待5秒以便后台TTS线程正常退出。
第一次失败:线程超时,join
失败 当我关闭UI时,控制台无情地打印出 警告:线程 Thread-4 在5秒后仍未退出!
。
原因分析与反思: 我很快意识到自己的愚蠢。await asyncio.sleep(3)
会让协程结结实实地"睡"上3秒。在此期间,它不会执行任何代码,自然也错过了我在循环开始处设置的 if self._exit():
检查。asyncio
的协作式多任务,意味着如果一个任务自己不主动"醒来"并检查状态,外界对它是无能为力的。
这是我学到的第一个教训:任何可能长时间等待的 await
点,都必须设计成可被外部事件中断的。
第二幕:在"取消"的迷宫中绕圈
为了解决 sleep
的问题,我开始了对 asyncio
任务管理机制的探索。
2.1 引入 asyncio.Event
,打造可中断的 sleep
我给 EdgeTTS
类增加了一个 asyncio.Event
实例 self._stop_event
。UI关闭时,除了设置全局标志,还会通过 loop.call_soon_threadsafe
来 set
这个事件。然后,我重写了 sleep
:
python
async def interruptible_sleep(self, delay: float):
sleep_task = asyncio.create_task(asyncio.sleep(delay))
stop_task = asyncio.create_task(self._stop_event.wait())
done, pending = await asyncio.wait(
{sleep_task, stop_task},
return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
这确实解决了 sleep
期间无法响应的问题。但线程依然没能在预期的等待后正常退出。
2.2 生产者-消费者模式的引入
我的任务队列可能有几千个元素,我的代码 tasks = [asyncio.create_task(...) for ...]
一次性创建了数千个任务。当我试图在退出时取消它们时,发现这个过程非常缓慢。
我意识到,管理如此大量的、大部分都阻塞在 async with semaphore
上的任务,本身就是一场灾难。于是,我花了半天时间,将代码重构为经典的生产者-消费者模型:
python
# 核心改动
async def _task_supervisor(self):
task_queue = asyncio.Queue(maxsize=10)
# 创建5个工人
worker_tasks = [
asyncio.create_task(self.worker(f'worker-{i}', task_queue))
for i in range(5)
]
# 生产者:向队列中放任务
for item in self.queue_tts:
await task_queue.put(item)
# 所有任务放入后,放"毒丸"来通知工人下班
for _ in range(5):
await task_queue.put(None)
await asyncio.gather(*worker_tasks)
async def worker(self, name, queue):
while True:
item = await queue.get()
if item is None: # 吃到毒丸
queue.task_done()
break
await self._process_single_item(item) # _process_single_item 是重构后的工作单元
queue.task_done()
我满以为这个优雅的模型能解决问题。但现实是,TTS线程依然没有退出!
日志显示,工人们确实吃到了"毒丸"或被 cancel()
,并打印了"即将退出"的日志。但 thread.join()
依然超时!
反思与困惑: 这是我调试过程中最痛苦的阶段。应用层的逻辑(worker
协程)明明已经终止,为什么底层的 asyncio
事件循环 (asyncio.run
) 却没有结束?这把我引向了问题的更深处。
第三幕:揪出阻塞事件循环的同步"叛徒"
唯一的解释是:事件循环被某些同步代码冻结了。
asyncio
事件循环是单线程的。任何一个耗时的、非异步的函数调用,都会像一颗石子卡住精密的齿轮一样,让整个系统停摆。
我开始逐行审查工作单元 _process_single_item
。很快,我找到了两个没有 await
的"犯罪嫌疑人":
self.convert_to_wav(...)
: 一个调用pydub
库转换音频格式的函数。这是一个纯粹的CPU密集型操作。self._signal(...)
: 一个调用PySide6.Signal.emit()
与UI线程通信的函数。
我把这两个"叛徒"用 loop.run_in_executor
发配到了线程池:
python
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self.convert_to_wav, "in.mp3", "out.wav")
await loop.run_in_executor(None, self._signal, "更新进度...")
情况有所好转,但偶尔还是会卡住,并伴随着一个新的、幽灵般的错误:RuntimeError: Event loop is closed
。这个错误甚至在我的线程看似已经结束后才打印出来。
反思与绝望: run_in_executor
像一个潘多拉魔盒。它解决了事件循环的阻塞,却带来了更难调试的"僵尸线程"问题------被派出去的线程无法被 cancel()
,并且在关闭时序上引发了新的竞态条件。我觉得我快要放弃 asyncio
了。
第四幕:回归简单,放弃过度设计
在复杂的模型中迷失后,我感觉过度设计了,问题更难解决,决定回归简单。废除了生产者-消费者模型,回到了最初一次性创建所有任务的模式,但增加了一个"看门狗"机制:
python
async def _exec(self):
# ... 创建所有 worker_tasks ...
# 创建一个看门狗,它的唯一职责就是等待停止信号,然后取消所有任务
watchdog_task = asyncio.create_task(self.watchdog(worker_tasks))
# 创建一个监控器,轮询全局标志,并触发看门狗
monitor_task = asyncio.create_task(self._exit_monitor())
await asyncio.gather(*worker_tasks, watchdog_task, monitor_task, return_exceptions=True)
这个模型虽然简单粗暴,但它的取消路径最短,最不容易出错。
最终的敌人:ProactorEventLoop
线程终于可以按时退出了!但 Event loop is closed
的错误依然像幽灵一样缠着我。
在翻阅了大量 aiohttp
和 asyncio
的 issue 后,我终于找到了问题的根源。这个错误是 Windows 平台上默认的 ProactorEventLoop
与 aiohttp
库在连接被强制关闭时的一个著名兼容性问题 。ProactorEventLoop
的底层资源清理机制有缺陷,导致在事件循环关闭后,仍有对象试图使用它。
终极解决方案:换掉 asyncio
的"引擎"
解决方案出乎意料地简单,却也需要对 asyncio
有足够的底层认识。在我的 BaseTTS
线程的 run
方法入口,我加入了平台判断,强制在 Windows 上使用更健壮的 SelectorEventLoop
:
python
# 在 BaseTTS.run() 方法的开始
import sys
import asyncio
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
同时,为了确保万无一失,我重写了 run
方法,用手动的 try...finally
块来管理事件循环的生命周期,并实现了一套包含"取消所有任务 -> 等待 gather -> 关闭异步生成器 -> 关闭循环 -> 强制GC"的、滴水不漏的关闭流程。
当我把这些最终的改动部署后,所有的错误都消失了。我的 asyncio
后台线程,终于被彻底"驯服"。它启动迅速,并发高效,关闭时干脆利落,无论面对多少任务和多复杂的异常情况,都表现得像一个可靠的老兵。
结语
这次痛苦的调试,像是一场 asyncio
的深度历练。我从中学到的,远不止是几个API的用法:
asyncio
不是多线程的替代品,而是它的搭档。理解它们各自的优缺点和边界,是混合编程的第一步。- "非阻塞"的代价是"协作"。你必须在代码的每一个角落都保持警惕,确保没有"叛徒"(同步阻塞代码)冻结你宝贵的事件循环。
- 为"关闭"而设计,其重要性不亚于业务逻辑。一个无法稳定关闭的系统,是不可靠的系统。
- 深入源码,了解底层 。当所有上层逻辑都无懈可击时,问题可能就出在你看不见的底层。对
asyncio
不同事件循环策略的了解,成了我解决问题的最后一根稻草。
asyncio
很美,但也确实很"坑"。希望我这份详尽的"踩坑实录",能让你在享受 asyncio
带来的性能提升时,也能从容地避开那些潜伏在深水区的陷阱。