11、锁的优化技术:细粒度锁、锁分段(lock striping)、读写锁优化、无锁数据结构基础

各位同学,咱们今天聊点硬核的——锁的优化。说实话,我早年写多线程代码时,最怕的就是锁。不加锁吧,数据乱套;加锁吧,性能崩盘。后来踩的坑多了,慢慢摸索出一些门道。今天就把这些经验掰开揉碎了讲给你们听。

11.1 细粒度锁:把大锁拆成小锁

什么叫细粒度锁?说白了,就是把一把大锁拆成多把小锁。你想想看,一个商场只有一个大门,所有人进出都挤这一个门,那不得堵死?细粒度锁的思路就是多开几个门,让不同的人走不同的门。

举个实际例子。假设我们要实现一个线程安全的哈希表。最粗暴的做法是整个表加一把大锁:

// 粗粒度锁版本
class ThreadSafeHashMap {
    std::unordered_map<int, std::string> map_;
    std::mutex mutex_;
public:
    void insert(int key, const std::string& value) {
        std::lock_guard<std::mutex> lock(mutex_);
        map_[key] = value;
    }
    
    std::string find(int key) {
        std::lock_guard<std::mutex> lock(mutex_);
        auto it = map_.find(key);
        return it != map_.end() ? it->second : "";
    }
};

这个版本有什么问题?所有操作串行化。哪怕两个线程操作的是完全不同的桶,也得排队等着。我在项目中遇到过这种情况:16核的机器,实际吞吐量还不如单线程。为什么?锁竞争太激烈了。

细粒度锁的做法是:每个桶或每组桶分配一把锁

// 细粒度锁版本
class FineGrainedHashMap {
    static constexpr int BUCKET_COUNT = 256;
    std::unordered_map<int, std::string> buckets_[BUCKET_COUNT];
    std::mutex mutexes_[BUCKET_COUNT];
    
    int getBucketIndex(int key) {
        return std::hash<int>{}(key) % BUCKET_COUNT;
    }
    
public:
    void insert(int key, const std::string& value) {
        int idx = getBucketIndex(key);
        std::lock_guard<std::mutex> lock(mutexes_[idx]);
        buckets_[idx][key] = value;
    }
    
    std::string find(int key) {
        int idx = getBucketIndex(key);
        std::lock_guard<std::mutex> lock(mutexes_[idx]);
        auto it = buckets_[idx].find(key);
        return it != buckets_[idx].end() ? it->second : "";
    }
};

关键点:细粒度锁的核心是减少锁的粒度,让不同线程操作不同数据时不会互相阻塞。但要注意,锁的数量不是越多越好——锁本身也有开销。

11.2 锁分段(Lock Striping):Java 的 ConcurrentHashMap 是怎么做的

锁分段,其实就是细粒度锁的一种经典实现。我记得最早接触这个概念是在看 Java 的 ConcurrentHashMap 源码时。它的思路是:把整个数据结构分成多个段(Segment),每个段独立加锁

为什么叫「分段」?你可以想象一个图书馆,分成多个阅览室。每个阅览室有自己的管理员(锁)。你要借书,只需要找对应阅览室的管理员,不用惊动整个图书馆。

在 C++ 中实现锁分段,我一般这样写:

template<typename Key, typename Value>
class StripedHashMap {
    static constexpr int STRIPE_COUNT = 16;
    
    struct StripedBucket {
        std::unordered_map<Key, Value> data;
        std::shared_mutex mutex;  // 读写锁
    };
    
    std::vector<StripedBucket> stripes_;
    
    int getStripeIndex(const Key& key) {
        return std::hash<Key>{}(key) % STRIPE_COUNT;
    }
    
public:
    StripedHashMap() : stripes_(STRIPE_COUNT) {}
    
    void insert(const Key& key, const Value& value) {
        int idx = getStripeIndex(key);
        std::unique_lock<std::shared_mutex> lock(stripes_[idx].mutex);
        stripes_[idx].data[key] = value;
    }
    
    Value find(const Key& key) {
        int idx = getStripeIndex(key);
        std::shared_lock<std::shared_mutex> lock(stripes_[idx].mutex);
        auto it = stripes_[idx].data.find(key);
        return it != stripes_[idx].data.end() ? it->second : Value{};
    }
};

我的经验:分段数一般取 2 的幂次,比如 16、32、64。这样取模运算可以用位运算优化。另外,分段数不要超过 CPU 核心数的 4 倍,否则锁的管理开销会反噬性能。

11.3 读写锁优化:读多写少的场景

读写锁,说白了就是区分读操作和写操作。读操作可以并发,写操作必须独占。这个优化在「读多写少」的场景下效果特别明显。

我举个例子。假设你有一个配置管理系统,配置信息几乎不变,但成千上万个线程要读取。如果用普通互斥锁,所有读线程都得排队——这太浪费了。

C++17 提供了 std::shared_mutex,用法很简单:

class ConfigManager {
    std::map<std::string, std::string> config_;
    mutable std::shared_mutex rw_mutex_;
    
public:
    std::string get(const std::string& key) {
        std::shared_lock<std::shared_mutex> lock(rw_mutex_);
        auto it = config_.find(key);
        return it != config_.end() ? it->second : "";
    }
    
    void set(const std::string& key, const std::string& value) {
        std::unique_lock<std::shared_mutex> lock(rw_mutex_);
        config_[key] = value;
    }
};

看到区别了吗?get 用的是 shared_lock,多个线程可以同时持有;set 用的是 unique_lock,必须独占。

注意:读写锁不是万能的。如果写操作频繁(比如写占比超过 20%),读写锁的性能可能还不如普通互斥锁。为什么?因为写锁会阻塞所有读锁,频繁的写操作会导致读线程「饿死」。

我曾经在一个日志系统中踩过这个坑。日志写入频繁,结果用了读写锁后,读线程几乎拿不到锁,系统响应时间飙升。后来改成无锁队列才解决问题。

11.4 无锁数据结构基础:CAS 和 ABA 问题

无锁编程,听起来很玄乎,其实核心就一句话:用原子操作代替锁。最常用的原子操作是 CAS(Compare-And-Swap)。

C++11 提供了 std::atomic,支持 CAS 操作:

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

// CAS 操作:如果当前值等于 expected,就更新为 desired
int expected = counter.load();
while (!counter.compare_exchange_weak(expected, expected + 1)) {
    // 如果 CAS 失败,expected 会被更新为当前值
    // 重试
}

无锁数据结构的基本思路是:用 CAS 循环代替锁。比如实现一个无锁栈:

template<typename T>
class LockFreeStack {
    struct Node {
        T data;
        Node* next;
    };
    
    std::atomic<Node*> head_{nullptr};
    
public:
    void push(const T& value) {
        Node* new_node = new Node{value, nullptr};
        new_node->next = head_.load();
        
        while (!head_.compare_exchange_weak(new_node->next, new_node)) {
            // 如果 head 被其他线程修改了,重试
        }
    }
    
    bool pop(T& value) {
        Node* old_head = head_.load();
        while (old_head && 
               !head_.compare_exchange_weak(old_head, old_head->next)) {
            // 重试
        }
        
        if (old_head) {
            value = old_head->data;
            delete old_head;
            return true;
        }
        return false;
    }
};

核心要点:无锁编程的关键是保证操作的原子性。CAS 循环本质上是一种乐观锁——假设没有冲突,如果冲突了就重试。

11.5 ABA 问题:无锁编程的经典陷阱

说到无锁编程,就不得不提 ABA 问题。这个问题我当年第一次遇到时,debug 了整整两天。

什么是 ABA 问题?假设线程 A 读取到指针 P 指向地址 X,然后线程 B 把 P 指向 Y,又改回 X。线程 A 的 CAS 检查发现 P 还是指向 X,就认为没有变化——但实际上中间已经变过了。

解决 ABA 问题的常见方法是使用带版本号的指针

// 使用双字 CAS(DCAS)或 tagged pointer
struct TaggedPointer {
    Node* ptr;
    uint64_t tag;
};

std::atomic<TaggedPointer> head_;

// 每次修改都递增 tag
TaggedPointer old = head_.load();
TaggedPointer new_val{new_node, old.tag + 1};
while (!head_.compare_exchange_weak(old, new_val)) {
    new_val.ptr->next = old.ptr;
    new_val.tag = old.tag + 1;
}

避坑指南:我曾经在一个高并发系统中使用无锁队列,结果因为 ABA 问题导致数据丢失。排查过程极其痛苦——因为 ABA 问题不是每次都出现,而是特定时序下才触发。从那以后,我养成了一个习惯:所有无锁数据结构都必须显式处理 ABA 问题

11.6 如何选择:一张决策表

说了这么多,到底什么时候用哪种技术?我整理了一张表:

场景 推荐技术 原因
读多写少(读 > 90%) 读写锁 读操作可并发,性能提升明显
写操作频繁 细粒度锁或锁分段 避免写锁阻塞所有读操作
数据结构简单(栈、队列) 无锁数据结构 性能最好,但实现复杂
数据结构复杂(树、图) 细粒度锁 无锁实现过于复杂,容易出错
对延迟要求极高 无锁数据结构 避免锁导致的上下文切换

嗯,这里要注意:没有银弹。我见过太多人一上来就搞无锁编程,结果 bug 修了三个月。我的建议是:先从简单的锁开始,用性能分析工具(比如 perf)找到瓶颈,再针对性地优化。

11.7 知识体系总览

最后,我用一张图把今天的内容串起来:

锁的优化技术知识体系 锁的优化技术 细粒度锁 锁分段 读写锁优化 无锁数据结构 每个桶/组桶独立加锁 减少锁竞争,提高并发度 分成多个Segment独立加锁 分段数取2的幂次 shared_lock / unique_lock 适合读多写少场景 CAS原子操作 注意ABA问题 核心原则:没有银弹,根据场景选择 先用简单锁,性能分析找到瓶颈后再优化

这张图把今天讲的内容都串起来了。你可以看到,四种优化技术各有侧重,但核心目标是一致的:减少锁竞争,提高并发性能

好了,关于锁的优化就讲到这里。记住我的一句话:先跑起来,再优化。不要一开始就追求极致性能,先把功能做对,然后用性能分析工具找瓶颈,最后针对性地应用这些技术。这样才是工程化的做法。


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