11、标准库演进(下):<threads.h>的现状与局限、<stdatomic.h>的实际应用
好,咱们接着聊标准库的演进。上一章讲了动态内存和日期时间,这一章咱们聚焦两个跟并发编程密切相关的头文件:<threads.h> 和 <stdatomic.h>。
说实话,这两个头文件在 C 标准库里的地位挺有意思的。一个(<threads.h>)是 C11 才引入的,但用的人不多,甚至有些编译器到现在都没完全支持。另一个(<stdatomic.h>)也是 C11 引入的,但它在嵌入式、系统编程、高性能计算领域已经成了标配。
为什么会这样?咱们一个个来看。
11.1 <threads.h>:一个“迟到”且“尴尬”的标准
C 语言直到 C11 标准才正式引入了线程支持,也就是 <threads.h>。你想想看,Java 在 1995 年就有线程了,POSIX 线程(pthreads)在 1995 年也基本成型了。C 语言作为系统编程的“老大哥”,到 2011 年才把线程纳入标准,确实有点晚。
我个人习惯用 pthreads 做多线程开发,所以第一次看到 <threads.h> 的时候,第一反应是:“嗯,这 API 怎么这么眼熟?”
说白了,<threads.h> 就是 pthreads 的一个“瘦身版”封装。它提供了:
thrd_t:线程标识符thrd_create():创建线程thrd_join():等待线程结束mtx_t:互斥锁cnd_t:条件变量tss_t:线程本地存储
看起来挺全的,对吧?但实际用起来,问题不少。
11.1.1 现状:支持度参差不齐
我在项目中遇到过最头疼的事:代码写好了,换了个编译器,编译不过了。
<threads.h> 的支持情况,我用一个表格给你看:
| 编译器/平台 | 支持状态 | 备注 |
|---|---|---|
| GCC (Linux) | 部分支持 | 需要链接 -lpthread,且某些实现不完整 |
| Clang (macOS) | 不支持 | Apple 的 Clang 默认不提供 <threads.h> |
| MSVC (Windows) | 支持(C11 模式) | 需要 /std:c11 或更高 |
| MinGW | 不支持 | 需要第三方实现 |
| 嵌入式编译器 | 几乎都不支持 | 资源受限,很少用线程 |
你看,这支持度,说实话挺尴尬的。我建议如果你要写跨平台的多线程代码,还是老老实实用 pthreads 或者平台原生 API 吧。
11.1.2 局限:功能太“瘦”了
<threads.h> 的 API 设计,说白了就是“够用但不好用”。
举个例子,它没有提供:
- 读写锁(pthreads 有
pthread_rwlock_t) - 屏障(pthreads 有
pthread_barrier_t) - 自旋锁(虽然可以用原子操作模拟)
- 线程池(这个得自己实现)
- 超时机制(
mtx_timedlock()有,但cnd_timedwait()的实现不一致)
我曾经在一个项目中试图用 <threads.h> 实现一个简单的生产者-消费者模型。嗯,写到条件变量那一步就卡住了——cnd_t 的 API 太简陋,连个 cnd_broadcast() 的语义都跟 pthreads 不完全一样。
<threads.h> 的代码,结果编译器直接报错说找不到这个头文件。后来我查了一下,Apple 的 Clang 默认不包含这个头文件,需要自己安装第三方实现(比如 mingw-w64 或者 pthreads-win32 的封装)。所以,如果你要写跨平台代码,<threads.h> 可能不是最好的选择。
11.1.3 一个简单的例子(看看就好)
虽然我不推荐在生产代码中用 <threads.h>,但为了让你有个直观感受,我还是写个例子:
#include <stdio.h>
#include <threads.h>
int worker(void *arg) {
int id = *(int *)arg;
printf("线程 %d 开始工作\n", id);
thrd_sleep(&(struct timespec){.tv_sec = 1}, NULL);
printf("线程 %d 工作结束\n", id);
return id * 2;
}
int main() {
thrd_t t1, t2;
int id1 = 1, id2 = 2;
int res1, res2;
if (thrd_create(&t1, worker, &id1) != thrd_success) {
fprintf(stderr, "创建线程1失败\n");
return 1;
}
if (thrd_create(&t2, worker, &id2) != thrd_success) {
fprintf(stderr, "创建线程2失败\n");
return 1;
}
thrd_join(t1, &res1);
thrd_join(t2, &res2);
printf("线程1返回: %d, 线程2返回: %d\n", res1, res2);
return 0;
}
你看,API 风格跟 pthreads 很像,但函数名更短了(thrd_create 代替 pthread_create)。不过,这也就是个“看起来像”的程度,真要深入用,你会发现各种小坑。
11.2 <stdatomic.h>:并发编程的“基石”
跟 <threads.h> 的尴尬处境不同,<stdatomic.h> 在 C 社区里接受度很高。为什么?因为它解决了一个实实在在的问题:多线程环境下的数据竞争。
你想想看,如果没有原子操作,两个线程同时修改一个变量,结果是不确定的。用互斥锁当然可以,但锁的开销大,而且容易死锁。原子操作提供了一种轻量级的同步方式。
11.2.1 核心类型和操作
<stdatomic.h> 提供了:
- 原子类型:
atomic_int、atomic_long、atomic_bool、atomic_flag等 - 原子操作:
atomic_store()、atomic_load()、atomic_exchange()、atomic_compare_exchange_strong()等 - 内存序:
memory_order_relaxed、memory_order_acquire、memory_order_release、memory_order_seq_cst等
我个人习惯把原子操作分为三类:
- 读写操作:
atomic_load、atomic_store——最基本的原子读写 - 交换操作:
atomic_exchange、atomic_compare_exchange——用于实现无锁数据结构 - 算术操作:
atomic_fetch_add、atomic_fetch_sub——用于计数器、引用计数等
11.2.2 实际应用:无锁计数器
我在项目中用得最多的就是原子计数器。比如,统计某个事件发生的次数,多个线程同时递增:
#include <stdio.h>
#include <stdatomic.h>
#include <threads.h> // 这里只是为了演示,实际项目中建议用 pthreads
atomic_int counter = 0;
int worker(void *arg) {
for (int i = 0; i < 100000; i++) {
atomic_fetch_add(&counter, 1);
}
return 0;
}
int main() {
thrd_t t1, t2;
thrd_create(&t1, worker, NULL);
thrd_create(&t2, worker, NULL);
thrd_join(t1, NULL);
thrd_join(t2, NULL);
printf("最终计数: %d (期望: 200000)\n", atomic_load(&counter));
return 0;
}
你看,用 atomic_fetch_add 代替了 counter++,就避免了数据竞争。而且性能比用互斥锁好得多——原子操作在硬件层面通常就是一条指令(比如 x86 的 LOCK XADD)。
atomic_fetch_add 和 atomic_fetch_sub 就够用了。但如果你需要更复杂的操作(比如“读取-修改-写入”),atomic_compare_exchange_strong 是你的好朋友。我曾经用它在嵌入式设备上实现了一个无锁的环形缓冲区,性能提升很明显。
11.2.3 内存序:一个需要小心的话题
原子操作有一个“隐藏”的参数:内存序(memory order)。默认是 memory_order_seq_cst(顺序一致性),这是最严格但也最慢的模式。
如果你对性能有极致要求,可以考虑放宽内存序:
memory_order_relaxed:只保证原子性,不保证任何顺序。适合计数器这种不需要同步的场景。memory_order_acquire/memory_order_release:用于实现“发布-订阅”模式。一个线程写入数据后 release,另一个线程 acquire 读取,保证看到完整的数据。memory_order_seq_cst:最严格,所有线程看到的内存操作顺序一致。
我曾经在调试一个多线程程序时,发现用 memory_order_relaxed 会导致某些线程看到“过时”的数据。后来改成 memory_order_acquire/release 就解决了。嗯,这里要注意:不要轻易使用 memory_order_relaxed,除非你非常清楚自己在做什么。
11.2.4 实际应用:无锁栈(简单版)
原子操作最经典的应用就是无锁数据结构。下面是一个简单的无锁栈(基于链表):
#include <stdatomic.h>
#include <stdlib.h>
typedef struct Node {
int value;
struct Node *next;
} Node;
typedef struct {
atomic_int count;
atomic_uintptr_t head; // 用 uintptr_t 存储指针,方便原子操作
} LockFreeStack;
void push(LockFreeStack *stack, int value) {
Node *new_node = malloc(sizeof(Node));
new_node->value = value;
Node *old_head;
do {
old_head = (Node *)atomic_load(&stack->head);
new_node->next = old_head;
} while (!atomic_compare_exchange_strong(
&stack->head,
(uintptr_t *)&old_head,
(uintptr_t)new_node
));
atomic_fetch_add(&stack->count, 1);
}
int pop(LockFreeStack *stack, int *value) {
Node *old_head;
Node *new_head;
do {
old_head = (Node *)atomic_load(&stack->head);
if (old_head == NULL) {
return 0; // 栈为空
}
new_head = old_head->next;
} while (!atomic_compare_exchange_strong(
&stack->head,
(uintptr_t *)&old_head,
(uintptr_t)new_head
));
*value = old_head->value;
free(old_head);
atomic_fetch_sub(&stack->count, 1);
return 1;
}
你看,这里用 atomic_compare_exchange_strong 实现了“比较-交换”循环(CAS loop)。这是无锁编程的核心模式。
memory_order 来避免 ABA 问题。我曾经在一个高并发系统中因为 ABA 问题导致数据损坏,排查了整整两天才找到原因。所以,无锁编程虽然性能好,但调试起来真的很痛苦。
11.3 知识体系图
为了让你更直观地理解这两个头文件的关系和定位,我画了一张图:
11.4 总结与建议
好了,咱们来收个尾。
<threads.h> 和 <stdatomic.h> 虽然都是 C11 引入的,但命运截然不同:
<threads.h>:定位是提供一个“标准化的线程 API”,但受限于支持度和功能缺失,实际使用价值有限。我个人建议,除非你确定目标平台完全支持,否则还是用 pthreads 或者平台原生 API 吧。<stdatomic.h>:定位是提供“轻量级的同步原语”,它确实做到了。在嵌入式、高性能计算、系统编程中,原子操作几乎是标配。我建议每个 C 程序员都掌握它,至少要知道怎么用atomic_fetch_add和atomic_compare_exchange。
最后,给你一个实用建议:如果你在写多线程代码,优先考虑 <stdatomic.h> 做简单的同步(计数器、标志位),用 pthreads 做复杂的同步(条件变量、读写锁)。至于 <threads.h>……嗯,先放一放吧,等它再成熟一些再说。
公众号:蓝海资料掘金营,微信deep3321