PDF翻译中的并发控制:用Semaphore防止API限流与资源耗尽

在批量翻译 PDF 的场景里,并发是最容易被低估的问题。一开始大家可能都会写一个简单的多线程循环,把几百个文件同时丢给翻译 API。结果往往有两种:要么触发对方的限流,大量请求失败;要么本地内存和连接数被占满,服务直接卡死。

这篇文章分享一个实用的并发控制方案:用 Python 的 asyncio.Semaphore 限制同时执行的翻译任务数,兼顾吞吐量和稳定性。

一、问题场景

假设你有一个翻译服务,需要批量处理 1000 个 PDF 文件。最朴素的写法可能是这样:

python 复制代码
import concurrent.futures

def translate_file(path):
    # 调用翻译 API
    return requests.post(API_URL, files={"file": open(path, "rb")})

with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor:
    executor.map(translate_file, files)

这段代码在文件少的时候没问题,但一旦任务量变大,会遇到三类问题:

  1. API 限流:翻译服务通常有 QPS 限制,超过后返回 429。
  2. 连接耗尽:大量并发连接占用本地端口和内存。
  3. 资源竞争:CPU/内存密集型任务(如 PDF 解析)同时运行,导致整体性能下降。

二、解决方案:Semaphore 限流

Semaphore 是一个计数信号量,允许同时获取许可的协程数量有限。把它放在翻译任务入口,就能天然控制并发度。

python 复制代码
import asyncio
import aiohttp
import aiofiles
from pathlib import Path

API_URL = "https://api.example.com/translate"
MAX_CONCURRENT = 5  # 根据 API 限流调整
semaphore = asyncio.Semaphore(MAX_CONCURRENT)


async def translate_one(session: aiohttp.ClientSession, file_path: str) -> dict:
    """单个文件翻译,受 Semaphore 保护"""
    async with semaphore:  # 同时最多 MAX_CONCURRENT 个协程进入
        async with aiofiles.open(file_path, "rb") as f:
            data = await f.read()

        form = aiohttp.FormData()
        form.add_field("file", data, filename=Path(file_path).name)
        form.add_field("target_lang", "zh")

        try:
            async with session.post(API_URL, data=form, timeout=30) as resp:
                resp.raise_for_status()
                result = await resp.json()
                return {"file": file_path, "status": "success", "result": result}
        except asyncio.TimeoutError:
            return {"file": file_path, "status": "timeout"}
        except Exception as e:
            return {"file": file_path, "status": "error", "message": str(e)}


async def translate_batch(file_paths: list[str]) -> list[dict]:
    """批量翻译入口"""
    async with aiohttp.ClientSession() as session:
        tasks = [translate_one(session, p) for p in file_paths]
        return await asyncio.gather(*tasks)

三、完整可运行示例

下面是一个带重试、进度日志和结果保存的完整示例:

python 复制代码
import asyncio
import aiohttp
import aiofiles
from pathlib import Path
from datetime import datetime

API_URL = "https://api.example.com/translate"
MAX_CONCURRENT = 5
RETRY = 2
semaphore = asyncio.Semaphore(MAX_CONCURRENT)


async def translate_with_retry(session, file_path: str, retries: int = RETRY) -> dict:
    """带重试的单个文件翻译"""
    for attempt in range(retries + 1):
        result = await translate_one(session, file_path)
        if result["status"] == "success" or attempt == retries:
            return result
        await asyncio.sleep(2 ** attempt)  # 指数退避
    return result


async def translate_one(session: aiohttp.ClientSession, file_path: str) -> dict:
    async with semaphore:
        try:
            async with aiofiles.open(file_path, "rb") as f:
                data = await f.read()

            form = aiohttp.FormData()
            form.add_field("file", data, filename=Path(file_path).name)
            form.add_field("target_lang", "zh")

            async with session.post(API_URL, data=form, timeout=30) as resp:
                if resp.status == 429:
                    return {"file": file_path, "status": "rate_limited"}
                resp.raise_for_status()
                return {"file": file_path, "status": "success"}
        except asyncio.TimeoutError:
            return {"file": file_path, "status": "timeout"}
        except Exception as e:
            return {"file": file_path, "status": "error", "message": str(e)}


async def main():
    pdf_dir = Path("pdfs")
    files = [str(p) for p in pdf_dir.glob("*.pdf")]

    print(f"[{datetime.now()}] Start translating {len(files)} files, max_concurrent={MAX_CONCURRENT}")

    async with aiohttp.ClientSession() as session:
        tasks = [translate_with_retry(session, f) for f in files]
        results = await asyncio.gather(*tasks)

    success = sum(1 for r in results if r["status"] == "success")
    failed = len(results) - success
    print(f"[{datetime.now()}] Done. success={success}, failed={failed}")

    # 保存失败列表,便于后续重试
    failed_files = [r["file"] for r in results if r["status"] != "success"]
    async with aiofiles.open("failed_files.txt", "w") as f:
        await f.write("\n".join(failed_files))


if __name__ == "__main__":
    asyncio.run(main())

四、Semaphore 与线程池的区别

很多人会问:用 ThreadPoolExecutor(max_workers=N) 不也能限流吗?

确实可以,但两者有本质区别:

维度 ThreadPoolExecutor asyncio.Semaphore
并发模型 多线程,适合 CPU/IO 混合任务 单线程协程,适合高 IO 任务
资源占用 每个线程有独立栈空间,数量多时有内存压力 协程轻量,可创建成千上万个
适用场景 PDF 解析等 CPU 密集型操作 网络请求等 IO 密集型操作
灵活性 固定线程数,调整不够细粒度 可动态调整、可嵌套使用

对于 PDF 翻译这种"上传文件 → 等待 API 响应 → 下载结果"的 IO 密集型任务,asyncio + Semaphore 通常是更好的选择。

五、生产环境进阶

  1. 动态限流:根据 API 返回的 429 频率自动调整 Semaphore 大小。
  2. 队列化:把任务先放入 Redis 队列,消费端用 Semaphore 控制并发。
  3. 超时与熔断:连续失败超过阈值时暂停任务,避免雪崩。
  4. 连接池 :复用 aiohttp.ClientSession,不要每次请求都新建连接。

六、总结

批量 PDF 翻译不是"并发越高越好"。合理的并发控制能显著提升成功率和稳定性。asyncio.Semaphore 是一个轻量、易用的限流工具,配合重试和日志,可以支撑大多数生产场景。

如果你的翻译服务正在被 429 或内存耗尽困扰,不妨先把并发度降下来,把成功率提上去。

标签:Python、并发编程、asyncio、PDF翻译、API限流

相关推荐
大黄说说1 小时前
Java 与 Kotlin 混合开发避坑指南:老项目平滑迁移 Kotlin 实操手册
java·开发语言·kotlin
snow@li2 小时前
SpringBoot:AOP日志切面全景梳理/原理+流程+实战+避坑
java·开发语言·spring boot
ma_king2 小时前
Spring Boot 接入飞书自定义机器人
java·后端
2401_894915532 小时前
部署 GEO 优化源码常见报错排查:端口、伪静态、缓存问题解决
java·运维·服务器·后端·缓存·开源
七牛开发者2 小时前
Codex 实践系列 Vol.04:用 Goal 和 Plan 管住一个长任务
java·数据库·人工智能·github·copilot
我命由我123453 小时前
Android Drawable - gradient
android·java·java-ee·kotlin·android studio·android-studio·android runtime
淡海水3 小时前
15-07-YooAsset面试篇-Unity性能优化与内存管理
java·unity·面试·性能优化·c#·游戏引擎
snow@li3 小时前
Java:跨平台原理与JDK、JRE、JVM全景深度解析
java·开发语言·jvm
Livia要学习3 小时前
Python闭包
开发语言·jvm·python