模块化编程中的多线程模块

多线程,说白了就是让程序同时干好几件事。我刚开始接触嵌入式多线程时,总觉得这东西是Linux上的专利。后来做了一款带网络交互的工业控制器,才意识到——嗯,裸机也能跑多线程,关键看你怎么封装。

今天咱们聊聊多线程模块的四个核心:线程封装、互斥锁与条件变量、线程池设计、原子操作。这些内容,我建议你当成工具箱里的常备件。

一、线程封装:别让OS细节污染你的业务代码

我在项目中遇到过最头疼的事:代码里到处是pthread_create、pthread_join,换一个RTOS就得重写一遍。后来我学乖了——做个抽象层。

先看一个简单的线程封装接口:

// thread.h
typedef void* (*thread_func_t)(void* arg);

typedef struct {
    void* handle;      // 平台相关句柄
    thread_func_t func;
    void* arg;
    char name[32];
    int priority;
    size_t stack_size;
} thread_t;

int thread_create(thread_t* t);
int thread_join(thread_t* t);
void thread_sleep(int ms);

这个结构体把线程的“身份证”都装进去了。名字、优先级、栈大小,这些在调试时特别有用。我记得有一次排查死锁,就是靠线程名字定位到具体任务的。

底层实现根据不同平台切换:

// thread_pthread.c
int thread_create(thread_t* t) {
    pthread_attr_t attr;
    pthread_attr_init(&attr);
    pthread_attr_setstacksize(&attr, t->stack_size);
    return pthread_create(
        (pthread_t*)&t->handle,
        &attr,
        t->func,
        t->arg
    );
}

你想想看,如果项目从Linux迁移到FreeRTOS,只需要换一个.c文件,上层业务代码完全不用动。这就是模块化的价值。

我的习惯:线程创建时一定要给名字。调试时打印日志,一眼就知道是哪个线程在干活。

二、互斥锁与条件变量:别让数据打架

多线程最怕什么?数据竞争。两个线程同时写一个变量,结果不可预测。互斥锁就是解决这个问题的。

但光有锁还不够。有时候线程需要等某个条件成立再继续,比如生产者-消费者模型。这时候条件变量就派上用场了。

看一个封装好的等待队列:

// sync.h
typedef struct {
    void* mutex;
    void* cond;
    int signaled;
} event_t;

int event_init(event_t* ev);
int event_wait(event_t* ev, int timeout_ms);
int event_signal(event_t* ev);
int event_broadcast(event_t* ev);

实现时要注意一点:条件变量必须和互斥锁配合使用。我曾经犯过一个错——先解锁再等待条件变量,结果信号在等待之前就发出了,线程永远等不到。

// 正确用法
int event_wait(event_t* ev, int timeout_ms) {
    pthread_mutex_lock(ev->mutex);
    while (!ev->signaled) {
        // 这里用while而不是if,防止虚假唤醒
        pthread_cond_timedwait(ev->cond, ev->mutex, &ts);
    }
    ev->signaled = 0;
    pthread_mutex_unlock(ev->mutex);
    return 0;
}
避坑指南:我曾经在条件变量等待时用了if而不是while,结果在高负载下偶发地跳过条件检查,导致数据错乱。记住:条件变量等待永远用while循环

三、线程池设计:别频繁创建销毁线程

频繁创建线程开销很大。我记得一个网络服务项目,每来一个请求就创建一个线程,结果系统负载一高,线程数飙到几百,上下文切换把CPU吃光了。

线程池的思路很简单:提前创建一批线程,任务来了就丢给空闲线程处理。

核心结构:

typedef struct task_s {
    void (*func)(void* arg);
    void* arg;
    struct task_s* next;
} task_t;

typedef struct {
    task_t* head;
    task_t* tail;
    int count;
    pthread_mutex_t lock;
    pthread_cond_t not_empty;
} task_queue_t;

typedef struct {
    pthread_t* threads;
    int thread_count;
    task_queue_t queue;
    int shutdown;
} thread_pool_t;

线程池的工作流程:

// 工作线程的循环
void* worker_loop(void* arg) {
    thread_pool_t* pool = (thread_pool_t*)arg;
    while (1) {
        pthread_mutex_lock(&pool->queue.lock);
        while (pool->queue.count == 0 && !pool->shutdown) {
            pthread_cond_wait(&pool->queue.not_empty, &pool->queue.lock);
        }
        if (pool->shutdown) {
            pthread_mutex_unlock(&pool->queue.lock);
            break;
        }
        // 取出任务
        task_t* task = pool->queue.head;
        pool->queue.head = task->next;
        pool->queue.count--;
        pthread_mutex_unlock(&pool->queue.lock);
        
        // 执行任务(注意:这里已经释放了锁)
        task->func(task->arg);
        free(task);
    }
    return NULL;
}

这里有个细节:执行任务时要释放锁。如果带着锁执行任务,其他线程连取任务都取不了,等于串行化了。

线程池大小怎么定?我一般按CPU核心数+1来设置。如果是IO密集型任务,可以适当增加。但别超过核心数的两倍,否则上下文切换开销会吃掉性能。

四、原子操作:无锁编程的基石

有些场景下,加锁太重了。比如一个计数器,多个线程同时加1。用互斥锁?太浪费。这时候原子操作就登场了。

C11标准提供了,但嵌入式环境不一定支持。我习惯自己封装一层:

// atomic.h
#if defined(__GNUC__) || defined(__clang__)
    #define atomic_add(ptr, val) __sync_fetch_and_add(ptr, val)
    #define atomic_sub(ptr, val) __sync_fetch_and_sub(ptr, val)
    #define atomic_inc(ptr)      __sync_fetch_and_add(ptr, 1)
    #define atomic_dec(ptr)      __sync_fetch_and_sub(ptr, 1)
    #define atomic_cas(ptr, old, new) __sync_bool_compare_and_swap(ptr, old, new)
#elif defined(_MSC_VER)
    #include <intrin.h>
    #define atomic_add(ptr, val) InterlockedExchangeAdd(ptr, val)
    // ... 其他平台适配
#endif

原子操作最经典的应用是引用计数:

typedef struct {
    atomic_int refcount;
    // ... 其他数据
} shared_data_t;

void ref(shared_data_t* data) {
    atomic_inc(&data->refcount);
}

void unref(shared_data_t* data) {
    if (atomic_dec(&data->refcount) == 1) {
        // 最后一个引用释放,可以安全销毁
        free(data);
    }
}

注意:原子操作只能保证单个变量的读写是原子的。如果你需要多个变量保持一致状态,还是得用锁。

我的经验:原子操作适合做计数器、标志位、引用计数。但别滥用——CAS循环在高冲突场景下性能反而比锁差。

知识体系总览

下面这张图把多线程模块的核心关系梳理清楚了:

多线程模块知识体系 多线程模块 线程封装 互斥锁+条件变量 线程池设计 原子操作 抽象接口:create/join/sleep 平台无关:pthread/FreeRTOS 命名与优先级管理 互斥锁:保护临界区 条件变量:等待/通知机制 while循环防虚假唤醒 任务队列管理 工作线程循环 优雅关闭机制 GCC/VC平台适配 引用计数与CAS

这张图把四个模块的关系和子内容都串起来了。你可以看到,线程池依赖互斥锁和条件变量,而原子操作是独立的基础工具。实际项目中,这四个模块经常组合使用。

好了,多线程模块就聊到这儿。记住:封装是为了复用,复用是为了少踩坑。下次写多线程代码时,试试把这些模块拿出来直接用,你会发现世界清净很多。


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