Semaphore 真能限流吗?从 asyncio 并发控制到生产级 API Rate Limiter
在 Python 异步开发里,你大概率见过这样的代码:
import asyncio
sem = asyncio.Semaphore(20)
async def request_api():
async with sem:
return await call_remote_api()
很多项目会把它描述成:
“给 API 加一个限流器,最多 20 个请求。”
这句话不能说完全错,但它隐藏了一个很危险的概念混淆。
asyncio.Semaphore(20)究竟限制的是:
- 最多 20 个并发请求?
- 每秒最多 20 个请求,也就是 20 QPS?
- 每秒最多消费 20 个 token?
- 还是每分钟最多 20 次调用?
答案只有一个:
Semaphore 原生限制的是并发数。
它不知道“一秒钟”是什么,也不知道一个请求消耗多少 token,更不知道第三方 API 给你的套餐到底是 60 RPM、20 QPS 还是 100000 TPM。
而在真正的 API 客户端、爬虫、AI 服务、数据采集系统和微服务中,这几个概念必须严格区分。
这篇文章,我们就把这件事彻底讲透,并最终实现一个真正可用的 Python 异步 API Rate Limiter。
一、先搞清楚:Semaphore 到底做了什么?
看看最常见的代码:
sem = asyncio.Semaphore(20)
async def worker():
async with sem:
await request()
可以把 Semaphore 想象成停车场。
停车场只有 20 个车位。
一个协程进入:
await sem.acquire()
占掉一个位置。
执行结束:
sem.release()
归还一个位置。
如果已经有 20 个任务进入:
Task 1 ─┐
Task 2 │
Task 3 │
... ├── 正在执行:最多 20 个
Task 20 ─┘
Task 21 ── 等待
Task 22 ── 等待
Task 23 ── 等待
所以:
asyncio.Semaphore(20)
表达的是:
同时处于受保护区域中的任务 <= 20
而不是:
每秒执行次数 <= 20
这是理解 API 限流的第一道分水岭。
二、为什么“并发 20”不等于“20 QPS”?
这里有一个非常实用的近似关系:
吞吐量 ≈ 并发数 / 平均响应时间
假设:
并发数 = 20
如果 API 平均 100ms 返回:
20 / 0.1 ≈ 200 QPS
你的:
Semaphore(20)
完全可能制造接近 200 QPS 的请求压力。
如果 API 只需要 50ms:
20 / 0.05 ≈ 400 QPS
但如果 API 突然变慢,每个请求需要 2 秒:
20 / 2 = 10 QPS
同一个:
Semaphore(20)
实际吞吐量可能从:
10 QPS
变化到:
400 QPS
甚至更高或更低。
所以 Semaphore 本质控制的是:
系统中同时有多少工作正在进行
而 Rate Limiter 控制的是:
单位时间内允许发生多少事件
这两个问题不是一回事。
三、三个经常被混在一起的指标
做 API 客户端时,我建议把限制明确拆成三层。
| 指标 | 解决的问题 | 常用工具 |
|---|---|---|
| Concurrency | 同时多少请求正在执行 | Semaphore |
| QPS / RPM | 单位时间允许多少请求 | Token Bucket / Sliding Window |
| TPS / TPM | 单位时间允许消费多少 token | Weighted Token Bucket |
例如某 AI API 可能规定:
最大并发:20
最大请求:60 RPM
最大 token:100000 TPM
你实际上面对的是三套约束,而不是一个数字。
合理的系统结构应该是:
┌─────────────────┐
请求 ─────────────>│ Concurrency │
│ Semaphore │
└────────┬────────┘
↓
┌─────────────────┐
│ Request Rate │
│ QPS / RPM │
└────────┬────────┘
↓
┌─────────────────┐
│ Token Rate │
│ TPS / TPM │
└────────┬────────┘
↓
API
这才是真正意义上的 API 流量控制。
四、真正的 Rate Limiter:Token Bucket
最常用的算法之一叫 Token Bucket,中文通常叫“令牌桶”。
想象桌子上有一个桶。
系统按照固定速度往里面放令牌:
20 tokens / second
每发送一次请求:
消耗 1 token
没有 token 时:
等待
核心规则非常简单:
tokens = min(
capacity,
tokens + elapsed * rate
)
其中:
rate
决定长期平均速度。
而:
capacity
决定系统允许多大的瞬时突发流量。
例如:
rate = 20
capacity = 40
意味着长期大约允许:
20 requests / second
但积累足够令牌之后,可以短时间快速发出最多约 40 个请求。
五、实现一个异步 Token Bucket
下面实现一个不依赖第三方库的版本:
import asyncio
import time
class AsyncTokenBucket:
def __init__(self, rate: float, capacity: float):
if rate <= 0:
raise ValueError("rate must be > 0")
if capacity <= 0:
raise ValueError("capacity must be > 0")
self.rate = float(rate)
self.capacity = float(capacity)
self._tokens = float(capacity)
self._updated_at = time.monotonic()
self._lock = asyncio.Lock()
def _refill(self, now: float):
elapsed = now - self._updated_at
self._tokens = min(
self.capacity,
self._tokens + elapsed * self.rate,
)
self._updated_at = now
async def acquire(self, amount: float = 1.0):
if amount <= 0:
return
if amount > self.capacity:
raise ValueError(
f"requested {amount} tokens, "
f"but bucket capacity is {self.capacity}"
)
while True:
async with self._lock:
now = time.monotonic()
self._refill(now)
if self._tokens >= amount:
self._tokens -= amount
return
missing = amount - self._tokens
wait_time = missing / self.rate
await asyncio.sleep(wait_time)
使用:
limiter = AsyncTokenBucket(
rate=20,
capacity=20,
)
async def worker():
await limiter.acquire()
await call_api()
现在限制的已经不是并发量,而是请求产生的速度。
这里还有一个很重要的工程细节:
time.monotonic()
不要轻易替换成:
time.time()
限流算法关心的是:
“过去了多少时间”
而不是:
“现在墙上时钟几点了”
系统时间可能因为人工调整、时间同步等发生变化,而单调时钟非常适合计算时间间隔。
六、burst 参数决定了“能不能突发”
假设:
AsyncTokenBucket(
rate=20,
capacity=100,
)
如果程序已经闲置了一段时间,桶中可能积满:
100 tokens
那么突然出现任务时,它可能立即放行很多请求。
长期平均速度依然受到:
20/s
控制,但短时间峰值会明显更大。
如果你的目标是让请求尽量平滑:
AsyncTokenBucket(
rate=20,
capacity=1,
)
会更接近:
每 50ms 放行一个请求
因为:
1 / 20 = 0.05 秒
所以别只关注:
rate
capacity同样是 Rate Limiter 的重要设计参数。
七、如果 API 要求“任意 60 秒最多 100 次”怎么办?
Token Bucket 更适合:
平均速率 + 可控 burst
但有些 API 的规则写得非常严格:
任何连续 60 秒窗口内最多 100 次请求
这时可以实现 Sliding Window。
import asyncio
import time
from collections import deque
class SlidingWindowLimiter:
def __init__(self, limit: int, period: float):
self.limit = limit
self.period = period
self._events = deque()
self._lock = asyncio.Lock()
async def acquire(self):
while True:
async with self._lock:
now = time.monotonic()
boundary = now - self.period
while (
self._events
and self._events[0] <= boundary
):
self._events.popleft()
if len(self._events) < self.limit:
self._events.append(now)
return
wait_time = (
self._events[0]
+ self.period
- now
)
await asyncio.sleep(wait_time)
例如:
limiter = SlidingWindowLimiter(
limit=100,
period=60,
)
每次:
await limiter.acquire()
即可把放行记录限制在滚动窗口中。
相比简单的“每到整分钟计数器归零”,Sliding Window 不容易产生边界突刺。
例如固定窗口可能出现:
12:00:59 发送 100 个
12:01:00 又发送 100 个
结果:
1 秒内打出去 200 个请求
这显然不是很多 API 服务商想看到的行为。
八、Semaphore 应该扔掉吗?
当然不是。
Semaphore 非常重要,只是不要让它做不属于它的工作。
真实系统中往往应该:
Semaphore + Rate Limiter
一起用。
例如:
import asyncio
from contextlib import asynccontextmanager
class APILimiter:
def __init__(
self,
max_concurrency: int,
qps: float,
burst: int,
):
self._semaphore = asyncio.Semaphore(
max_concurrency
)
self._request_limiter = AsyncTokenBucket(
rate=qps,
capacity=burst,
)
@asynccontextmanager
async def limit(self):
await self._semaphore.acquire()
try:
# 尽量靠近真正发送请求的位置再限速
await self._request_limiter.acquire()
yield
finally:
self._semaphore.release()
使用:
limiter = APILimiter(
max_concurrency=20,
qps=10,
burst=5,
)
async def fetch():
async with limiter.limit():
return await call_api()
此时两个约束分别发挥作用:
同时执行数量 <= 20
长期发送速率 ≈ 10 QPS
这才是我们真正想要的行为。
九、为什么 Rate Limiter 要尽量靠近真正的 HTTP 请求?
看下面这种代码:
await rate_limiter.acquire()
await wait_database()
await prepare_data()
await semaphore.acquire()
response = await request()
这里有一个隐藏问题。
你在:
await rate_limiter.acquire()
的时候已经消费了一个请求额度。
但真正发送 HTTP 请求可能是在几秒以后。
大量任务经过不同长度的排队后,还有可能在后面重新聚集。
因此更好的思想是:
拿到最终发送资格
↓
Rate Limiter
↓
马上发 HTTP 请求
例如:
async with semaphore:
await rate_limiter.acquire()
response = await client.get(url)
代价是等待 Rate Limiter 的任务也占用了 Semaphore 槽位。
这通常是可以接受的,因为当 Rate Limit 已经成为系统瓶颈时,继续扩大内部并发本身也没有多少意义。
更复杂的大规模系统,则可以进一步实现独立的 admission scheduler,也就是专门的请求调度器。
十、每秒 token 数怎么限制?
来到 AI API 最容易踩坑的部分。
假设 API 限制:
10000 tokens / second
请求 A 可能消耗:
100 tokens
请求 B:
2000 tokens
如果两者都只:
await limiter.acquire()
当然是不够的。
我们真正需要的是:
await limiter.acquire(token_cost)
幸运的是,刚才实现的 Token Bucket 已经天然支持这一点:
token_limiter = AsyncTokenBucket(
rate=10_000,
capacity=20_000,
)
请求 100 token:
await token_limiter.acquire(100)
请求 2000 token:
await token_limiter.acquire(2000)
这就是所谓的:
Weighted Rate Limiting
不同请求的成本不同。
十一、同时限制 QPS、token 和并发
现在把三个维度全部组合起来:
from contextlib import asynccontextmanager
class ProductionAPILimiter:
def __init__(
self,
max_concurrency: int,
qps: float,
request_burst: int,
tokens_per_second: float | None = None,
token_burst: int | None = None,
):
self._semaphore = asyncio.Semaphore(
max_concurrency
)
self._request_limiter = AsyncTokenBucket(
rate=qps,
capacity=request_burst,
)
if tokens_per_second is not None:
if token_burst is None:
token_burst = int(tokens_per_second)
self._token_limiter = AsyncTokenBucket(
rate=tokens_per_second,
capacity=token_burst,
)
else:
self._token_limiter = None
@asynccontextmanager
async def limit(
self,
token_cost: int = 0,
):
await self._semaphore.acquire()
try:
await self._request_limiter.acquire(1)
if (
self._token_limiter is not None
and token_cost > 0
):
await self._token_limiter.acquire(
token_cost
)
yield
finally:
self._semaphore.release()
初始化:
limiter = ProductionAPILimiter(
max_concurrency=20,
qps=10,
request_burst=5,
tokens_per_second=5000,
token_burst=10000,
)
调用:
async def ask_model(prompt):
estimated_tokens = estimate_tokens(prompt)
async with limiter.limit(
token_cost=estimated_tokens
):
return await call_llm(prompt)
现在我们终于拥有三个彼此独立的安全阀:
Concurrency <= 20
Requests ≈ 10 / second
Tokens ≈ 5000 / second
十二、LLM 的输出 token 还不知道,怎么办?
这是实际工程里很有意思的问题。
发送请求之前,你通常知道:
输入 token 数
却不知道:
模型最终会生成多少输出 token
一种比较保守的方式是提前预留:
input_tokens + max_output_tokens
例如:
reserved = (
input_tokens
+ max_output_tokens
)
await token_limiter.acquire(reserved)
请求结束后,如果最终只用了:
actual_tokens
理论上可以:
refund = reserved - actual_tokens
把未使用部分退回桶中。
另一种简单策略则是不退款,换取实现简单和更保守的流量控制。
生产系统选择哪种,需要看上游服务到底怎样计算 token 配额。
这里有个非常重要的原则:
Rate Limiter 应该模拟服务端真正执行的配额规则,而不是自己想象一套规则。
十三、真正的生产环境还必须处理 429
即使本地 Rate Limiter 写得很漂亮,你依然可能收到:
HTTP 429 Too Many Requests
原因很多:
同一个 API Key 被其他机器使用
服务端采用与你不同的窗口算法
不同接口共享同一个 quota
服务商动态收紧配额
客户端和服务端时间窗口并不完全一致
因此:
本地 Rate Limiter != 永远不会 429
收到 429 后,一个成熟客户端应该优先查看:
Retry-After
并进行退避。
下面给一个实用版本。
import asyncio
import random
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
def parse_retry_after(value):
if not value:
return None
try:
return max(0.0, float(value))
except ValueError:
pass
try:
retry_at = parsedate_to_datetime(value)
if retry_at.tzinfo is None:
retry_at = retry_at.replace(
tzinfo=timezone.utc
)
now = datetime.now(timezone.utc)
return max(
0.0,
(retry_at - now).total_seconds(),
)
except (TypeError, ValueError):
return None
调用逻辑:
async def fetch_with_retry(
client,
limiter,
url,
token_cost=0,
):
max_attempts = 5
for attempt in range(max_attempts):
async with limiter.limit(
token_cost=token_cost
):
response = await client.get(
url,
timeout=10,
)
if response.status_code != 429:
response.raise_for_status()
return response
retry_after = parse_retry_after(
response.headers.get("Retry-After")
)
if retry_after is not None:
delay = retry_after
else:
base = min(
0.5 * (2 ** attempt),
30,
)
delay = random.uniform(
base * 0.5,
base,
)
await asyncio.sleep(delay)
raise RuntimeError(
"API rate limit retries exhausted"
)
这里有两个细节非常重要。
第一,重试也应该:
重新进入 limiter
不能绕过 Rate Limiter。
否则:
越 429
→ 重试越多
→ 请求越多
→ 429 越严重
最后形成典型的重试风暴。
第二,多个任务不要采用完全相同的退避时间。
加入随机抖动:
random.uniform(...)
可以避免大量客户端同时:
睡眠
→ 醒来
→ 再次一起攻击 API
这就是常说的:
Exponential Backoff + Jitter
十四、一个常见反模式:发现 Semaphore 满了就 sleep
有些代码会这样写:
if sem.locked():
await asyncio.sleep(1)
async with sem:
await request()
看起来像在“限流”。
实际上这仍然没有建立可靠的单位时间配额模型。
例如 100 个任务可能同时发现 Semaphore 被锁:
100 个任务同时 sleep(1)
一秒后:
100 个任务又一起醒来
这反而容易制造流量尖峰。
真正的 Rate Limiter 不应该靠:
“感觉请求多了就 sleep”
而应该明确维护:
时间 + 配额 + 消耗
三个状态。
十五、单机版 Rate Limiter 还有一个巨大限制
假设你部署了:
4 个 Python Worker
每个进程都有:
AsyncTokenBucket(rate=20)
结果整个服务实际上可能达到:
4 × 20 = 80 QPS
如果部署到 Kubernetes:
10 Pods × 4 Workers
问题会更加明显。
所以进程内 Rate Limiter 只能限制:
当前进程
无法自动形成全局限流。
十六、分布式 Rate Limiter 应该怎么做?
当多个实例共享同一个 API Key 时,架构通常要变成:
Worker A ─┐
Worker B ├──> Redis / Central Limiter ──> API
Worker C ┤
Worker D ─┘
共享状态可以维护:
当前 token 数
上次 refill 时间
窗口请求次数
API Key 配额
如果使用 Redis,关键更新通常应该:
原子执行
常见做法包括:
Redis Lua Script
Redis Sorted Set + Sliding Window
Redis Counter + TTL
中央调度服务
否则两个实例可能同时看到:
还有 1 个额度
然后各自都认为自己可以发送一个请求。
结果额度直接超限。
所以:
单进程并发安全,不等于分布式并发安全。
这是从“能工作的脚本”进入“生产系统”必须跨过去的一道坎。
十七、到底应该选哪一种算法?
可以按目标选择。
只想限制连接/API 并发
直接使用:
asyncio.Semaphore(20)
例如保护:
HTTP Connection Pool
数据库连接
GPU 推理槽位
浏览器实例
文件句柄
想控制平均 QPS
使用:
Token Bucket
它允许合理的 burst,吞吐率也很好。
希望请求非常平滑
考虑:
Leaky Bucket
或者:
Token Bucket + 很小的 capacity
API 明确规定滚动时间窗口
例如:
100 requests / 60 seconds
优先考虑:
Sliding Window
请求成本不同
例如:
LLM token
图片大小
数据库扫描行数
计算资源消耗
使用:
Weighted Token Bucket
多进程、多机器共享 quota
使用:
Redis / Central Rate Limiter
十八、真正实用的 API 客户端架构
一个成熟的异步调用链,我更推荐设计成:
┌───────────────┐
Producer ─────────>│ Request Queue │
└───────┬───────┘
↓
┌───────────────┐
│ Concurrency │
│ Semaphore │
└───────┬───────┘
↓
┌───────────────┐
│ Request Rate │
│ QPS / RPM │
└───────┬───────┘
↓
┌───────────────┐
│ Weighted Rate │
│ TPS / TPM │
└───────┬───────┘
↓
HTTP Client
↓
┌───────────────┐
│ 429 / 5xx │
│ Retry+Jitter │
└───────────────┘
↓
Metrics
并监控至少这些指标:
requests_total
requests_in_flight
rate_limit_wait_seconds
429_total
retry_total
request_latency
token_consumed
queue_depth
因为一个真正可靠的 Rate Limiter,不只是“别把 API 打挂”。
它还应该让你知道:
系统究竟为什么慢?
到底是:
Semaphore 满了?
QPS 用完了?
token quota 用完了?
上游 API 太慢?
重试太多?
任务队列积压?
这些问题如果没有指标,很容易被错误地归咎于 asyncio 性能。
十九、最后记住这张表
| 写法 | 真正限制的东西 |
|---|---|
Semaphore(20) | 同时执行的任务数量 |
TokenBucket(rate=20) | 长期平均约 20 次/秒 |
SlidingWindow(100, 60) | 任意滚动 60 秒内最多 100 次 |
bucket.acquire(2000) | 消耗 2000 个配额单位 |
| Redis Rate Limiter | 多实例共享全局配额 |
Retry-After + Backoff | 服务端拒绝后的恢复行为 |
所以,回到最初的问题:
sem = asyncio.Semaphore(20)
它限制的是:
并发数。
不是:
20 QPS。
更不是:
20 tokens / second。
如果你的 API 同时存在:
并发限制
+
请求频率限制
+
token 配额
那么正确方案也应该由多个相互独立的控制器共同完成。
二十、结语:限流真正限制的不是“速度”,而是失控
刚开始写异步程序时,我们很容易追求:
更多协程
更多并发
更高吞吐
但真正进入生产环境以后,你会慢慢发现:
优秀系统追求的并不是“能跑多快”,而是:
在明确的边界内,
尽可能稳定地跑快。
asyncio.Semaphore是一个很优秀的同步原语。
问题从来不是 Semaphore 不好,而是我们给了它一个它并不负责的任务。
控制并发,用 Semaphore。
控制 QPS,用真正的时间型 Rate Limiter。
控制 token,用加权配额。
面对 429,尊重 Retry-After并正确退避。
进入分布式环境,再把状态搬到共享存储或者中央调度器。
当这些概念真正分开以后,你会发现所谓的“限流”已经不再是代码里随手写下的:
Semaphore(20)
而变成了一套完整的流量治理模型。
这一步,也是 Python 异步程序从“跑起来”走向“长期稳定运行”的重要标志。
如果让你重新审视正在维护的项目,不妨看看:你的 Semaphore,现在究竟是在控制并发,还是正被迫假装成一个 Rate Limiter?

343

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



