先说说我自己的经历。有次写个爬虫工具,需要调用几十个外部程序处理图片。一开始用subprocess.call一个个执行。跑起来后CPU占用不到10%,内存也只用了500MB。可程序跑完花了半小时。我盯着屏幕干着急,明明电脑配置不错,就是没法利用起来。后来查了一下,发现是同步调用卡住了流程。每个子进程都得等前一个结束才能启动。
这就好比你手上有10个快递要送。如果你一次只送一个,等回来再拿第二个,花的时间肯定比一次全送出去长。异步子进程就是这个道理。它让你可以一次性丢出多个任务,各个任务独立跑,互不干扰。主程序不用等,可以继续做其他事。
Python里做这个主要靠asyncio.subprocess模块。拿create_subprocess_shell来说,它可以创建子进程,同时不阻塞事件循环。配合asyncio.wait或者gather,几十个任务能同时启动。
举个例子。假设你要批量转换1000张图片的大小。用同步方式写代码是这样的:
import subprocess
for i in range(1000):
subprocess.run(f'convert input_{i}.jpg -resize 800 output_{i}.jpg', shell=True)
这个循环跑起来,你会发现任务管理器里永远只有一个进程在工作。1000张图,每张耗时0.3秒,总时间就是300秒。换成异步写法:
import asyncio
async def convert_one(i):
await asyncio.create_subprocess_shell(f'convert input_{i}.jpg -resize 800 output_{i}.jpg')
async def main():
tasks = [convert_one(i) for i in range(1000)]
await asyncio.gather(tasks)
asyncio.run(main())
同一时间你最多能并行跑多少个任务,取决于你的CPU内核数和内存大小。如果你的机器有8个核,理论上能同时跑8个任务。1000张图分125批,每批跑8张,每批耗时0.3秒,总时间只要37.5秒。效率提升了8倍。
有人担心并发太高会拖垮系统。这个确实要注意。常见的做法是用信号量限制并发数。比如你只想同时跑10个进程,可以定义一个信号量:
sem = asyncio.Semaphore(10)
async def limited_convert(i):
async with sem:
await asyncio.create_subprocess_shell(f'convert input_{i}.jpg output_{i}.jpg')
这样即使你有1000个任务,同一时间也只有10个进程在跑。不会把内存吃满,也不会让CPU过载。系统响应速度还在可控范围内。
另一个实际问题:收集结果。很多时候启动子进程不是目的,拿到输出才是。create_subprocess_shell返回的对象有stdout和stderr属性。配合communicate方法,可以异步读取输出:
proc = await asyncio.create_subprocess_shell('command', stdout=asyncio.subprocess.PIPE)
stdout, stderr = await proc.communicate()
这里有个陷阱。如果子进程输出量很大,不加缓冲就直接读,可能导致内存爆掉。最好设置limit参数,或者按行流式处理。比如用readline逐行读取。
我踩过的一个坑是Windows系统。Python的asyncio在Windows上有些限制。创建子进程时不能混用shell和Pipe。解决方案是用create_subprocess_exec代替shell,把命令和参数分开传。跨平台写代码时,最好在if sys.platform == 'win32'的判断下做特殊处理。
异步子进程不是银弹。如果你的外部命令本身耗时很短,比如几十毫秒就结束,异步带来的提升不大。因为创建进程本身也有开销。同步调用就够了。异步真正的价值在命令执行时间长、批量大、IO密集的场景。
最后提醒一点。用异步子进程时,记得给每个子进程设置超时。不然某个进程卡住了,整个任务都停在那。可以用asyncio.wait_for包裹协程实现超时控制。
实际项目中用这套方案,我的图片处理任务从30分钟降到了4分钟。机器利用率也上去了。你手上如果有类似需求,可以试试看。先从少量任务测试,确认没问题再批量跑。这样既性能又好,又不会出乱子。