7. 线程安全的数据结构:队列、栈与读写锁

说到并发编程,最基础也最绕不开的话题,就是线程安全的数据结构。你想想看,多线程环境下,大家共享同一块数据,如果没有保护机制,那简直就是一场灾难。我在早期做后台服务时,就吃过这个亏——一个全局队列被多个线程同时push和pop,结果数据错乱得让人头皮发麻。

今天我们就来聊聊三种最常用的线程安全数据结构:队列、栈,以及它们的核心保护机制——读写锁。嗯,说白了,就是给这些容器穿上“防弹衣”。

7.1 线程安全的队列

队列这东西,太常见了。生产者消费者模型里,它就是那个“中转站”。但普通队列在多线程下,push和pop同时进行,轻则数据丢失,重则程序崩溃。

我个人的习惯是,用std::queue加互斥锁封装一层。但要注意,不是简单加锁就完事了。你想想看,如果队列为空时,消费者线程还在不断pop,那怎么办?

这里有个经典做法——条件变量配合互斥锁。看代码:

#include <queue>
#include <mutex>
#include <condition_variable>

template<typename T>
class ThreadSafeQueue {
private:
    std::queue<T> queue_;
    mutable std::mutex mutex_;
    std::condition_variable cond_;

public:
    void push(T value) {
        std::lock_guard<std::mutex> lock(mutex_);
        queue_.push(std::move(value));
        cond_.notify_one();  // 通知等待的消费者
    }

    T pop() {
        std::unique_lock<std::mutex> lock(mutex_);
        cond_.wait(lock, [this] { return !queue_.empty(); });
        T value = std::move(queue_.front());
        queue_.pop();
        return value;
    }

    bool try_pop(T& value) {
        std::lock_guard<std::mutex> lock(mutex_);
        if (queue_.empty()) return false;
        value = std::move(queue_.front());
        queue_.pop();
        return true;
    }
};

核心要点:

  • pop() 使用条件变量等待,避免忙等待浪费CPU
  • try_pop() 提供非阻塞版本,适合轮询场景
  • 互斥锁用 mutable 修饰,因为 empty() 等const方法也需要加锁

我的经验: 曾经在项目中,我直接用 std::queuefront()pop() 分开调用,中间没加锁。结果两个线程同时pop,一个线程读到了脏数据。嗯,从那以后,我所有队列操作都封装成原子操作。

7.2 线程安全的栈

栈和队列很像,但它是后进先出。多线程环境下,栈的问题和队列类似——top和pop不能分开。我曾经见过一个同事,在top之后、pop之前,另一个线程把栈清空了,结果top返回的引用直接悬空。

来看一个线程安全的栈实现:

template<typename T>
class ThreadSafeStack {
private:
    std::stack<T> stack_;
    mutable std::mutex mutex_;
    std::condition_variable cond_;

public:
    void push(T value) {
        std::lock_guard<std::mutex> lock(mutex_);
        stack_.push(std::move(value));
        cond_.notify_one();
    }

    std::shared_ptr<T> pop() {
        std::unique_lock<std::mutex> lock(mutex_);
        cond_.wait(lock, [this] { return !stack_.empty(); });
        auto sp = std::make_shared<T>(std::move(stack_.top()));
        stack_.pop();
        return sp;
    }

    bool try_pop(T& value) {
        std::lock_guard<std::mutex> lock(mutex_);
        if (stack_.empty()) return false;
        value = std::move(stack_.top());
        stack_.pop();
        return true;
    }
};

注意: 这里 pop() 返回的是 std::shared_ptr<T>,而不是直接返回 T。为什么?因为如果直接返回 T,在拷贝构造时可能抛出异常,而此时栈顶元素已经被移除了。用智能指针可以避免这个问题——先构造智能指针,再pop,异常安全。

7.3 读写锁:std::shared_mutex

好了,队列和栈都用了互斥锁。但互斥锁有个问题——它太“霸道”了。读操作之间其实不冲突,但互斥锁让所有读操作串行化了。你想想看,如果10个线程同时读,互斥锁会让它们排队,这多浪费啊。

读写锁就是来解决这个问题的。C++17引入了 std::shared_mutex,它支持两种锁模式:

  • 共享锁(shared_lock):多个线程可以同时持有,适合读操作
  • 独占锁(unique_lock):只有一个线程可以持有,适合写操作

看个例子:

#include <shared_mutex>
#include <unordered_map>

class ThreadSafeCache {
private:
    std::unordered_map<int, std::string> cache_;
    mutable std::shared_mutex rw_mutex_;

public:
    std::string read(int key) {
        std::shared_lock lock(rw_mutex_);  // 共享锁,可多个线程同时读
        auto it = cache_.find(key);
        if (it != cache_.end()) {
            return it->second;
        }
        return "";
    }

    void write(int key, const std::string& value) {
        std::unique_lock lock(rw_mutex_);  // 独占锁,写时禁止读
        cache_[key] = value;
    }
};

读写锁的核心规则:

操作 锁类型 并发性
shared_lock 多个线程可同时读
unique_lock 写时独占,禁止其他读写

我的建议: 读写锁不是万能的。如果写操作非常频繁,读写锁的开销可能比互斥锁还大。为什么?因为每次写都要等待所有读完成,频繁切换上下文反而更慢。我个人习惯是:读多写少(比如配置缓存)用读写锁;读写均衡或写多读少,直接用互斥锁。

7.4 知识体系图

下面这张图,帮你理清本章的核心逻辑:

线程安全数据结构知识体系 线程安全队列 std::queue + mutex 条件变量等待 try_pop 非阻塞 线程安全栈 std::stack + mutex shared_ptr 返回 异常安全保证 读写锁 std::shared_mutex shared_lock 读 unique_lock 写 核心原则 1. 操作必须原子化:push/pop/top 不能分开 2. 条件变量避免忙等待,提高CPU利用率 3. 读写锁适合读多写少场景,否则用互斥锁

7.5 避坑指南

最后,分享几个我踩过的坑:

  • 不要返回引用:队列的 front() 返回引用,但多线程下这个引用可能瞬间失效。要么拷贝返回,要么用智能指针。
  • 条件变量的虚假唤醒wait() 可能在没有 notify 的情况下返回。所以一定要用谓词判断条件,就像我代码里写的 [this] { return !queue_.empty(); }
  • 读写锁的升级问题:不能从 shared_lock 直接升级到 unique_lock,否则可能死锁。如果需要升级,先释放共享锁,再获取独占锁。

我曾经踩过的坑: 有一次我写了一个缓存系统,用读写锁保护。结果写操作频繁,读操作被长时间阻塞,导致服务响应变慢。后来我改成读写分离——写操作写入临时区,读操作从主区读,定期交换。嗯,性能一下就上来了。

好了,线程安全的数据结构就聊到这里。记住,锁只是工具,真正重要的是理解你的并发场景。读多写少?读写均衡?还是写多读少?不同的场景,选择不同的策略。别盲目用读写锁,也别死磕互斥锁。

公众号:蓝海资料掘金营,微信 deep3321