多线程编程复盘|实现一个高并发线程安全的令牌桶系统
今天我做的一道题是一个很典型的多线程编程题。
我这次没有把重点放在补 main 场景上,而是先把题目里最核心的三个类拆开实现:
TokenManagerTokenProducerTokenConsumer
我感觉这道题表面上是“令牌桶”,但真正考察的是:
- 共享状态怎么保护
- 阻塞等待怎么写
- 线程怎么退出
- 多个生产者 / 多个消费者怎么围绕同一个共享资源协作
这篇文章我就按这个顺序来复盘:
先列完整题目,再做分析,然后结合我自己的代码讲我是怎么实现的。
一、题目完整要求
题目是:
阿里 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):
- 必须保证“检查令牌够不够”和“实际扣减令牌”是一个原子过程
- 不能出现两个消费者都以为自己能消费成功
- 不能把令牌数扣成负数
令牌不足时
如果消费者选择阻塞等待,那就不是简单加个锁就能解决了,而是必须引入:
mutexcondition_variable
也就是说,这题不仅考互斥访问,还考条件同步。
3. “支持 N 个消费者”不是多建几个对象这么简单
题目里有一句很关键:
支持同时启动 N(如 5)个消费者,每个运行在独立线程。
这句话的重点不是“实例化 5 个对象”,而是:
要让多个独立线程并发地访问同一个
TokenManager。
也就是说,多个消费者不是各自消费各自的桶,而是:
- 同时盯着同一个令牌桶
- 同时竞争同一批令牌
- 谁先抢到锁、谁先看到条件满足、谁就先消费
所以这里真正验证的是:
你的
TokenManager在多消费者竞争下,是否仍然正确。
4. 这题还隐含着“线程退出语义”问题
题目本身重点写的是生产和消费,但我真正动手时很快就发现:
如果只关注“怎么生产”和“怎么消费”,而不考虑“怎么停”,代码很容易写成半成品。
比如:
- 消费者可能一直阻塞在等待条件上
- 生产者线程可能一直循环不退出
- 对象析构时线程可能还在跑
所以这题虽然题干没大篇幅写“关闭流程”,但实际上实现时一定绕不开:
- 停止标志怎么设计
- 阻塞线程如何被唤醒退出
- 线程和对象生命周期如何对齐
也就是说,这题不仅考“跑起来”,还考“怎么收尾”。
三、我的实现思路:先拆成三个类
这次我没有先去写一个大而全的 main,而是先把题目要求抽成三个类:
TokenManagerTokenProducerTokenConsumer
原因很简单:
我觉得这道题真正容易出问题的地方都在类内部逻辑里,尤其是同步细节,而不是 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_:当前桶里有多少 tokenstopped_:系统是否停止
这两个变量都可能被多个线程同时读写,所以它们必须和同步原语绑在一起管理:
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()
也就是说,系统关闭时更合理的顺序应该是:
- 先
manager->Stop() - 再
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()到底在干什么- 阻塞线程应该怎么退出
- 线程运行时间应该怎么统计
这些东西单看都不大,但放到并发环境里都会被无限放大。
所以这次对我来说,最有价值的不是“我实现了一个令牌桶”,而是我把这类题里真正容易出问题的一批关键细节,靠自己把它们一遍遍改顺了。