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字节或者可以传指针的,用邮箱。数据量大或者需要拷贝的,用消息队列。
最后说一句:同步机制的选择没有绝对的对错,只有合适不合适。我见过有人用信号量实现消息队列的功能,也能跑,但代码可读性差、维护困难。选对工具,事半功倍。选错工具,后面全是坑。
好了,这一章的内容就到这里。下一章我们聊聊内存管理,那可是另一个容易出问题的地方。