cswork / 系统设计

第 22 章 / 共 57 章 · 约 7 分钟

并发题 2:限速执行器

本页目录
  1. 题目
  2. 先澄清
  3. 限速算法对比
  4. 整体结构
  5. 令牌桶:不需要后台线程
  6. 执行器主体
  7. 值得主动讨论的点
  8. 常见失分点
  9. 章节测验

题目

实现一个执行器:调用方 submit(task) 提交任务,执行器保证每秒最多启动 R 个任务,同时最多 K 个任务并发运行。典型场景是调用有配额的第三方 API(比如每秒 10 次、最多 5 个并发连接),以及批量抓取、批量发送通知。

这题把阻塞队列、信号量和一个新组件——令牌桶——组合在一起,很考设计取舍。

先澄清

问题不同答案带来的设计差异
限的是启动速率、并发数,还是都限?速率用令牌桶,并发用信号量
允许突发吗?令牌桶的容量 B
超速时阻塞提交者、排队,还是拒绝?submit 是否阻塞,队列是否有界
需要拿到结果吗?submit 返回 Future
单进程还是多机?多机需要集中计数,本题先做单进程

下面按“都限、允许小突发、队列有界、满了阻塞提交者、返回 Future”来设计。

限速算法对比

算法思路优点缺点
固定窗口每秒一个计数器最简单窗口边界附近可能瞬间通过约 2R 个
滑动窗口日志记录每个请求的时间戳精确内存 O(R),随限额线性增长
漏桶以恒定速率流出输出平滑不允许突发
令牌桶以速率 R 生成令牌,桶上限 B允许有限突发,实现简单需要选择 B

执行器场景选令牌桶:既限制长期平均速率,又允许短时间内用掉攒下的令牌。

整体结构

限速执行器的结构
滚动到此处后加载图表…
图表可横向滚动,展开后可缩放查看。
查看图表源码
flowchart LR
  accTitle: 限速执行器的结构
  submit["submit()"] --> q["有界任务队列"] --> disp["调度线程"]
  disp -->|"1"| permit["拿并发许可(Semaphore K)"]
  permit -->|"2"| token["拿速率令牌(TokenBucket R, B)"]
  token -->|"3"| worker["交给工作线程立即执行"]
  worker -->|"任务结束"| release["归还并发许可"]

为什么先拿并发许可,再拿令牌? 这是这道题最容易被忽略的细节。如果先拿令牌、再等许可,令牌会在手里“放旧”:几个任务先后拿到令牌,然后都卡在等许可;某一刻许可同时空出来,它们就在同一瞬间启动,瞬时速率突破 R。反过来,先拿许可再拿令牌,拿到令牌立刻启动,启动时间严格受令牌桶约束。

这个顺序也有代价:拿到许可之后、等令牌的这段时间里,这个并发名额是空着的,没有任务在跑。速率是瓶颈时(每秒 R 个远小于 K 个并发能消化的量),空着的名额本来也用不上,这个代价可以接受;如果并发才是瓶颈,就要重新权衡。面试中说明这一点即可。

为什么还要单独的信号量,不能只靠线程池大小 K? 标准线程池(Java 的 newFixedThreadPool、Python 的 ThreadPoolExecutor)内部有一个无界队列。调度线程拿到令牌后把任务丢进去,如果 K 个线程都在忙,任务会在池子里排队,之后才真正启动——令牌在任务实际开始前就被消耗了,速率控制和实际启动时间脱节。信号量保证“交给线程池时一定有空闲线程”。

令牌桶:不需要后台线程

常见误区是起一个后台线程,每 1/R 秒往桶里加一个令牌。更好的做法是惰性补充:每次取令牌时,按距离上次的时间差一次性补上。

import threading, time

class TokenBucket:
    def __init__(self, rate, burst, clock=time.monotonic):
        self.rate, self.burst, self.clock = rate, burst, clock
        self.tokens, self.last = float(burst), clock()
        self.lock = threading.Lock()

    def acquire(self):
        with self.lock:
            now = self.clock()
            self.tokens = min(self.burst, self.tokens + (now - self.last) * self.rate)
            self.last = now
            self.tokens -= 1                  # 可以变负:预约未来的令牌
            wait = -self.tokens / self.rate if self.tokens < 0 else 0
        if wait > 0:
            time.sleep(wait)                  # 在锁外睡

两个关键点:

  • 允许令牌为负,表示预约了未来的令牌,并据此算出要等多久。多个调用方会自动排好队:后来的人看到的欠额更大,等得更久。代价是:调用方预约之后如果被取消(线程被中断、执行器关闭),已经扣掉的额度不会自动还回来,这段配额就浪费了。在意的话,可以在取消时把令牌加回去,只是已经在睡的调用方不会因此提前醒来。
  • 在锁外 sleep。如果在锁里睡,其他调用方全被堵在锁上,也没法及时响应关闭。

执行器主体

import queue
from concurrent.futures import Future, ThreadPoolExecutor

class RateLimitedExecutor:
    def __init__(self, rate, burst, max_concurrency, queue_size):
        self.bucket = TokenBucket(rate, burst)
        self.slots = threading.Semaphore(max_concurrency)
        self.pool = ThreadPoolExecutor(max_concurrency)
        self.tasks = queue.Queue(queue_size)      # 有界:满了 put 会阻塞,形成背压
        threading.Thread(target=self._dispatch, daemon=True).start()

    def submit(self, fn):
        fut = Future()                            # 示意;Java 中对应 CompletableFuture
        self.tasks.put((fn, fut))
        return fut

    def _dispatch(self):
        while True:
            item = self.tasks.get()
            if item is None:                      # 关闭哨兵
                break
            fn, fut = item
            self.slots.acquire()                  # 1. 并发许可
            self.bucket.acquire()                 # 2. 速率令牌
            self.pool.submit(self._run, fn, fut)  # 3. 立即启动

    def _run(self, fn, fut):
        try:
            fut.set_result(fn())
        except Exception as e:
            fut.set_exception(e)                  # 任务异常不能拖垮执行器
        finally:
            self.slots.release()

值得主动讨论的点

背压:任务队列有界,满了让 submit 阻塞,上游自然慢下来。也可以改成满了立即拒绝,取决于调用方能否承受阻塞。

关闭语义:

  • shutdown():不再接收新任务,往队列放一个哨兵;调度线程处理完前面的任务后退出,再关闭线程池。
  • shutdown_now():清空队列,把还没启动的任务的 Future 标记为取消。

异常隔离:任务抛出的异常必须被捕获并放进 Future,finally 里归还许可。否则一个出错的任务会永久占掉一个并发名额,K 次之后执行器彻底停摆。

时钟:令牌桶用单调时钟 time.monotonic(),不要用会被 NTP 调整的 time.time();时钟可注入,测试时用假时钟验证“突发 B 个之后,第 B+1 个需要等 1/R 秒”。

单个调度线程会成为瓶颈吗? 它只做“拿许可、拿令牌、投递”,非常轻;而且速率本来就被限制在 R,通常不是瓶颈。

多租户:每个租户一个令牌桶,调度线程轮询各租户的队列,防止一个大租户饿死其他人。

分布式:多台机器共享配额时,令牌桶的状态要放到集中存储(比如 Redis),用原子脚本完成“补充 + 扣减”;或者把总配额按机器数静态切分,简单但不够灵活。

常见失分点

  • 用后台线程定时补令牌,还在锁里 sleep。
  • 只靠线程池大小控制并发,忽略了池内部的无界队列。
  • 先拿令牌再等并发许可,导致瞬时速率超限。
  • 任务异常时没有归还许可。
  • 完全没有关闭逻辑,测试进程退不出去。

章节测验

6 道选择题
  1. 为什么要先拿并发许可,再拿速率令牌?
  2. 固定窗口限流的主要缺陷是?
  3. 令牌桶里为什么要在锁外 sleep?
  4. 为什么不能只靠线程池大小 K 来控制并发?
  5. 如果 _run 没在 finally 里归还许可,任务抛异常会导致什么?
  6. 允许令牌数变为负数,起到什么作用?

章节讨论

正在加载讨论…