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;
}

看着简单对吧?但反汇编出来,你会发现它干了三件事:

  1. 读取整个32位字
  2. 修改其中第3位
  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。

避坑指南

我曾经在一个项目里,把位域和原子操作混着用。位域用锁保护,但读取时用了原子加载。结果锁没释放,原子读到的数据是中间状态。修了两天。

所以记住几条铁律:

  • 要么全用锁,要么全用原子操作。别混搭。
  • 位域结构体如果有锁保护,所有访问都必须加锁。
  • 不要假设位域在内存中的布局。不同编译器可能不一样。
  • 位域不能用于信号处理函数或中断上下文。用原子操作代替。

知识体系图

位域与多线程知识体系 核心问题:非原子性 方案一:互斥锁 方案二:读写锁 方案三:原子操作 每个位域操作加锁 适合频繁写场景 读不阻塞,写互斥 适合读多写少场景 位掩码 + atomic 适合高性能路径 ⚠ 注意:不要混用锁和原子操作,不要假设位域布局

总结

位域本身不提供原子性保证。多线程下必须用锁或原子操作来保护。

我个人更倾向于用互斥锁保护整个位域结构体,简单可靠。只有在性能敏感路径上,才会考虑原子操作+位掩码的方案。

嗯,记住一点:不要自作聪明。位域+多线程的组合,老老实实加锁就对了。


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