26、位域与多线程:位域操作的原子性、位域与锁的配合使用
多线程环境下操作位域,是个容易踩坑的地方。
我早年做嵌入式通信协议栈时,就吃过这个亏。当时用位域管理状态标志,多核环境下跑着跑着就出诡异bug。查了两天才发现——位域操作不是原子的。
说白了,你以为一次赋值搞定,实际上CPU可能拆成好几步执行。线程一读、线程二写,数据就乱了。
位域操作的原子性问题
先看个例子。假设我们有个设备状态结构体:
struct DeviceStatus {
unsigned int powerOn : 1;
unsigned int ready : 1;
unsigned int error : 1;
unsigned int reserved : 29;
};
你写个函数去设置error位:
void setError(struct DeviceStatus *s) {
s->error = 1;
}
看着简单对吧?但反汇编出来,你会发现它干了三件事:
- 读取整个32位字
- 修改其中第3位
- 写回整个32位字
这就是经典的“读-改-写”操作。两个线程同时执行,结果可想而知。
核心结论:C语言标准不保证位域操作的原子性。哪怕你只改1个bit,编译器也可能操作整个存储单元。
为什么不能直接用位域做线程同步
我见过有人这么写:
// 错误示范
while (status->ready == 0) {
// 等待
}
status->powerOn = 1;
这代码在多线程下必出问题。ready位的读取可能读到旧值,powerOn的写入可能被覆盖。
你想想看,如果线程A刚读完ready为1,还没来得及写powerOn,线程B就把ready改回0了。线程A还傻乎乎地继续往下走。
注意:位域不能替代原子变量或互斥锁。别指望编译器帮你做线程安全。
位域与锁的配合使用
那位域到底能不能在多线程中用?能。但必须配合锁。
我个人习惯的做法是:
#include <pthread.h>
struct SharedFlags {
unsigned int flagA : 1;
unsigned int flagB : 1;
unsigned int flagC : 1;
unsigned int : 29; // 填充位
};
struct SharedData {
struct SharedFlags flags;
pthread_mutex_t mutex;
};
void setFlagA(struct SharedData *data) {
pthread_mutex_lock(&data->mutex);
data->flags.flagA = 1;
pthread_mutex_unlock(&data->mutex);
}
int getFlagA(struct SharedData *data) {
int ret;
pthread_mutex_lock(&data->mutex);
ret = data->flags.flagA;
pthread_mutex_unlock(&data->mutex);
return ret;
}
这样每个位域操作都包在锁里,保证原子性。
小技巧:如果多个位域经常一起读写,建议用一个锁保护整个结构体。别给每个位域单独加锁,那反而增加死锁风险。
性能考量:锁粒度与位域访问
锁粒度太粗,性能差。太细,容易死锁。
我建议按访问模式分组:
| 访问模式 | 建议策略 | 锁粒度 |
|---|---|---|
| 频繁读,偶尔写 | 读写锁(pthread_rwlock) | 整个位域结构体 |
| 频繁写 | 互斥锁 | 按功能分组 |
| 读多写少,且位域独立 | 原子操作 + 位掩码 | 无锁(用atomic) |
举个例子。如果flagA和flagB在逻辑上属于同一组状态,就用同一个锁。如果它们完全独立,可以考虑拆成不同的结构体,各自加锁。
替代方案:用原子操作模拟位域
有些场景下,锁太重了。比如中断上下文或高性能路径。
这时候可以用原子操作配合位掩码:
#include <stdatomic.h>
#define FLAG_A (1U << 0)
#define FLAG_B (1U << 1)
#define FLAG_C (1U << 2)
atomic_uint flags;
void setFlagA(void) {
atomic_fetch_or(&flags, FLAG_A);
}
void clearFlagA(void) {
atomic_fetch_and(&flags, ~FLAG_A);
}
int getFlagA(void) {
return (atomic_load(&flags) & FLAG_A) != 0;
}
这样既保留了位域的内存效率,又保证了原子性。不过可读性差了点,你得自己维护位掩码。
我的建议:普通应用用锁+位域。高性能场景用原子操作+位掩码。别混用,容易出bug。
避坑指南
我曾经在一个项目里,把位域和原子操作混着用。位域用锁保护,但读取时用了原子加载。结果锁没释放,原子读到的数据是中间状态。修了两天。
所以记住几条铁律:
- 要么全用锁,要么全用原子操作。别混搭。
- 位域结构体如果有锁保护,所有访问都必须加锁。
- 不要假设位域在内存中的布局。不同编译器可能不一样。
- 位域不能用于信号处理函数或中断上下文。用原子操作代替。
知识体系图
总结
位域本身不提供原子性保证。多线程下必须用锁或原子操作来保护。
我个人更倾向于用互斥锁保护整个位域结构体,简单可靠。只有在性能敏感路径上,才会考虑原子操作+位掩码的方案。
嗯,记住一点:不要自作聪明。位域+多线程的组合,老老实实加锁就对了。
公众号:蓝海资料掘金营,微信deep3321