2、基础知识:线程、进程、并发、并行、临界区、竞态条件、原子操作等基本概念
TinyML 实时任务接口的核心挑战,在于如何在资源极度受限的微控制器(MCU)上安全、高效地调度推理任务。要理解线程安全与互斥控制的必要性,首先必须厘清操作系统与并发编程中的一组基础概念。这些概念并非孤立的术语,而是构成了分析实时系统正确性与性能的“坐标系”。本章将逐一剖析进程与线程的差异、并发与并行的本质区别,并重点讲解临界区、竞态条件与原子操作——它们是理解互斥锁(Mutex)、信号量(Semaphore)等同步机制的理论基石。下图展示了这些核心概念之间的逻辑关系与层次结构:
上图清晰地揭示了两个层次:上层是任务载体(进程与线程),下层是安全机制(临界区、竞态条件、原子操作)。在 TinyML 场景中,我们通常直接操作线程(或 RTOS 任务),并通过互斥锁等机制来保护临界区,从而消除竞态条件。下面逐一深入剖析。
2.1 进程与线程:任务的组织形态
核心原理
进程是操作系统进行资源分配的基本单位,拥有独立的地址空间、文件描述符、信号处理器等。进程间的内存相互隔离,一个进程的崩溃通常不会直接影响其他进程。线程是 CPU 调度的基本单位,是进程内的一个执行流。同一进程内的多个线程共享该进程的地址空间、全局变量和静态数据,但每个线程拥有独立的栈空间和寄存器上下文。
在 TinyML 领域,绝大多数场景运行的是 RTOS(如 FreeRTOS、Zephyr、RT-Thread),这些系统通常只提供线程(任务)概念,而不提供完整的进程模型。这是因为进程的地址空间隔离需要 MMU(内存管理单元)支持,而多数 MCU(如 Cortex-M 系列)不具备 MMU。因此,TinyML 开发者面对的是“多线程共享内存”的模型,这既是效率的来源,也是线程安全问题的根源。
| 对比维度 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 独立 | 共享 |
| 资源开销 | 大(PCB、页表等) | 小(仅栈和TCB) |
| 通信方式 | IPC(管道、消息队列) | 直接读写共享变量 |
| 上下文切换 | 慢(需切换地址空间) | 快(仅保存寄存器) |
| TinyML适用性 | 几乎不用 | 标准模型 |
判断方法
在嵌入式开发中,判断一个执行单元是“进程”还是“线程”,最简单的方法是查看其地址空间是否隔离。如果两个执行流可以互相访问对方的全局变量(通过指针或 extern 声明),那么它们必然是同一进程内的线程。如果访问对方内存会导致段错误(Segmentation Fault),则它们是不同进程。在 FreeRTOS 中,所有 task 都是线程,共享同一个内存映射。
案例拆解:传感器数据采集线程与推理线程
假设一个智能音箱设备,使用 Cortex-M7 内核,运行 FreeRTOS。系统中有两个线程:sensor_task 负责以 100Hz 频率读取麦克风阵列数据,inference_task 负责运行语音唤醒模型。两个线程共享一个环形缓冲区 audio_buffer。由于它们共享地址空间,sensor_task 写入数据时,inference_task 可能正在读取同一块内存,这就引入了竞态条件。若将 sensor_task 设计为独立进程,则需要复制音频数据,这在 MCU 上既不现实也无必要。因此,正确的做法是保持线程模型,并通过互斥锁保护缓冲区。
2.2 并发与并行:时间维度的两种“同时”
核心原理
并发(Concurrency)是指多个任务在同一个时间段内交替执行,但在任何一个时间点,只有一个任务在运行(单核场景)。这是通过时间片轮转或优先级抢占实现的。并行(Parallelism)是指多个任务在同一个时间点同时执行,这要求多核处理器(如 Cortex-A 系列或双核 Cortex-M)。
对于 TinyML 而言,绝大多数 MCU 是单核的,因此我们处理的是并发问题。并发关注的是“如何设计任务结构以应对不确定性”,而并行关注的是“如何利用多核提升吞吐量”。一个常见的误解是:只要用了 RTOS,任务就是“同时”运行的。实际上,在单核上,RTOS 只是快速地在任务间切换,制造了“同时”的假象。
判断方法
判断一个系统是并发还是并行,只需看CPU 核心数量:单核 = 并发;多核 = 并行(也可能同时包含并发)。在代码层面,可以通过 hardware_concurrency()(C++11)或查看芯片手册确认核心数。在 TinyML 中,如果使用 CMSIS-RTOS2 封装,可以通过 osKernelGetSysTimerCount() 观察任务切换频率,但无法直接判断并行性——必须查阅硬件手册。
案例拆解:单核 MCU 上的“伪并行”推理
以 STM32F746(单核 Cortex-M7,主频 216MHz)为例,运行两个任务:task_A 执行图像分类推理(约 50ms),task_B 执行温湿度读取(约 2ms)。由于是单核,task_A 和 task_B 不可能真正同时执行。如果 task_A 优先级更高,则 task_B 必须等待 task_A 完成或被抢占。若使用时间片轮转,则每个任务运行 1ms 后切换,宏观上看两者都在推进,但微观上任何时刻只有一个任务在运行。这就是典型的并发。若换成双核 STM32H745,则 task_A 可运行在 Cortex-M7 核上,task_B 运行在 Cortex-M4 核上,这才是并行。
2.3 临界区、竞态条件与原子操作:线程安全的三大支柱
核心原理
临界区(Critical Section)是指访问共享资源(如全局变量、外设寄存器、缓冲区)的代码段。这段代码在同一时刻只允许一个线程进入。竞态条件(Race Condition)是指多个线程并发访问共享数据,且最终结果依赖于线程执行的精确时序。例如,两个线程同时对全局变量 counter 执行 counter++,在汇编层面这通常对应三条指令:加载(LOAD)、加一(ADD)、存储(STORE)。如果线程 A 执行完 LOAD 后被线程 B 抢占,线程 B 完成整个 counter++,然后线程 A 恢复执行 ADD 和 STORE,那么最终 counter 只增加了 1 次,而不是 2 次——这就是典型的竞态条件。
原子操作(Atomic Operation)是指不可被中断的操作,要么全部执行,要么完全不执行。在 MCU 上,原子操作通常依赖硬件指令,如 ARM 的 LDREX/STREX(Load-Exclusive/Store-Exclusive)或 CMSIS 提供的 __disable_irq()/__enable_irq() 中断屏蔽。原子操作是构建锁的基础,但并非所有操作都能原子化(如浮点运算、结构体拷贝)。
判断方法
识别竞态条件的关键是检查共享变量是否被多线程读写,且读写操作是否在汇编层面被拆分为多条指令。一个实用的方法是:在代码中搜索所有全局变量和静态变量,然后检查它们是否被多个线程引用。如果存在“读-改-写”模式(如 counter++、flag = !flag),则极有可能存在竞态条件。对于临界区的识别,可以问自己:这段代码是否修改了其他线程也能看到的数据?如果是,那么这段代码就是临界区。
案例拆解:推理结果标志位的竞态条件
考虑一个 TinyML 语音识别系统,inference_task 完成推理后设置全局标志 result_ready = true,display_task 检测到该标志后读取结果并显示。初始代码如下:
// 共享变量
bool result_ready = false;
float result_score = 0.0f;
// 推理线程
void inference_task(void) {
// ... 执行推理 ...
result_score = 0.95f; // 写入结果
result_ready = true; // 设置标志
}
// 显示线程
void display_task(void) {
if (result_ready) {
printf("Score: %.2f\n", result_score);
result_ready = false;
}
}
这段代码存在两个问题:1) 编译器可能将 result_ready = true 优化为对寄存器操作,导致显示线程看不到更新(需要 volatile 关键字);2) 即使使用 volatile,result_score 的写入和 result_ready 的写入之间可能被抢占,导致显示线程看到 result_ready == true 但 result_score 尚未更新完成。正确的做法是将“写结果”和“置标志”放入临界区,使用互斥锁保护:
// 使用互斥锁
osMutexId_t result_mutex;
void inference_task(void) {
// ... 执行推理 ...
osMutexAcquire(result_mutex, osWaitForever);
result_score = 0.95f;
result_ready = true;
osMutexRelease(result_mutex);
}
void display_task(void) {
osMutexAcquire(result_mutex, osWaitForever);
if (result_ready) {
printf("Score: %.2f\n", result_score);
result_ready = false;
}
osMutexRelease(result_mutex);
}
常见误区与总结复盘
- 误区一:认为
volatile可以解决所有并发问题。实际上,volatile只保证编译器不优化对变量的访问,不保证操作的原子性,也不提供内存屏障。 - 误区二:在中断服务函数(ISR)中使用互斥锁。ISR 中不能阻塞,因此不能使用
osMutexAcquire。ISR 与线程通信应使用信号量(Semaphore)或消息队列,且信号量的give操作必须是从 ISR 安全版本(如osSemaphoreRelease)。 - 误区三:忽略优先级反转。当低优先级线程持有锁,高优先级线程等待时,中优先级线程可能抢占低优先级线程,导致高优先级线程无限期等待。解决方法是使用优先级继承(Priority Inheritance)或优先级天花板(Priority Ceiling)协议。