多线程并发执行ffmpeg子进程时Python程序无法正常终止的问题求助
我基于ffmpeg的ffplay封装了一个Python GUI媒体播放器,其中用到了回放增益(replay gain)来统一音乐音量。但因为没找到能计算回放增益的共享库,我只能通过subprocess启动ffmpeg子进程,从它的stderr输出中获取回放增益数据。
我给每个音乐文件单独开一个线程来执行ffmpeg子进程——如果在GUI主线程里直接跑这些子进程,会导致播放时出现短暂卡顿(播放器本身也在独立线程中运行)。但我发现当同时运行2个及以上的ffmpeg子进程线程时,Python GUI无法正常终止。
我先解释下“无法正常终止”的具体表现:我用Geany作为IDE,它会在终端窗口中运行程序。正常终止时,终端会显示(program exited with code: 0) Press return to continue.,按Enter就能直接关闭窗口;但出问题时,同样的提示会出现,但按Enter窗口不会关闭,必须点击左上角的X或者按Ctrl+C才能关掉。
我用定时器来创建并启动线程,在我的系统上,单个线程大概2秒就能完成。如果把定时器间隔设为3秒或更长,程序就能正常终止;但如果把间隔缩短到2秒以内(我现在设的是100毫秒,而且因为各种原因不想用更长的间隔),就会出现上述问题。
我把GUI精简成了最小示例,这个例子没有播放器,只是一个会启动一系列线程(每个线程都运行ffmpeg子进程)的小部件。我当前的环境是Python 3.12、PyQt 5.15、Linux Mint 22.1,ffmpeg版本是7.1。
以下是示例代码:
import sys, subprocess, queue, threading from PyQt5 import QtWidgets, QtGui, QtCore class AudioPlayerDialog(QtWidgets.QDialog): def __init__(self, mediaList): super().__init__() vbox = QtWidgets.QVBoxLayout() vbox.addWidget(QtWidgets.QLabel('WIDGET')) self.setLayout(vbox) self.calcGainTimer = None self.gdCalcThreads = None self.finished.connect(self.closeEvent) self.mediaList = mediaList self.mediaListLen = len(mediaList) self.normGainList = [-1. for x in mediaList] self.buildNormGainList() def closeEvent(self, event): print('**** close event') if self.calcGainTimer is not None: print('**** stopping calcGainTimer') self.calcGainTimer.stop() if self.gdCalcThreads is not None: print('**** checking gain calc threads') for idx, t in self.gdCalcThreads.copy().items(): if t.is_alive(): print('**** waiting for thread {} to finish'.format(idx)) t.join() print('**** closing ...') def buildNormGainList(self): self.calcGainQueue = queue.LifoQueue(maxsize=self.mediaListLen + 10) for i in range(self.mediaListLen-1, -1, -1): self.calcGainQueue.put_nowait(i) # setup gain/dur calculation thread dictionary self.gdCalcThreads = dict() # For each expiration of the following timer, a thread will be # started to calculate the replay gain for a particular file # and a check will determine if all files have been processed. print('@@@@ starting updt timer') self.calcGainTimer = QtCore.QTimer() self.calcGainTimer.timeout.connect(self.stopGainDurCalcWhenDone) self.calcGainTimer.setInterval(100) # interval in msec. self.calcGainTimer.start() def stopGainDurCalcWhenDone(self): # this is the target of the updt timer try: # start a thread if the queue is not empty idx = self.calcGainQueue.get_nowait() if idx not in self.gdCalcThreads: print('@@@@ creating thread for', idx) thread = threading.Thread(None, self.requestGain, args=(idx,)) self.gdCalcThreads[idx] = thread thread.start() except queue.Empty: pass except Exception as err: print('**** ERROR: get request from calcGainQueue failed.\n\t'\ '{}'.format(err)) # check if any thread finished but updates not yet finished for idx,t in self.gdCalcThreads.items(): if not t.is_alive() and self.normGainList[idx] < 0.: # Thread finished; update not yet completed. Invert the # sign of the gain, which indicates update completed. self.normGainList[idx] = -self.normGainList[idx] print('@@@@ update finished for', idx) # check if all files have been processed; stop updt timer if so done = True # assume all calc done for i in range(self.mediaListLen): if self.normGainList[i] < 0.: done = False break if done: print('@@@@ all calc are done -----------------------') # all files have been processed print('@@@@ stopping updt timer') self.calcGainTimer.stop() self.calcGainTimer = None self.gdCalcThreads = None TEST = False # set True to pass dummy filename of ffmpeg def requestGain(self, idx): print('@@@@ start subprocess for', idx) if self.TEST: fileName = 'XXXX' else: fileName = self.mediaList[idx] cmd = ['ffmpeg', '-i', '{}'.format(fileName), '-hide_banner', '-af', 'replaygain', '-f', 'null', '-'] proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) try: _,err = proc.communicate(timeout=30) text = err.decode('utf8') except subprocess.TimeoutExpired: print('**** Wait for report from subprocess expired') proc.kill() text = '' except Exception as err: print('**** Unable to get report from subprocess: {}'.format(err)) proc.kill() text = '' # actual program parses gain from text gain = 1. self.normGainList[idx] = -gain # save negative gain if __name__ == '__main__': app = QtWidgets.QApplication(sys.argv) # put full-qualified filenames of media in list mediaList = [ ] apd = AudioPlayerDialog(mediaList) apd.exec() print('\nFINI') sys.exit()
我尝试过不给子进程设置PIPE,问题依然存在。另外,我把requestGain方法里的TEST设为True时,会给ffmpeg传一个无效的文件名XXXX,这样线程会快速结束,不会出现并发的情况,程序就能正常终止——这也验证了问题确实出在多个ffmpeg子进程线程并发运行的场景下。
我还做了这些排查:
- 用
top监控进程:所有ffmpeg子进程都能正常结束,最后只剩Python进程和终端窗口。但程序退出后,终端回到命令行提示符却无法响应键盘输入,只能按Ctrl+C刷新提示符,或者点击窗口X关闭。 - 把
subprocess调用换成time.sleep(5),即使多线程并发,程序也能正常终止——这说明问题确实和ffmpeg子进程的调用有关,而不是多线程本身的问题。 - 尝试用
concurrent.futures.ThreadPoolExecutor替换手动创建线程,问题依旧。 - 调整子进程的超时逻辑(比如给
communicate加timeout、捕获TimeoutExpired异常),也没能解决问题。
现在我想知道:
- 为什么多个
ffmpeg子进程线程并发时,程序无法正常终止? - 有什么办法能进一步排查出“残留”的资源或进程?
- 如何在保持100毫秒定时器间隔的前提下,解决这个终止问题?
备注:内容来源于stack exchange,提问作者dave

