# 前情

PS: 以下内容参考 RT-Thread 标准版本文档
RTOS 的加入大大的减少了时序冲突,也带来了新的问题.
如何让线程 按照一定顺序执行?
线程的执行顺序由优先级决定
同等优先级读入顺序,顺序执行,单次最大执行时间由时间片决定

按这种情况,则会存在 一块 RAM 内 在同一时间内,被两个线程操作.
例如,LCD 显示 Camera 采集的图像这个需求.
仅仅依赖上述限制,就会出现,LCD 开开心心的在画图,摄像头啪的一下,很快啊,LCD 没有闪,LCD 被换了一幅图都没有发觉,然后继续画,就出现 "脏数据" , 图就显示异常了

这时候就涉及到了 共享内存的排他性,我们需要做到同一时间内有且仅有一个线程对共享内存进行操作,
如上面举的例子,LCD 和 Camera 操作内存的时候 需要两个线程需要互斥并且需要按一定顺序去执行,这样子才能实现目标功能
这种行为就被称为同步.

几个概念:

  1. 什么是同步?
    同步是指按预定的先后次序进行运行.
    线程的同步是指 多个线程 通过 特定机制 来控制线程之间的 执行顺序.
    换而言之,没有同步,线程之间将是无序的.
    并非说无序不好,如果线程和线程之间 本没有冲突 或 临界资源 那么,无序也无可厚非
  2. 什么是临界区?
    多个线程同时 访问或操作 统一块区域 (代码).
  3. 什么线程互斥
    对于临界资源访问的排他性,当多个线程都需要使用临界资源时,任何时刻最多只允许一个线程去使用.
    线程互斥可以视为一个特殊的线程同步

对于线程同步,无论使用什么方法,其核心都是: 临界区有且仅有最多一个线程运行

# 线程同步方式

# 信号量

# 什么是信号量:

RT-Thraed 官方 举的例子很形象,我就发表自己的见解了,下面来自 RT-Thraed 标准文档

  1. 当停车场空的时候,停车场的管理员发现有很多空车位,此时会让外面的车陆续进入停车场获得停车位
  2. 当停车场的车位满的时候,管理员发现已经没有空车位,将禁止外面的车进入停车场,车辆在外排队等候
  3. 当停车场内有车离开时,管理员发现有空的车位让出,允许外面的车进入停车场待空车位填满后,又禁止外部车辆进入

这个例子中,管理员相当于信号量,空车位的个数为信号量的值,停车区相当于临界区,车辆就相当于线程
车辆 获取 车位,可以视为 线程 通过尝试拿走信号量的值,来获取 临界资源

# 工作机制

每个信号量对象都有一个信号量值和等待线程队列
信号量的值对应了信号量对象的实例数目,资源数目
当信号量值为 0 时,线程再通过 信号量 申请进入临界区的时候,就会被挂起到该信号量的等待队列上,直到新的信号量实例产生

# 信号量控制

在 RT-Thread 中,信号量控制块 (scb) 是操作系统控制信号量的一个数据结构,由 struct rt_semaphore 表示,
其句柄由 rt_sem_t 表示.
简单来说,信号量控制块 在 C 语言 中的体现 是信号量结构体,而句柄就是指向这个数据类型的指针
在 rt-thraed 中 信号量 由 rt_ipc_object 派生,被 IPC 容器管理,最大值为 65535.

至于什么是容器,我理解 为同种功能 或 同种 通信方式共同存储的地方,例如 IPC 容器,就只存放进程间通信的 结构体,什么信号量,互斥量,管道,消息队列之类
更简单点说就是,我们把水果分为 苹果,香蕉,梨... 但是他们都是水果这个容器,但是 西红柿想进来就不行,因为他是蔬菜,不属于水果这个容器

PS: 初始值为 1 的信号量,为二值信号

# 信号量的管理

在 RT-Thread 中 关于 信号量的 以共有一下四种:

  1. 创建 / 初始化: rt_sem_create/init() . create 为动态创建, init 为静态创建
  2. 获取 : rt_sem_take/trytake() . trytake 是非堵塞 获取 信号量.
  3. 释放 : rt_sem_release() .
  4. 删除 / 脱离 : rt_sem_delete/detach() . detach 仅仅是将信号量从链表中移除
    alt 图片来源: https://www.rt-thread.org/document/site/#/rt-thread-version/rt-thread-standard/programming-manual/ipc1/ipc1?id=%e4%bf%a1%e5%8f%b7%e9%87%8f

# 创建信号量

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_NORT_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.
  2. 请求和保持:一个线程中存在释放和获取两个操作
  3. 不剥夺:信号量的最大值为 1
  4. 循环等待:在 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); // 释放信号量,让其他线程可以处理临界区数据
    // 开锁,我不放开,没有人能拿到这个锁
	}
}

现实中死锁的例子大概就像这样:
alt

两个线程的死锁大概是这样子的 (写代码有些麻烦,就直接画个图,很丑つ﹏⊂)

双横线的箭头表示当前执行到的位置,单线箭头表示 语句之间的等待关系
线程 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 , 且 CB 未释放 buf 的时候就 进入了就绪态
那么 B 就会被中断,先去执行 C , 这样子就导致 A 线程的优先级比 C 高,但 后于 C 执行
这就算优先级反转

# 互斥量的管理方式

与信号量类似,互斥量 也分为 静态和动态 创建,对应的废弃方式也是分为 脱离 和 删除.
释放和获取的接口是一致的
alt 图片来源: https://www.rt-thread.org/document/site/#/rt-thread-version/rt-thread-standard/programming-manual/ipc1/ipc1?id=%e4%ba%92%e6%96%a5%e9%87%8f

# 创建和删除互斥量

这一块是 动态 创建和删除 互斥量
创建互斥量
接口如下:

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.
当该互斥量的持有计数为零时,互斥量回复空闲状态,可被其他线程获取,等待在该信号量上的线程将被唤醒

如果线程的运行优先级被互斥量提升。那么当互斥量被释放后,线程恢复为持有互斥量前的优先级

优先级恢复

# 应用场景

互斥量的使用比较单一,因为它是信号量的一种,并且它是以锁的形式存在
在初始化的时候,互斥量永远都处于开锁的状态,而被线程持有的时候则立刻转为闭锁的状态.

互斥量更适合于:

  1. 单一线程内部信号量多次持有信号量,导致 无法释放 (目前没有遇到,遇到再处理)
  2. 可能会由于多线程同步而造成优先级翻转的情况,这个比较适合用在 单一传感器 需要被多个线程获取境界资源的场景

# 事件集

# 什么是事件集

事件集是线程同步的一种方式,一个事件集可以包含多个世界,利用事件可以完成一对多,多对多的线程间同步.
例如,当按键按下 LED 点亮,或者 ADC 检测到电压过低 OLED , LED, 蜂鸣器同时报警.
RTT 官方的例子很不错。我就直接搬过来啦
以下文字来源于 RTT 文档中心 -->RT-Thread 标准版 --> 内核 --> 事件集部分

以坐公交为例说明事件,在公交站等公交时可能有以下几种情况:
①P1 坐公交去某地,只有一种公交可以到达目的地,等到此公交即可出发
②P1 坐公交去某地,有 3 种公交都可以到达目的地,等到其中任意一辆即可出发
③P1 约另一人 P2 一起去某地,则 P1 必须要等到 “同伴 P2 到达公交站” 与 “公交到达公交站” 两个条件都满足后,才能出发
将 P1 去某地视为 线程,将 “公交到达公交站”、“同伴 P2 到达公交站” 视为 事件 的发生,
情况①是特定事件唤醒线程
情况②是任意单个事件唤醒线程
情况③是多个事件同时发生才唤醒线程

# 工作机制

与信号量不同,它可以实现一对多,多对多的同步.
即一个线程和多个事件的关系可设置为:

  1. 任一事件唤醒线程
  2. 多个事件共同唤醒一个线程
  3. 多个线程同步多个事件

在 RT-Thread 中 事件集的特点:

  1. 事件至于线程相关,事件之间相互独立:每个线程有 32 个事件标志位,采用 32bit 的无符号整型数据进行记录,每一个 bit 代表一个事件;
  2. 事件仅有同步功能,无法传输数据
  3. 事件无排队性,多次向线程发送同一事件 (如果未来得及读走), 其效果等同于之发送一次

在 RT-Thread 中,每个线程都拥有一个事件信息标记,它有三个属性,

  1. RT_EVENT_FLAG_AND (逻辑与),
  2. RT_EVENT_FLAG_OR (逻辑或)
  3. RT_EVENT_FLAG_CLEAR (清除标记)

当线程等待事件同步时,可以通过 32 个事件标志 和 这个事件信息标记来判断当前接收的事件是否满足同步条件

# 管理方式

RT-Thread 的接口做的还是很不错的,事件集的接口 和 信号量,互斥量的接口都十分类似
都分为静态和动态创建删除,再加特有的 发送和接收 函数
alt 图片来源: https://www.rt-thread.org/document/site/#/rt-thread-version/rt-thread-standard/programming-manual/ipc1/ipc1?id=%e4%ba%8b%e4%bb%b6%e9%9b%86

# 创建和删除事件集

以下为动态创建控制块
创建
创建 信号量的时候,内核会先创建一个 事件集控制块,然后进行初始化

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 的可选参数如下:

  1. RT_EVENT_FLAG_OR : 逻辑与
  2. RT_EVENT_FLAG_AND : 逻辑或
  3. RT_EVENT_FLAG_CLEAR : 选择清除重置事件标志位

当用户调用接口时,系统根据 set 参数 和 接收选项 option 来判断它要接受的事件是否发生
如果已经发生,则根据参数 option 上是否设置有 RT_EVENT_FLAG_CLEAR 来决定是否重置事件的相应标志位
如果没有发生,则把等待的 setoption 参数填入线程本身的结构中,然后把线程挂起在此事件上,直到其等待的事件满足条件或等待时间超过指定的超时时间

# 应用场景

我自己目前没用过,事件集 很难说它的适用场景,下面这段话直接来自于 RT-Thread 官方.
等我琢磨明白了我再写篇博客,举个实际的例子 (T_T).

事件集可使用于多种场合,它能够在一定程度上替代信号量,用于线程间同步.
一个线程或中断服务例程发送一个事件给事件集对象,而后等待的线程被唤醒并对相应的事件进行处理.

但是它与信号量不同的是,事件的发送操作在事件未清除前,是不可累计的,而信号量的释放动作是累计的.

事件的另一个特性是,接收线程可等待多种事件,即多个事件对应一个线程或多个线程.
同时按照线程等待的参数,可选择是 “逻辑或” 触发还是 “逻辑与” 触发.

这个特性也是信号量等所不具备的,信号量只能识别单一的释放动作,而不能同时等待多种类型的释放.

# 笔记总结

写的代码太少,以至于很多很难分出 三者之间的应用场景.
举不出例子,明天试着拿知识做点小实验.


大道五十,天衍四十九,人遁其一!