并发设计模式:不变模式、保护性暂停、两阶段终止、工作窃取
并发编程里,有些坑是绕不过去的。比如共享数据被乱改、线程等不到结果、任务分配不均。这些问题,前辈们早就总结出了套路。今天我就把这四个经典模式掰开揉碎讲清楚。
一、不变模式(Immutable Pattern)
说白了,就是对象一旦创建,内部状态就再也不变了。你想想看,如果数据不会变,那还加什么锁?
核心思想:用不可变性替代锁同步,从根本上消除竞态条件。
我在项目中遇到过最典型的场景——配置信息。系统启动时加载一次配置,之后所有线程只读不写。用不变模式再合适不过。
// 一个不可变的配置类
typedef struct {
const int max_connections;
const int timeout_ms;
const char* log_path;
} Config;
// 构造函数——一次性初始化
Config* config_create(int max_conn, int timeout, const char* path) {
Config* cfg = (Config*)malloc(sizeof(Config));
if (!cfg) return NULL;
// 注意:这里用 const 指针,但结构体本身还是可变的
// 真正的不可变需要确保所有成员在初始化后不被修改
*(int*)&cfg->max_connections = max_conn;
*(int*)&cfg->timeout_ms = timeout;
cfg->log_path = strdup(path);
return cfg;
}
注意:C语言没有真正的const保证。上面的代码用了强制类型转换,这其实是个hack。更稳妥的做法是:把结构体定义藏在.c文件里,只暴露getter函数。
嗯,这里要注意——不变模式不是万能药。如果数据需要频繁更新,就别硬套了。我见过有人把计数器也做成不可变的,结果每次更新都要新建对象,性能惨不忍睹。
二、保护性暂停(Guarded Suspension)
这个模式解决的是「条件不满足时,线程该怎么办」的问题。最简单的做法是忙等待——一直循环检查条件。但CPU会被白白吃掉。
保护性暂停的思路是:条件不满足就挂起线程,等条件变了再唤醒。说白了就是「等通知,别傻等」。
// 一个简单的保护性暂停实现
typedef struct {
pthread_mutex_t mutex;
pthread_cond_t cond;
int ready; // 条件变量
void* data; // 共享数据
} GuardedObject;
void guarded_wait(GuardedObject* go) {
pthread_mutex_lock(&go->mutex);
while (!go->ready) { // 注意:用while不是if
pthread_cond_wait(&go->cond, &go->mutex);
}
// 条件满足,处理数据...
pthread_mutex_unlock(&go->mutex);
}
void guarded_signal(GuardedObject* go, void* new_data) {
pthread_mutex_lock(&go->mutex);
go->data = new_data;
go->ready = 1;
pthread_cond_signal(&go->cond); // 唤醒等待线程
pthread_mutex_unlock(&go->mutex);
}
个人习惯:条件检查一定要用while循环,不要用if。为什么?因为pthread_cond_wait可能被「伪唤醒」。我曾经踩过这个坑——用if检查,结果线程被唤醒后条件其实还没满足,直接往下走就出错了。
这个模式在生产者-消费者场景里特别常见。我做过一个日志系统,后台线程等日志队列有数据了才写文件,用的就是保护性暂停。
三、两阶段终止(Two-Phase Termination)
线程怎么优雅地停下来?很多人直接pthread_cancel,这太粗暴了。资源没释放、文件没关闭,直接杀掉线程会出大问题。
两阶段终止的思路是:先发个「准备停止」的信号,让线程自己收拾干净,然后再真正终止。
// 两阶段终止的典型实现
typedef struct {
pthread_t thread;
volatile int stop_requested; // 阶段1:停止请求标志
// 其他资源...
} Worker;
void* worker_routine(void* arg) {
Worker* w = (Worker*)arg;
while (!w->stop_requested) {
// 执行正常任务...
// 检查是否有停止请求
if (w->stop_requested) {
// 阶段2:清理资源
cleanup_resources();
break;
}
// 继续工作...
}
return NULL;
}
void worker_stop(Worker* w) {
// 阶段1:发送停止请求
w->stop_requested = 1;
// 等待线程自己退出(阶段2由线程内部完成)
pthread_join(w->thread, NULL);
}
关键点:volatile关键字不能少。编译器可能会优化掉对stop_requested的检查,导致线程永远停不下来。我遇到过这种bug,排查了一整天。
我曾经写过一个网络服务器,每个连接一个线程。关闭服务器时,先给所有线程发停止信号,等它们把未处理完的请求处理完、关闭socket,再真正退出。这就是两阶段终止的典型应用。
四、工作窃取(Work Stealing)
任务分配不均怎么办?有的线程忙死,有的线程闲死。工作窃取就是让空闲线程去「偷」忙线程的任务来做。
你想想看,每个线程有自己的任务队列。做完自己的任务后,看看别人队列尾部有没有任务,有的话就偷过来做。这样负载就自动均衡了。
// 工作窃取的核心逻辑(伪代码)
typedef struct {
TaskQueue* own_queue; // 自己的任务队列
TaskQueue** all_queues; // 所有线程的队列
int thread_id;
int total_threads;
} WorkerThread;
Task* steal_task(WorkerThread* wt) {
// 随机选一个其他线程的队列
int target = rand() % wt->total_threads;
if (target == wt->thread_id) return NULL;
// 从目标队列尾部偷任务(减少竞争)
return task_queue_steal(wt->all_queues[target]);
}
void* worker_loop(void* arg) {
WorkerThread* wt = (WorkerThread*)arg;
while (1) {
// 先处理自己的任务
Task* task = task_queue_pop(wt->own_queue);
if (!task) {
// 自己的队列空了,尝试偷任务
task = steal_task(wt);
}
if (task) {
execute_task(task);
} else {
// 所有队列都空,休眠等待
usleep(100);
}
}
}
我建议:偷任务时从队列尾部偷,而不是头部。因为头部通常是最近加入的任务,可能还在被原线程使用,从尾部偷可以减少锁冲突。
工作窃取在C语言里实现起来有点麻烦,因为没有现成的双端队列。我做过一个简易版本,用链表实现,每个节点加个锁。性能虽然比不上Java的ForkJoinPool,但应付一般场景够了。
四种模式对比
| 模式 | 解决什么问题 | 核心机制 | 适用场景 |
|---|---|---|---|
| 不变模式 | 共享数据被并发修改 | 对象不可变,无需加锁 | 配置信息、常量数据 |
| 保护性暂停 | 条件不满足时线程等待 | 条件变量 + 互斥锁 | 生产者-消费者、等待结果 |
| 两阶段终止 | 线程安全停止 | 停止标志 + 清理阶段 | 服务器关闭、资源清理 |
| 工作窃取 | 任务分配不均 | 双端队列 + 偷取机制 | 并行计算、任务调度 |
知识体系图
这四个模式,说白了就是解决并发编程中四个最头疼的问题:数据竞争、线程等待、线程终止、负载均衡。我个人习惯是,写代码前先想想「这个场景属于哪一类问题」,然后直接套用对应的模式,能省不少事。
避坑指南:我曾经在一个项目里同时用了保护性暂停和两阶段终止,结果条件变量和停止标志互相干扰,导致线程死锁。后来我把停止标志也放到条件变量的检查条件里,才解决问题。记住:多个同步机制叠加时,一定要考虑它们之间的交互。
嗯,今天就讲到这里。这些模式不是孤立的,实际项目中经常组合使用。比如工作窃取里可以用保护性暂停来等待任务,两阶段终止里可以用不变模式来保存清理时需要的数据。多练练,自然就熟练了。