3、线程间同步机制:信号量原理与使用、互斥量与优先级继承、事件集机制、邮箱与消息队列、同步机制选型对比

各位同学,咱们今天聊聊RT-Thread里线程间同步的那些事儿。说实话,这部分内容在工业级产品里,比你想的还要重要。我见过太多系统跑飞、数据错乱,最后查下来都是同步机制没用好。

你想想看,一个系统里十几个线程同时跑,它们要访问共享资源、要互相传递数据、要协调执行顺序。如果没有一套可靠的同步机制,那不乱套才怪。RT-Thread给我们提供了五种主要的同步手段:信号量、互斥量、事件集、邮箱和消息队列。每种都有它的脾气和适用场景。

3.1 信号量原理与使用

信号量,说白了就是一个计数器。它用来控制多个线程对共享资源的访问。我习惯把它想象成一个停车场门口的显示屏——显示还有多少个空车位。

信号量有两种操作:take(获取)和release(释放)。获取时如果信号量值大于0,就减1然后继续执行;如果等于0,线程就挂起等待。释放时信号量值加1,如果有线程在等待,就唤醒一个。

核心要点:信号量适用于资源计数和简单的同步场景。比如控制一个缓冲区的访问,或者实现"生产者-消费者"模型。

来看个实际例子。我在一个数据采集项目中,用信号量控制DMA缓冲区的访问:

/* 创建信号量,初始值为1,表示资源可用 */
rt_sem_t sem = rt_sem_create("dma_sem", 1, RT_IPC_FLAG_FIFO);

/* 线程A:数据采集线程 */
void thread_a_entry(void *parameter)
{
    while (1)
    {
        /* 等待信号量,获取缓冲区访问权 */
        rt_sem_take(sem, RT_WAITING_FOREVER);
        
        /* 填充DMA缓冲区 */
        fill_dma_buffer();
        
        /* 释放信号量 */
        rt_sem_release(sem);
        
        rt_thread_mdelay(10);
    }
}

/* 线程B:数据处理线程 */
void thread_b_entry(void *parameter)
{
    while (1)
    {
        rt_sem_take(sem, RT_WAITING_FOREVER);
        
        /* 处理DMA缓冲区数据 */
        process_dma_data();
        
        rt_sem_release(sem);
        
        rt_thread_mdelay(20);
    }
}

我的经验:信号量的初始值很关键。初始值为1就是二值信号量,相当于互斥锁。初始值大于1就是计数信号量,适合管理多个同类资源。我曾经在一个项目中把初始值设错了,结果两个线程同时写缓冲区,数据全乱了。排查了两天才找到问题。

3.2 互斥量与优先级继承

互斥量,是信号量的一个特殊变种。它专门用来解决"优先级反转"这个老大难问题。

什么是优先级反转?我举个例子你就明白了。假设有三个线程:高优先级A、中优先级B、低优先级C。C拿到了互斥量,A在等C释放。这时候B突然就绪了,因为B优先级比C高,所以B抢占了C。结果A明明优先级最高,却要等B执行完,再等C释放互斥量。这就是优先级反转。

RT-Thread的互斥量实现了优先级继承协议。什么意思呢?当高优先级线程等待一个被低优先级线程持有的互斥量时,系统会临时把低优先级线程的优先级提升到和高优先级一样。这样中优先级的线程就没法插队了。

/* 创建互斥量 */
rt_mutex_t mutex = rt_mutex_create("protect_mutex", RT_IPC_FLAG_FIFO);

/* 高优先级线程 */
void high_prio_thread(void *parameter)
{
    while (1)
    {
        rt_mutex_take(mutex, RT_WAITING_FOREVER);
        
        /* 访问共享资源 */
        access_shared_resource();
        
        rt_mutex_release(mutex);
        
        rt_thread_mdelay(5);
    }
}

/* 低优先级线程 */
void low_prio_thread(void *parameter)
{
    while (1)
    {
        rt_mutex_take(mutex, RT_WAITING_FOREVER);
        
        /* 长时间占用资源 */
        long_running_operation();
        
        rt_mutex_release(mutex);
        
        rt_thread_mdelay(100);
    }
}

注意:互斥量只能在线程中使用,不能在中断服务函数中使用。因为优先级继承需要线程调度器的支持。另外,互斥量必须由同一个线程获取和释放,不能跨线程操作。我曾经见过新手在中断里释放互斥量,结果系统直接崩溃。

3.3 事件集机制

事件集,是我个人很喜欢的一个同步机制。它不像信号量那样只记录一个数值,而是可以同时管理多个事件标志。每个事件标志就是一个bit,32位的事件集最多可以管理32个不同的事件。

事件集支持两种等待模式:逻辑与和逻辑或。逻辑与就是所有指定的事件都发生了才唤醒;逻辑或是只要有一个事件发生就唤醒。这个灵活性在实际项目中非常有用。

/* 定义事件标志 */
#define EVENT_DATA_READY    (1 << 0)
#define EVENT_TIMEOUT       (1 << 1)
#define EVENT_ERROR         (1 << 2)

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

/* 等待多个事件,逻辑或模式 */
void worker_thread(void *parameter)
{
    rt_uint32_t recved_events;
    
    while (1)
    {
        /* 等待数据就绪或超时或错误,任何一个发生都唤醒 */
        rt_event_recv(event, 
                      EVENT_DATA_READY | EVENT_TIMEOUT | EVENT_ERROR,
                      RT_EVENT_FLAG_OR | RT_EVENT_FLAG_CLEAR,
                      RT_WAITING_FOREVER,
                      &recved_events);
        
        if (recved_events & EVENT_DATA_READY)
        {
            process_data();
        }
        else if (recved_events & EVENT_TIMEOUT)
        {
            handle_timeout();
        }
        else if (recved_events & EVENT_ERROR)
        {
            handle_error();
        }
    }
}

我的建议:事件集特别适合一个线程等待多个条件的场景。比如一个控制线程要等待传感器数据、按键输入、定时器超时等多个事件。用事件集一个API调用就能搞定,比用多个信号量优雅得多。

3.4 邮箱与消息队列

邮箱和消息队列,是用来在线程间传递数据的。它们俩的区别,说白了就是一个传小数据,一个传大数据。

邮箱传递的是指针,固定4字节。适合传递小数据或者指向数据块的指针。消息队列传递的是实际数据,可以自定义大小。适合传递结构体或者数据块。

特性 邮箱 消息队列
数据大小 固定4字节(指针) 自定义,最大可达4KB
传输方式 传指针,不拷贝数据 传数据,需要拷贝
内存开销 大(需要数据缓冲区)
适用场景 传递控制命令、状态码 传递数据包、结构体
/* 邮箱示例:传递命令 */
rt_mailbox_t mb = rt_mb_create("cmd_mb", 10, RT_IPC_FLAG_FIFO);

/* 发送线程 */
void sender_thread(void *parameter)
{
    void *cmd_ptr;
    
    while (1)
    {
        cmd_ptr = get_next_command();
        rt_mb_send(mb, (rt_uint32_t)cmd_ptr);
        rt_thread_mdelay(50);
    }
}

/* 消息队列示例:传递数据包 */
rt_mq_t mq = rt_mq_create("data_mq", 
                          sizeof(struct data_packet), 
                          20, 
                          RT_IPC_FLAG_FIFO);

/* 发送数据包 */
void send_data_packet(void)
{
    struct data_packet pkt;
    fill_packet(&pkt);
    rt_mq_send(mq, &pkt, sizeof(pkt));
}

避坑指南:我曾经在一个项目中用邮箱传递结构体指针,结果发送线程把结构体释放了,接收线程还在用。这就是典型的悬空指针问题。用邮箱传指针时,一定要确保指针指向的内存是有效的,而且要有明确的所有权管理。

3.5 同步机制选型对比

好了,五种同步机制都讲完了。你可能会问,那我到底该用哪个?嗯,这里我给大家一个选型指南。

同步机制 核心用途 能否在中断中使用 是否支持超时 典型场景
信号量 资源计数、同步 可以(释放操作) 支持 控制缓冲区访问、生产者-消费者
互斥量 保护共享资源 不可以 支持 保护全局变量、硬件寄存器
事件集 多条件等待 可以(发送事件) 支持 等待多个外设事件、状态机
邮箱 传递小数据 可以(发送) 支持 传递命令、状态码、指针
消息队列 传递大数据 可以(发送) 支持 传递数据包、日志消息

我个人习惯这样选:

  • 如果只是简单的资源互斥访问,用互斥量。别用信号量代替互斥量,优先级反转会让你头疼。
  • 如果需要计数或者同步多个线程的执行顺序,用信号量。
  • 如果一个线程要等好几个条件,用事件集。比创建多个信号量清爽多了。
  • 如果要传数据,先看数据大小。小于4字节或者可以传指针的,用邮箱。数据量大或者需要拷贝的,用消息队列。

最后说一句:同步机制的选择没有绝对的对错,只有合适不合适。我见过有人用信号量实现消息队列的功能,也能跑,但代码可读性差、维护困难。选对工具,事半功倍。选错工具,后面全是坑。

好了,这一章的内容就到这里。下一章我们聊聊内存管理,那可是另一个容易出问题的地方。