最近在重构一个数据处理服务,需要并发调用十几个外部命令行工具(ffmpeg、wkhtmltopdf之类)。本来以为把 subprocess.run() 换成 asyncio.create_subprocess_exec() 就完事了,结果踩了一串坑,分享给同样在折腾异步子进程的同学。
坑1:在async函数里直接用 subprocess.run() 阻塞整个事件循环
这是最常犯的错。很多人知道async函数,但习惯了同步写法:
python
async def process_video(path):
# ❌ 这会阻塞整个事件循环!
result = subprocess.run(["ffmpeg", "-i", path, "output.mp4"], capture_output=True)
return result.stdout
subprocess.run() 是同步阻塞调用。在async函数里直接用它,整个事件循环都会卡住,其他协程全部停摆。如果你的FastAPI接口里这么写,一个请求就能把服务冻住。
正确做法是用 asyncio.create_subprocess_exec():
python
async def process_video(path):
# ✅ 异步等待子进程
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-i", path, "output.mp4",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
return stdout
坑2:stdout/stderr管道没消费导致死锁
这个坑极其隐蔽。当你创建子进程并设置了 stdout=PIPE,但忘记读取输出时:
python
async def run_tool(cmd):
proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE)
# ❌ 如果子进程输出了大量数据填满管道缓冲区(通常64KB),
# 子进程会阻塞在write()上,你的await也永远不会返回
await proc.wait() # 死锁!
return proc.returncode
操作系统管道缓冲区有限,子进程往stdout写满了就卡住,等你来读。但你只在 wait(),不去读,双方互相等------死锁。
解决方法:始终用 communicate() 同时读stdout和stderr:
python
async def run_tool(cmd):
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate() # ✅ 同时消费两个管道
return proc.returncode, stdout, stderr
如果确实不需要输出,重定向到DEVNULL:
python
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL
)
坑3:大量并发子进程耗尽文件描述符
每个子进程至少占3个fd(stdin/stdout/stderr的管道),加上communicate的缓冲区。我一开始并发起了50个子进程,直接 OSError: [Errno 24] Too many open files。
解决方案:
python
# 1. 查看当前限制
import resource
print(resource.getrlimit(resource.RLIMIT_NOFILE)) # 通常1024
# 2. 用Semaphore控制并发数
sem = asyncio.Semaphore(10) # 最多10个并发子进程
async def run_with_limit(cmd):
async with sem:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
return proc.returncode, stdout
# 3. 或者临时提高限制(需要权限)
# resource.setrlimit(resource.RLIMIT_NOFILE, (65536, 65536))
Semaphore是最靠谱的方式,既控制fd消耗,也避免把CPU打满。
坑4:子进程超时与僵死处理
有些命令行工具偶尔会卡死(说的就是你,wkhtmltopdf)。communicate() 本身没有超时参数(Python 3.11之前),直接await可能永远等不回来:
python
# ❌ 可能永远卡住
stdout, stderr = await proc.communicate()
# ✅ 用wait_for加超时
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
except asyncio.TimeoutError:
proc.kill() # 发SIGKILL
await proc.wait() # 等待进程回收,避免僵尸进程
raise
注意两点:
kill()之后一定要wait(),否则子进程变成僵尸进程占用PIDkill()发SIGKILL是强制终止,如果子进程有子子进程,它们可能变成孤儿进程。更干净的做法是杀进程组:
python
import os
import signal
# 创建子进程时指定新的进程组
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
preexec_fn=os.setsid # 新进程组
)
# 超时后杀整个进程组
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
except asyncio.TimeoutError:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
await proc.wait()
坑5:Windows上的兼容性地狱
如果你的服务需要跨平台,Windows是一堆坑的集合:
create_subprocess_exec在Windows上不支持preexec_fn参数(Windows没有进程组概念)- 杀进程要用
proc.terminate()而不是发信号 - 路径中的反斜杠和空格需要特殊处理
- 编码问题:stdout默认是系统编码(GBK),不是UTF-8
python
import sys
async def run_cross_platform(cmd):
kwargs = {
"stdout": asyncio.subprocess.PIPE,
"stderr": asyncio.subprocess.PIPE,
}
if sys.platform != "win32":
kwargs["preexec_fn"] = os.setsid
proc = await asyncio.create_subprocess_exec(*cmd, **kwargs)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
except asyncio.TimeoutError:
if sys.platform == "win32":
proc.terminate()
else:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
await proc.wait()
raise
# Windows编码处理
if sys.platform == "win32":
stdout = stdout.decode("gbk", errors="replace")
stderr = stderr.decode("gbk", errors="replace")
return stdout, stderr
总结
| 坑 | 现象 | 解法 |
|---|---|---|
| 同步subprocess阻塞 | 事件循环卡死 | 用create_subprocess_exec |
| 管道未消费 | 死锁 | communicate()或DEVNULL |
| fd耗尽 | Too many open files | Semaphore控制并发 |
| 子进程僵死 | 永久挂起 | wait_for超时+kill+wait |
| Windows兼容 | 各种报错 | 条件分支+terminate |
异步子进程看起来简单,实际上涉及操作系统管道、进程管理、信号处理等底层细节。踩完这些坑之后,我对"异步"这个概念理解深了不少------它不只是把def改成async def,而是要真正理解你的代码在事件循环里是怎么调度的。
以上都是实际项目中遇到的问题,希望帮你少走弯路。