别把 async 当银弹:在 CPU 密集型图像处理服务中,优秀工程师为什么要敢于说“不”

别把 async 当银弹:在 CPU 密集型图像处理服务中,优秀工程师为什么要敢于说“不”

在 Python 编程里,asyncioasync/await、异步 I/O 这些词很有吸引力。它们听起来现代、优雅、高性能,也常常出现在高并发 Web 服务、实时消息推送、网络爬虫、网关服务和微服务架构中。

但真正成熟的工程师都知道:技术不是越新越好,而是越适合越好。

如果你的系统是一个纯 CPU 密集型图像处理服务,比如批量压缩图片、生成缩略图、做滤镜计算、图像识别预处理、像素级变换,有人坚持要“全量 async 重构”,这时我会明确拒绝。

不是因为我排斥异步,而是因为我理解异步。


一、先说结论:什么时候我会明确拒绝用异步?

当一个服务满足以下特征时,我会非常谨慎,甚至明确拒绝“全量 async 重构”:

  1. 主要耗时来自 CPU 计算,而不是网络、磁盘、数据库等 I/O 等待。
  2. 任务执行期间长时间占用 Python 解释器或底层计算资源。
  3. 系统瓶颈已经明确是 CPU 利用率打满。
  4. 现有问题可以通过多进程、任务队列、算法优化、C 扩展、GPU 加速解决。
  5. 引入 async 会显著增加代码复杂度,却不能带来对应收益。
  6. 团队缺少异步调试、压测、监控和异常处理经验。
  7. 重构目标模糊,只是因为“别人都在用 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 仍然可能有价值,但它应该用于合适的边界。

例如,一个完整请求可能包含:

  1. 接收 HTTP 请求。
  2. 从对象存储下载图片。
  3. 调用图像处理逻辑。
  4. 上传处理结果。
  5. 写入数据库记录。
  6. 返回任务状态。

其中,下载、上传、数据库访问属于 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,最后决策

技术世界变化很快,但有些原则不会过时:

先定位瓶颈,再选择方案。
先验证收益,再大规模重构。
先保护简单性,再追求先进性。

愿你在每一次技术选型中,都不只是追逐潮流,而是成为那个能看清本质、守住质量、也敢于温柔而坚定地说“不”的工程师。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

铭渊老黄

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值