第4章 线程间同步-信号量:从机制到实战
信号量,说白了就是操作系统里用来管理资源的“令牌”。谁拿到令牌,谁就能干活。拿不到的,乖乖排队等着。
我在做第一个RT-Thread项目时,对信号量的理解就停留在“嗯,大概是个计数器”。结果调试的时候,两个线程抢串口打印,打印出来的数据乱成一锅粥。从那以后,我才真正重视起信号量的用法。
4.1 信号量的工作机制
信号量的核心就是一个整数 + 一个等待队列。这个整数代表可用资源的数量。
- 获取信号量(take):如果计数值 > 0,减1,继续执行。如果计数值 = 0,线程挂起,进入等待队列。
- 释放信号量(release):计数值加1。如果有线程在等待,唤醒其中一个。
你想想看,这像不像食堂打饭?窗口有空位(资源 > 0),你就直接去打饭。没空位了,你就得排队等着。有人吃完走了(释放资源),排在最前面的人就能上去。
关键点:信号量的操作是原子的。RT-Thread内部会关中断或使用锁来保护这个计数值的修改。你不用担心两个线程同时take会导致数据错乱。
4.2 二值信号量与计数信号量
信号量分两种:二值信号量和计数信号量。名字听着玄乎,其实区别很简单。
| 类型 | 计数值范围 | 典型用途 | 我个人的理解 |
|---|---|---|---|
| 二值信号量 | 0 或 1 | 互斥访问、中断与线程同步 | 就是一个“是否可用”的开关 |
| 计数信号量 | 0 ~ 最大值 | 管理多个同类资源、生产者-消费者 | 就是一个“还剩几个”的计数器 |
二值信号量,说白了就是一把锁。要么有人用着(0),要么空闲(1)。我在项目中常用它来做中断与线程的同步——中断里释放信号量,线程里等待信号量,这样中断服务程序就能轻量快速地退出。
计数信号量,可以管理N个相同的资源。比如你有3个DMA通道,初始计数值设为3。每个线程用之前take一下,用完了release。这样最多允许3个线程同时使用DMA。
我曾经踩过的坑:把二值信号量当互斥量用,结果出现了优先级反转。后来才换成互斥量(mutex)。记住:二值信号量适合“通知”,互斥量适合“保护共享资源”。别搞混了。
4.3 经典生产者-消费者问题实战
生产者-消费者问题是多线程同步的经典考题。一个线程生产数据,一个线程消费数据。中间有个缓冲区。
问题在于:
- 缓冲区满了,生产者不能再生产,得等着。
- 缓冲区空了,消费者不能再消费,也得等着。
用信号量来解决,思路非常清晰:
- 用一个计数信号量
empty表示空位数量,初始值 = 缓冲区大小。 - 用一个计数信号量
full表示数据数量,初始值 = 0。 - 用一个互斥量
mutex保护缓冲区的读写操作。
下面是我在STM32上跑过的代码,你可以直接参考:
#include <rtthread.h>
#define BUFFER_SIZE 10
static rt_uint8_t buffer[BUFFER_SIZE];
static rt_uint8_t write_index = 0, read_index = 0;
/* 信号量和互斥量控制块 */
static rt_sem_t empty_sem;
static rt_sem_t full_sem;
static rt_mutex_t mutex;
/* 生产者线程 */
static void producer_entry(void *parameter)
{
rt_uint8_t data = 0;
while (1)
{
/* 等待空位 */
rt_sem_take(empty_sem, RT_WAITING_FOREVER);
/* 加锁保护缓冲区 */
rt_mutex_take(mutex, RT_WAITING_FOREVER);
buffer[write_index] = data;
write_index = (write_index + 1) % BUFFER_SIZE;
rt_mutex_release(mutex);
/* 通知消费者有数据了 */
rt_sem_release(full_sem);
data++;
rt_thread_mdelay(50); /* 模拟生产耗时 */
}
}
/* 消费者线程 */
static void consumer_entry(void *parameter)
{
rt_uint8_t data;
while (1)
{
/* 等待数据 */
rt_sem_take(full_sem, RT_WAITING_FOREVER);
/* 加锁保护缓冲区 */
rt_mutex_take(mutex, RT_WAITING_FOREVER);
data = buffer[read_index];
read_index = (read_index + 1) % BUFFER_SIZE;
rt_mutex_release(mutex);
/* 通知生产者有空位了 */
rt_sem_release(empty_sem);
rt_kprintf("消费: %d\n", data);
rt_thread_mdelay(100); /* 模拟消费耗时 */
}
}
/* 初始化 */
int semaphore_producer_consumer_init(void)
{
empty_sem = rt_sem_create("empty", BUFFER_SIZE, RT_IPC_FLAG_FIFO);
full_sem = rt_sem_create("full", 0, RT_IPC_FLAG_FIFO);
mutex = rt_mutex_create("mutex", RT_IPC_FLAG_FIFO);
rt_thread_t producer = rt_thread_create("producer",
producer_entry, RT_NULL, 1024, 5, 10);
rt_thread_t consumer = rt_thread_create("consumer",
consumer_entry, RT_NULL, 1024, 5, 10);
rt_thread_startup(producer);
rt_thread_startup(consumer);
return 0;
}
/* 使用 MSH 命令调用 */
MSH_CMD_EXPORT(semaphore_producer_consumer_init,
"start producer-consumer demo");
我的调试习惯:先用 rt_kprintf 打印每个信号量的当前值,确认take和release的配对是否正确。曾经有一次我忘了release empty信号量,结果生产者生产了10个数据后就永远停住了。打印一看,empty一直为0,问题瞬间定位。
4.4 信号量的核心流程
为了让你更直观地理解信号量的工作流程,我画了一张图:
4.5 避坑指南与调试技巧
信号量用起来简单,但坑也不少。我总结了几条经验:
- 初始化值要算清楚:计数信号量的初始值代表“一开始有多少资源可用”。生产者-消费者里,empty初始值是缓冲区大小,full初始值是0。搞反了,程序直接跑飞。
- 中断里只能release,不能take:中断服务程序里调用
rt_sem_take会导致线程挂起,但中断里不允许阻塞。RT-Thread会断言报错。 - 超时时间别设成永久等待:除非你100%确定信号量一定会被释放。否则一旦出bug,线程就永远卡死了。我习惯在调试阶段设一个有限超时,比如1000个tick,配合打印定位问题。
- 优先级反转:用二值信号量保护共享资源时,低优先级线程拿着信号量,高优先级线程等着,中优先级线程抢占了低优先级线程的CPU——高优先级反而跑不过中优先级。解决方案是用互斥量(mutex),它内置了优先级继承机制。
我曾经犯过的错:在一个中断里释放了信号量,但中断频率太高,导致信号量计数值溢出。RT-Thread默认会检查最大值,但如果你创建时设了 RT_IPC_FLAG_FIFO 没设上限,计数值会一直涨。后来我加了限流,确保release的频率不超过take的频率。
信号量是RT-Thread里最常用的同步手段之一。理解它的工作机制,掌握二值和计数的区别,再亲手跑一遍生产者-消费者的例子,后面遇到复杂的同步场景就不会慌了。