多线程编程设计模式:线程安全单例模式、生产者-消费者模式、读者-写者模式、工作窃取模式、Future/Promise模式
多线程编程里,设计模式这东西,说白了就是前人踩过的坑总结出来的套路。我刚开始搞并发编程那会儿,总觉得锁就完事了,后来项目一复杂,才发现光靠蛮力锁是不行的。今天咱们聊聊五种经典模式,都是我实际项目中反复用过的。
1. 线程安全单例模式
单例模式,顾名思义,一个类全局只有一个实例。多线程环境下,你得保证这个「唯一性」不被破坏。我见过有人直接用双重检查锁定,结果忘了加 volatile,程序跑着跑着就崩了。
正确的做法是这样的:
// C11 标准下的线程安全单例
#include <stdatomic.h>
#include <stdlib.h>
typedef struct {
int data;
} Singleton;
static atomic_flag initialized = ATOMIC_FLAG_INIT;
static Singleton* instance = NULL;
Singleton* get_instance() {
if (instance == NULL) {
// 第一次检查,避免每次加锁
if (!atomic_flag_test_and_set(&initialized)) {
// 加锁成功,创建实例
instance = (Singleton*)malloc(sizeof(Singleton));
instance->data = 42;
} else {
// 其他线程等待初始化完成
while (instance == NULL) {
// 自旋等待,或者用条件变量
thrd_yield();
}
}
}
return instance;
}
注意:我曾经在项目中用过 pthread_once,结果发现不同平台的行为有细微差异。后来我改用 atomic_flag,可移植性更好。记住,单例的析构也是个坑——多线程环境下谁负责释放?我建议用 atexit 注册清理函数。
2. 生产者-消费者模式
这个模式太经典了。一个线程生产数据,另一个线程消费数据。中间用个缓冲区解耦。我做过一个日志系统,生产者是业务线程,消费者是写磁盘的线程,中间用环形缓冲区。
核心代码大概长这样:
#include <threads.h>
#include <stdatomic.h>
#define BUFFER_SIZE 1024
typedef struct {
int data[BUFFER_SIZE];
atomic_int head;
atomic_int tail;
mtx_t mutex;
cnd_t not_full;
cnd_t not_empty;
} RingBuffer;
void producer(RingBuffer* buf, int item) {
mtx_lock(&buf->mutex);
while ((buf->head - buf->tail) == BUFFER_SIZE) {
cnd_wait(&buf->not_full, &buf->mutex); // 缓冲区满了,等着
}
buf->data[buf->head % BUFFER_SIZE] = item;
buf->head++;
cnd_signal(&buf->not_empty); // 通知消费者
mtx_unlock(&buf->mutex);
}
int consumer(RingBuffer* buf) {
mtx_lock(&buf->mutex);
while (buf->head == buf->tail) {
cnd_wait(&buf->not_empty, &buf->mutex); // 没数据,等着
}
int item = buf->data[buf->tail % BUFFER_SIZE];
buf->tail++;
cnd_signal(&buf->not_full); // 通知生产者
mtx_unlock(&buf->mutex);
return item;
}
个人经验:条件变量一定要配合 while 循环使用,不能用 if。为什么?因为虚假唤醒是真实存在的。我曾经被这个坑过,调试了一整天才发现是 if 的问题。
3. 读者-写者模式
读多写少的场景,用这个模式最合适。比如配置中心,大部分时间都在读配置,偶尔才更新一次。你想想看,如果每次读都加互斥锁,那性能得多差。
我习惯用读写锁来实现:
#include <threads.h>
typedef struct {
int config_value;
rwlock_t lock; // 假设有读写锁实现
} ConfigCenter;
int read_config(ConfigCenter* cc) {
rwlock_rdlock(&cc->lock);
int val = cc->config_value;
rwlock_unlock(&cc->lock);
return val;
}
void write_config(ConfigCenter* cc, int new_val) {
rwlock_wrlock(&cc->lock);
cc->config_value = new_val;
rwlock_unlock(&cc->lock);
}
关键点:读写锁不是万能的。如果写操作很频繁,读者会被饿死。我建议在写操作不多于 10% 的场景下使用。否则,直接用互斥锁反而更简单。
4. 工作窃取模式
这个模式在任务调度里很常见。每个线程有自己的任务队列,如果自己的队列空了,就去偷别人的任务。说白了就是负载均衡的终极形态。
我做过一个并行计算框架,用的就是工作窃取。每个线程维护一个双端队列:
// 工作窃取的核心思想
// 每个线程有自己的任务队列
// 线程优先处理自己队列尾部的任务(LIFO,缓存友好)
// 空闲线程从其他队列头部偷任务(FIFO,减少竞争)
typedef struct {
Task tasks[MAX_TASKS];
atomic_int top; // 线程自己操作尾部
atomic_int bottom; // 其他线程偷取时操作头部
mtx_t steal_lock;
} WorkQueue;
// 线程自己取任务
Task* take_own_task(WorkQueue* wq) {
int t = atomic_load(&wq->top) - 1;
atomic_store(&wq->top, t);
if (t < atomic_load(&wq->bottom)) {
// 队列空了或者被偷光了
atomic_store(&wq->top, atomic_load(&wq->bottom));
return NULL;
}
return &wq->tasks[t];
}
// 其他线程偷任务
Task* steal_task(WorkQueue* wq) {
mtx_lock(&wq->steal_lock);
int b = atomic_load(&wq->bottom);
if (b < atomic_load(&wq->top)) {
Task* t = &wq->tasks[b];
atomic_store(&wq->bottom, b + 1);
mtx_unlock(&wq->steal_lock);
return t;
}
mtx_unlock(&wq->steal_lock);
return NULL;
}
避坑指南:我曾经在实现工作窃取时,忘了处理 ABA 问题。atomic 操作虽然快,但 CAS 的 ABA 问题在无锁队列里很致命。建议用 tagged pointer 或者 double-CAS 来解决。
5. Future/Promise 模式
这个模式在 C++ 里很常见,C 语言里也能实现。说白了就是异步获取结果——你提交一个任务,得到一个 Future 对象,等需要结果的时候再取。
我实现过一个简单的版本:
#include <threads.h>
#include <stdatomic.h>
typedef struct {
int result;
atomic_bool ready;
mtx_t mutex;
cnd_t cond;
} Future;
void promise_set(Future* f, int val) {
mtx_lock(&f->mutex);
f->result = val;
atomic_store(&f->ready, true);
cnd_broadcast(&f->cond); // 通知所有等待的线程
mtx_unlock(&f->mutex);
}
int future_get(Future* f) {
mtx_lock(&f->mutex);
while (!atomic_load(&f->ready)) {
cnd_wait(&f->cond, &f->mutex); // 等待结果
}
int val = f->result;
mtx_unlock(&f->mutex);
return val;
}
我的建议:Future 模式的关键在于「分离提交和获取」。我习惯把 Future 设计成一次性的——set 之后就不能再改了。这样语义清晰,也避免了很多并发问题。
知识体系总览
这五种模式各有各的适用场景。我画了张图,帮你理清思路:
模式对比与选择
实际项目中怎么选?我整理了个表格,方便你对照:
| 模式 | 核心思想 | 适用场景 | 常见陷阱 |
|---|---|---|---|
| 线程安全单例 | 全局唯一实例 | 配置管理、日志记录器 | 双重检查锁定的 volatile 缺失 |
| 生产者-消费者 | 解耦生产与消费 | 日志系统、任务队列 | 虚假唤醒、缓冲区溢出 |
| 读者-写者 | 读并发、写互斥 | 配置中心、缓存 | 写者饥饿、锁升级死锁 |
| 工作窃取 | 动态负载均衡 | 并行计算、Fork/Join 框架 | ABA 问题、任务粒度太小 |
| Future/Promise | 异步结果获取 | 异步 I/O、并行计算 | 忘记处理超时、回调地狱 |
总结一下:这五种模式不是孤立的。我经常把 Future 和生产者-消费者结合起来用——生产者提交任务返回 Future,消费者执行完设置结果。工作窃取也可以和 Future 搭配,实现真正的异步并行。嗯,多线程编程就是这样,模式是死的,人是活的。