第十四章:多线程设计模式

多线程编程,说白了就是让多个工人同时干活。但工人多了,协调就成了大问题。我这些年做系统编程,踩过不少坑,也总结出几个经典的设计模式。今天咱们就聊聊这四个最常用的:单例模式、生产者-消费者、读者-写者,还有工作窃取。

核心观点:设计模式不是花架子,是前人用血泪换来的最佳实践。你想想看,如果每个多线程项目都从零开始设计同步策略,那得重复造多少轮子?

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