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限流

相关推荐
默辨9 小时前
我用Java后端的视角读了一遍JoyAgent-JDGenie
java·开发语言
SQL-First布道者9 小时前
⚡ Spring JDBC 完整体系 · 第 5 讲 · Spring Boot Starter JDBC
java·spring boot·spring·mybatis·spring jdbc
宋哥转AI10 小时前
深入理解 AI Agent · 多 Agent 编排 #03:Supervisor 与 Orchestrator——Dream-SaaS 双层编排架构拆解
java·人工智能·ai
木井巳10 小时前
【JavaEE】Spring Web MVC 入门
java·spring boot·spring·servlet·java-ee
2601_9621286410 小时前
【JavaWeb06】Tomcat基础入门:架构理解与基本配置指南
java·架构·tomcat
要开心吖ZSH10 小时前
腾讯 IM × TRTC 联动实战:后端如何撑起一次视频问诊(混流录制篇)
java·音视频·腾讯云·健康医疗·即时通讯·im
吴声子夜歌10 小时前
Java——类、对象及方法(二)
java·开发语言
2401_8906034010 小时前
Python入门语法(一)
java·开发语言·python
小蒜学长10 小时前
springboot党建云课堂学习与管理系统(代码+数据库+LW)
java·数据库·spring boot·后端·学习
mldong10 小时前
六语言引擎实现对比:同构背后的妥协与差异
java·架构