线程同步概念
255.2.6线程同步概念
本节没有新增或修改应用层业务代码。视频中展示的是通过全局搜索查看 RT-Thread 5.0.2 的 rt_thread_yield()、rt_enter_critical() 和 rt_exit_critical() 等内核实现;按照“只收录课堂业务代码、不抄入库函数源码”的要求,标题下不放置源码块。
一、线程同步概念
1.1 线程同步的概念
裸机程序通常以一个主循环配合中断运行;引入 RTOS 后,多个线程会在调度器控制下交替执行。即使本课程使用的是单核 STM32,同一时刻只有一个线程真正占用 CPU,线程仍可能在任意调度点被切换,因此多步操作之间可能穿插其他线程或中断的操作。
当多个执行流共享变量、缓冲区或外设时,如果没有约定访问次序和访问权限,就可能出现数据被覆盖、读取到半更新状态或业务步骤前后颠倒等问题。线程同步机制正是用来协调这些执行流的。
1.1.1 线程同步
线程同步是指多个线程借助特定机制建立执行次序或协作关系,使它们在满足条件时继续运行、在条件未满足时等待。同步并不表示“几个线程在同一时刻做同一件事”,也不仅仅表示固定为“线程 2 → 线程 1 → 线程 3”;它还包括资源就绪通知、生产者与消费者配合、等待多个事件等情形。
需要分清两个相近概念:
| 概念 | 关注的问题 | 示例 |
|---|---|---|
| 同步(synchronization) | 谁应当先做、什么条件满足后谁才能继续 | 采集线程写完一帧数据后,显示线程才开始读取 |
| 互斥(mutual exclusion) | 某段时间内谁拥有共享资源的独占访问权 | 多个线程访问同一个串口发送缓冲区时,只允许一个线程完成整帧操作 |
互斥可以看成线程同步的一种特殊形式。RT-Thread 本章重点介绍的同步对象包括信号量、互斥量和事件集:
- 信号量可以表示通知、令牌数量或可用资源数量;
- 互斥量专门用于线程之间对共享资源的互斥访问;
- 事件集用多个事件位表达一个或多个条件,可按“任一条件”或“所有条件”组合等待。
原笔记把“相同优先级线程的执行顺序”写成随机并不准确。RT-Thread 是基于优先级的抢占式调度器:更高优先级就绪线程优先运行;同优先级线程可按照就绪队列和时间片轮转。实际先后还受线程启动顺序、阻塞与唤醒时刻、系统 tick 和中断影响,因此不能依赖一次观察到的自然先后顺序表达业务约束;需要确定次序时,必须使用同步对象明确建立关系。
1.1.2 临界区
临界区(critical section)是访问共享资源、并且不能与其他冲突访问交错执行的一段代码。共享资源可能是:
- 多个线程都会读写的全局变量、链表或环形缓冲区;
- 由多条指令共同维护的一组数据不变量;
- 串口、I²C、SPI 等不能被多个调用者任意穿插操作的外设;
- 线程和中断服务程序共同访问的状态。
“临界区”强调的是访问共享资源的代码范围,不是说代码本身天然具有保护能力。只有所有竞争者都遵守同一种保护规则,互斥才会成立。
竞争条件(race condition)是指程序结果依赖不可预测的执行交错。例如两个线程同时对共享计数器执行“读取旧值 → 加一 → 写回”时,两者可能读到相同旧值,最后只增加一次。单条 C 语句也可能编译成多条机器指令,所以不能仅凭“代码只有一行”就认定操作具有原子性。
还要纠正“临界区代码不能被抢占”这一过度概括:
- 使用关中断或锁调度器保护时,当前线程在相应范围内确实不会按普通路径被其他线程抢占;
- 使用互斥量或二值信号量保护时,持锁线程仍可能被与该资源无关的更高优先级线程抢占,但其他遵守同一把锁的线程无法同时进入受保护区域,资源的一致性仍然成立。
因此,临界区真正要求的是“冲突访问不能重叠”,并非任何场景都要求 CPU 从头到尾绝不切换线程。
1.1.3 临界区的保护方式
课程列出三类保护方式。它们阻止的对象、代价和适用范围并不相同:
| 方式 | 能阻止什么 | 中断是否继续响应 | 典型用途与限制 |
|---|---|---|---|
| 关闭中断 | 当前单核 CPU 上的线程切换和受屏蔽中断对共享数据的并发访问 | 否 | 最短的底层原子操作;会增加中断延迟,必须极短 |
| 禁止调度 | 其他线程抢占当前线程 | 是 | 只防线程竞争,不能防 ISR 访问;临界区内不能执行会阻塞的操作 |
| 信号量/互斥量 | 其他遵守同一同步对象的线程进入受保护区域 | 是 | 等待者可挂起,不必全程占住 CPU;适合较长的线程级资源访问 |
无论采用哪一种方式,都应保证进入和退出严格配对,并让保护范围覆盖完整的一致性操作。范围过小仍会发生竞争,范围过大会增加中断延迟、调度延迟或其他线程的等待时间。
1.1.3.1 关闭中断
RT-Thread 使用以下两个底层接口保存并恢复中断状态:
rt_hw_interrupt_disable():屏蔽当前端口规定的可屏蔽中断,并返回调用前的中断状态;rt_hw_interrupt_enable(level):用传入的level恢复先前状态,不能简单理解为无条件“打开所有中断”。
典型逻辑是“保存旧状态 → 关中断 → 修改共享内核状态 → 恢复旧状态”。保存并恢复旧状态非常重要:如果调用前中断本来就是关闭的,退出内层临界区时不应错误地提前开启中断。
视频回看了 rt_thread_yield() 的内核实现。该函数先取得当前线程,再调用 rt_hw_interrupt_disable();在中断关闭期间重装当前线程的剩余时间片并设置主动让出标志;随后用保存的 level 恢复中断,最后才调用 rt_schedule()。这里的关中断只保护极短的调度状态更新,不是让整个线程业务都在关中断状态下执行。
关闭中断的影响是全局性的:受屏蔽中断不能及时响应,由这些中断触发的系统 tick、外设服务和抢占调度也会被推迟。因此应遵守以下原则:
- 临界区只保留必要的几条操作,不做打印、延时、等待 IPC 或大数据处理;
- 退出时传回原先保存的
level; - 若共享数据还会被 ISR 访问,线程级互斥量不能代替中断保护;
- 在多核系统中,关闭本地 CPU 中断并不能阻止另一核心并发访问,还需要自旋锁等跨核机制。本课程 STM32F103 单核工程不涉及这一分支。
1.1.3.2 禁止调度
视频思维导图和配套 RT-Thread 5.0.2 源码中的接口是:
rt_enter_critical():锁住线程调度器;rt_exit_critical():解除一层调度锁,并在最外层退出时按需要重新调度。
原笔记中的 rt_thread_scheduler_lock() / rt_thread_scheduler_unlock() 不是本节实际展示的接口,应以上述名称为准。
配套单核源码用 rt_scheduler_lock_nest 记录嵌套层数。每次 rt_enter_critical() 将层数加一,每次 rt_exit_critical() 将层数减一;只有降到零才真正恢复调度。这使同一线程中的嵌套调用可以成对工作,但也意味着少调用一次退出函数就会让调度器长期保持锁定。
锁调度器期间,硬件中断仍然可以响应,所以这种方式与关中断有两个关键区别:
- 它能阻止普通线程切换,却不能阻止 ISR 修改同一份数据;
- 它对中断实时性的影响较小,但会推迟其他线程运行。
调度被锁定时不能调用会使当前线程阻塞或挂起的接口,例如延时、永久等待信号量或等待消息。否则当前线程要求睡眠,调度器却又不允许选择其他线程,容易造成断言失败或系统失去正常调度。调度锁同样只适合短小、确定不会阻塞的线程级临界区。
1.1.3.3 互斥特性保护临界区
线程级锁的基本使用顺序是:
- 进入临界区前获取同步对象;
- 获取成功后访问共享资源;
- 在所有正常和异常退出路径上释放同步对象;
- 其他线程获取不到时,根据接口参数立即返回、限时等待或挂起等待。
二值信号量和互斥量都能形成“一次只让一个线程进入”的效果,但二者不能视为完全相同:
- 二值信号量本质上仍是无所有权的计数/通知对象,可以由不同执行流获取和释放;
- 互斥量有明确所有者,只能由持有它的线程释放,并具有递归持有和优先级继承等资源锁语义。
使用线程级锁时,持有者可以被无关的高优先级线程抢占;真正被阻止的是其他试图获取同一个锁的线程。与长时间关中断相比,这通常能保留更好的中断响应和系统可调度性。
要避免几个常见错误:获取后忘记释放、错误路径提前返回而漏释放、以相反顺序获取多把锁导致死锁,以及在持锁期间执行无界耗时或永久等待操作。
1.1.4 信号量
信号量(semaphore)可以理解为“可用令牌的计数器 + 等待队列”。在 RT-Thread 中:
- 当前值大于 0 时,获取操作成功并把计数减一;
- 当前值等于 0 时,调用者可按等待参数选择立即返回、等待指定 tick 或永久等待;
- 释放时,如果已有线程正在等待,则优先唤醒等待线程;如果没有等待线程,计数加一。
等待时间的单位是 OS tick,不是固定的毫秒。只有结合工程的 RT_TICK_PER_SECOND,才能把 tick 换算为实际时间。
课程用“厕所”和“停车场”帮助理解,但应把模型对应准确:
| 用法 | 常见初值 | 含义 |
|---|---|---|
| 同步通知 | 0 | 条件尚未发生,等待线程先阻塞;生产者或 ISR 完成工作后释放一次,通知等待者继续 |
| 二值锁 | 1 | 只有一个令牌;成功获取后变为 0,其他线程等待,使用完再释放 |
| 资源计数 | N | 有 N 个同类资源实例;每获取一个资源就减一,归还后加一 |
RT-Thread 内核只有通用计数信号量对象,并没有另一个会把数值硬性限制为 0/1 的独立“二值信号量”类型。所谓二值信号量,是应用按 0/1 规则使用普通信号量;如果没有等待者却连续多次释放,其计数仍可能超过 1。因此把它当锁时必须保证获取与释放严格匹配。
停车场初值为 5,表示五个可独立占用的车位,最多允许五个使用者各自占用一个资源;它不表示可以让五个线程同时进入同一段非可重入代码。若真正共享的资源只有一个,就应使用初值为 1 的锁或互斥量。
信号量没有所有权:释放者可以不是先前的获取者,这使它很适合“线程等待、另一个线程或 ISR 发出通知”的场景;但也正因为没有所有权,它不具备互斥量的递归持有和优先级继承能力。用于保护普通线程共享资源时,通常优先考虑互斥量;用于通知、线程与中断同步或资源计数时,信号量更自然。
1.1.5 互斥量
互斥量(mutex)是面向线程共享资源保护的内核对象。课程中的 mtx 只是 mutex 常用的名称缩写,可作为变量名或对象名理解,不是一个决定行为的“标志位”。
它的工作过程是:线程进入临界区前获取互斥量;获取成功后成为所有者;其他线程再获取同一个互斥量时会等待;所有者完成资源访问后释放,等待者才有机会继续。
互斥量和二值信号量都能呈现“锁定/解锁”的外观,但 RT-Thread 5.0.2 中的重要区别如下:
| 特性 | 二值信号量用作锁 | 互斥量 |
|---|---|---|
| 所有权 | 无 | 有,记录持有线程 |
| 谁能释放 | 不要求必须是获取者 | 只能由所有者线程释放 |
| 同一线程重复获取 | 会因令牌已用完而等待,可能自锁 | 支持递归获取,并记录持有次数;必须对应次数地释放 |
| 优先级反转保护 | 无 | 支持优先级继承,必要时临时提升持锁线程优先级 |
| ISR 中使用 | 可用信号量释放操作通知线程 | 互斥量依赖线程所有权,不能在 ISR 中获取或释放 |
| 更适合的用途 | 通知、计数;简单场景也可互斥 | 普通线程之间保护唯一共享资源 |
优先级继承用于缓解经典优先级反转:低优先级线程持有互斥量、高优先级线程等待它时,内核临时提高持锁线程的优先级,使其尽快完成临界区并释放资源,避免被无关的中优先级线程长期抢占。它不能修复死锁或无限期持锁,所以互斥量仍应尽早释放。
选择保护方式时可以先问两个问题:
- 共享数据是否会被 ISR 访问?如果会,需要极短的中断保护或专门的中断安全设计,不能只加线程互斥量。
- 只是线程之间保护一个共享资源,还是要传递通知/统计资源数量?前者优先考虑互斥量,后者优先考虑信号量或事件集。
核对依据:本课程视频与配套工程中的 RT-Thread 5.0.2 rt-thread/src/thread.c、rt-thread/src/scheduler_up.c、rt-thread/src/ipc.c 和 rt-thread/include/rtdef.h,以及 RT-Thread 官方线程间同步文档、官方中断管理文档、RT-Thread v5.0.2 thread.c、RT-Thread v5.0.2 scheduler_up.c 和 RT-Thread v5.0.2 ipc.c。