# 前情
PS: 以下内容参考 RT-Thread 标准版本文档
RTOS 的加入大大的减少了时序冲突,也带来了新的问题.
如何让线程 按照一定顺序执行?
线程的执行顺序由优先级决定
同等优先级读入顺序,顺序执行,单次最大执行时间由时间片决定
按这种情况,则会存在 一块 RAM 内 在同一时间内,被两个线程操作.
例如,LCD 显示 Camera 采集的图像这个需求.
仅仅依赖上述限制,就会出现,LCD 开开心心的在画图,摄像头啪的一下,很快啊,LCD 没有闪,LCD 被换了一幅图都没有发觉,然后继续画,就出现 "脏数据" , 图就显示异常了
这时候就涉及到了 共享内存的排他性,我们需要做到同一时间内有且仅有一个线程对共享内存进行操作,
如上面举的例子,LCD 和 Camera 操作内存的时候 需要两个线程需要互斥并且需要按一定顺序去执行,这样子才能实现目标功能
这种行为就被称为同步.
几个概念:
- 什么是同步?
同步是指按预定的先后次序进行运行.
线程的同步是指 多个线程 通过 特定机制 来控制线程之间的 执行顺序.
换而言之,没有同步,线程之间将是无序的.
并非说无序不好,如果线程和线程之间 本没有冲突 或 临界资源 那么,无序也无可厚非 - 什么是临界区?
多个线程同时 访问或操作 统一块区域 (代码). - 什么线程互斥
对于临界资源访问的排他性,当多个线程都需要使用临界资源时,任何时刻最多只允许一个线程去使用.
线程互斥可以视为一个特殊的线程同步
对于线程同步,无论使用什么方法,其核心都是: 临界区有且仅有最多一个线程运行
# 线程同步方式
# 信号量
# 什么是信号量:
RT-Thraed 官方 举的例子很形象,我就发表自己的见解了,下面来自 RT-Thraed 标准文档
- 当停车场空的时候,停车场的管理员发现有很多空车位,此时会让外面的车陆续进入停车场获得停车位
- 当停车场的车位满的时候,管理员发现已经没有空车位,将禁止外面的车进入停车场,车辆在外排队等候
- 当停车场内有车离开时,管理员发现有空的车位让出,允许外面的车进入停车场待空车位填满后,又禁止外部车辆进入
这个例子中,管理员相当于信号量,空车位的个数为信号量的值,停车区相当于临界区,车辆就相当于线程
车辆 获取 车位,可以视为 线程 通过尝试拿走信号量的值,来获取 临界资源
# 工作机制
每个信号量对象都有一个信号量值和等待线程队列
信号量的值对应了信号量对象的实例数目,资源数目
当信号量值为 0 时,线程再通过 信号量 申请进入临界区的时候,就会被挂起到该信号量的等待队列上,直到新的信号量实例产生
# 信号量控制
在 RT-Thread 中,信号量控制块 (scb) 是操作系统控制信号量的一个数据结构,由 struct rt_semaphore
表示,
其句柄由 rt_sem_t
表示.
简单来说,信号量控制块 在 C 语言 中的体现 是信号量结构体,而句柄就是指向这个数据类型的指针
在 rt-thraed 中 信号量 由 rt_ipc_object 派生,被 IPC 容器管理,最大值为 65535.
至于什么是容器,我理解 为同种功能 或 同种 通信方式共同存储的地方,例如 IPC 容器,就只存放进程间通信的 结构体,什么信号量,互斥量,管道,消息队列之类
更简单点说就是,我们把水果分为 苹果,香蕉,梨... 但是他们都是水果这个容器,但是 西红柿想进来就不行,因为他是蔬菜,不属于水果这个容器
PS: 初始值为 1 的信号量,为二值信号
# 信号量的管理
在 RT-Thread 中 关于 信号量的 以共有一下四种:
- 创建 / 初始化:
rt_sem_create/init()
.create
为动态创建,init
为静态创建 - 获取 :
rt_sem_take/trytake()
.trytake
是非堵塞 获取 信号量. - 释放 :
rt_sem_release()
. - 删除 / 脱离 :
rt_sem_delete/detach()
.detach
仅仅是将信号量从链表中移除
# 创建信号量
RT-Thread 中,创建信号量接口如下:
rt_sem_t rt_sem_create(const char *name, | |
rt_uint32_t value, | |
rt_uint8_t flag); |
当这个函数被调用的时候,系统会从对象管理器中申请一个 semaphore 对象,并初始化这个对象,然后初始化 父类 IPC 对象以及 semaphore 相关部分
函数参数中有一个 flag. 这个 参数 代表了当信号量值为 0 时,线程等待时的排序方式.RT_IPC_FLAG_FIFO
: 先进先出,线程采用队列的方式,谁先来谁先处理RT_IPC_FLAG_PRIO
: 等待队列按优先级排队,
PS: RT_IPC_FLAG_FIFO
属于非实时调度方式,除非应用程序非常在意先来后到,否则建议采用 RT_IPC_FLAG_PRIO, 即确保线程的实时性
# 删除信号量
删除 create 生成的信号量
rt_err_t rt_sem_delete(rt_sem_t sem); |
当释放 信号量时,有线程正在等待信号量,那么函数会先唤醒等待在该信号量上的线程,然后 再释放信号量资源.
# 初始化信号量 和 脱离信号量
对于静态信号量,内存空间再编译时就被分配了,放在数据段或未初始化的数据段上,就像放在 猫舍的 猫猫
这时候,只需要 init 信号量即可,让其回到自己家中
rt_err_t rt_sem_init(rt_sem_t sem, // 信号量对象的句柄 | |
const char *name, // 信号量名称 | |
rt_uint32_t value, // 信号量初始值 | |
rt_uint8_t flag) // 信号量标志 |
与动态创建相比,init 少了申请内存空间的部分,其他还是一致的
与动态创建的信号量不同的是,静态的信号量无法被释放,只能从内核对象管理器中移除,
毕竟空间是编译过程中分配出来的,又不能让程序自己再编译自己一次
函数接口如下:
rt_err_t rt_sem_detach(rt_sem_t sem); |
于动态创建的 信号量相似,内核先唤醒挂在该信号等待列表上的线程,然后将这信号量从内核管理器上脱离.
等待线程会获得一个错误码.
PS: 对于那些一直要使用的 信号量 建议直接设置为静态的,这样子可以保证线程启动时,即存在信号量.
对于 在一定时间内使用的信号量,或者是间歇性使用的信号量,则 使用 create 来创建,这样子有利于内存资源 的重复利用
# 获取信号量
在 RT-Thread 中 存在两种 获取信号量的方式:阻塞获取 和 非阻塞获取
对于阻塞获取:
当线程执行到获取信号量时,如果 信号量 非空,则使信号量 -1, 并执行对应的程序
如果为空,申请该信号量的线程将根据 time 参数的情况选择直接返回、或挂起等待一段时间、或永久等待,直到其他线程释放信号量或者超时
rt_err_t rt_sem_take (rt_sem_t sem, // 信号量对象的句柄 | |
rt_int32_t time // 指定的等待时间,单位是操作系统时钟节拍 | |
); |
time
参数除了正常 的参数之外有两个宏,分别代表,永久等待 和 直接返回.RT_WAITING_NO
和 RT_WAITING_FOREVER
.
对于非阻塞获取:
如果 我们不想线程某个线程 在数据采集时 就被挂起,则可以使用 无等待的方式获取信号量
接口如下:
rt_err_t rt_sem_trytake(rt_sem_t sem); |
当 信号量值不可用时,线程直接返回 -RT_ETIMOUT
然后回到正常执行线程其他程序
PS: 当 time
参数设置为 RT_WAITING_NO
时 等价于 非阻塞获取
# 释放信号量
释放信号量可以唤醒挂起在该信号量上的线程.
接口如下:
rt_err_t rt_sem_release(rt_sem_t sem); |
当 信号量的等待线程中没有线程时,信号量值 +1, 如果存在等待线程则由该线程获取其产生的信号
# 应用场合
我觉得 信号量十分适合 传感器数据采集线程 和 显示线程的同步.
准确来说,但凡涉及到多个线程同时访问 统一临界资源的时候就很合适,毕竟 "脏数据" 还是很烦人的
中断和线程之间也可以 使用信号量来 同步
最典型的就是串口通信.
当串口没有收到数据的时候,数据处理线程被挂起
一旦串口收到数据接收完数据后释放信号量,如上文所属,当等待列表有线程挂起时,信号量已就位就会直接启动等待线程队列的线程
最典型的例子就是 FinSH, msh 交互时响应速度极快
还可以用于资源计数器
这一块就类似 CPU 和高速缓存。可能是数据采集线程过于慢 而 数据处理线程需要数据过多且处理速度快,
这样子就可以 初始化信号量不为 1 , 这样子通过多次 take 信号量直到信号量位空才开始工作,就能达到 降速同步的效果
PS: 一般资源计数类型多是混合方式的线程间同步,因为对于单个的资源处理依然存在线程的多重访问,这就需要对一个单独的资源进行访问、处理,并进行锁方式的互斥操作.
# 互斥量
# 什么是互斥量
这一块官方的例子就不太合适了.
我来说一说我的理解,我觉得信号量就是一个示波器,当你在使用的时候,其他人就不能使用,只有等你使用完毕后,其他人才能去使用
# 工作机制
互斥量可以视为 一个比较特殊的 信号量,因为只有 0 和 1.
但与信号量不同的地方在于,互斥量支持递归访问,和 避免优先级反转.
例如,数据处理优先级的高,但是由于数据并没有次啊及完毕,处理线程只能先挂起,等待采集线程执行完毕
这时就可以考虑使用互斥锁.
互斥量只有两种状态,开锁 | 闭锁。当有线程持有它时,互斥量处于上锁状态。当这个线程解锁互斥量时,互斥量处于 空闲态.
当一个线程持有 互斥量时,其他线程无法对齐进行开锁或持有,而且互斥量的拥有者能再此获得这个锁.
就类似于你给你的保险柜又套了一个更大的保险柜.
官方文档里有一句话说的不太明白:这个特性与一般的二值信号量有很大的不同:在信号量中, 因为已经不存在实例, 线程递归持有会发生主动挂起(最终形成死锁)
可能官方认为看到这的都是有很扎实 操作系统功底 的人,所以没有介绍一下 什么是递归持有,什么是死锁以及 递归持有 是如何形成死锁的
我在这简单的说一下下,毕竟我 OS 学的也很烂,要不然也不至于来看 理解多线程编程
死锁:
所谓的死锁就是 两个或两个以上的线程互相持有对方所需要的资源,如果线程不主动释放资源,两个程序都处于挂起态,一直不被执行
产生死锁需要满足一些条件:
- 互斥:释放和拿走信号都是一个线程,而且这个信号量最大值为 1.
- 请求和保持:一个线程中存在释放和获取两个操作
- 不剥夺:信号量的最大值为 1
- 循环等待:在 RTT 中为 rt_sem_take 使用
RT_WAITING_FOREVER
参数
一般来说,单线程死锁只可能是递归持有 (虽然我也不明白为什么要递归持有)
满足以上条件的 代码大概长成这样 (不是死锁):
void thread1_entry(void *parameter) | |
{ | |
// 互斥 只有 0 和 1, 我拿走后,其他线程想用只能等我释放 | |
rt_sem_create("sem_lock", 1, RT_IPC_FLAG_PRIO); | |
while (1) | |
{ | |
// 尝试上锁,拿不到就死等, | |
rt_sem_take(&sem_lock, RT_WAITING_FOREVER); // 获取信号量,获取成功后就可以对临界区数据写入 | |
handle_RecData(RecBuf); // 这里进行数据处理,RecBuf 为临界区数据 | |
rt_sem_release(&sem_lock); // 释放信号量,让其他线程可以处理临界区数据 | |
// 开锁,我不放开,没有人能拿到这个锁 | |
} | |
} |
现实中死锁的例子大概就像这样:
两个线程的死锁大概是这样子的 (写代码有些麻烦,就直接画个图,很丑つ﹏⊂)
双横线的箭头表示当前执行到的位置,单线箭头表示 语句之间的等待关系
线程 1 和 线程 2 相互等待所需的资源被释放就形成死锁
递归持有
递归还是很熟悉的,所谓的递归持有就是在一个线程中多次 take 了 同一个信号量.
但由于 该信号量是二值信号,在第一次被 take 之后,信号量就归为 0. 如果没有外部的线程释放信号量,或者当前线程主动释放信号量,那么就会产生死锁
代码在 rt-thread 中的 样子大概是这样的
void thread1_entry(void *parameter) | |
{ | |
while (1) | |
{ | |
/* 临 界 区,上 锁 进 行 操 作 */ | |
// 获取信号量,此时信号量为被获取,变成了 0 | |
rt_sem_take(&sem_lock, RT_WAITING_FOREVER); | |
/* | |
操作临界区 1 | |
*/ | |
rt_sem_take(&sem_lock, RT_WAITING_FOREVER); | |
// 想再次获取,但是此时由于信号量为 0, 获取不到就一直等待, | |
// 但是释放信号量的操作在下面,所以信号量根本释放不了,这个线程就锁死在上面那句语句,成了解不开的死锁 | |
// 所以下面的部分永远不会执行到 | |
/* | |
操作临界区 2 | |
*/ | |
rt_sem_release(&sem_lock); // 释放信号量,让其他线程可以处理临界区数据 | |
rt_sem_release(&sem_lock); // 释放信号量,让其他线程可以处理临界区数据 | |
} | |
} |
使用二值信号进行递归持有很容易导致死锁,所以遇到这种情况建议直接使用 互斥量.
关于优先级反转
所谓的优先级反转就是,因为逻辑设计不合理而导致线程真正的优先级和预设的优先级不同.
当 高优先级的线程 A
和 低优先级的线程 B
共享相同资源 buf 时,
会存在 低优先级 B
占用 buf
, 而 高优先级的 A
已就绪,
这时候就会 切换到 A
, 然而 buf
被占用, A
只能先被挂起,等待 B
释放 buf
当这时候如果存在一个优先级介于 A
B
之间的线程 C
, 且 C
在 B
未释放 buf
的时候就 进入了就绪态
那么 B
就会被中断,先去执行 C
, 这样子就导致 A
线程的优先级比 C
高,但 后于 C
执行
这就算优先级反转
# 互斥量的管理方式
与信号量类似,互斥量 也分为 静态和动态 创建,对应的废弃方式也是分为 脱离 和 删除.
释放和获取的接口是一致的
# 创建和删除互斥量
这一块是 动态 创建和删除 互斥量
创建互斥量
接口如下:
rt_mutex_t rt_mutex_create (const char* name, // 互斥量的名称 | |
rt_uint8_t flag // 已作废,均按 RT_IPC_FLAG_PRIO 处理 | |
); |
虽然官方说作废了,但在两个初始化中还是有赋值的操作的,只不过在 take 时进行了修改,只建议各位手动填入 RT_IPC_FLAG_PRIO
.
删除互斥量
接口如下:
rt_err_t rt_mutex_delete (rt_mutex_t mutex); |
同样的,所有被挂起的线程都会被环形,并获得一个 其他错误的 错误返回值
# 初始化和脱离互斥量
以下方法适用于静态互斥量
初始化互斥量
rt_err_t rt_mutex_init (rt_mutex_t mutex, // 互斥量对象的句柄 | |
const char* name, // 互斥量的名称 | |
rt_uint8_t flag // 该标志已经作废 | |
); |
脱离互斥量
rt_err_t rt_mutex_detach (rt_mutex_t mutex); |
使用该函数接口后,内核先唤醒所有挂在该互斥量上的线程(线程的返回值是 -RT_ERROR), 然后系统将该互斥量从内核对象管理器中脱离
# 获取互斥量
任何时刻,互斥量只能被一个线程持有
rt_err_t rt_mutex_take (rt_mutex_t mutex, // 被申请的互斥量 | |
rt_int32_t time); // 超时时间 |
如果互斥量没有被控制,那么线程成功获取该互斥量.
如果互斥量已经被当前线程线程控制,则该互斥量的持有计数加 1, 当前线程也不会挂起等待。这样就可以避免递归持有导致锁死
如果互斥量已经被其他线程占有,则当前线程在该互斥量上挂起等待,直到其他线程释放它或者等待时间超过指定的超时时间
# 无等待获取互斥量
当用户不想在申请的互斥量上挂起线程进行等待时,可以使用无等待方式获取互斥量,无等待获取互斥量使用下面的函数接口:
rt_err_t rt_mutex_trytake(rt_mutex_t mutex); |
# 释放互斥量
线程完成互斥量所控制的资源访问后,应该尽快的释放其控制的互斥量,使得其他线程能及时获取该互斥量
避免加锁过久导致实时性降低
rt_err_t rt_mutex_release(rt_mutex_t mutex); |
使用该函数的线程必须是该互斥量的所有者,每释放一次该互斥量,持有数就 -1.
当该互斥量的持有计数为零时,互斥量回复空闲状态,可被其他线程获取,等待在该信号量上的线程将被唤醒
如果线程的运行优先级被互斥量提升。那么当互斥量被释放后,线程恢复为持有互斥量前的优先级
# 应用场景
互斥量的使用比较单一,因为它是信号量的一种,并且它是以锁的形式存在
在初始化的时候,互斥量永远都处于开锁的状态,而被线程持有的时候则立刻转为闭锁的状态.
互斥量更适合于:
- 单一线程内部信号量多次持有信号量,导致 无法释放 (目前没有遇到,遇到再处理)
- 可能会由于多线程同步而造成优先级翻转的情况,这个比较适合用在 单一传感器 需要被多个线程获取境界资源的场景
# 事件集
# 什么是事件集
事件集是线程同步的一种方式,一个事件集可以包含多个世界,利用事件可以完成一对多,多对多的线程间同步.
例如,当按键按下 LED 点亮,或者 ADC 检测到电压过低 OLED , LED, 蜂鸣器同时报警.
RTT 官方的例子很不错。我就直接搬过来啦
以下文字来源于 RTT 文档中心 -->RT-Thread 标准版 --> 内核 --> 事件集部分
以坐公交为例说明事件,在公交站等公交时可能有以下几种情况:
①P1 坐公交去某地,只有一种公交可以到达目的地,等到此公交即可出发
②P1 坐公交去某地,有 3 种公交都可以到达目的地,等到其中任意一辆即可出发
③P1 约另一人 P2 一起去某地,则 P1 必须要等到 “同伴 P2 到达公交站” 与 “公交到达公交站” 两个条件都满足后,才能出发
将 P1 去某地视为 线程,将 “公交到达公交站”、“同伴 P2 到达公交站” 视为 事件 的发生,
情况①是特定事件唤醒线程
情况②是任意单个事件唤醒线程
情况③是多个事件同时发生才唤醒线程
# 工作机制
与信号量不同,它可以实现一对多,多对多的同步.
即一个线程和多个事件的关系可设置为:
- 任一事件唤醒线程
- 多个事件共同唤醒一个线程
- 多个线程同步多个事件
在 RT-Thread 中 事件集的特点:
- 事件至于线程相关,事件之间相互独立:每个线程有 32 个事件标志位,采用 32bit 的无符号整型数据进行记录,每一个 bit 代表一个事件;
- 事件仅有同步功能,无法传输数据
- 事件无排队性,多次向线程发送同一事件 (如果未来得及读走), 其效果等同于之发送一次
在 RT-Thread 中,每个线程都拥有一个事件信息标记,它有三个属性,
RT_EVENT_FLAG_AND
(逻辑与),RT_EVENT_FLAG_OR
(逻辑或)RT_EVENT_FLAG_CLEAR
(清除标记)
当线程等待事件同步时,可以通过 32 个事件标志 和 这个事件信息标记来判断当前接收的事件是否满足同步条件
# 管理方式
RT-Thread 的接口做的还是很不错的,事件集的接口 和 信号量,互斥量的接口都十分类似
都分为静态和动态创建删除,再加特有的 发送和接收 函数
# 创建和删除事件集
以下为动态创建控制块
创建
创建 信号量的时候,内核会先创建一个 事件集控制块,然后进行初始化
rt_event_t rt_event_create(const char* name, // 事件集的名称 | |
rt_uint8_t flag // RT_IPC_FLAG_FIFO 或 RT_IPC_FLAG_PRIO | |
); |
调用该函数接口时,系统会从对象管理器中分配事件集对象,并初始化这个对象,然后初始化父类 IPC 对象
看看人家,框架设计的好,写代码都轻松很多
删除
delete 只能删除 create 出来的事件
rt_err_t rt_event_delete(rt_event_t event); |
# 初始化和脱离事件集
初始化
rt_err_t rt_event_init( rt_event_t event, // 事件集对象的句柄 | |
const char* name, // 事件集的名称 | |
rt_uint8_t flag // 事件集的标志 | |
); |
调用该接口时,需指定静态事件集对象的句柄(即指向事件集控制块的指针), 然后系统会初始化事件集对象,并加入到系统对象容器中进行管理
脱离
rt_err_t rt_event_detach(rt_event_t event); |
用户调用这个函数时,系统首先唤醒所有挂在该事件集等待队列上的线程(线程的返回值是 - RT_ERROR), 然后将该事件集从内核对象管理器中脱离
# 发送事件
发送事件函数可以发送事件集中的一个或多个事件
rt_err_t rt_event_send( rt_event_t event, // 事件集对象的句柄 | |
rt_uint32_t set // 发送的一个或多个事件的标志值 | |
); |
# 接收事件
内核使用 32 位的无符号整数来标识事件集,它的每一位代表一个事件,因此一个事件集对象可同时等待接收 32 个事件.
内核可以通过指定选择参数 “逻辑与” 或 “逻辑或” 来选择如何激活线程
使用 “逻辑与” 参数表示只有当所有等待的事件都发生时才激活线程,
而使用 “逻辑或” 参数则表示只要有一个等待的事件发生就激活线程
rt_err_t rt_event_recv(rt_event_t event, // 事件集对象的句柄 | |
rt_uint32_t set, // 接收线程感兴趣的事件 | |
rt_uint8_t option, // 接收选项 | |
rt_int32_t timeout, // 指定超时时间 | |
rt_uint32_t* recved // 指向接收到的事件 | |
); |
option
的可选参数如下:
RT_EVENT_FLAG_OR
: 逻辑与RT_EVENT_FLAG_AND
: 逻辑或RT_EVENT_FLAG_CLEAR
: 选择清除重置事件标志位
当用户调用接口时,系统根据 set
参数 和 接收选项 option
来判断它要接受的事件是否发生
如果已经发生,则根据参数 option
上是否设置有 RT_EVENT_FLAG_CLEAR
来决定是否重置事件的相应标志位
如果没有发生,则把等待的 set
和 option
参数填入线程本身的结构中,然后把线程挂起在此事件上,直到其等待的事件满足条件或等待时间超过指定的超时时间
# 应用场景
我自己目前没用过,事件集 很难说它的适用场景,下面这段话直接来自于 RT-Thread 官方.
等我琢磨明白了我再写篇博客,举个实际的例子 (T_T).
事件集可使用于多种场合,它能够在一定程度上替代信号量,用于线程间同步.
一个线程或中断服务例程发送一个事件给事件集对象,而后等待的线程被唤醒并对相应的事件进行处理.
但是它与信号量不同的是,事件的发送操作在事件未清除前,是不可累计的,而信号量的释放动作是累计的.
事件的另一个特性是,接收线程可等待多种事件,即多个事件对应一个线程或多个线程.
同时按照线程等待的参数,可选择是 “逻辑或” 触发还是 “逻辑与” 触发.
这个特性也是信号量等所不具备的,信号量只能识别单一的释放动作,而不能同时等待多种类型的释放.
# 笔记总结
写的代码太少,以至于很多很难分出 三者之间的应用场景.
举不出例子,明天试着拿知识做点小实验.
大道五十,天衍四十九,人遁其一!