RT_Thread学习--线程间同步

在之前的博客中,我们学习了线程管理以及时钟管理,在这一篇博客中,开始线程间同步的学习。

线程间同步

多线程实时系统中,线程间同步使得多个线程之间相互配合,共同完成一项工作。例如:要将一个温度传感器获取到的信息打印输出到显示器上,那么,需要有一个线程从传感器中接收数据并将数据写到共享内存,同时需要另外一个线程周期性的从共享内存中读取数据并发送到显示器输出。大致流程如图所示:
RT_Thread学习--线程间同步_第1张图片
这个时候我们思考一个问题:以上两个线程能不能同时访问这块共享内存呢?答案是不可以的。因为假设线程1还没有将数据完全写入共享内存,这个时候线程2就过来读了,那么就有可能会导致数据的错乱。为了防止这种情况的出现,两个线程访问的动作必须是互斥进行的。也就是说,只有当一个线程操作这块共享内存的动作结束之后才开始下一个线程对这块共享内存的操作。
同步是指按预定的先后次序进行运行,线程同步是指多个线程通过特定的机制(如互斥量,事件对象,临界区)来控制线程之间的执行顺序,也可以说是在线程之间通过同步建立起执行顺序的关系,如果没有同步,那线程之间将是无序的。
多个线程操作 / 访问同一块区域(代码),这块代码就称为临界区,上述例子中的共享内存块就是临界区。线程互斥是指对于临界区资源访问的排它性。当多个线程都要使用临界区资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。线程互斥可以看成是一种特殊的线程同步。
其实虽然线程间同步有很多种方式,但是其核心思想都是:在访问临界区的时候,只允许一个(或一类)线程运行。
对于线程间同步,在RT-Thread中,使用到了信号量(semaphore)互斥量(mutex)以及事件集(event)

一、信号量

信号量是一个非负的动态变化的值。

1.1 信号量工作机制
信号量:一种轻型的用于解决线程间同步问题的内核对象,线程可以获取获释放它,进而达到同步或者互斥的目的。
信号量可以类比停车位管理人员,有多少个信号量就有多少个停车位,线程可以类比车辆,线程通过信号量访问公共资源,就类似于车辆获得管理员的允许取得停车位。其工作示意图如图所示:

RT_Thread学习--线程间同步_第2张图片
1.2 信号量控制块

在信号量的工作示意图中,提到了控制块的概念。信号量的控制块是操作系统用于管理信号量的一个数据结构,并由结构体struct rt_semaphore表示:

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。

1.3 信号量的管理模式
对于信号量的操作主要包括:
RT_Thread学习--线程间同步_第3张图片
1.3.1 创建和删除信号量
当创建一个信号量时,内核首先创建一个信号量控制块,然后对该控制块进行基本的初始化工作,创建信号量使用下面的函数接口:

rt_sem_t rt_sem_create(const char *name,
						rt_uint32_t value,
						rt_uint8_t flag);

对象管理器分配一个semaphore对象---->初始化该对象------>初始化父类IPC对象及相关部分
其中name表示信号量的名字,value表示信号量的初始值,flag是信号量的标志,有两种:
RT_IPC_FLAG_FIFO :线程以FIFO的方式获取信号量
RT_IPC_FLAG_PRIO:线程以优先级排队的方式获取信号量

系统不再使用信号量时,可通过删除信号量以释放系统资源,适用于动态创建的信号量。删除信号量使用下面的函数接口:

rt_err_t rt_sem_delete(rt_sem_t sem);

1. 先唤醒等待该信号量的线程
2. 释放信号量内存资源供等待的线程使用

1.3.2 初始化和脱离信号量
对于静态信号量对象,它的内存空间在编译时期就被编译器分配出来,放在读写数据段或未初始化数据段上,此时使用信号量就不再需要使用 rt_sem_create 接口来创建它,而只需在使用前对它进行初始化即可。初始化信号量对象可使用下面的函数接口:

rt_err_t rt_sem_init(rt_sem_t sem,
					const char *name,
					rt_uint32_t value,
					rt_uint8_t flag)

从参数上来看:初始化信号量就比创建信号量多了一个sem对象的传参工作。其初始化的步骤和创建一致。

脱离信号量就是让信号量对象从内核对象管理器中脱离,适用于静态初始化的信号量。脱离信号量使用下面的函数接口:

rt_err_t rt_sem_detach(rt_sem_t sem);

使用该函数后,内核先唤醒所有挂在该信号量等待队列上的线程,然后将该信号量从内核对象管理器中脱离

1.3.3 获取信号量
线程通过获取信号量来获得信号量资源实例,当信号量值大于零时,线程将获得信号量,并且相应的信号量值会减 1,获取信号量使用下面的函数接口:

rt_err_t rt_sem_take (rt_sem_t sem, rt_int32_t time);

该函数是为线程争取信号量的操作,如果值为0,则通过time传入的等待时间决定是直接返回,还是等待一段时间或者永久等待。如大于0,则获取相应的信号量
注意:time指的是等待的时间,单位是操作系统的时钟节拍(OS Tick

1.3.4 无等待获取信号量

rt_err_t rt_sem_trytake(rt_sem_t sem);

这个函数与 rt_sem_take(sem, 0) 的作用相同,即当线程申请的信号量资源实例不可用的时候,它不会等待在该信号量上,而是直接返回 - RT_ETIMEOUT。

1.3.5 释放信号量

释放信号量可以唤醒在该信号量上挂起的线程。释放信号量使用下面的函数接口:

rt_err_t rt_sem_release(rt_sem_t sem);

例如当信号量的值等于零时,并且有线程等待这个信号量时,释放信号量将唤醒等待在该信号量线程队列中的第一个线程,由它获取信号量;否则将把信号量的值加 1

1.4 信号量应用示例
参见 https://www.rt-thread.org/document/site/

1.5 信号量的使用场合
1.5.1 线程同步

持有该信号量的线程完成工作,释放信号量,可以把等待在这个信号量上的线程唤醒,让它执行下一部分工作。

1.5.2 锁
信号量初始化为1的情况,此时信号量要么为1,要么为0,所以也称作二值信号量。来个图:
RT_Thread学习--线程间同步_第4张图片
1.5.3 中断与线程的同步
信号量的值初始为 0,当 FinSH 线程试图取得信号量时,因为信号量值是 0,所以它会被挂起。当console 设备有数据输入时,产生中断,从而进入中断服务例程。在中断服务例程中,它会读取console设备的数据,并把读得的数据放入 UART buffer 中进行缓冲,而后释放信号量,释放信号量的操作将唤醒 shell 线程。在中断服务例程运行完毕后,如果系统中没有比 shell 线程优先级更高的就绪线程存在时,shell 线程将持有信号量并运行,从 UART buffer 缓冲区中获取输入的数据
RT_Thread学习--线程间同步_第5张图片
注意:中断与线程间的互斥不能采用信号量(锁)的方式,而应采用开关中断的方式。

1.5.4 资源计数
资源计数适合于线程间工作处理速度不匹配的场合,这个时候信号量可以做为前一线程工作完成个数的计数,而当调度到后一线程时,它也可以以一种连续的方式一次处理多个事件。
注: 一般资源计数类型多是混合方式的线程间同步,因为对于单个的资源处理依然存在线程的多重访问,这就需要对一个单独的资源进行访问、处理,并进行锁方式的互斥操作。

二、互斥量

2.1 互斥量工作机制
互斥量又称作是相互排斥的信号量。
互斥量与信号量的不同的是:

  1. 拥有互斥量的线程拥有互斥量的所有权
  2. 互斥量支持递归访问且能防止线程优先级翻转
  3. 互斥量只能由持有线程释放,而信号量可以由任何线程释放

互斥量工作状态:

  1. 打开:线程释放,开锁状态
  2. 关闭:线程持有,闭锁状态

对于优先级的翻转,在https://editor.csdn.net/md/?articleId=109553199已经给出过解释,不再赘述。在介绍优先级翻转的时候,就有提到过,互斥量通过优先级的继承算法可以解决优先级的翻转,所以在这里仅介绍优先级继承是怎么解决优先级翻转的。
其实用一句话解释就是:提高某个占有某种资源的低优先级线程的优先级,使之与所有等待该资源的线程中优先级最高的那个线程的优先级相等,然后执行,而当这个低优先级线程释放该资源时,优先级重新回到初始设定

注:在获取信号量之后,需尽快释放,并且在持有互斥量的过程中,不得再进行互斥量线程优先级的更改。

2.2 互斥量控制块
互斥量控制块:管理互斥量的一个数据结构rt_mutex,其对象从rt_ipc_object中派生,由IPC容器所管理。
互斥量的句柄:rt_mutex_t,C语言中互斥量控制块的指针

struct rt_mutex
{
	struct rt_ipc_object parent; /* 继 承 自 ipc_object 类 */
	rt_uint16_t value; /* 互 斥 量 的 值 */
	rt_uint8_t original_priority; /* 持 有 线 程 的 原 始 优 先 级 */
	rt_uint8_t hold; /* 持 有 线 程 的 持 有 次 数 */
	struct rt_thread *owner; /* 当 前 拥 有 互 斥 量 的 线 程 */
};
/* rt_mutext_t 为 指 向 互 斥 量 结 构 体 的 指 针 类 型 */
typedef struct rt_mutex* rt_mutex_t;

2.3 互斥量的管理方式
对一个互斥量的操作包含:创建 / 初始化互斥量、获取互斥量、释放互斥量、删除 / 脱离互斥
量。
RT_Thread学习--线程间同步_第6张图片
2.3.1 创建和删除互斥量
内核创建一个互斥量控制块,然后对其进行初始化。使用的接口是:

rt_mutex_t rt_mutex_create (const char* name, rt_uint8_t flag);

调用这个函数时,系统将先从对象管理器中分配一个 mutex 对象,并初始化这个对象,然后初始化父类 IPC 对象以及与 mutex相关的部分。其flag标志有RT_IPC_FLAG_PRIO和RT_IPC_FLAG_FIFO两种,含义与信号量一致,不再赘述。
删除互斥量:适用于动态创建(rt_mutex_create的互斥量)

rt_err_t rt_mutex_delete (rt_mutex_t mutex);

删除时,所有等待互斥量线程被唤醒,该互斥量从内核对象管理器链表中被删除,释放资源(内存空间)。

2.3.2 初始化和脱离
上述为动态互斥量,这里为静态互斥量。
静态创建的互斥量,其占用的内存空间与信号量都是在系统编译的时候,由编译器分配的,且一般放在读写数据段或未初始化数据段中。初始化的接口是:

rt_err_t rt_mutex_init (rt_mutex_t mutex, const char* name, rt_uint8_t flag);

其中参数mutex是互斥量对象的句柄,由用户提供,并指向互斥量对象的内存块。
脱离互斥量则是针对静态互斥量的。脱离接口调用后,内核会先唤醒所有挂在该互斥量上的线程,然后系统将该互斥量从内核对象管理器中脱离。

rt_err_t rt_mutex_detach (rt_mutex_t mutex);

2.3.3 获取互斥量
线程获取了互斥量,就拥有了该互斥量的所有权,即:在某一个时刻,一个互斥量只能被一个线程持有。

rt_err_t rt_mutex_take (rt_mutex_t mutex, rt_int32_t time);

两种情况:

  1. 互斥量没有被其他线程占有,则获得该互斥量。如果互斥量已经被当前线程控制,则该互斥量的持有计数加一,当前线程也不会挂起等待
  2. 互斥量被其他线程占有,当前线程在该互斥量上挂起等待,直到timeout或者其他线程释放。

2.3.4 释放互斥量
线程完成互斥资源访问,应尽快释放其占有的互斥量,以供其他线程使用。

rt_err_t rt_mutex_release(rt_mutex_t mutex);

调用该接口,则占用该互斥量的线程将其释放,每释放一次,持有计数减一。当计数为0的时候,该互斥量变为可用,则等待该互斥量的线程将被唤醒。另外,在谈到优先级的继承的时候说过,线程会由于占用的互斥资源而被提升优先级,同样,该互斥量被释放后,该线程的优先级将会恢复到持有该互斥量之前的状态。

2.4 应用示例
不做记录,自行理解。

2.5 互斥量的使用场合

  1. 线程多次持有互斥量,可以避免同一线程多次递归调用而造成死锁。
  2. 避免由于多线程同步而造成优先级翻转。

事件集

一个事件集可以包含多个事件,利用事件集可以完成一对多或者多对多的线程同步。

3.1 事件集工作机制
一个线程与多个事件的关系可以表述为:其中任意一个事件或者多个个事件同时发生的时候才唤醒后续的线程进行处理。这种多个事件的集合可以用一个32位无符号整型变量来表示变量的每一位代表一个事件,线程通过“逻辑与”或“逻辑或”将一个或者多个事件关联起来,形成事件集。
事件的逻辑与:也称关联型同步,指的是线程与若干事件都发生同步。
事件的逻辑或:也称独立型同步,指的是线程与任何事件之一发生同步。
RT-Thread的事件集特点:

  1. 事件只与线程相关,事件间相互独立:每个线程可拥有 32 个事件标志,采用一个 32 bit 无符号整型数进行记录,每一个 bit 代表一个事件;
  2. 事件仅用于同步,不提供数据传输功能;
  3. 事件无排队性,即多次向线程发送同一事件 (如果线程还未来得及读走),其效果等同于只发送一次。
    每个线程都拥有一个事件信息标记,有三个属性:
  4. RT_EVENT_FLAG_AND(逻辑与)
  5. RT_EVENT_FLAG_OR(逻辑或)
  6. RT_EVENT_FLAG_CLEAR(清除标记)

RT_Thread学习--线程间同步_第7张图片上图描述:线程 #1 的事件标志中第 1 位和第 30 位被置位,如果事件信息标记位设为逻辑与,则表示线程 #1 只有在事件 1 和事件 30 都发生以后才会被触发唤醒,如果事件信息标记位设为逻辑或,则事件1 或事件 30 中的任意一个发生都会触发唤醒线程 #1。如果信息标记同时设置了清除标记位,则当线程 #1唤醒后将主动把事件 1 和事件 30 清为零,否则事件标志将依然存在(即置 1)。

3.2 事件集控制块
于管理事件的一个数据结构,由结构体 struct rt_event表示。 rt_event_t表示的是事件集的句柄,在 C 语言中的实现是事件集控制块的指针。

struct rt_event
{
	struct rt_ipc_object parent; /* 继 承 自 ipc_object 类 */
	/* 事件集合,每一 bit 表示 1个事件,bit位的值可以标记某事件是否发生 */
	rt_uint32_t set;
};
/* rt_event_t 是 指 向 事 件 结 构 体 的 指 针 类 型 */
typedef struct rt_event* rt_event_t;

rt_event 对象从 rt_ipc_object 中派生,由 IPC 容器所管理。

3.3 事件集的管理方式
包含:创建 / 初始化事件集、发送事件、接收事件、删除 / 脱离事件集。
RT_Thread学习--线程间同步_第8张图片3.3.1 创建和删除事件集
内核首先创建一个事件集控制块,然后对该事件集控制块进行基本的初始化。

rt_event_t rt_event_create(const char* name, rt_uint8_t flag);

调用该函数接口时,系统会从对象管理器中分配事件集对象,并初始化这个对象,然后初始化父类 IPC对象。
其中flag有RT_IPC_FLAG_FIFO和RT_IPC_FLAG_PRIO两种。具体含义不再赘述,参见信号量的创建。
对于事件集的删除,则是通过删除对象控制块来释放系统资源,使用的接口为:

rt_err_t rt_event_delete(rt_event_t event);

在调用 rt_event_delete 函数删除一个事件集对象时,应该确保该事件集不再被使用。在删除前会唤醒所有挂起在该事件集上的线程(线程的返回值是 - RT_ERROR),然后释放事件集对象占用的内存块。

3.3.2 初始化和脱离事件集
静态事件集对象的内存是在系统编译时由编译器发分配的(同信号量互斥量),一般放在读写数据段或者未初始化数据段(同信号量和互斥量)。
初始化调用的接口是:

rt_err_t rt_event_init(rt_event_t event, const char* name, rt_uint8_t flag);

调用该接口时,需指定静态事件集对象的句柄(即指向事件集控制块的指针),然后系统会初始化事件集对象,并加入到系统对象容器中进行管理。其参数含义不再赘述。
系统不再使用 rt_event_init() 初始化的事件集对象时,通过脱离事件集对象控制块来释放系统资源。脱离事件集是将事件集对象从内核对象管理器中脱离。使用的接口是:

rt_err_t rt_event_detach(rt_event_t event);

用户调用这个函数时,系统首先唤醒所有挂在该事件集等待队列上的线程(线程的返回值是 -RT_ERROR),然后将该事件集从内核对象管理器中脱离。

3.3.3 发送事件
发送事件函数可以发送事件集中的一个或者多个事件。使用的是:

rt_err_t rt_event_send(rt_event_t event, rt_uint32_t set);

其中set表示的是event事件集对象的事件标志值。设定完该参数,然后遍历等待在event事件集对象上的等待线程链表,判断是否有线程的事件激活要求与当前event对象事件标志值匹配,如果有,则唤醒该线程。
set:发送的一个或多个事件的标志值。

3.3.4 接收事件
有事件的发送,就有事件的接收。内核使用 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);

当用户调用这个接口时,系统首先根据 set 参数和接收选项 option判断它要接收的事件是否发生如果已经发生,则根据参数 option 上是否设置有 RT_EVENT_FLAG_CLEAR 来决定是否重置事件的相应标志位,然后返回(其中 recved 参数返回接收到的事件);如果没有发生,则把等待的 set 和 option 参数填入线程本身的结构中,然后把线程挂起在此事件上,直到其等待的事件满足条件或等待时间超过指定的超时时间。如果超时时间设置为零,则表示当线程要接受的事件没有满足其要求时就不等待,而直接返回 -RT_ETIMEOUT。
option的值:

/* 选 择 逻 辑 与 或 逻 辑 或 的 方 式 接 收 事 件 */
RT_EVENT_FLAG_OR
RT_EVENT_FLAG_AND
/* 选 择 清 除 重 置 事 件 标 志 位 */
RT_EVENT_FLAG_CLEAR

3.4 事件集使用场合

  1. 事件的发送操作在事件未清除前,是不可累计的,而信号量的释放动作是累计的。
  2. 接收线程可等待多种事件,即多个事件对应一个线程或多个线程。
  3. 接收线程可等待多种事件,即多个事件对应一个线程或多个线程。
    一个事件集中包含 32 个事件,特定线程只等待、接收它关注的事件。可以是一个线程等待多个事件的到来(线程 1、2 均等待多个事件,事件间可以使用 “与” 或者 “或” 逻辑触发线程),也可以是多个线程等待一个事件的到来(事件 25)。当有它们关注的事件发生时,线程将被唤醒并进行后续的处理动作。

有点晚了,线程间同步就到这里,下一篇学习线程间的通信,加油!

你可能感兴趣的:(RT-Thread学习,操作系统)