第四章:线程间同步与通信——信号量、互斥量、事件集、邮箱、消息队列

各位同学,咱们今天聊点硬核的。线程间同步与通信,说白了就是让多个任务能好好说话、不打架。我在RT-Thread社区混了这么多年,见过太多因为同步没做好导致系统崩溃的案例。嗯,这节课我就把压箱底的经验掏出来,跟你们掰扯清楚。

4.1 信号量:最基础的同步工具

信号量,你可以把它想象成一个停车场门口的计数器。每进来一辆车,计数器减1;出去一辆,加1。当计数器为0时,后来的车就得排队等着。

核心要点:信号量主要用于“资源可用数量”的计数,或者作为“事件发生”的标志。

我在项目中遇到过这样一个场景:一个ADC采集任务,需要等DMA传输完成才能处理数据。用二值信号量就特别合适——DMA中断里释放信号量,采集任务等待信号量。代码长这样:

/* 创建二值信号量 */
rt_sem_t adc_sem = rt_sem_create("adc", 0, RT_IPC_FLAG_FIFO);

/* DMA中断回调 */
void dma_complete_callback(void)
{
    rt_sem_release(adc_sem);  // 释放信号量,通知采集任务
}

/* 采集任务 */
void adc_task_entry(void *parameter)
{
    while (1) {
        rt_sem_take(adc_sem, RT_WAITING_FOREVER);  // 等待DMA完成
        process_adc_data();  // 处理数据
    }
}

我的小建议:信号量适合“通知一次,消费一次”的场景。如果你需要多次通知,记得用计数信号量,别用二值信号量。

4.2 互斥量:解决优先级反转的利器

互斥量和信号量看着像,但有个本质区别:互斥量支持优先级继承。什么意思?我举个例子你就明白了。

假设有三个任务:高优先级任务A、中优先级任务B、低优先级任务C。C拿到了互斥量,A在等这个互斥量。这时候B开始运行,抢占了C的CPU时间。结果就是:A明明优先级最高,却被B间接阻塞了——这就是经典的优先级反转

互斥量的优先级继承机制会临时把C的优先级提升到A的级别,让C尽快释放互斥量。嗯,这个设计真的很巧妙。

我曾经踩过的坑:在中断服务函数里使用互斥量!互斥量涉及优先级继承,不能在中断上下文使用。如果你需要在中断和任务之间同步,请用信号量。

使用互斥量时,我建议遵循一个原则:谁持有,谁释放。千万别在一个任务里拿锁,在另一个任务里释放——这会导致优先级继承机制失效。

4.3 事件集:一对多的通知机制

事件集,说白了就是一个32位的标志位集合。你可以等待多个事件中的任意一个发生,或者等待所有事件都发生。这比信号量灵活多了。

我记得有个项目需要同时等待按键按下和定时器超时,用事件集就特别方便:

/* 创建事件集 */
rt_event_t event = rt_event_create("event", RT_IPC_FLAG_FIFO);

#define EVENT_KEY_PRESS    (1 << 0)
#define EVENT_TIMEOUT      (1 << 1)

/* 任务等待事件 */
void task_entry(void *parameter)
{
    rt_uint32_t recv_set = 0;
    
    while (1) {
        /* 等待按键按下或超时,任意一个发生就返回 */
        rt_event_recv(event, EVENT_KEY_PRESS | EVENT_TIMEOUT,
                      RT_EVENT_FLAG_OR | RT_EVENT_FLAG_CLEAR,
                      RT_WAITING_FOREVER, &recv_set);
        
        if (recv_set & EVENT_KEY_PRESS) {
            handle_key_press();
        }
        if (recv_set & EVENT_TIMEOUT) {
            handle_timeout();
        }
    }
}

关键区别:信号量是“计数”机制,事件集是“标志位”机制。事件集可以同时等待多个条件,信号量只能等待一个。

4.4 邮箱:小数据量的高效传递

邮箱,本质上是一个固定大小的消息队列,但每个消息只能放4个字节(一个指针或一个整数)。你想想看,如果你只需要传递一个状态码或者一个指针,用邮箱比用消息队列更轻量。

我在做传感器数据采集时,经常用邮箱传递数据指针:

/* 创建邮箱,容量为10 */
rt_mailbox_t mb = rt_mb_create("mb", 10, RT_IPC_FLAG_FIFO);

/* 发送任务 */
void sender_entry(void *parameter)
{
    void *data_buf = malloc(256);
    fill_data(data_buf);
    rt_mb_send(mb, (rt_uint32_t)data_buf);  // 发送指针
}

/* 接收任务 */
void receiver_entry(void *parameter)
{
    rt_uint32_t addr;
    rt_mb_recv(mb, &addr, RT_WAITING_FOREVER);
    void *data = (void *)addr;
    process_data(data);
    free(data);  // 记得释放内存
}

注意:邮箱传递的是值拷贝,不是引用。如果你传递指针,要确保指针指向的内存是有效的,并且处理好生命周期。

4.5 消息队列:复杂数据的传输通道

消息队列是邮箱的升级版。每个消息可以自定义大小,支持任意长度的数据。RT-Thread的消息队列内部做了数据拷贝,所以发送方和接收方不需要共享内存。

我曾经在一个网络协议栈项目里,用消息队列传递不同长度的网络包。代码结构大概是这样的:

/* 创建消息队列,每个消息最大128字节,队列深度20 */
rt_mq_t mq = rt_mq_create("mq", 128, 20, RT_IPC_FLAG_FIFO);

/* 发送网络包 */
void net_send_packet(uint8_t *data, uint16_t len)
{
    rt_mq_send(mq, data, len);  // 自动拷贝数据
}

/* 接收网络包 */
void net_recv_task(void *parameter)
{
    uint8_t buf[128];
    
    while (1) {
        rt_mq_recv(mq, buf, sizeof(buf), RT_WAITING_FOREVER);
        process_net_packet(buf);
    }
}

性能提醒:消息队列涉及内存拷贝,如果传递的数据很大(比如几KB),建议用邮箱传递指针,或者用共享内存+信号量的方式。

4.6 如何选择?我的实战经验

很多初学者会问:这么多同步机制,到底该用哪个?我根据多年经验,整理了一个选择指南:

场景 推荐机制 原因
资源计数(如缓冲池管理) 信号量 天然适合计数场景
保护共享资源(如全局变量) 互斥量 防止优先级反转
等待多个条件 事件集 支持多事件组合等待
传递小数据(4字节以内) 邮箱 轻量、高效
传递大数据或不定长数据 消息队列 自动拷贝,使用方便
中断与任务同步 信号量 中断中只能使用信号量和消息队列

我的核心建议:能用信号量解决的问题,就别用互斥量。能用邮箱传递的数据,就别用消息队列。越简单的机制,出问题的概率越小。

嗯,这节课的内容就到这里。记住一句话:同步机制没有银弹,理解每种机制的适用场景,比死记硬背API更重要。下一节课,我会讲内存管理,到时候再跟你们分享一些内存泄漏的排查经验。