线程同步_互斥量
257.2.8线程同步_互斥量
#include <rtthread.h> // 引入 RT-Thread 内核接口,线程与互斥量函数都在其中声明
#define DBG_TAG "main" // 设置 rtdbg 日志输出所使用的模块标签
#define DBG_LVL DBG_LOG // 设置日志级别,允许输出调试和错误日志
#include <rtdbg.h> // 引入 LOG_D、LOG_E 等调试日志宏
rt_thread_t th1_ptr,th2_ptr,th3_ptr; // 保存三个动态线程对象的句柄
rt_mutex_t mutex1_ptr; // 保存动态互斥量 mutex1 的句柄
int a=0; // 课程源码保留的全局变量,本例最终代码没有使用它
void th1_printf(void *parameter) // 线程 th1 的入口函数;parameter 是创建线程时传入的参数
{ // th1_printf 函数体开始
rt_thread_t current_ptr; // 课程源码保留的局部变量,本例没有使用
while(1) // 让 th1 不断获取、占用并释放同一个互斥量
{ // th1 无限循环体开始
rt_mutex_take(mutex1_ptr,RT_WAITING_FOREVER); // 永久等待 mutex1;成功后 th1 成为它的持有者
rt_kprintf("In %s,func %s is running! the line is %d!\r\n" ,__FILE__,__FUNCTION__, __LINE__); // 打印文件名、函数名和当前源码行号
rt_thread_mdelay(500); // 持锁延时约 500 ms,用来人为拉长临界区以便观察实验
rt_mutex_release(mutex1_ptr); // 由持有者 th1 释放 mutex1,并唤醒优先级最高的等待线程
} // th1 无限循环体结束
} // th1_printf 函数体结束
void th2_printf(void *parameter) // 线程 th2 的入口函数,用来监视 th3 的当前优先级
{ // th2_printf 函数体开始
while(1) // 让 th2 周期性打印观察结果
{ // th2 无限循环体开始
rt_kprintf("func %s , th3_ptr prio is %d!\r\n" ,__FUNCTION__,th3_ptr->current_priority); // 直接读取 th3 控制块中的当前有效优先级
rt_thread_mdelay(100); // th2 每隔约 100 ms 观察一次 th3 的优先级
} // th2 无限循环体结束
} // th2_printf 函数体结束
void th3_printf(void *parameter) // 线程 th3 的入口函数,它是实验中的低优先级互斥量持有者
{ // th3_printf 函数体开始
while(1) // 让 th3 不断获取、占用并释放同一个互斥量
{ // th3 无限循环体开始
rt_mutex_take(mutex1_ptr,RT_WAITING_FOREVER); // 永久等待 mutex1;成功后 th3 成为它的持有者
rt_kprintf("In %s,func %s is running! the line is %d!\r\n" ,__FILE__,__FUNCTION__, __LINE__); // 打印文件名、函数名和当前源码行号
rt_thread_mdelay(1000); // 持锁延时约 1000 ms,扩大 th1 等待 th3 的观察窗口
rt_mutex_release(mutex1_ptr); // 由持有者 th3 释放 mutex1,并在需要时恢复其基础优先级
} // th3 无限循环体结束
} // th3_printf 函数体结束
int main(void) // RT-Thread 用户主线程的入口函数
{ // main 函数体开始
int count = 1; // 初始化循环计数变量;课程源码只用它维持主线程循环
mutex1_ptr = rt_mutex_create("mutex1",RT_IPC_FLAG_PRIO); // 动态创建名为 mutex1 的互斥量
if(mutex1_ptr == RT_NULL) // 判断互斥量是否因内存不足等原因创建失败
{ // 互斥量创建失败分支开始
LOG_E("mutex1 rt_mutex_create failed!\r\n"); // 输出互斥量创建失败日志
return -RT_ENOMEM; // 以“内存不足”错误码结束 main
} // 互斥量创建失败分支结束
th1_ptr = rt_thread_create("th1", th1_printf, NULL, 1024, 7, 10); // 创建 th1:栈 1024 字节、优先级 7、时间片 10 tick
if(th1_ptr == RT_NULL) // 判断 th1 是否创建失败
{ // th1 创建失败分支开始
LOG_E("th1_printf rt_thread_create failed!\r\n"); // 输出 th1 创建失败日志
return -RT_ENOMEM; // 以“内存不足”错误码结束 main
} // th1 创建失败分支结束
rt_thread_startup(th1_ptr); // 启动 th1;它的优先级高于主线程,可能立即抢占运行
th2_ptr = rt_thread_create("th2", th2_printf, NULL, 1024, 8, 10); // 创建 th2:栈 1024 字节、优先级 8、时间片 10 tick
if(th2_ptr == RT_NULL) // 判断 th2 是否创建失败
{ // th2 创建失败分支开始
LOG_E("th2_printf rt_thread_create failed!\r\n"); // 输出 th2 创建失败日志
return -RT_ENOMEM; // 以“内存不足”错误码结束 main
} // th2 创建失败分支结束
rt_thread_startup(th2_ptr); // 启动 th2;它会周期性读取 th3_ptr 指向的线程控制块
th3_ptr = rt_thread_create("th3", th3_printf, NULL, 1024, 9, 10); // 创建 th3:栈 1024 字节、优先级 9、时间片 10 tick
if(th3_ptr == RT_NULL) // 判断 th3 是否创建失败
{ // th3 创建失败分支开始
LOG_E("th3_printf rt_thread_create failed!\r\n"); // 输出 th3 创建失败日志
return -RT_ENOMEM; // 以“内存不足”错误码结束 main
} // th3 创建失败分支结束
rt_thread_startup(th3_ptr); // 启动 th3,使其参与互斥量竞争
while (count++) // count 非 0 时持续循环;课程源码借此让 main 长期运行
{ // main 循环体开始
LOG_D("Hello RT-Thread!"); // 输出主线程调试信息
rt_thread_mdelay(1000); // 主线程延时约 1000 ms,让其他就绪线程运行
} // main 循环体结束
return RT_EOK; // 理论上的正常返回值;本例通常不会执行到这里
} // main 函数体结束
源码说明: 上面是课程配套工程
rttcode/07MUTEX/applications/main.c的最终业务代码。仅移除了文件头版权注释并添加逐行说明,变量、调用顺序、参数、字符串和业务逻辑均保持原样。源码中的未使用变量、未检查互斥量接口返回值以及线程启动顺序带来的隐患也没有擅自修改,相关问题在正文中单独分析。
一、互斥量
互斥量(mutex,互斥锁)用于保护必须由一个线程独占访问的共享资源。线程成功取得互斥量后成为它的持有者(owner);其他线程再获取同一把互斥量时,只能立即失败或进入等待。持有者完成访问后释放互斥量,其他等待线程才有机会进入。
这里的重点不是让某个线程永远不被抢占,而是保证:所有遵守同一加锁规则的线程,在同一时刻最多只有一个进入受保护的代码段。 持锁线程仍可能被中断服务程序打断,也可能被调度器切走;本节甚至故意在持锁期间调用 rt_thread_mdelay(),使持有者进入延时状态。互斥量并不会关闭系统抢占。
临界区是什么
临界区是访问共享状态、且不能与其他相关访问交错执行的一段代码。例如:
取得 mutex
读取共享状态
修改共享状态或完成一整笔外设事务
释放 mutex
a++ 在 C 语句层面看似只有一步,实际通常包含读取、加一、写回等操作;两个线程交错执行可能丢失更新。串口场景也不应只理解成“保护一次 DR 寄存器写入”,更常见的是保护一整段发送流程、软件缓冲区或驱动内部状态,防止两条消息互相穿插。若中断服务程序也访问同一资源,不能直接在中断中获取互斥量,而应使用驱动提供的中断安全机制、短临界区或其他合适的 IPC 设计。
互斥量与信号量的关键区别
| 对比项 | 互斥量 | 信号量 |
|---|---|---|
| 主要用途 | 保护共享资源的互斥访问 | 资源计数、事件通知和线程同步 |
| 所有权 | 有明确持有者 | 没有持有者概念 |
| 谁能释放 | 只能由当前持有线程释放 | 可以由其他线程或中断释放 |
| 同一线程重复获取 | RT-Thread 支持递归获取,并记录 hold 次数 | 二值用法下再次获取通常会等待 |
| 优先级反转处理 | RT-Thread 互斥量实现优先级继承 | 普通信号量不提升持有资源者的优先级 |
| 中断上下文 | 不能在中断中获取或释放 | 信号量可以在中断中释放,但不能阻塞等待 |
互斥量虽然常被称为“特殊的二值信号量”,但它不只是一个 0/1 计数:所有权、递归持有次数和优先级继承都是普通二值信号量没有的语义。
什么是优先级反转
假设 RT-Thread 中有三个线程,数字越小优先级越高:
| 线程 | 优先级 | 角色 |
|---|---|---|
| 高优先级线程 H | 7 | 需要访问共享资源 |
| 中优先级线程 M | 8 | 与该资源无关 |
| 低优先级线程 L | 9 | 已经占有共享资源 |
H 发现资源被 L 占用后只能等待。若 M 此时就绪,它会抢占 L;L 得不到运行时间,就无法尽快释放资源,最终 H 反而被与资源无关、优先级更低的 M 间接拖延。这才是经典的优先级反转。
上一节通过两个信号量故意规定 th1 → th2 → th1 的运行顺序属于正常的线程同步,不能仅因为低优先级线程先执行就称为优先级反转。判断关键是:高优先级线程是否因为低优先级资源持有者又被中优先级线程抢占,而遭受非预期的额外延迟。
RT-Thread 的互斥量使用优先级继承缓解这一问题:当优先级 7 的 H 等待优先级 9 的 L 所持有的互斥量时,L 的当前有效优先级临时提高到 7。于是处于就绪态的 L 不会再被优先级 8 的 M 抢占,可以尽快完成临界区并释放互斥量;释放后,L 的优先级再按其基础优先级和仍持有的其他互斥量重新计算。
优先级继承消除的是中优先级线程造成的额外、可能无界的拖延,并不能消除 H 等待 L 完成剩余临界区所必需的时间。因此工程上仍应缩短持锁时间,避免在锁内执行长延时、阻塞 I/O 或复杂计算;它也不能自动解决忘记解锁、锁顺序相反造成的死锁。
1.1 建工程
课程创建了 07MUTEX 工程。配套工程配置可确认:
- RT-Thread 版本为 5.0.2,芯片为 STM32F103ZE,系统主频配置为 72 MHz。
- 工具链为 GCC,下载器为 ST-LINK。
- 控制台使用 UART1,发送、接收引脚分别为 PA9、PA10。
RT_TICK_PER_SECOND = 1000,主线程优先级为10,并启用了RT_USING_MUTEX与动态内存。
本节没有新增外部电路。UART1 只是承担控制台打印,互斥量完全由 RT-Thread 内核软件实现,不对应 MCU 内部某个“互斥量硬件模块”。
互斥量有动态和静态两套生命周期:
| 对象类型 | 对象存储 | 创建/初始化 | 销毁方式 |
|---|---|---|---|
| 动态互斥量 | 内核动态分配 | rt_mutex_create() | rt_mutex_delete() |
| 静态互斥量 | 调用者提供 struct rt_mutex | rt_mutex_init() | rt_mutex_detach() |
两套接口必须正确配对,不能对动态对象调用 detach,也不能对静态对象调用 delete。本例使用 rt_mutex_create(),所以 mutex1_ptr 是动态对象句柄;由于例程没有退出路径,最终源码没有删除它。
动态对象不再使用时调用 rt_mutex_delete():内核会唤醒该互斥量上的所有等待线程,使其等待调用以错误状态结束,然后删除对象并释放内存。静态对象则调用 rt_mutex_detach(),它会让对象脱离内核管理,但不会释放调用者提供的结构体存储。销毁前必须先保证持有者和其他线程不会再访问该句柄,否则会形成悬空引用。
动态创建接口为:
rt_mutex_t rt_mutex_create(const char *name, rt_uint8_t flag);
name 是内核对象名称,创建成功返回互斥量句柄,失败返回 RT_NULL。
版本纠错: 视频口头说明
flag可以像信号量一样选择RT_IPC_FLAG_FIFO或RT_IPC_FLAG_PRIO。但课程实际使用的 RT-Thread 5.0.2 已把互斥量的该参数标记为废弃:rt_mutex_create()和rt_mutex_init()都会忽略传入值,并把等待队列固定设置为RT_IPC_FLAG_PRIO。这是为了让等待者按优先级排列并配合优先级继承。课程传入RT_IPC_FLAG_PRIO与实际行为一致,但不能据此推导出该版本传入 FIFO 就会按 FIFO 工作。
1.2 复制线程代码
课程从上一节复制两个动态线程,再增加第三个线程,最终设置为:
| 线程 | 基础优先级 | 时间片 | 主要任务 |
|---|---|---|---|
th1 | 7 | 10 tick | 获取互斥量,持锁延时 500 ms,再释放 |
th2 | 8 | 10 tick | 每 100 ms 打印一次 th3 的当前优先级 |
th3 | 9 | 10 tick | 获取互斥量,持锁延时 1000 ms,再释放 |
代码先创建互斥量,再创建和启动会使用它的线程。这一顺序很重要:rt_thread_startup() 之后线程就可能被调度,如果线程已经执行 rt_mutex_take(),而互斥量句柄尚未初始化,就会访问无效对象。
不过,课程最终代码还存在一个更隐蔽的启动顺序问题:th2 在 th3_ptr 被赋值之前就已经启动,而 th2 会立即解引用 th3_ptr->current_priority。配套工程中主线程优先级是 10,th2 是 8,启动后具备立即抢占主线程的条件,因此这里存在访问 RT_NULL 的竞态,行为未定义,严重时可触发异常。课堂源码按要求原样保留;可靠工程应先把被其他线程依赖的全部对象和句柄创建完成,再统一启动线程,或用明确的同步条件保证初始化完成。
互斥量常用操作为:
rt_err_t rt_mutex_take(rt_mutex_t mutex, rt_int32_t time);
rt_err_t rt_mutex_trytake(rt_mutex_t mutex);
rt_err_t rt_mutex_release(rt_mutex_t mutex);
| 操作 | 关键行为 |
|---|---|
rt_mutex_take() | 空闲时取得所有权;已由其他线程持有时,根据等待时间返回或阻塞;成功返回 RT_EOK |
rt_mutex_trytake() | 等价于 rt_mutex_take(mutex, RT_WAITING_NO),不可用时立即返回 -RT_ETIMEOUT |
rt_mutex_release() | 仅持有者可以释放;非持有者调用返回 -RT_ERROR;有等待者时把所有权交给队首等待线程 |
rt_mutex_take() 的 time 单位是 OS tick:RT_WAITING_NO 表示不等待,RT_WAITING_FOREVER 表示永久等待,正数表示最多等待相应 tick 数。本工程 1000 tick/s,因此 100 tick 名义上约等于 100 ms,但不能把这个换算当成所有工程都成立。与此不同,源码中的 rt_thread_mdelay(500)、rt_thread_mdelay(1000) 参数本来就是毫秒。
互斥量具有递归持有能力:同一个持有线程再次调用 take 不会把自己挂起,而是使内部 hold 计数加 1;之后必须成功调用相同次数的 release 才会真正解锁。RT-Thread 5.0.2 的 hold 是 8 位字段,达到 RT_MUTEX_HOLD_MAX 后再次获取会返回 -RT_EFULL。递归能力不等于可以随意重复加锁;加锁、解锁次数不匹配仍会长期占锁。
1.3 互斥量优先级反转
本实验观察的是 th3_ptr->current_priority,它是线程当前参与调度的有效优先级,可能因优先级继承临时变化;线程创建时设置的基础优先级仍是 9。
典型时序如下:
| 阶段 | 发生的动作 | th3 当前优先级 | 调度意义 |
|---|---|---|---|
| 1 | th3 成功取得 mutex1 | 9 | 低优先级线程持有共享资源 |
| 2 | th1 尝试获取同一互斥量并阻塞 | 从 9 提升到 7 | th3 继承最高优先级等待者 th1 的优先级 |
| 3 | th3 处于就绪态 | 7 | 优先级 8 的 th2 不能抢占就绪的 th3 |
| 4 | th3 释放互斥量 | 恢复为 9 | 所有权直接交给等待队首的 th1,随后重新调度 |
| 5 | th1 从 take 返回并执行临界区 | th3 为 9 | 高优先级线程终于取得共享资源 |
串口中会交替看到 th3 的当前优先级为 9 和 7,这正是优先级继承生效和撤销的结果。原稿中“从 7 提升为 9”的说法方向相反:RT-Thread 数字越小优先级越高,所以这里是从数值 9 提升到数值 7,再恢复为 9。
源码在持锁期间调用 rt_thread_mdelay(1000),这是为了让现象更明显,并不是推荐的临界区写法。th3 延时期间虽然仍持有锁、当前优先级也可能已经被提升为 7,但它处于延时/挂起状态,不是就绪线程;此时 th2 仍然可以运行并打印。优先级继承只影响就绪线程之间的调度,不会把正在睡眠的锁持有者强行变成可运行状态。
th2 直接读取 th3_ptr->current_priority 适合课堂诊断,却让应用代码依赖内核线程控制块的内部字段。实际业务通常只需要正确使用互斥量,而不应靠轮询内部优先级来完成同步。
还应正确理解“保护临界区”:
th1持锁时,th3不能取得同一把mutex1;反之亦然。th2根本不获取mutex1,所以互斥量不会禁止它运行。- 中断、未遵守加锁规则的线程以及访问另一资源的线程仍可能执行。
- 因而共享资源的所有线程访问路径都必须遵守同一把锁的协议,漏掉任意一条路径都会破坏互斥性。
1.4 信号量代码实验现象
视频把 th3 的 rt_mutex_release(mutex1_ptr) 临时移到 th2 中,用来验证所有权。结果是:
th3已经取得mutex1,所以它是唯一合法持有者。th2调用rt_mutex_release()时,内核发现当前线程不是 owner,返回-RT_ERROR,互斥量并没有解锁。th3回到循环后再次获取自己已持有的互斥量。由于 RT-Thread 互斥量允许递归获取,它会增加hold计数并继续执行,所以视频中会反复看到th3的打印。th1始终拿不到真正被释放的互斥量,只能继续等待。
课程随后把释放操作放回 th3,最终代码已经恢复为“谁获取、谁释放”,于是 th1 与 th3 都能轮流进入临界区,th3 的当前优先级也会在 9 和 7 之间按条件变化。
这个临时实验还说明了检查返回值的重要性。课程代码忽略了 take 和 release 的返回值,所以非法释放失败、递归计数最终溢出等错误不会被业务逻辑发现。实际写法应只在 rt_mutex_take() 返回 RT_EOK 后访问共享资源,并检查 rt_mutex_release() 是否成功。
与上一节信号量“一个线程获取、另一个线程释放来传递接力棒”的写法相比,互斥量绝不能这样跨线程交接:
信号量:A take,B release —— 可以用于通知或许可传递
互斥量:A take,只能 A release —— 用于保护 A 正在独占访问的资源
本节最终源码还有几个应识别但不在抄录时擅自修改的细节:
- 全局变量
a和th1_printf()中的current_ptr没有被使用,属于课堂编辑过程留下的变量。 parameter在三个线程入口中均未使用,编译器可能给出未使用参数警告。- 创建线程失败时,先前已动态创建的互斥量或线程没有统一清理;具有退出路径的产品代码应设计成对的失败回收流程。
while (count++)长期运行会使有符号int最终溢出,不适合作为工程中的永久循环条件,这里仍按课程源码保留。__LINE__打印的是编译时该语句所在的物理行号,增加或删除空行都会改变视频中看到的数字;行号不是线程状态或执行次数。
使用互斥量时可归纳为四条规则:
- 先完成互斥量和所有依赖句柄的初始化,再启动可能使用它们的线程。
- 所有访问同一共享资源的线程都使用同一把互斥量,并保证成功加锁后必有对应解锁。
- 持锁时间尽可能短,不在锁内进行不必要的延时或阻塞操作。
- 不在中断服务程序中使用互斥量;线程与中断共享数据时另选中断安全方案。
核对依据: 本节工程版本、最终源码和配置以课程配套
07MUTEX工程为准;互斥量所有权、递归持有、优先级继承、废弃的flag参数和接口返回值,已与 RT-Thread 5.0.2 的ipc.c、rtdef.h,以及官方线程间同步说明和互斥量 API 文档交叉核对。