第十四章:多线程设计模式
多线程编程,说白了就是让多个工人同时干活。但工人多了,协调就成了大问题。我这些年做系统编程,踩过不少坑,也总结出几个经典的设计模式。今天咱们就聊聊这四个最常用的:单例模式、生产者-消费者、读者-写者,还有工作窃取。
核心观点:设计模式不是花架子,是前人用血泪换来的最佳实践。你想想看,如果每个多线程项目都从零开始设计同步策略,那得重复造多少轮子?
14.1 线程安全单例模式
单例模式,顾名思义,就是一个类在整个进程中只有一个实例。听起来简单,但多线程环境下就麻烦了——两个线程同时检查实例是否为空,然后各自创建,这就违背了单例的初衷。
我个人习惯用双重检查锁定(Double-Checked Locking)加上内存屏障。为什么?因为性能好,而且安全。
// 线程安全的单例模式(C11 标准)
#include <stdatomic.h>
#include <stdlib.h>
typedef struct {
int data;
// ... 其他字段
} Singleton;
static atomic_intptr_t instance = ATOMIC_VAR_INIT(0);
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
Singleton* get_instance(void) {
// 第一次检查,无锁
Singleton* p = (Singleton*)atomic_load(&instance);
if (p == NULL) {
// 加锁后第二次检查
pthread_mutex_lock(&mutex);
p = (Singleton*)atomic_load(&instance);
if (p == NULL) {
p = (Singleton*)malloc(sizeof(Singleton));
if (p) {
atomic_store(&instance, (intptr_t)p);
}
}
pthread_mutex_unlock(&mutex);
}
return p;
}
注意:我曾经在项目里看到有人直接用 pthread_once 实现单例,这其实更简单。但如果你需要延迟初始化或者控制生命周期,双重检查锁定更灵活。不过一定要用原子操作,否则编译器优化会搞乱你的内存序。
14.2 生产者-消费者模式
这个模式太经典了。一个线程生产数据,另一个线程消费数据。中间用个缓冲区解耦。我做过一个日志系统,就是用这个模式——业务线程只管写日志,后台线程负责刷盘。
缓冲区用有界队列,配合条件变量。生产者满了就等,消费者空了也等。嗯,这里要注意:条件变量的虚假唤醒(spurious wakeup)是个坑,必须用 while 循环检查条件。
// 生产者-消费者模式核心代码
typedef struct {
int* buffer;
size_t capacity;
size_t head;
size_t tail;
size_t count;
pthread_mutex_t mutex;
pthread_cond_t not_full;
pthread_cond_t not_empty;
} BoundedQueue;
void produce(BoundedQueue* q, int item) {
pthread_mutex_lock(&q->mutex);
while (q->count == q->capacity) {
pthread_cond_wait(&q->not_full, &q->mutex);
}
q->buffer[q->tail] = item;
q->tail = (q->tail + 1) % q->capacity;
q->count++;
pthread_cond_signal(&q->not_empty);
pthread_mutex_unlock(&q->mutex);
}
int consume(BoundedQueue* q) {
pthread_mutex_lock(&q->mutex);
while (q->count == 0) {
pthread_cond_wait(&q->not_empty, &q->mutex);
}
int item = q->buffer[q->head];
q->head = (q->head + 1) % q->capacity;
q->count--;
pthread_cond_signal(&q->not_full);
pthread_mutex_unlock(&q->mutex);
return item;
}
小技巧:如果你用 C++,可以用 std::queue 配合 std::condition_variable。但 C 语言里,我建议自己实现环形缓冲区,性能更好,而且没有动态内存分配的开销。
14.3 读者-写者模式
这个模式解决的是:多个读者可以同时读,但写者必须独占。说白了,就是读写锁的应用场景。我做过一个配置管理系统,配置项被大量线程读取,但只有管理员偶尔修改。用读写锁,读性能提升了 10 倍。
但要注意:读写锁有偏向性。默认实现往往偏向读者,导致写者饿死。我在项目中遇到过这个问题——配置更新迟迟得不到执行,因为读者太多。
// 读者-写者模式(使用读写锁)
#include <pthread.h>
typedef struct {
pthread_rwlock_t rwlock;
int shared_data;
} SharedResource;
void read_resource(SharedResource* res) {
pthread_rwlock_rdlock(&res->rwlock);
// 读取 shared_data
printf("Read: %d\n", res->shared_data);
pthread_rwlock_unlock(&res->rwlock);
}
void write_resource(SharedResource* res, int value) {
pthread_rwlock_wrlock(&res->rwlock);
// 修改 shared_data
res->shared_data = value;
printf("Write: %d\n", value);
pthread_rwlock_unlock(&res->rwlock);
}
避坑指南:我曾经在一个高并发服务里用读写锁,结果写者等了 3 秒才拿到锁。后来改用 pthread_rwlockattr_setkind_np 设置成写者优先,问题解决。记住:默认的读写锁是读者优先,如果你有写者延迟敏感的场景,一定要改。
14.4 工作窃取模式
工作窃取(Work Stealing)是任务并行里的高级玩法。每个线程有自己的任务队列,做完自己的任务后,去偷别人的任务。这比全局任务队列好在哪里?减少了锁竞争。
我实现过一个图像处理管线,每个线程处理一帧图像的一部分。如果某个线程处理得快,就去偷慢线程的任务。整体吞吐量提升了 30%。
// 工作窃取模式的核心思想(伪代码)
typedef struct {
Deque* local_queue; // 每个线程自己的双端队列
// ...
} Worker;
// 线程主循环
void* worker_thread(void* arg) {
Worker* w = (Worker*)arg;
while (1) {
Task* task = deque_pop_front(w->local_queue); // 从头部取任务
if (task == NULL) {
// 本地队列空了,尝试窃取
task = steal_task(w);
}
if (task) {
execute(task);
} else {
// 所有队列都空,等待或退出
break;
}
}
return NULL;
}
Task* steal_task(Worker* thief) {
// 随机选一个受害者线程
Worker* victim = pick_random_worker();
// 从受害者队列尾部窃取任务(减少冲突)
return deque_pop_back(victim->local_queue);
}
关键点:工作窃取为什么从尾部偷?因为每个线程从头部取任务,偷窃者从尾部偷,这样冲突概率最小。这是 Cilk 和 Java Fork/Join 框架的核心设计。你想想看,如果都从头部取,那锁竞争得多激烈?
14.5 四种模式对比
| 模式 | 适用场景 | 核心机制 | 常见坑 |
|---|---|---|---|
| 单例模式 | 全局唯一资源(配置、日志) | 双重检查锁定 + 原子操作 | 内存序问题、编译器优化 |
| 生产者-消费者 | 数据流处理、异步日志 | 有界队列 + 条件变量 | 虚假唤醒、死锁 |
| 读者-写者 | 读多写少的数据(配置、缓存) | 读写锁 | 写者饿死、锁升级 |
| 工作窃取 | 任务并行、负载不均衡 | 双端队列 + 随机窃取 | 窃取冲突、任务粒度 |
14.6 知识体系图
下面这张图展示了四种模式的关系和适用场景。我建议你把它存下来,写代码前先看看,选对模式能省一半功夫。
我的建议:别想着一次学会所有模式。先掌握生产者-消费者和单例模式,这两个最常用。读者-写者和工作窃取,等你遇到具体场景再深入。我当年就是先啃了前两个,后面两个是在项目里被逼着学会的。
好了,这四种模式就聊到这儿。记住:模式是工具,不是教条。根据你的实际场景灵活选用,别硬套。多线程编程,说到底就是管理好共享资源的访问。你只要把「谁在什么时候能访问什么」想清楚,代码就不会出大问题。
公众号:蓝海资料掘金营,微信deep3321