第1章:库的并发与同步——线程安全设计、锁策略、原子操作、无锁数据结构

各位同学,咱们今天聊点硬核的。库的并发与同步,说白了就是解决一个问题:多个线程同时访问同一份数据时,怎么保证不出乱子

我刚开始写多线程程序时,觉得加锁就完事了。后来线上服务莫名其妙地卡死,查了三天才发现是死锁。嗯,从那以后我再也不敢轻视并发设计了。

1.1 线程安全设计:你的库扛得住并发吗?

一个库是线程安全的,意味着多个线程可以同时调用它的函数,而不会产生数据竞争或未定义行为。我个人习惯把线程安全分为三个层次:

  • 不可重入:函数内部有全局或静态变量,不加任何保护。多线程调用必崩。
  • 线程安全:通过锁或原子操作保护了共享数据。可以放心并发调用。
  • 可重入:函数不依赖任何外部状态,所有数据都来自参数或栈。这是最高境界。

核心原则:设计库时,默认所有函数都可能被多线程调用。不要假设调用者会帮你加锁。

我在项目中遇到过这样一个坑:一个日志库,内部用了静态缓冲区来格式化字符串。单线程跑得好好的,一上多线程就出现日志内容错乱。原因就是缓冲区被多个线程同时写。解决方案很简单——要么每个线程用独立的缓冲区,要么加锁。

1.2 锁策略:选对锁,少踩坑

锁是并发编程中最基础的同步工具。但锁不是万能的,用不好反而会拖垮性能。

1.2.1 互斥锁(std::mutex)

最常用的锁。谁拿到锁,谁就拥有临界区的独占权。我建议用 std::lock_guardstd::unique_lock 来管理锁的生命周期,避免忘记解锁。

#include <mutex>

std::mutex mtx;
int shared_counter = 0;

void safe_increment() {
    std::lock_guard<std::mutex> lock(mtx);
    ++shared_counter;  // 临界区
    // 锁在 lock_guard 析构时自动释放
}

1.2.2 读写锁(std::shared_mutex)

读多写少的场景,读写锁比互斥锁高效得多。多个线程可以同时读,但写操作是独占的。

#include <shared_mutex>

std::shared_mutex rw_mtx;
std::map<int, std::string> cache;

std::string read_cache(int key) {
    std::shared_lock<std::shared_mutex> lock(rw_mtx);
    return cache[key];  // 多个线程可以同时读
}

void write_cache(int key, const std::string& val) {
    std::unique_lock<std::shared_mutex> lock(rw_mtx);
    cache[key] = val;  // 写操作独占
}

我的经验:读写锁不是万能的。如果写操作非常频繁,读写锁的开销可能比普通互斥锁还大。你想想看,每次写都要等所有读完成,读又要等写完成,反而增加了上下文切换。

1.2.3 自旋锁(std::atomic_flag)

自旋锁不会让线程休眠,而是忙等待。适合临界区极短(几条指令)的场景。

class SpinLock {
    std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
    void lock() {
        while (flag.test_and_set(std::memory_order_acquire)) {
            // 忙等待
        }
    }
    void unlock() {
        flag.clear(std::memory_order_release);
    }
};

警告:我曾经在一个高并发服务里用了自旋锁,临界区稍微长了一点(几十微秒),结果 CPU 直接飙到 100%。自旋锁只适合纳秒级的临界区,否则老老实实用互斥锁。

1.3 原子操作:轻量级的同步利器

原子操作是 CPU 直接支持的不可分割的操作。比锁轻量得多,没有上下文切换的开销。

C++11 提供了 std::atomic 模板,支持整数、指针等类型的原子操作。常用的操作有:

  • load() / store():原子读/写
  • fetch_add() / fetch_sub():原子加减
  • compare_exchange_weak() / compare_exchange_strong():CAS(比较并交换)
#include <atomic>

std::atomic<int> counter{0};

void increment() {
    counter.fetch_add(1, std::memory_order_relaxed);
}

// 用 CAS 实现无锁的 push(单生产者单消费者场景)
std::atomic<int> head{0};
int buffer[1024];

bool push(int val) {
    int old_head = head.load(std::memory_order_relaxed);
    int new_head = (old_head + 1) % 1024;
    // 如果 head 没被其他线程修改,就更新
    return head.compare_exchange_weak(old_head, new_head,
                                      std::memory_order_release,
                                      std::memory_order_relaxed);
}

这里要注意内存序(memory order)的选择。我个人习惯:

  • 计数器累加用 memory_order_relaxed,不关心顺序
  • 生产者-消费者场景用 memory_order_release / memory_order_acquire
  • 搞不清楚的时候,用 memory_order_seq_cst(最严格,但性能最差)

1.4 无锁数据结构:高性能的终极方案

无锁数据结构,说白了就是不用锁,只靠原子操作来保证线程安全。好处是:

  • 不会死锁
  • 没有锁竞争导致的上下文切换
  • 性能通常比加锁版本高

但代价是:实现极其复杂,容易出 bug。我建议你:除非性能瓶颈明确在锁上,否则别碰无锁

1.4.1 无锁栈(Treiber Stack)

一个经典的无锁栈实现,基于 CAS 操作:

#include <atomic>

template<typename T>
class LockFreeStack {
    struct Node {
        T data;
        Node* next;
    };
    std::atomic<Node*> head{nullptr};

public:
    void push(T val) {
        Node* new_node = new Node{val, nullptr};
        new_node->next = head.load(std::memory_order_relaxed);
        // CAS:如果 head 没变,就把新节点设为 head
        while (!head.compare_exchange_weak(new_node->next, new_node,
                                           std::memory_order_release,
                                           std::memory_order_relaxed)) {
            // 失败则重试,new_node->next 会被更新为最新的 head
        }
    }

    bool pop(T& val) {
        Node* old_head = head.load(std::memory_order_relaxed);
        while (old_head != nullptr) {
            if (head.compare_exchange_weak(old_head, old_head->next,
                                           std::memory_order_acquire,
                                           std::memory_order_relaxed)) {
                val = old_head->data;
                delete old_head;  // 注意:这里可能有 ABA 问题
                return true;
            }
        }
        return false;
    }
};

避坑指南:上面的代码有 ABA 问题——一个节点被弹出后又被重新压入,导致 CAS 误判。我曾经在这个问题上折腾了两天。解决方案是用带标记的指针(tagged pointer)或 hazard pointer。嗯,这些内容咱们后面章节再细讲。

1.4.2 无锁队列(Michael-Scott Queue)

这是最常用的无锁队列,支持多生产者多消费者(MPMC)。核心思路是用两个原子指针:head 和 tail。

// 简化版:只展示核心逻辑
template<typename T>
class LockFreeQueue {
    struct Node {
        T data;
        std::atomic<Node*> next{nullptr};
    };
    std::atomic<Node*> head;
    std::atomic<Node*> tail;

public:
    LockFreeQueue() {
        Node* dummy = new Node;
        head.store(dummy, std::memory_order_relaxed);
        tail.store(dummy, std::memory_order_relaxed);
    }

    void enqueue(T val) {
        Node* new_node = new Node{val, nullptr};
        while (true) {
            Node* last = tail.load(std::memory_order_acquire);
            Node* next = last->next.load(std::memory_order_acquire);
            if (last == tail.load(std::memory_order_acquire)) {
                if (next == nullptr) {
                    if (last->next.compare_exchange_weak(next, new_node,
                                                         std::memory_order_release,
                                                         std::memory_order_relaxed)) {
                        tail.compare_exchange_weak(last, new_node,
                                                   std::memory_order_release,
                                                   std::memory_order_relaxed);
                        return;
                    }
                } else {
                    tail.compare_exchange_weak(last, next,
                                               std::memory_order_release,
                                               std::memory_order_relaxed);
                }
            }
        }
    }
};

1.5 知识体系总览

下面这张图总结了本章的核心知识结构,方便你对照复习:

库的并发与同步 线程安全设计 不可重入 线程安全 可重入 锁策略 互斥锁 (mutex) 读写锁 (shared_mutex) 自旋锁 (atomic_flag) 原子操作 load / store fetch_add / fetch_sub CAS (compare_exchange) 无锁数据结构 无锁栈 (Treiber) 无锁队列 (MS-Queue) ABA问题 / Hazard Pointer 选择建议 • 简单场景 → 互斥锁 + lock_guard • 读多写少 → 读写锁 (shared_mutex) • 极短临界区 → 自旋锁 或 原子操作 • 性能极致 → 无锁数据结构(慎用!)

1.6 总结与避坑

好了,咱们把这一章的核心点捋一捋:

  • 线程安全设计:从设计之初就考虑并发,不要事后补救
  • 锁策略:根据场景选锁,互斥锁最通用,读写锁适合读多写少,自旋锁只适合极短临界区
  • 原子操作:轻量级同步,适合计数器、标志位等简单场景
  • 无锁数据结构:高性能但高复杂度,非必要不使用

我的建议:初学者先从互斥锁入手,把 std::lock_guard 用熟练。等遇到性能瓶颈了,再考虑原子操作和无锁结构。别一上来就追求「无锁」,那玩意儿调试起来能让你怀疑人生。

我曾经接手过一个项目,前任工程师用无锁队列实现了一个消息分发模块。代码看起来很高大上,但线上偶尔会出现消息丢失。我花了整整一周,用 ThreadSanitizerGDB 反复排查,最后发现是内存序用错了。改成 memory_order_seq_cst 后问题解决,但性能下降了 30%。嗯,这就是无锁的代价——你很难在正确性和性能之间找到完美的平衡点。

下一章咱们会深入讲 死锁的检测与预防,包括死锁的四个必要条件、银行家算法、以及实际工程中如何避免死锁。到时候我会分享一个我亲手踩过的死锁大坑,保证让你印象深刻。


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