别把 async 当银弹:在 CPU 密集型图像处理服务中,优秀工程师为什么要敢于说“不”
在 Python 编程里,asyncio、async/await、异步 I/O 这些词很有吸引力。它们听起来现代、优雅、高性能,也常常出现在高并发 Web 服务、实时消息推送、网络爬虫、网关服务和微服务架构中。
但真正成熟的工程师都知道:技术不是越新越好,而是越适合越好。
如果你的系统是一个纯 CPU 密集型图像处理服务,比如批量压缩图片、生成缩略图、做滤镜计算、图像识别预处理、像素级变换,有人坚持要“全量 async 重构”,这时我会明确拒绝。
不是因为我排斥异步,而是因为我理解异步。
一、先说结论:什么时候我会明确拒绝用异步?
当一个服务满足以下特征时,我会非常谨慎,甚至明确拒绝“全量 async 重构”:
- 主要耗时来自 CPU 计算,而不是网络、磁盘、数据库等 I/O 等待。
- 任务执行期间长时间占用 Python 解释器或底层计算资源。
- 系统瓶颈已经明确是 CPU 利用率打满。
- 现有问题可以通过多进程、任务队列、算法优化、C 扩展、GPU 加速解决。
- 引入 async 会显著增加代码复杂度,却不能带来对应收益。
- 团队缺少异步调试、压测、监控和异常处理经验。
- 重构目标模糊,只是因为“别人都在用 async”。
一句话:如果问题不是 I/O 等待,async 往往不是解药。
二、为什么 CPU 密集型任务不适合全量 async?
很多人误以为:
“async 是高并发,所以性能一定更好。”
这是一个常见误区。
异步编程擅长解决的是:程序在等待 I/O 时不要傻等。
比如:
import asyncio
import aiohttp
async def fetch(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
return await resp.text()
async def main():
urls = [
"https://example.com",
"https://python.org",
"https://github.com",
]
results = await asyncio.gather(*(fetch(url) for url in urls))
print([len(r) for r in results])
asyncio.run(main())
这个例子里,程序大部分时间在等待网络响应。等待期间,事件循环可以切换去处理其他请求,所以 async 很有价值。
但图像处理通常是另一种情况:
from PIL import Image, ImageFilter
def process_image(input_path, output_path):
image = Image.open(input_path)
image = image.resize((800, 600))
image = image.filter(ImageFilter.SHARPEN)
image.save(output_path)
这类任务的主要成本是 CPU 计算、图像解码、像素处理、压缩编码。任务执行时,并不是“等别人返回”,而是本机 CPU 正在干活。
这时候你把它改成:
async def process_image_async(input_path, output_path):
image = Image.open(input_path)
image = image.resize((800, 600))
image = image.filter(ImageFilter.SHARPEN)
image.save(output_path)
看起来用了 async,但本质并没有变。函数内部没有真正释放事件循环的等待点,CPU 仍然被占满。甚至更糟:这个函数会阻塞事件循环,让其他协程也无法正常调度。
把同步 CPU 代码外面套一层 async,不叫异步优化,叫异步装饰。
三、一个错误的全量 async 重构示例
假设有一个图片批处理服务,原始版本如下:
from pathlib import Path
from PIL import Image, ImageFilter
def resize_and_filter(path: Path, output_dir: Path):
image = Image.open(path)
image = image.resize((800, 600))
image = image.filter(ImageFilter.SHARPEN)
output_path = output_dir / path.name
image.save(output_path)
def batch_process(input_dir: str, output_dir: str):
input_path = Path(input_dir)
output_path = Path(output_dir)
output_path.mkdir(exist_ok=True)
for image_path in input_path.glob("*.jpg"):
resize_and_filter(image_path, output_path)
有人可能会说:“我们改成 async,就能并发处理了。”
于是写出这样的代码:
import asyncio
from pathlib import Path
from PIL import Image, ImageFilter
async def resize_and_filter(path: Path, output_dir: Path):
image = Image.open(path)
image = image.resize((800, 600))
image = image.filter(ImageFilter.SHARPEN)
output_path = output_dir / path.name
image.save(output_path)
async def batch_process(input_dir: str, output_dir: str):
input_path = Path(input_dir)
output_path = Path(output_dir)
output_path.mkdir(exist_ok=True)
tasks = [
resize_and_filter(image_path, output_path)
for image_path in input_path.glob("*.jpg")
]
await asyncio.gather(*tasks)
这段代码看起来并发了,实际上问题很多。
首先,resize_and_filter 内部没有 await。它并不会在执行中主动让出控制权。其次,图像处理逻辑仍然会占用 CPU。再次,一次性创建大量任务还可能带来内存压力。
更关键的是,它给团队制造了一种错觉:代码已经“异步高性能”了。
这种错觉很危险。
四、正确方向:CPU 密集型优先考虑多进程
对于 CPU 密集型任务,更合适的方案通常是:
from pathlib import Path
from concurrent.futures import ProcessPoolExecutor
from PIL import Image, ImageFilter
import os
def resize_and_filter(args):
input_file, output_dir = args
image = Image.open(input_file)
image = image.resize((800, 600))
image = image.filter(ImageFilter.SHARPEN)
output_path = Path(output_dir) / Path(input_file).name
image.save(output_path)
return str(output_path)
def batch_process(input_dir: str, output_dir: str):
input_path = Path(input_dir)
output_path = Path(output_dir)
output_path.mkdir(exist_ok=True)
image_files = list(input_path.glob("*.jpg"))
workers = max(os.cpu_count() - 1, 1)
with ProcessPoolExecutor(max_workers=workers) as executor:
jobs = [
(str(image_file), str(output_path))
for image_file in image_files
]
for result in executor.map(resize_and_filter, jobs):
print(f"processed: {result}")
为什么这里用 ProcessPoolExecutor?
因为 Python 中很多 CPU 密集型代码会受到 GIL 的影响。多线程适合 I/O 密集型任务,但 CPU 密集型任务往往需要多进程,让多个 Python 进程真正并行使用多个 CPU 核心。
当然,如果底层库本身已经释放 GIL,比如某些 NumPy、OpenCV、Pillow 内部操作,那线程也可能有收益。但工程判断不能靠猜,必须通过压测和 profiling 来验证。
五、async 不是完全不能用,而是不该全量滥用
在图像处理服务里,async 仍然可能有价值,但它应该用于合适的边界。
例如,一个完整请求可能包含:
- 接收 HTTP 请求。
- 从对象存储下载图片。
- 调用图像处理逻辑。
- 上传处理结果。
- 写入数据库记录。
- 返回任务状态。
其中,下载、上传、数据库访问属于 I/O 场景,适合异步;图像处理本身属于 CPU 场景,更适合进程池、任务队列或专用计算服务。
一个更合理的架构是:
HTTP API 层
|
| 接收请求,快速校验
v
任务队列
|
| 分发任务
v
CPU Worker 进程池
|
| 图像处理
v
对象存储 / 数据库
如果使用 FastAPI,可以这样组织:
from fastapi import FastAPI, UploadFile
from concurrent.futures import ProcessPoolExecutor
import asyncio
import os
app = FastAPI()
pool = ProcessPoolExecutor(max_workers=max(os.cpu_count() - 1, 1))
def heavy_image_process(file_path: str) -> str:
# 这里放真正 CPU 密集型图像处理逻辑
# 例如 resize、filter、encode、AI preprocessing
return f"processed-{file_path}"
@app.post("/images")
async def upload_image(file: UploadFile):
content = await file.read()
input_path = f"/tmp/{file.filename}"
with open(input_path, "wb") as f:
f.write(content)
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
pool,
heavy_image_process,
input_path
)
return {"result": result}
这个方案里,API 层可以是 async,因为它要处理上传、读取、等待任务结果等 I/O 行为。但真正的 CPU 任务被丢进进程池,不阻塞事件循环。
这不是“拒绝 async”,而是把 async 放在它应该在的位置上。
六、判断是否使用 async 的工程清单
我通常会用一张非常直接的判断表。
| 问题 | 如果答案是“是” | 技术倾向 |
|---|---|---|
| 是否大量等待网络响应? | 是 | async / aiohttp / httpx |
| 是否大量等待数据库或缓存? | 是 | async DB driver 或连接池 |
| 是否主要做数学计算、图像处理、压缩编码? | 是 | 多进程 / C 扩展 / GPU |
| CPU 是否经常打满? | 是 | 优化算法、扩容 Worker、多进程 |
| 事件循环是否被阻塞? | 是 | 移走阻塞任务 |
| 团队是否能维护复杂 async 调用链? | 否 | 谨慎引入 |
| 改造目标是否可量化? | 否 | 暂停重构 |
一个优秀工程师不会问:“这个技术时不时髦?”
他会问:
“瓶颈在哪里?收益是什么?代价是什么?失败后如何回滚?”
七、如何用数据说服团队不要全量 async?
拒绝不是拍桌子。优秀工程师的“不”,必须建立在事实和专业判断之上。
我通常会做三件事。
第一,用 profiling 找瓶颈。
import cProfile
import pstats
def main():
batch_process("./input", "./output")
if __name__ == "__main__":
profiler = cProfile.Profile()
profiler.enable()
main()
profiler.disable()
stats = pstats.Stats(profiler)
stats.sort_stats("cumtime").print_stats(20)
如果结果显示主要耗时都在图像解码、resize、filter、save,那么重构 async 并不能解决核心问题。
第二,做小规模基准测试。
import time
def benchmark(func, *args):
start = time.perf_counter()
func(*args)
end = time.perf_counter()
print(f"{func.__name__}: {end - start:.2f}s")
分别测试:
同步单进程
线程池
进程池
伪 async
进程池 + async API 层
第三,用压测结果讨论,而不是用情绪讨论。
关注这些指标:
平均响应时间
P95 / P99 延迟
CPU 使用率
内存占用
任务吞吐量
失败率
队列堆积长度
代码复杂度
排障成本
如果“全量 async 重构”不能改善这些指标,就不应该成为优先方案。
八、比 async 更值得优先做的优化
在 CPU 密集型图像服务中,我会优先考虑这些方向。
1. 限制图片尺寸和输入质量
很多性能问题不是算法差,而是输入不可控。
from PIL import Image
MAX_WIDTH = 2000
MAX_HEIGHT = 2000
def validate_image(path):
image = Image.open(path)
width, height = image.size
if width > MAX_WIDTH or height > MAX_HEIGHT:
raise ValueError("image is too large")
return image
2. 避免重复解码和重复保存
图片解码、编码很贵。如果中间流程频繁保存临时文件,性能会明显下降。
3. 使用批处理和任务队列
对于耗时任务,HTTP 请求不一定要同步等待完成。可以返回任务 ID,让后端 Worker 异步处理。
@app.post("/tasks")
async def create_task(file: UploadFile):
# 保存文件
# 投递任务到队列
return {
"task_id": "abc123",
"status": "queued"
}
4. 使用更合适的底层库
在一些场景下,OpenCV、NumPy、libvips、Rust/C++ 扩展、GPU 推理服务都可能比“把代码改成 async”更有效。
5. 做容量规划
如果单台机器 8 核 CPU,每个任务平均占用 1 个核心 500ms,那么吞吐上限是可以估算的。工程系统不是靠信仰扩容,而是靠模型和数据扩容。
九、优秀工程师为什么要敢于说“不”?
因为工程不是许愿池。
每一次技术选择,背后都有成本:
学习成本
重构成本
测试成本
排障成本
监控成本
团队交接成本
线上事故成本
机会成本
“全量 async 重构”听起来很先进,但如果问题本质是 CPU 密集计算,它可能带来的是:
代码更难读
调用链更难追踪
异常更难处理
性能没有提升
线上问题更隐蔽
新人更难维护
真正优秀的工程师不是永远说“可以”,而是能在关键时刻说:
“这个方向不解决主要矛盾,我们不应该这样做。”
这句话背后不是保守,而是负责。
对业务负责,对团队负责,对代码未来三年的维护者负责,也对凌晨两点被报警叫醒的自己负责。
十、怎么优雅地拒绝“全量 async 重构”?
拒绝也需要方法。
不要说:
“async 没用。”
可以说:
“async 对 I/O 密集场景非常有效,但我们当前瓶颈主要在 CPU 图像处理。全量 async 改造成本高,收益不确定。我建议先做 profiling 和小规模 benchmark。如果数据证明瓶颈在 I/O,我们再引入 async;如果瓶颈在 CPU,则优先采用进程池、任务队列和底层库优化。”
这是一种更专业的表达方式:既不否定技术,也不盲目跟风。
还可以提出替代方案:
第一阶段:profiling,确认瓶颈。
第二阶段:用进程池改造核心处理链路。
第三阶段:引入任务队列削峰。
第四阶段:API 层保留 async,用于文件上传、状态查询等 I/O 操作。
第五阶段:根据压测结果评估是否继续优化底层图像库。
这比一句“不要用 async”更有建设性。
十一、一个可落地的推荐架构
对于纯 CPU 密集型图像处理服务,我推荐:
FastAPI / Flask API 层
|
| 接收请求,做参数校验
v
消息队列
|
| Celery / RQ / Dramatiq / Kafka
v
多进程 Worker
|
| Pillow / OpenCV / libvips / NumPy
v
对象存储 + 数据库
|
| 记录状态和结果地址
v
客户端轮询或回调通知
这个架构的优势是:
API 层轻量
CPU 任务隔离
Worker 可独立扩容
失败任务可重试
队列可削峰
监控指标更清晰
在这个方案中,async 不是主角,但可以是配角。它可以用于 API 层处理并发连接,也可以用于状态查询、通知回调、对象存储访问。但它不应该强行接管 CPU 密集型核心逻辑。
十二、总结:不要迷信 async,要尊重问题本身
Python 编程的魅力,不在于把所有代码都写成最新范式,而在于用简洁、清晰、可靠的方式解决真实问题。
面对一个纯 CPU 密集型图像处理服务,我会明确拒绝“全量 async 重构”。原因很简单:
async 解决的是等待问题,不是计算问题。
优秀工程师敢于说“不”,不是为了显得强硬,而是为了保护系统不被错误方向拖入复杂泥潭。
真正的专业判断应该是:
I/O 密集:考虑 async
CPU 密集:考虑多进程、算法优化、底层库、GPU
混合场景:分层治理,把 async 放在 I/O 边界
不确定:先 profiling,再 benchmark,最后决策
技术世界变化很快,但有些原则不会过时:
先定位瓶颈,再选择方案。
先验证收益,再大规模重构。
先保护简单性,再追求先进性。
愿你在每一次技术选型中,都不只是追逐潮流,而是成为那个能看清本质、守住质量、也敢于温柔而坚定地说“不”的工程师。


314

被折叠的 条评论
为什么被折叠?



