信号量
以生活中的停车场为例来理解信号量的概念:
①当停车场空的时候,停车场的管理员发现有很多空车位,此时会让外面的车陆续进入停车场获得停车位;
②当停车场的车位满的时候,管理员发现已经没有空车位,将禁止外面的车进入停车场,车辆在外排队等候;
③当停车场内有车离开时,管理员发现有空的车位让出,允许外面的车进入停车场;待空车位填满后,又禁止外部车辆进入。
在此例子中,管理员就相当于信号量,管理员手中空车位的个数就是信号量的值(非负数,动态变化);停车位相当于公共资源(临界区),车辆相当于线程。车辆通过获得管理员的允许取得停车位,就类似于线程通过获得信号量访问公共资源。
信号量工作机制
信号量是一种轻型的用于解决线程间同步问题的内核对象,线程可以获取或释放它,从而达到同步或互斥的目的。
信号量工作示意图如下图所示,每个信号量对象都有一个信号量值和一个线程等待队列,信号量的值对应了信号量对象的实例数目、资源数目,假如信号量值为 5,则表示共有 5 个信号量实例(资源)可以被使用,当信号量实例数目为零时,再申请该信号量的线程就会被挂起在该信号量的等待队列上,等待可用的信号量实例(资源)。
信号量控制块
在 RT-Thread 中,信号量控制块是操作系统用于管理信号量的一个数据结构,由结构体 struct rt_semaphore
表示。另外一种 C 表达方式 rt_sem_t
,表示的是信号量的句柄,在 C 语言中的实现是指向信号量控制块的指针。信号量控制块结构的详细定义如下:
struct rt_semaphore
{
struct rt_ipc_object parent; /* 继承自 ipc_object 类 */
rt_uint16_t value; /* 信号量的值 */
};
/* rt_sem_t 是指向 semaphore 结构体的指针类型 */
typedef struct rt_semaphore* rt_sem_t;
rt_semaphore
对象从 rt_ipc_object
中派生,由 IPC 容器所管理,信号量的最大值是 65535。
信号量的管理方式
信号量控制块中含有信号量相关的重要参数,在信号量各种状态间起到纽带的作用。信号量相关接口如下图所示,对一个信号量的操作包含:创建/初始化信号量、获取信号量、释放信号量、删除/脱离信号量。
创建和删除信号量
当创建一个信号量时,内核首先创建一个信号量控制块,然后对该控制块进行基本的初始化工作,创建信号量使用下面的函数接口:
rt_sem_t rt_sem_create(const char *name,
rt_uint32_t value,
rt_uint8_t flag);
当调用这个函数时,系统将先从对象管理器中分配一个 semaphore 对象,并初始化这个对象,然后初始化父类 IPC 对象以及与 semaphore 相关的部分。在创建信号量指定的参数中,信号量标志参数决定了当信号量不可用时,多个线程等待的排队方式。当选择 RT_IPC_FLAG_FIFO
(先进先出)方式时,那么等待线程队列将按照先进先出的方式排队,先进入的线程将先获得等待的信号量;当选择 RT_IPC_FLAG_PRIO
(优先级等待)方式时,等待线程队列将按照优先级进行排队,优先级高的等待线程将先获得等待的信号量。
Note
注:
RT_IPC_FLAG_FIFO
属于非实时调度方式,除非应用程序非常在意先来后到,并且你清楚地明白所有涉及到该信号量的线程都将会变为非实时线程,方可使用RT_IPC_FLAG_FIFO
,否则建议采用RT_IPC_FLAG_PRIO
,即确保线程的实时性。
下表描述了该函数的输入参数与返回值:
参数 | 描述 |
---|---|
name | 信号量名称 |
value | 信号量初始值 |
flag | 信号量标志,它可以取如下数值: RT_IPC_FLAG_FIFO 或 RT_IPC_FLAG_PRIO |
返回 | |
RT_NULL | 创建失败 |
信号量的控制块指针 | 创建成功 |
系统不再使用信号量时,可通过删除信号量以释放系统资源,适用于动态创建的信号量。删除信号量使用下面的函数接口:
rt_err_t rt_sem_delete(rt_sem_t sem);
调用这个函数时,系统将删除这个信号量。如果删除该信号量时,有线程正在等待该信号量,那么删除操作会先唤醒等待在该信号量上的线程(等待线程的返回值是 -RT_ERROR
),然后再释放信号量的内存资源。下表描述了该函数的输入参数与返回值:
参数 | 描述 |
---|---|
sem | rt_sem_create() 创建的信号量对象 |
返回 | |
RT_EOK | 删除成功 |
初始化和脱离信号量
对于静态信号量对象,它的内存空间在编译时期就被编译器分配出来,放在读写数据段或未初始化数据段上,此时使用信号量就不再需要使用 rt_sem_create
接口来创建它,而只需在使用前对它进行初始化即可。初始化信号量对象可使用下面的函数接口:
rt_err_t rt_sem_init(rt_sem_t sem,
const char *name,
rt_uint32_t value,
rt_uint8_t flag)
当调用这个函数时,系统将对这个 semaphore 对象进行初始化,然后初始化 IPC 对象以及与 semaphore 相关的部分。信号量标志可用上面创建信号量函数里提到的标志。下表描述了该函数的输入参数与返回值:
参数 | 描述 |
---|---|
sem | 信号量对象的句柄 |
name | 信号量名称 |
value | 信号量初始值 |
flag | 信号量标志,它可以取如下数值: RT_IPC_FLAG_FIFO 或 RT_IPC_FLAG_PRIO |
返回 | |
RT_EOK | 初始化成功 |
脱离信号量就是让信号量对象从内核对象管理器中脱离,适用于静态初始化的信号量。脱离信号量使用下面的函数接口:
rt_err_t rt_sem_detach(rt_sem_t sem);
使用该函数后,内核先唤醒所有挂在该信号量等待队列上的线程,然后将该信号量从内核对象管理器中脱离。原来挂起在信号量上的等待线程将获得 -RT_ERROR
的返回值。下表描述了该函数的输入参数与返回值:
参数 | 描述 |
---|---|
sem | 信号量对象的句柄 |
返回 | |
RT_EOK | 脱离成功 |
获取信号量
线程通过获取信号量来获得信号量资源实例,当信号量值大于零时,线程将获得信号量,并且相应的信号量值会减 1,获取信号量使用下面的函数接口:
rt_err_t rt_sem_take (rt_sem_t sem, rt_int32_t time);
在调用这个函数时,如果信号量的值等于零,那么说明当前信号量资源实例不可用,申请该信号量的线程将根据 time
参数的情况选择直接返回、或挂起等待一段时间、或永久等待,直到其他线程或中断释放该信号量。如果在参数 time
指定的时间内依然得不到信号量,线程将超时返回,返回值是 -RT_ETIMEOUT
。下表描述了该函数的输入参数与返回值:
参数 | 描述 |
---|---|
sem | 信号量对象的句柄 |
time | 指定的等待时间,单位是操作系统时钟节拍(OS Tick) |
返回 | |
RT_EOK | 成功获得信号量 |
-RT_ETIMEOUT | 超时依然未获得信号量 |
-RT_ERROR | 其他错误 |
无等待获取信号量
当用户不想在申请的信号量上挂起线程进行等待时,可以使用无等待方式获取信号量,无等待获取信号量使用下面的函数接口:
rt_err_t rt_sem_trytake(rt_sem_t sem);
这个函数与 rt_sem_take(sem, RT_WAITING_NO)
的作用相同,即当线程申请的信号量资源实例不可用的时候,它不会等待在该信号量上,而是直接返回 -RT_ETIMEOUT
。下表描述了该函数的输入参数与返回值:
参数 | 描述 |
---|---|
sem | 信号量对象的句柄 |
返回 | |
RT_EOK | 成功获得信号量 |
-RT_ETIMEOUT | 获取失败 |
释放信号量
释放信号量可以唤醒挂起在该信号量上的线程。释放信号量使用下面的函数接口:
rt_err_t rt_sem_release(rt_sem_t sem);
例如当信号量的值等于零时,并且有线程等待这个信号量时,释放信号量将唤醒等待在该信号量线程队列中的第一个线程,由它获取信号量;否则将把信号量的值加 1。下表描述了该函数的输入参数与返回值:
参数 | 描述 |
---|---|
sem | 信号量对象的句柄 |
返回 | |
RT_EOK | 成功释放信号量 |
信号量应用示例
信号量的使用
假设我们有一个烧水的场景。我们需要烧水的设备(例如电水壶)和一位厨师。厨师需要烧水来煮饭,而电水壶需要等待水烧开后才能关闭。这里,我们可以使用信号量来实现厨师和电水壶之间的同步。
代码实现
#include <rtthread.h>
// 定义信号量
rt_sem_t sem;
// 厨师线程
void chef_thread(void *parameter)
{
rt_kprintf("厨师:开始烧水...\n");
//烧水
rt_thread_delay(RT_TICK_PER_SECOND * 5);
rt_kprintf("厨师:水烧开了!\n");
// 发送信号量
rt_sem_release(sem);
}
// 电水壶线程
void kettle_thread(void *parameter)
{
rt_kprintf("电水壶:等待水烧开...\n");
// 等待信号量
rt_sem_take(sem, RT_WAITING_FOREVER);
rt_kprintf("电水壶:水烧开了,关闭电源...\n");
}
int main(void)
{
// 创建信号量
sem = rt_sem_create("sem", 0, RT_IPC_FLAG_FIFO);
if (sem == RT_NULL)
{
rt_kprintf("创建信号量失败\n");
return -1;
}
// 创建厨师线程
rt_thread_t chef_tid = rt_thread_create("chef", chef_thread, RT_NULL, 1024, 10, 10);
if (chef_tid!= RT_NULL)
{
rt_thread_startup(chef_tid);
}
// 创建电水壶线程
rt_thread_t kettle_tid = rt_thread_create("kettle", kettle_thread, RT_NULL, 1024, 10, 10);
if (kettle_tid!= RT_NULL)
{
rt_thread_startup(kettle_tid);
}
return 0;
}
实验现象
生产者消费者例程
例程描述:
假设我们有一个餐厅,厨师(生产者)负责制作菜肴,服务员(消费者)负责将菜肴送给顾客。厨师将制作好的菜肴放在一个托盘(缓冲区)上,服务员从托盘上取走菜肴送给顾客。这里,我们使用生产者消费者模型来模拟厨师和服务员之间的协作。
代码实现:
#include <rtthread.h>
// 定义缓冲区
#define BUFFER_SIZE 5
rt_uint8_t buffer[BUFFER_SIZE];
rt_uint8_t in = 0;
rt_uint8_t out = 0;
// 信号量
rt_sem_t empty_sem = RT_NULL;
rt_sem_t full_sem = RT_NULL;
// 生产者线程(厨师)
void chef_thread(void *parameter)
{
rt_uint8_t dish = 0;
while (1)
{
// 制作菜肴
rt_kprintf("厨师:制作菜肴 %d...\n", dish);
rt_thread_delay(RT_TICK_PER_SECOND * 2);
// 将菜肴放入缓冲区
rt_sem_take(empty_sem, RT_WAITING_FOREVER);
buffer[in] = dish;
in = (in + 1) % BUFFER_SIZE;
rt_sem_release(full_sem);
rt_thread_delay(RT_TICK_PER_SECOND); // 添加延迟
dish++;
}
}
// 消费者线程(服务员)
void waiter_thread(void *parameter)
{
while (1)
{
// 等待菜肴
rt_sem_take(full_sem, RT_WAITING_FOREVER);
rt_uint8_t dish = buffer[out];
out = (out + 1) % BUFFER_SIZE;
rt_sem_release(empty_sem);
// 送给顾客
rt_kprintf("服务员:送给顾客菜肴 %d...\n", dish);
rt_thread_delay(RT_TICK_PER_SECOND * 2); // 添加延迟
}
}
int main(void)
{
// 创建信号量
empty_sem = rt_sem_create("empty", BUFFER_SIZE, RT_IPC_FLAG_FIFO);
full_sem = rt_sem_create("full", 0, RT_IPC_FLAG_FIFO);
// 创建生产者线程
rt_thread_t chef_tid = rt_thread_create("chef", chef_thread, RT_NULL, 1024, 10, 10);
if (chef_tid!= RT_NULL)
{
rt_thread_startup(chef_tid);
}
// 创建消费者线程
rt_thread_t waiter_tid = rt_thread_create("waiter", waiter_thread, RT_NULL, 1024, 10, 10);
if (waiter_tid!= RT_NULL)
{
rt_thread_startup(waiter_tid);
}
return 0;
}
实验现象:
信号量的使用场合
信号量是一种非常灵活的同步方式,可以运用在多种场合中。形成锁、同步、资源计数等关系,也能方便的用于线程与线程、中断与线程间的同步中。
线程同步
线程同步是信号量最简单的一类应用。例如,使用信号量进行两个线程之间的同步,信号量的值初始化成 0,表示具备 0 个信号量资源实例;而尝试获得该信号量的线程,将直接在这个信号量上进行等待。
当持有信号量的线程完成它处理的工作时,释放这个信号量,可以把等待在这个信号量上的线程唤醒,让它执行下一部分工作。这类场合也可以看成把信号量用于工作完成标志:持有信号量的线程完成它自己的工作,然后通知等待该信号量的线程继续下一部分工作。
锁(该功能仅做了解)
锁,单一的锁常应用于多个线程间对同一共享资源(即临界区)的访问。信号量在作为锁来使用时,通常应将信号量资源实例初始化成 1,代表系统默认有一个资源可用,因为信号量的值始终在 1 和 0 之间变动,所以这类锁也叫做二值信号量。如下图所示,当线程需要访问共享资源时,它需要先获得这个资源锁。当这个线程成功获得资源锁时,其他打算访问共享资源的线程会由于获取不到资源而挂起,这是因为其他线程在试图获取这个锁时,这个锁已经被锁上(信号量值是 0)。当获得信号量的线程处理完毕,退出临界区时,它将会释放信号量并把锁解开,而挂起在锁上的第一个等待线程将被唤醒从而获得临界区的访问权。
Note
注:在计算机操作系统发展历史上,人们早期使用二值信号量来保护临界区,但是在1990年,研究人员发现了使用信号量保护临界区会导致无界优先级反转的问题,因此提出了互斥量的概念。如今,我们已经不使用二值信号量来保护临界区,互斥量取而代之。
中断与线程的同步
信号量也能够方便地应用于中断与线程间的同步,例如一个中断触发,中断服务例程需要通知线程进行相应的数据处理。这个时候可以设置信号量的初始值是 0,线程在试图持有这个信号量时,由于信号量的初始值是 0,线程直接在这个信号量上挂起直到信号量被释放。当中断触发时,先进行与硬件相关的动作,例如从硬件的 I/O 口中读取相应的数据,并确认中断以清除中断源,而后释放一个信号量来唤醒相应的线程以做后续的数据处理。例如 FinSH 线程的处理方式,如下图所示。
信号量的值初始为 0,当 FinSH 线程试图取得信号量时,因为信号量值是 0,所以它会被挂起。当 console 设备有数据输入时,产生中断,从而进入中断服务例程。在中断服务例程中,它会读取 console 设备的数据,并把读得的数据放入 UART buffer 中进行缓冲,而后释放信号量,释放信号量的操作将唤醒 shell 线程。在中断服务例程运行完毕后,如果系统中没有比 shell 线程优先级更高的就绪线程存在时,shell 线程将持有信号量并运行,从 UART buffer 缓冲区中获取输入的数据。
Note
注:中断与线程间的互斥不能采用信号量(锁)的方式,而应采用开关中断的方式。
资源计数
信号量也可以认为是一个递增或递减的计数器,需要注意的是信号量的值非负。例如:初始化一个信号量的值为 5,则这个信号量可最大连续减少 5 次,直到计数器减为 0。资源计数适合于线程间工作处理速度不匹配的场合,这个时候信号量可以做为前一线程工作完成个数的计数,而当调度到后一线程时,它也可以以一种连续的方式一次处理多个事件。例如,生产者与消费者问题中,生产者可以对信号量进行多次释放,而后消费者被调度到时能够一次处理多个信号量资源。
Note
注:一般资源计数类型多是混合方式的线程间同步,因为对于单个的资源处理依然存在线程的多重访问,这就需要对一个单独的资源进行访问、处理,并进行锁方式的互斥操作。