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 知识体系总览
下面这张图是我这些年总结的并发编程知识体系,从基础到进阶,一目了然:
15.6 避坑指南
这些年我总结了几条血的教训:
- 别迷信无锁——无锁编程在低竞争场景下优势明显,高竞争时CAS自旋反而更耗CPU
- 内存序别偷懒——一律用seq_cst虽然安全,但性能损失大。该用acquire/release就用
- 测试要跑够——并发bug往往在特定时序下才出现,我一般跑24小时压力测试
- 工具要用好——ThreadSanitizer、Helgrind这些工具能帮你发现数据竞争
我曾经踩过的坑:在一个金融交易系统里,用了无锁队列但没处理ABA问题。上线后偶尔出现订单丢失,查了三天才发现是节点被重复回收导致的。从那以后,我所有无锁数据结构都加了版本号。
嗯,并发编程这条路,说白了就是跟时间和顺序较劲。从pthread到C11原子操作,再到无锁编程,每一步都是对底层硬件理解的加深。我个人建议,先从简单的原子计数器开始练手,慢慢过渡到无锁数据结构。别一上来就搞复杂的无锁队列,容易翻车。