13. 并发数据结构:无锁队列、读写锁、RCU、并发哈希表

并发编程,说白了就是让多个线程同时干活,还不打架。但打架是常态,不打架才需要技术。我早年做网络中间件的时候,一个多线程日志模块让我调了整整两周,最后发现是锁竞争把性能拖垮了。从那以后,我对并发数据结构就特别上心。

今天咱们聊四个核心的并发数据结构:无锁队列、读写锁、RCU、并发哈希表。每个都是实战中高频出现的利器。

13.1 无锁队列:CAS 与 ABA 问题

无锁队列,核心思想就是不用互斥锁,靠原子操作来保证线程安全。最常见的实现是基于 CAS(Compare-And-Swap)。

CAS 操作长这样:

bool atomic_compare_exchange_strong(atomic_int* p, int* expected, int desired);

它会比较 *pexpected,如果相等就把 *p 改成 desired,返回 true;否则把 *p 写回 expected,返回 false。整个过程是原子的。

一个简单的无锁队列(单生产者单消费者)可以这样写:

#include <stdatomic.h>

typedef struct {
    int data;
} node_t;

typedef struct {
    atomic_int head;
    atomic_int tail;
    node_t* buffer;
    int capacity;
} lockfree_queue_t;

void lockfree_queue_init(lockfree_queue_t* q, int cap) {
    q->head = 0;
    q->tail = 0;
    q->buffer = (node_t*)malloc(cap * sizeof(node_t));
    q->capacity = cap;
}

int lockfree_queue_push(lockfree_queue_t* q, int val) {
    int tail = atomic_load(&q->tail);
    int head = atomic_load(&q->head);
    if (tail - head >= q->capacity) return -1; // 队列满
    q->buffer[tail % q->capacity].data = val;
    atomic_store(&q->tail, tail + 1);
    return 0;
}

int lockfree_queue_pop(lockfree_queue_t* q, int* val) {
    int head = atomic_load(&q->head);
    int tail = atomic_load(&q->tail);
    if (head >= tail) return -1; // 队列空
    *val = q->buffer[head % q->capacity].data;
    atomic_store(&q->head, head + 1);
    return 0;
}

嗯,这里要注意:上面的代码只适用于单生产者单消费者场景。多生产者多消费者要复杂得多,需要处理 ABA 问题。

ABA 问题:线程1读取指针 A,然后被调度出去。线程2把 A 释放掉,又分配了一个新节点,恰好地址也是 A。线程1醒来后 CAS 比较发现还是 A,就以为没变化,实际上数据已经变了。我当年在消息中间件里就踩过这个坑,排查了两天才定位到。

解决 ABA 问题,常见做法是使用带标签的指针(tagged pointer),每次 CAS 同时比较地址和版本号。

13.2 读写锁:读多写少的场景利器

读写锁,说白了就是允许多个线程同时读,但写的时候只能有一个线程,且不能有读者。

为什么需要它?你想想看,很多业务场景是读多写少。比如配置表,99% 的时间在查询,只有 1% 的时间在更新。如果用普通互斥锁,读线程之间也会互相阻塞,白白浪费 CPU。

POSIX 线程库提供了读写锁:

#include <pthread.h>

pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;

// 读锁
pthread_rwlock_rdlock(&rwlock);
// 读取共享数据...
pthread_rwlock_unlock(&rwlock);

// 写锁
pthread_rwlock_wrlock(&rwlock);
// 修改共享数据...
pthread_rwlock_unlock(&rwlock);

个人经验:读写锁不是万能的。如果写操作频繁,写锁会饿死读线程。我在一个实时监控系统里就遇到过,写线程每 10ms 更新一次状态,读线程几乎拿不到锁。后来改成了 RCU 方案才解决。

读写锁的性能对比:

场景 普通互斥锁 读写锁
读多写少(9:1)
读写均衡(5:5) 中等 中等
写多读少(1:9) 慢(写锁竞争)

13.3 RCU:读端无锁的终极方案

RCU(Read-Copy-Update)是 Linux 内核里的一把利器。它的核心思想是:读操作完全不加锁,写操作通过“复制-修改-替换”的方式完成,然后等待所有读者退出后再释放旧数据。

为什么能做到读端无锁?因为 RCU 利用了“指针赋值是原子的”这个特性。读者只需要读取一个指针,不需要任何锁。写者先复制一份数据,修改,然后原子地更新指针。

一个简化的 RCU 示例:

#include <stdatomic.h>
#include <stdlib.h>

typedef struct {
    int key;
    int value;
} data_t;

atomic_intptr_t g_ptr; // 全局指针,指向当前有效数据

// 读操作:无锁
int rcu_read(int key) {
    data_t* p = (data_t*)atomic_load(&g_ptr);
    // 这里假设 key 匹配,实际需要遍历
    return p->value;
}

// 写操作:复制-修改-替换
void rcu_update(int key, int new_val) {
    data_t* old = (data_t*)atomic_load(&g_ptr);
    data_t* new_data = (data_t*)malloc(sizeof(data_t));
    *new_data = *old; // 复制
    new_data->value = new_val; // 修改
    atomic_store(&g_ptr, (intptr_t)new_data); // 替换
    // 等待所有读者退出(synchronize_rcu)
    free(old); // 释放旧数据
}

核心要点:RCU 的关键在于“等待所有读者退出”这一步。在 Linux 内核中,这通过 synchronize_rcu() 实现,它会阻塞直到所有 CPU 都经历了一次上下文切换。用户态实现通常使用 epoch-based reclamationhazard pointers

RCU 的适用场景:

  • 读操作极其频繁,写操作极少(如路由表、配置表)
  • 对读延迟要求极高,不能容忍任何锁开销
  • 数据结构更新不频繁,但更新时允许短暂阻塞

我曾经在一个金融交易系统中使用 RCU 管理订单簿,读延迟从 200ns 降到了 50ns,效果非常明显。

13.4 并发哈希表:分桶锁与无锁设计

并发哈希表,说白了就是让多个线程同时操作哈希表,不打架。最常见的实现是分桶锁(bucket lock)——每个桶一把锁,不同桶的线程可以并行操作。

分桶锁的代码骨架:

#include <pthread.h>
#include <stdlib.h>

#define BUCKET_COUNT 1024

typedef struct entry {
    int key;
    int value;
    struct entry* next;
} entry_t;

typedef struct {
    entry_t* head;
    pthread_mutex_t lock;
} bucket_t;

bucket_t hashtable[BUCKET_COUNT];

void hashtable_init() {
    for (int i = 0; i < BUCKET_COUNT; i++) {
        hashtable[i].head = NULL;
        pthread_mutex_init(&hashtable[i].lock, NULL);
    }
}

int hashtable_insert(int key, int value) {
    int idx = key % BUCKET_COUNT;
    pthread_mutex_lock(&hashtable[idx].lock);
    // 插入或更新链表
    entry_t* e = hashtable[idx].head;
    while (e) {
        if (e->key == key) {
            e->value = value;
            pthread_mutex_unlock(&hashtable[idx].lock);
            return 0;
        }
        e = e->next;
    }
    entry_t* new_e = malloc(sizeof(entry_t));
    new_e->key = key;
    new_e->value = value;
    new_e->next = hashtable[idx].head;
    hashtable[idx].head = new_e;
    pthread_mutex_unlock(&hashtable[idx].lock);
    return 1;
}

分桶锁的优点是简单、可控。缺点是如果哈希函数不好,某个桶的链表太长,性能会下降。

更高级的方案是无锁哈希表。它结合了无锁链表和 CAS 操作,实现起来相当复杂。我建议除非性能要求极其苛刻,否则先用分桶锁。

避坑指南:我曾经在一个项目中把桶数设成了质数,以为能均匀分布。结果业务数据恰好有规律,大量 key 落到了同一个桶。后来改用 std::hash 配合位运算取模,才解决问题。记住:哈希函数比桶数更重要。

13.5 知识体系总览

下面这张图总结了四种并发数据结构的核心逻辑和适用场景:

并发数据结构核心逻辑 无锁队列 读写锁 RCU 并发哈希表 CAS 原子操作 ABA 问题 单/多生产者消费者 无锁链表 读锁共享 写锁独占 读多写少场景 写锁饿死问题 读端无锁 复制-修改-替换 等待读者退出 Epoch Reclamation 分桶锁 无锁哈希 哈希函数设计 动态扩容 选择建议 • 读多写少、读延迟敏感 → RCU • 读多写少、写不频繁 → 读写锁 • 高吞吐、低延迟消息传递 → 无锁队列 • 通用键值存储、中等并发 → 分桶锁哈希表 • 写操作频繁 → 避免读写锁,考虑无锁方案 • 数据结构复杂 → 优先用锁,再考虑无锁 • 性能调优 → 先测量,再优化 • 避免过早优化 → 简单方案先跑起来

13.6 实战中的选择

说了这么多,到底怎么选?我个人的经验是:

  • 先跑起来,再优化。不要一开始就上无锁方案,先用分桶锁或读写锁把功能做对。
  • 测量是王道。用 perf 或火焰图看看锁竞争到底占了多少时间。有时候瓶颈根本不在锁上。
  • RCU 是双刃剑。它读端快,但写端要等待所有读者退出,延迟不稳定。实时系统慎用。
  • 无锁队列适合流水线。如果数据流是单向的,无锁队列能发挥最大威力。

一句话总结:并发数据结构没有银弹。理解每种方案的原理和代价,根据实际场景做取舍,才是真正的调优之道。


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