一、手写 MPSC(多写一读)阻塞队列
这是异步日志库的核心结构——多个生产者(业务线程)写日志,一个消费者(刷盘线程)读日志。
难点:多写需要保护,单读不需要。
整体操作流程:
-
push(多生产者):
- 获取互斥锁,保护队列的并发访问。
- 如果队列已停止,丢弃数据。
- 将数据推入队列。
- 释放锁后调用
notify_one唤醒消费者(解锁后再通知,减少线程唤醒时的锁争用)。
-
pop(单消费者):
- 获取互斥锁。
- 等待条件:队列不为空或队列已停止(
cv.wait自动释放/重获锁)。 - 如果已停止且队列为空,返回默认值(或抛异常)。
- 取出队首元素并返回。
-
stop:
- 获取锁,设置停止标志。
notify_all唤醒消费者线程,使其从wait中退出。
template <typename T>
class MPSCBlockingQueue {
std::queue<T> queue_;
std::mutex mtx_;
std::condition_variable cv_;
bool stopped_ = false;
public:
void push(T item) {
{
std::lock_guard<std::mutex> lock(mtx_);// ① 获取锁(多生产者互斥)
if (stopped_) return; // ② 已停止则丢弃
queue_.push(std::move(item)); // ③ 入队
} // ④ 解锁
cv_.notify_one(); // ⑤ 唤醒消费者(解锁后 notify 减少竞争)
}
T pop() {
std::unique_lock<std::mutex> lock(mtx_); // ① 获取锁
cv_.wait(lock, [this] { // ② 等待条件:有数据或已停止
return stopped_ || !queue_.empty();
});
if (stopped_ && queue_.empty()) {
return T{}; // ③ 已停止且无数据 → 返回空值
}
T item = std::move(queue_.front()); // ④ 取出队首元素
queue_.pop();
return item; // ⑤ 返回数据
}
void stop() {
{
std::lock_guard<std::mutex> lock(mtx_);// ① 获取锁
stopped_ = true; // ② 设置停止标志
}
cv_.notify_all(); // ③ 唤醒所有等待的线程
}
};
为什么叫 MPSC?
- M(Multi):push 可以被多个线程同时调用(通过 mutex 保护)。
- S(Single):pop 只能被一个线程调用(不需要保护,因为就一个消费者)。
- C(Consumer):消费者线程消费数据。
性能特点:
- push 在竞争激烈时互斥锁是瓶颈(多线程抢同一把锁)。
- 改进方向:用 无锁 SPSC 队列(你的 SimpleAsyncLogger 用的就是这思路)。
- 带宽大于无锁方案(因为单消费者不需要 CAS),但延迟抖动更严重(锁争用)。
二、手写线程安全的 LRU(Least Recently Used) 缓存(LeetCode 146)
核心思想:缓存空间有限,放不下所有数据。当满了要淘汰时,淘汰最久没被用过的那一个——因为最近用过的很可能马上还要用。
功能:
get(key):存在返回 value,不存在返回 -1。put(key, value):不存在则插入;如果容量满,淘汰最近最少使用的。
数据结构选择:
- 用
unordered_map实现 $O(1)$ 查找。 - 用 双向链表 维护使用顺序(最近使用的在头部,最久未使用的在尾部)。
- map 的 value 存
list的迭代器,实现 $O(1)$ 移动到头部。
整体操作流程:
-
get(key):
- 在
unordered_map中查找 key。 - 不存在 → 返回 -1。
- 存在 → 将该节点移到链表头部(表示最近使用过),返回对应的 value。
- 在
-
put(key, value):
- 在
unordered_map中查找 key。 - 已存在:更新 value,将节点移到链表头部。
- 不存在:
- 如果缓存已满(
size >= capacity),淘汰链表尾部节点(最久未使用),从 map 中删除对应 key。 - 在链表头部插入新节点
(key, value),在 map 中记录 key → 迭代器的映射。
- 如果缓存已满(
- 在
class LRUCache {
int capacity_;
list<pair<int, int>> items_; // 双向链表:头=最近使用,尾=最久未使用
unordered_map<int, list<pair<int, int>>::iterator> cache_;// key → 链表节点的迭代器
public:
LRUCache(int capacity) : capacity_(capacity) {}
int get(int key) {
auto it = cache_.find(key); // ① 在 map 中查找
if (it == cache_.end()) return -1; // ② 不存在 → 返回 -1
items_.splice(items_.begin(), items_, it->second);// ③ 移到链表头部(最近使用)
return it->second->second; // ④ 返回 value
}
void put(int key, int value) {
auto it = cache_.find(key);
if (it != cache_.end()) { // ① key 已存在
it->second->second = value; // ② 更新 value
items_.splice(items_.begin(), items_, it->second);// ③ 移到链表头部
return;
}
if (cache_.size() >= capacity_) { // ④ 缓存已满
auto& last = items_.back(); // ⑤ 取链表尾部(最久未使用)
cache_.erase(last.first); // ⑥ 从 map 中删除
items_.pop_back(); // ⑦ 从链表中删除
}
items_.emplace_front(key, value); // ⑧ 在链表头部插入
cache_[key] = items_.begin(); // ⑨ 在 map 中记录位置
}
};
核心操作解析:
splice 是 std::list 的"搬家"操作——把节点从一个位置移到另一个位置,不分配内存、不拷贝数据、迭代器不失效:
// 把 it->second(一个 list 节点)从当前位置移到 items_.begin() 之前
items_.splice(items_.begin(), items_, it->second);
// 相当于 O(1) 时间把"刚访问的节点"移到链表头部
时间复杂度:get 和 put 都是 $O(1)$。 空间复杂度:$O(capacity)$。
多线程安全版本(在 get 和 put 外套 mutex):
class ThreadSafeLRUCache {
LRUCache cache_;
std::mutex mtx_;
public:
int get(int key) {
std::lock_guard<std::mutex> lock(mtx_);
return cache_.get(key);
}
void put(int key, int value) {
std::lock_guard<std::mutex> lock(mtx_);
cache_.put(key, value);
}
};
三、面试追问 Q&A
Q1:为什么不用 shared_mutex 保护 LRU?
A:get 也会修改链表(把访问的节点移到头部),不是纯读操作。用 shared_mutex 的读锁不行,因为读操作也有写(修改链表顺序)。如果有"读但不修改顺序"的版本,可以用 shared_mutex 优化。
Q2:splice 为什么是 $O(1)$?
A:list 的 splice 只是调整 3 对 prev/next 指针的指向。不分配内存,不调用拷贝构造。对比 vector 在中间插入需要搬移 $O(N)$ 个元素。
Q3:为什么 LRU 不用单链表? A:因为"移到头部"需要先找到前驱节点修改指针,单链表查找前驱需要 $O(N)$。