15、并发编程的现代化:从pthread到C11原子操作、线程安全的数据结构、无锁编程入门

并发编程,说白了就是让程序同时干多件事。早些年我们用pthread,后来发现坑太多。C11标准出来后,事情变得有意思了。今天聊聊我这些年踩过的坑,以及怎么用现代C语言写出靠谱的并发代码。

15.1 从pthread到C11原子操作

pthread是个好东西,但用起来真不省心。我记得刚入行那会儿,一个简单的计数器,用pthread_mutex_lock包着,结果死锁了三天。后来才发现是加锁顺序搞反了。

C11引入了,提供了原子操作。说白了就是硬件级别的操作保证,比加锁轻量得多。

// 老式pthread写法
#include <pthread.h>
int counter = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void increment() {
    pthread_mutex_lock(&mutex);
    counter++;
    pthread_mutex_unlock(&mutex);
}

// C11原子操作写法
#include <stdatomic.h>
atomic_int counter = 0;

void increment() {
    atomic_fetch_add(&counter, 1);
}

你想想看,原子操作省去了锁的上下文切换,性能提升不是一星半点。我在一个高并发网关项目里,把pthread锁换成原子操作后,吞吐量提升了40%。

核心区别:pthread锁是操作系统级别的阻塞机制,原子操作是CPU指令级别的非阻塞保证。能用原子操作就别用锁。

15.2 内存序:别让编译器耍了你

原子操作有个坑——内存序。C11提供了六种内存序,但常用的就三种。我刚开始用的时候,全用的memory_order_seq_cst,性能还不如加锁。后来才明白,选对内存序很重要。

内存序 保证强度 性能开销 适用场景
memory_order_relaxed 最弱 几乎为零 计数器、统计
memory_order_acquire/release 中等 较小 生产者-消费者
memory_order_seq_cst 最强 较大 复杂同步场景
// 正确的内存序使用
atomic_int flag = 0;
atomic_int data = 0;

// 线程1:生产者
void producer() {
    atomic_store(&data, 42, memory_order_release);
    atomic_store(&flag, 1, memory_order_release);
}

// 线程2:消费者
void consumer() {
    while (atomic_load(&flag, memory_order_acquire) == 0);
    int val = atomic_load(&data, memory_order_acquire);
    // 这里val一定是42
}

我曾经犯过的错:在生产者-消费者模式里用了memory_order_relaxed,结果消费者读到了flag=1但data还是旧值。调试了整整两天才发现是内存序的问题。

15.3 线程安全的数据结构

写线程安全的数据结构,我建议遵循三个原则:

  • 最小化临界区——只保护真正需要保护的数据
  • 避免锁嵌套——死锁的根源
  • 优先用原子操作——能原子就别加锁

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

#include <stdatomic.h>
#include <stdbool.h>

#define QUEUE_SIZE 1024

typedef struct {
    int data[QUEUE_SIZE];
    atomic_int head;
    atomic_int tail;
} lockfree_queue_t;

bool queue_push(lockfree_queue_t* q, int val) {
    int t = atomic_load(&q->tail, memory_order_relaxed);
    int h = atomic_load(&q->head, memory_order_acquire);
    
    if ((t + 1) % QUEUE_SIZE == h) {
        return false;  // 队列满
    }
    
    q->data[t] = val;
    atomic_store(&q->tail, (t + 1) % QUEUE_SIZE, memory_order_release);
    return true;
}

bool queue_pop(lockfree_queue_t* q, int* val) {
    int h = atomic_load(&q->head, memory_order_relaxed);
    int t = atomic_load(&q->tail, memory_order_acquire);
    
    if (h == t) {
        return false;  // 队列空
    }
    
    *val = q->data[h];
    atomic_store(&q->head, (h + 1) % QUEUE_SIZE, memory_order_release);
    return true;
}

个人习惯:写无锁数据结构时,先用单线程跑通逻辑,再用两个线程做压力测试。我一般会跑一晚上,第二天看有没有数据不一致。

15.4 无锁编程入门

无锁编程,说白了就是不用互斥锁,靠原子操作和CAS(Compare-And-Swap)来保证线程安全。听起来很酷,但坑特别多。

CAS操作是核心:

// 无锁栈的push操作
typedef struct node {
    int value;
    struct node* next;
} node_t;

atomic_intptr_t stack = 0;  // 栈顶指针

void push(int val) {
    node_t* new_node = malloc(sizeof(node_t));
    new_node->value = val;
    
    node_t* old_top;
    do {
        old_top = (node_t*)atomic_load(&stack, memory_order_relaxed);
        new_node->next = old_top;
    } while (!atomic_compare_exchange_weak(
        &stack, 
        (intptr_t*)&old_top, 
        (intptr_t)new_node,
        memory_order_release,
        memory_order_relaxed
    ));
}

这里有个经典问题——ABA问题。我当年在做一个内存池时,就因为这个bug排查了整整一周。

ABA问题:线程1读取栈顶为A,准备CAS。此时线程2把A、B都弹出,又push回A。线程1的CAS成功,但栈的结构已经变了。解决方案是用带版本号的指针(tagged pointer)。

15.5 知识体系总览

下面这张图是我这些年总结的并发编程知识体系,从基础到进阶,一目了然:

并发编程现代化知识体系 pthread基础 C11原子操作 内存序模型 线程安全数据结构 无锁编程 CAS与ABA问题 生产者-消费者 读写锁优化 内存屏障 性能调优与调试技巧

15.6 避坑指南

这些年我总结了几条血的教训:

  • 别迷信无锁——无锁编程在低竞争场景下优势明显,高竞争时CAS自旋反而更耗CPU
  • 内存序别偷懒——一律用seq_cst虽然安全,但性能损失大。该用acquire/release就用
  • 测试要跑够——并发bug往往在特定时序下才出现,我一般跑24小时压力测试
  • 工具要用好——ThreadSanitizer、Helgrind这些工具能帮你发现数据竞争

我曾经踩过的坑:在一个金融交易系统里,用了无锁队列但没处理ABA问题。上线后偶尔出现订单丢失,查了三天才发现是节点被重复回收导致的。从那以后,我所有无锁数据结构都加了版本号。

嗯,并发编程这条路,说白了就是跟时间和顺序较劲。从pthread到C11原子操作,再到无锁编程,每一步都是对底层硬件理解的加深。我个人建议,先从简单的原子计数器开始练手,慢慢过渡到无锁数据结构。别一上来就搞复杂的无锁队列,容易翻车。

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