在批量翻译 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)
这段代码在文件少的时候没问题,但一旦任务量变大,会遇到三类问题:
- API 限流:翻译服务通常有 QPS 限制,超过后返回 429。
- 连接耗尽:大量并发连接占用本地端口和内存。
- 资源竞争: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 通常是更好的选择。
五、生产环境进阶
- 动态限流:根据 API 返回的 429 频率自动调整 Semaphore 大小。
- 队列化:把任务先放入 Redis 队列,消费端用 Semaphore 控制并发。
- 超时与熔断:连续失败超过阈值时暂停任务,避免雪崩。
- 连接池 :复用
aiohttp.ClientSession,不要每次请求都新建连接。
六、总结
批量 PDF 翻译不是"并发越高越好"。合理的并发控制能显著提升成功率和稳定性。asyncio.Semaphore 是一个轻量、易用的限流工具,配合重试和日志,可以支撑大多数生产场景。
如果你的翻译服务正在被 429 或内存耗尽困扰,不妨先把并发度降下来,把成功率提上去。
标签:Python、并发编程、asyncio、PDF翻译、API限流