多线程编程复盘|线程安全令牌桶系统

11 阅读16分钟

多线程编程复盘|实现一个高并发线程安全的令牌桶系统

今天我做的一道题是一个很典型的多线程编程题。
我这次没有把重点放在补 main 场景上,而是先把题目里最核心的三个类拆开实现:

  • TokenManager
  • TokenProducer
  • TokenConsumer

我感觉这道题表面上是“令牌桶”,但真正考察的是:

  • 共享状态怎么保护
  • 阻塞等待怎么写
  • 线程怎么退出
  • 多个生产者 / 多个消费者怎么围绕同一个共享资源协作

这篇文章我就按这个顺序来复盘:
先列完整题目,再做分析,然后结合我自己的代码讲我是怎么实现的。


一、题目完整要求

题目是:

阿里 C++ 一面:请设计并实现一个高并发线程安全的令牌桶系统

题目描述

请设计并实现一个高并发线程安全的令牌桶系统,包含以下组件:


1. 令牌管理器 TokenManager

职责

负责维护令牌桶中可用令牌的数量,最大令牌数量为 maxTokens

要提供的接口

addToken()

向令牌桶添加一个令牌。
当桶中令牌数量已经达到上限(>= maxTokens)时,丢弃新令牌,不溢出。

consumeTokens(int n)

消费令牌。
当桶中可用令牌数量 >= n 时,允许消费并返回;
否则可以有两种设计:

  • 直接返回失败
  • 或者阻塞等待,直到有足够令牌可用

要求

所有操作必须线程安全。
可选支持:当令牌不足时,消费者阻塞等待。


2. 令牌生产者 TokenProducer

职责

每隔 500ms 产生一个令牌,并尝试提交给令牌管理器。

要求

支持在不同线程运行多个生产者,并发地向令牌管理器添加令牌。


3. 令牌消费者 TokenConsumer

职责

持续从令牌管理器中消费令牌。

具体要求

  • 每次消费 3 个令牌
  • 当桶中可用令牌不足 3 个时,阻塞等待,直到令牌足够
  • 支持同时启动 N 个消费者(例如 5 个)
  • 每个消费者都运行在独立线程

二、题目分析

这道题如果只看表面,会觉得它是在让人实现一个“限流模型”。
但我实际写下来以后,感觉它更像一道多线程同步设计题

因为真正难的地方不在“桶”这个抽象,而在下面这些问题:


1. 这是一个典型的共享资源竞争模型

整道题里最核心的共享资源其实只有一个:
令牌桶里的当前令牌数。

但围绕它会有多类线程同时操作:

  • 生产者线程:不断往里加 token
  • 消费者线程:不断从里拿 token
  • 多个消费者之间还会互相竞争同一批 token

所以这道题本质上就是:

多个线程同时访问并修改同一个共享状态,如何保证逻辑仍然正确。

这就决定了:
TokenManager 一定会成为整个系统最核心的同步层。


2. 题目真正考的是“线程安全 + 条件同步”

如果只是单线程,问题很简单:

  • 当前令牌数够,就减掉
  • 不够,就返回失败

但一旦进入多线程场景,逻辑就变复杂了:

生产者这边

多个生产者可能同时调用 addToken()

  • 令牌不能加乱
  • 不能超过最大容量
  • 桶满时还要正确丢弃

消费者这边

多个消费者可能同时调用 consumeTokens(3)

  • 必须保证“检查令牌够不够”和“实际扣减令牌”是一个原子过程
  • 不能出现两个消费者都以为自己能消费成功
  • 不能把令牌数扣成负数

令牌不足时

如果消费者选择阻塞等待,那就不是简单加个锁就能解决了,而是必须引入:

  • mutex
  • condition_variable

也就是说,这题不仅考互斥访问,还考条件同步


3. “支持 N 个消费者”不是多建几个对象这么简单

题目里有一句很关键:

支持同时启动 N(如 5)个消费者,每个运行在独立线程。

这句话的重点不是“实例化 5 个对象”,而是:

要让多个独立线程并发地访问同一个 TokenManager

也就是说,多个消费者不是各自消费各自的桶,而是:

  • 同时盯着同一个令牌桶
  • 同时竞争同一批令牌
  • 谁先抢到锁、谁先看到条件满足、谁就先消费

所以这里真正验证的是:

你的 TokenManager 在多消费者竞争下,是否仍然正确。


4. 这题还隐含着“线程退出语义”问题

题目本身重点写的是生产和消费,但我真正动手时很快就发现:

如果只关注“怎么生产”和“怎么消费”,而不考虑“怎么停”,代码很容易写成半成品。

比如:

  • 消费者可能一直阻塞在等待条件上
  • 生产者线程可能一直循环不退出
  • 对象析构时线程可能还在跑

所以这题虽然题干没大篇幅写“关闭流程”,但实际上实现时一定绕不开:

  • 停止标志怎么设计
  • 阻塞线程如何被唤醒退出
  • 线程和对象生命周期如何对齐

也就是说,这题不仅考“跑起来”,还考“怎么收尾”。


三、我的实现思路:先拆成三个类

这次我没有先去写一个大而全的 main,而是先把题目要求抽成三个类:

  • TokenManager
  • TokenProducer
  • TokenConsumer

原因很简单:
我觉得这道题真正容易出问题的地方都在类内部逻辑里,尤其是同步细节,而不是 main 里起几个线程。


四、TokenManager:整个系统最核心的一层

我最后的 TokenManager 是这版:

#include <mutex>
#include <condition_variable>

class TokenManager {
 private:
  const int max_tokens_;
  std::mutex mtx_;
  int current_tokens_;
  std::condition_variable cv_;
  bool stopped_;

 public:
  TokenManager(int max_tokens)
      : max_tokens_(max_tokens), current_tokens_(0), stopped_(false) {}

  void addToken();
  bool consumeTokens(int n);
  ~TokenManager() = default;
  void Stop();
};

void TokenManager::addToken() {
  std::unique_lock<std::mutex> lock(mtx_);
  bool added = false;
  if (current_tokens_ < max_tokens_ && !stopped_) {
    current_tokens_++;
    added = true;
  }
  lock.unlock();
  if (added) cv_.notify_one();
}

bool TokenManager::consumeTokens(int n) {
  if (n > max_tokens_ || n <= 0) return false;
  std::unique_lock<std::mutex> lock(mtx_);
  cv_.wait(lock, [this, n]() {
    return current_tokens_ >= n || stopped_;
  });
  if (stopped_) return false;
  current_tokens_ -= n;
  return true;
}

void TokenManager::Stop() {
  std::unique_lock<std::mutex> lock(mtx_);
  stopped_ = true;
  lock.unlock();
  cv_.notify_all();
}

1. 共享状态为什么必须集中在 TokenManager

这里最核心的共享状态有两个:

int current_tokens_;
bool stopped_;

其中:

  • current_tokens_:当前桶里有多少 token
  • stopped_:系统是否停止

这两个变量都可能被多个线程同时读写,所以它们必须和同步原语绑在一起管理:

std::mutex mtx_;
std::condition_variable cv_;

也就是说,我这里的设计思路是:

把“共享状态”和“保护它的同步工具”放在同一个类里统一管理。

这样 Producer 和 Consumer 都不直接碰这些底层状态,而是只通过接口:

  • addToken()
  • consumeTokens()
  • Stop()

来访问它。


2. addToken() 的核心逻辑

void TokenManager::addToken() {
  std::unique_lock<std::mutex> lock(mtx_);
  bool added = false;
  if (current_tokens_ < max_tokens_ && !stopped_) {
    current_tokens_++;
    added = true;
  }
  lock.unlock();
  if (added) cv_.notify_one();
}

我这里想解决两件事:

第一,不能超过桶容量

这就是:

if (current_tokens_ < max_tokens_ && !stopped_)

只有:

  • 桶还没满
  • 系统还没停止

才允许往里加 token。

第二,只有真正加成功了才通知

我没有直接每次都 notify_one(),而是加了:

bool added = false;

只有这次 token 真的被放进桶里,才去唤醒一个等待中的消费者。

我这次很明确地记住了一个点:

条件变量的通知应该和“状态有效变化”绑定。
桶满时 token 被丢弃,这种情况下其实没必要叫醒消费者。


3. consumeTokens() 的核心逻辑

bool TokenManager::consumeTokens(int n) {
  if (n > max_tokens_ || n <= 0) return false;
  std::unique_lock<std::mutex> lock(mtx_);
  cv_.wait(lock, [this, n]() {
    return current_tokens_ >= n || stopped_;
  });
  if (stopped_) return false;
  current_tokens_ -= n;
  return true;
}

这里我做了三层处理。

第一层:参数合法性检查

if (n > max_tokens_ || n <= 0) return false;

这一步是为了防止:

  • n <= 0 这种本来就不合理的消费请求
  • n > max_tokens_ 这种永远不可能成功的等待

否则消费者可能一直阻塞在一个根本无法满足的条件上。


第二层:令牌不足时阻塞等待

cv_.wait(lock, [this, n]() {
  return current_tokens_ >= n || stopped_;
});

这句是整个题的核心。

它表示:

  • 如果令牌不够,就睡眠等待
  • 被唤醒后重新检查条件
  • 只有“令牌够了”或者“系统停了”才继续

这里我这次是彻底想明白了:

condition_variable 不是条件本身。
真正的条件是 current_tokens_ >= n || stopped_
cv_ 只是让线程在条件不满足时挂起、在状态变化后再被唤醒。


第三层:检查和扣减必须在同一临界区内完成

if (stopped_) return false;
current_tokens_ -= n;

这一点很关键。

多个消费者并发时,不能出现这种情况:

  • 线程 A 看见“令牌够了”
  • 线程 B 也看见“令牌够了”
  • 两个线程都去扣减
  • 结果把令牌扣穿

所以“检查条件满足”和“真正扣减令牌”必须放在同一把锁保护下完成。


4. 为什么我加了 Stop()

void TokenManager::Stop() {
  std::unique_lock<std::mutex> lock(mtx_);
  stopped_ = true;
  lock.unlock();
  cv_.notify_all();
}

这个函数的意义很明确:

  • 把系统状态改成停止
  • 然后唤醒所有阻塞中的消费者

这里必须用:

cv_.notify_all();

因为停机不是普通生产事件,不是只唤醒一个线程就够了。
停机时我要的是:

所有正在等待的消费者都醒来,检查到 stopped_ == true 后退出。


五、TokenProducer:生产线程的循环和退出

我最后的 Producer 是这版:

#include <thread>
#include <chrono>
#include <atomic>
#include <memory>

#include "TokenManager.h"

class TokenProducer {
 private:
  std::shared_ptr<TokenManager> token_manager_;
  std::thread prod_thread_;
  std::atomic<bool> running_ = false;

 public:
  TokenProducer(std::shared_ptr<TokenManager> token_manager)
      : token_manager_(token_manager) {}

  void start();
  void stop();
  ~TokenProducer() { stop(); };
};

void TokenProducer::start() {
  if (prod_thread_.joinable()) return;
  running_ = true;
  prod_thread_ = std::thread([this]() {
    while (running_.load()) {
      token_manager_->addToken();
      std::this_thread::sleep_for(std::chrono::milliseconds(500));
    }
  });
}

void TokenProducer::stop() {
  running_ = false;
  if (prod_thread_.joinable()) {
    prod_thread_.join();
  }
}

1. 为什么这里我用 shared_ptr<TokenManager>

我这里没有再用引用,而是:

std::shared_ptr<TokenManager> token_manager_;

原因不是因为它会让 TokenManager 内部自动线程安全,而是因为:

Producer 线程一旦启动,就需要保证 TokenManager 在线程结束前还活着。

如果只用引用,那我必须非常严格地人工保证生命周期顺序。
shared_ptr 在这里提供的是:

共享对象的生命周期安全。

这个点我这次也算彻底分清了:

  • shared_ptr:解决“对象活多久”
  • mutex / condition_variable:解决“对象内部怎么并发访问”

这两者不是一回事。


2. 为什么 running_ 必须是原子变量

这里我写的是:

std::atomic<bool> running_ = false;

因为:

  • 生产线程里在不断读 running_
  • 主线程 stop() 里会写 running_ = false

这已经是典型的跨线程共享标志位了。
如果这里用普通 bool,那就是 data race。

所以这次我也顺手把一个常见点记牢了:

停止标志这种跨线程共享变量,不能直接裸用普通 bool
要么用 atomic,要么用锁保护。


3. start() 为什么先判断 joinable()

if (prod_thread_.joinable()) return;

这一步是为了防止重复启动。

因为一个 std::thread 对象如果已经关联了线程,再直接覆盖它会出问题。
所以这里先拦掉“已经启动过但还没回收”的情况,是最稳妥的做法。


4. 我这版 Producer 的周期语义

我现在的生产循环是:

while (running_.load()) {
  token_manager_->addToken();
  std::this_thread::sleep_for(std::chrono::milliseconds(500));
}

这意味着:

  • 线程启动后会立刻先生产一个 token
  • 然后再每隔 500ms 继续生产

严格来说,这和“先等 500ms,再产第一个 token”不是完全一样的语义。
但我这次先接受这一版,因为当前重点是把并发结构搭起来,而不是抠定时精度。


5. stop() 里为什么既要改标志,又要 join()

void TokenProducer::stop() {
  running_ = false;
  if (prod_thread_.joinable()) {
    prod_thread_.join();
  }
}

这里我这次也彻底想清楚了:

  • running_ = false:通知线程该停了
  • join():等待线程真正结束

这两个动作不是一回事。

以前很容易误以为:

join() 就是在“停止线程”

其实不是。
join() 只是等待线程退出,真正让线程退出的是它自己的循环条件。


六、TokenConsumer:阻塞消费、回调、计数上限和时间统计

我最后的 Consumer 是这版:

#include <memory>
#include <chrono>
#include <thread>
#include <atomic>
#include <functional>

#include "TokenManager.h"

class TokenConsumer {
 private:
  std::shared_ptr<TokenManager> token_manager_;
  std::thread cons_thread;
  std::atomic<bool> running_ = false;
  std::function<void(bool)> callback_; 
  const int token_per_consume_;
  const int max_consume_count_;
  int consume_count_ = 0;

  std::chrono::steady_clock::time_point start_time_;
  std::chrono::steady_clock::time_point end_time_;

 public:
  TokenConsumer(std::shared_ptr<TokenManager> token_manager, 
                int token_per_consume, 
                int max_consume_time, 
                std::function<void(bool)> callback)
    : token_manager_(token_manager),
      token_per_consume_(token_per_consume), 
      max_consume_count_(max_consume_time),
      callback_(callback) {}

  void start();
  void stop();
  long long duration_time();
  ~TokenConsumer();
};

void TokenConsumer::start() {
  if (cons_thread.joinable()) return;
  running_ = true;  
  cons_thread = std::thread([this]() {
    start_time_ = std::chrono::steady_clock::now();
    while (running_.load()) {
      if (consume_count_ >= max_consume_count_) {
        break;
      }
      bool success = token_manager_->consumeTokens(token_per_consume_);
      if (!success) break;
      consume_count_++;
      if (callback_)
        callback_(success);
    }
    end_time_ = std::chrono::steady_clock::now();
    running_ = false;
  });
}

void TokenConsumer::stop() {
  running_ = false;
  if (cons_thread.joinable()) {
    cons_thread.join();
  }
}

long long TokenConsumer::duration_time() {
  if (running_.load()) return -1;
  return std::chrono::duration_cast<std::chrono::milliseconds>(
      end_time_ - start_time_).count();
}

TokenConsumer::~TokenConsumer() {
  stop();
}

1. Consumer 为什么也持有 shared_ptr<TokenManager>

原因和 Producer 一样:

  • Consumer 自己跑在线程里
  • 线程里要不断访问 TokenManager
  • 所以这个共享对象必须保证在线程结束前一直有效

所以这里我也用了:

std::shared_ptr<TokenManager> token_manager_;

2. 我加了一个回调 callback_

std::function<void(bool)> callback_;

然后在消费成功后调用:

if (callback_)
  callback_(success);

我这么设计的目的是给 Consumer 加一个扩展点:

  • 后面如果想打印日志
  • 统计成功次数
  • 做外部处理

都可以通过回调做,而不用把这些逻辑全写死在消费者线程里。


3. 为什么我用 max_consume_count_ 控制退出

const int max_consume_count_;
int consume_count_ = 0;

然后在线程里:

if (consume_count_ >= max_consume_count_) {
  break;
}

也就是说,我没有让消费者无限循环,而是给了它一个“最多消费多少次”的边界。

我这次也顺手把变量名理顺了:

  • 这里统计的是“次数”,不是“时间”
  • 所以用 count 比用 time 更准确

这个命名问题看似小,但我这次确实感受到:
多线程代码本来就复杂,命名再模糊,自己后面复盘都会乱。


4. 消费失败为什么直接退出

bool success = token_manager_->consumeTokens(token_per_consume_);
if (!success) break;

这里我的想法很明确:

一旦 consumeTokens() 返回 false,说明当前消费者任务已经没有继续的意义了。
典型情况就是:

  • TokenManager 已经 Stop()
  • 或者参数本身非法

这时候不如直接退出线程,而不是继续空转。


5. 时间统计为什么放在线程函数内部

我这里用了:

start_time_ = std::chrono::steady_clock::now();
...
end_time_ = std::chrono::steady_clock::now();

并且都放在线程函数内部。

我的目的是:

  • start_time_:表示消费者线程真正开始执行的时间
  • end_time_:表示消费者线程真正退出的时间

这样:

long long TokenConsumer::duration_time() {
  if (running_.load()) return -1;
  return std::chrono::duration_cast<std::chrono::milliseconds>(
      end_time_ - start_time_).count();
}

返回的才是这个消费者线程从开始到结束的总运行时长。


6. 这版 Consumer::stop() 的边界

我这里的 stop() 是:

void TokenConsumer::stop() {
  running_ = false;
  if (cons_thread.joinable()) {
    cons_thread.join();
  }
}

这个写法能做的事情是:

  • 通知线程别再继续循环
  • 然后等待线程退出

但它有一个我已经意识到、这次先接受的边界:

如果消费者线程当前正阻塞在 consumeTokens() 里面,仅仅改 running_ = false 并不能立刻把它唤醒。

所以这版 Consumer::stop() 并不是一个完全独立的停止机制。
我这次的取舍是:

  • 先接受这个边界
  • 把真正的阻塞唤醒交给 TokenManager::Stop()

也就是说,系统关闭时更合理的顺序应该是:

  1. manager->Stop()
  2. consumer.stop()

七、这次我真正踩过并记住的细节

这道题最有价值的,不是“我写了三个类”,而是我把下面这些细节真正踩过、改过、想清楚了。


1. 共享状态一定要初始化

比如:

  • current_tokens_(0)
  • stopped_(false)

这种初值不能模糊,否则整个同步逻辑都建立在未定义状态上。


2. 通知要和有效状态变化绑定

不是每次操作都 notify,而是:

  • 真加进 token 了,才通知
  • 没加进去,就不要制造无效唤醒

3. wait 的本质是“醒来再检查”

消费者不是“醒了就扣”,而是“醒了以后重新检查条件”。


4. shared_ptr 不是内部线程安全的来源

它解决的是:

  • 生命周期
  • 所有权

不是:

  • 共享状态并发访问

后者仍然只能靠锁和条件变量。


5. 停止标志不能裸用普通 bool

running_ 这种跨线程共享的标志位,要么 atomic,要么加锁,不能直接裸用普通变量。


6. join() 不是停止线程

join() 只是等待线程结束。
真正让线程结束的是:

  • 停止标志
  • 阻塞唤醒机制
  • 线程自己的退出条件

7. 时间统计必须在线程函数内部打点

尤其是:

  • start_time_
  • end_time_

如果打点位置不对,算出来的“运行时间”就没有意义。


8. “支持 N 个消费者”真正考的是共享资源竞争

这句话不只是“多建几个对象”,而是:

多个独立线程同时竞争同一个 TokenManager
从而验证我的同步设计是不是在并发场景下仍然正确。


八、这次我没有继续写 main

这次我最后停在了三个核心类,没有继续去补完整的运行场景和测试代码。
原因不是我不想写,而是我觉得这轮复盘的重点已经很清楚了:

  • TokenManager 的同步逻辑捋顺了
  • Producer 的线程循环和退出捋顺了
  • Consumer 的阻塞消费、计数上限、时间统计也捋顺了

而题目里“运行要求和测试场景”那一部分,本质上还是为了验证这些核心逻辑。
既然我这次没有写 main,那我就不硬凑这一段。


九、最后总结

这道题让我最大的感受就是:

多线程代码最难的地方,往往不是“把线程开起来”,而是把每一个看起来很小的细节都放到正确的位置上。

比如这次我真正反复想清楚的,都是这种细节:

  • current_tokens_ 有没有初始化
  • notify 时机对不对
  • wait 有没有带谓词
  • running_ 是不是原子变量
  • shared_ptr 到底解决什么问题
  • join() 到底在干什么
  • 阻塞线程应该怎么退出
  • 线程运行时间应该怎么统计

这些东西单看都不大,但放到并发环境里都会被无限放大。

所以这次对我来说,最有价值的不是“我实现了一个令牌桶”,而是我把这类题里真正容易出问题的一批关键细节,靠自己把它们一遍遍改顺了。