多线程编程设计模式:线程安全单例模式、生产者-消费者模式、读者-写者模式、工作窃取模式、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 之后就不能再改了。这样语义清晰,也避免了很多并发问题。

知识体系总览

这五种模式各有各的适用场景。我画了张图,帮你理清思路:

多线程编程设计模式知识体系 并发设计模式 线程安全单例 双重检查锁定 生产者-消费者 环形缓冲区+条件变量 读者-写者 读写锁优化 工作窃取 双端队列+负载均衡 Future/Promise 异步结果获取 每种模式解决特定的并发问题,选择时需考虑读写比例、任务粒度、延迟要求 读多写少 任务调度 异步编程

模式对比与选择

实际项目中怎么选?我整理了个表格,方便你对照:

模式 核心思想 适用场景 常见陷阱
线程安全单例 全局唯一实例 配置管理、日志记录器 双重检查锁定的 volatile 缺失
生产者-消费者 解耦生产与消费 日志系统、任务队列 虚假唤醒、缓冲区溢出
读者-写者 读并发、写互斥 配置中心、缓存 写者饥饿、锁升级死锁
工作窃取 动态负载均衡 并行计算、Fork/Join 框架 ABA 问题、任务粒度太小
Future/Promise 异步结果获取 异步 I/O、并行计算 忘记处理超时、回调地狱

总结一下:这五种模式不是孤立的。我经常把 Future 和生产者-消费者结合起来用——生产者提交任务返回 Future,消费者执行完设置结果。工作窃取也可以和 Future 搭配,实现真正的异步并行。嗯,多线程编程就是这样,模式是死的,人是活的。

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