10. 结构体与多线程:结构体作为线程参数、线程安全的结构体设计

多线程编程,说白了就是让程序同时干几件事。但事情一多,就容易打架。结构体作为数据的“集装箱”,在多线程环境里怎么安全地传递和共享,是个绕不开的坎儿。我早年做嵌入式数据采集系统时,就吃过结构体共享的亏——两个线程同时写一个结构体字段,结果数据全乱了,查了三天才找到原因。

10.1 结构体作为线程参数

创建线程时,我们经常需要传一些参数进去。结构体是最自然的载体——把一堆相关数据打包,一次性传过去。

10.1.1 传值还是传指针?

这个问题我每次面试新人都会问。答案是:传指针。因为pthread_create的最后一个参数是void*,你传结构体本身进去,会被隐式转换成指针,编译器会报警告。而且传值意味着拷贝整个结构体,效率低。

// 正确的做法:传结构体指针
typedef struct {
    int id;
    char name[32];
    float value;
} TaskParam;

void* worker(void* arg) {
    TaskParam* param = (TaskParam*)arg;
    printf("Task %d: %s, value=%.2f\n", param->id, param->name, param->value);
    return NULL;
}

int main() {
    pthread_t tid;
    TaskParam param = {1, "sensor_read", 3.14};
    pthread_create(&tid, NULL, worker, &param);
    pthread_join(tid, NULL);
    return 0;
}
⚠️ 注意:传指针时,必须保证结构体的生命周期覆盖线程的执行期。如果主线程在子线程结束前就释放了结构体,子线程就会访问野指针。我见过太多人犯这个错——在循环里创建线程,传的是局部变量的地址,结果线程启动时变量已经销毁了。

10.1.2 动态分配更安全

我个人习惯用malloc分配结构体,然后传给线程。线程用完后自己free。这样生命周期完全由线程控制,不会出现主线程提前释放的问题。

void* worker(void* arg) {
    TaskParam* param = (TaskParam*)arg;
    // 处理数据...
    free(param);  // 线程自己释放
    return NULL;
}

int main() {
    pthread_t tid;
    TaskParam* param = malloc(sizeof(TaskParam));
    param->id = 1;
    strcpy(param->name, "sensor_read");
    param->value = 3.14;
    pthread_create(&tid, NULL, worker, param);
    pthread_detach(tid);  // 分离线程,不等待
    return 0;
}

10.2 线程安全的结构体设计

多个线程同时读写同一个结构体,就会出问题。你想想看,一个线程在读某个字段,另一个线程在写,读到的数据可能半新不旧——这就是数据竞争。

10.2.1 互斥锁保护

最直接的办法:给结构体加一把锁。每个操作前先上锁,操作完解锁。

typedef struct {
    int x;
    int y;
    pthread_mutex_t lock;
} Point;

void point_set(Point* p, int x, int y) {
    pthread_mutex_lock(&p->lock);
    p->x = x;
    p->y = y;
    pthread_mutex_unlock(&p->lock);
}

void point_get(Point* p, int* x, int* y) {
    pthread_mutex_lock(&p->lock);
    *x = p->x;
    *y = p->y;
    pthread_mutex_unlock(&p->lock);
}
💡 小技巧:锁的粒度要适中。锁太大,并发性能差;锁太小,容易死锁。我一般把锁放在结构体内部,和它保护的数据紧挨着。这样代码可读性好,也不容易忘。

10.2.2 原子操作

如果只是读写一个int或指针,用原子操作比互斥锁轻量得多。C11标准提供了stdatomic.h,嵌入式里也常用GCC的__sync_系列。

#include <stdatomic.h>

typedef struct {
    atomic_int counter;
    atomic_flag flag;
} AtomicStruct;

void atomic_increment(AtomicStruct* as) {
    atomic_fetch_add(&as->counter, 1);
}

bool atomic_test_flag(AtomicStruct* as) {
    return atomic_flag_test_and_set(&as->flag);
}

原子操作的好处是不用加锁,性能好。但只能处理简单的读写,复杂的业务逻辑还是得用互斥锁。

10.2.3 读写锁优化

读多写少的场景,用读写锁更高效。多个线程可以同时读,但写的时候独占。

typedef struct {
    int data[1024];
    pthread_rwlock_t rwlock;
} SharedData;

void read_data(SharedData* sd, int index) {
    pthread_rwlock_rdlock(&sd->rwlock);
    int val = sd->data[index];
    pthread_rwlock_unlock(&sd->rwlock);
    // 使用val...
}

void write_data(SharedData* sd, int index, int val) {
    pthread_rwlock_wrlock(&sd->rwlock);
    sd->data[index] = val;
    pthread_rwlock_unlock(&sd->rwlock);
}
🔑 核心原则:线程安全的结构体设计,本质上就是数据 + 同步机制的封装。把锁和数据放在一起,提供安全的访问接口,不让外部直接操作内部字段。这就是面向对象思想在C语言里的体现。

10.3 避坑指南

我曾经在一个项目里,结构体里有个指针成员,指向动态分配的内存。两个线程共享这个结构体,一个线程释放了指针指向的内存,另一个线程还在用——段错误,程序直接崩了。从那以后,我总结了几条铁律:

  1. 不要返回结构体内部指针——如果必须返回,用引用计数管理生命周期。
  2. 结构体里的锁要初始化——用PTHREAD_MUTEX_INITIALIZER或pthread_mutex_init,别漏了。
  3. 拷贝结构体时要深拷贝——如果结构体里有指针成员,浅拷贝会导致多个线程共享同一块内存。
  4. 避免锁的嵌套——一个线程持有一个锁,再去申请另一个锁,很容易死锁。如果必须嵌套,保证所有线程的加锁顺序一致。

10.4 知识体系图

下面这张图总结了结构体与多线程的核心知识点,你看一眼就能理清脉络:

结构体与多线程知识体系 结构体作为线程参数 传指针 vs 传值 传指针效率高,注意生命周期 动态分配结构体 malloc + free,线程自己管理 结构体数组作为参数 传首地址 + 索引或范围 ⚠️ 生命周期问题 主线程不能提前释放 线程安全的结构体设计 互斥锁保护 pthread_mutex_t,粒度适中 原子操作 atomic_int/atomic_flag,轻量 读写锁优化 读多写少场景,并发读 ⚠️ 死锁与数据竞争 加锁顺序一致,避免嵌套 核心思想:数据 + 同步机制 = 线程安全的结构体

10.5 实战建议

说了这么多,最后给几条实在的建议:

  • 优先用互斥锁——简单可靠,不容易出错。等性能瓶颈出现时再考虑优化。
  • 结构体设计时就把锁考虑进去——别等出问题了再加锁,那时候改代码成本高。
  • 写单元测试——多线程的bug很难复现,写几个线程同时读写,跑个几千次看看会不会崩。
  • 用工具检查——Valgrind的helgrind工具能检测数据竞争,我每次上线前都会跑一遍。

嗯,结构体与多线程这块,说白了就是“数据安全”四个字。你只要记住:共享数据要加锁,生命周期要管好,指针别乱飞。做到这三点,大部分问题都能避免。


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