Semaphore 真能限流吗?从 asyncio 并发控制到生产级 API Rate Limiter

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 RPM20 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单位时间允许消费多少 tokenWeighted 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?

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

铭渊老黄

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

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

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

打赏作者

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

抵扣说明:

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

余额充值