RISC-V Linux 嵌入式编程技术 · 第六章

线程与协同

从单线程过渡到多线程 → 采集、控制、通信三线 → 共享状态、干净停与竞态深入理解
— 6.1 / 6.2 / 6.3 —

板卡:荔枝派 4A 工具链:RuyiSDK 脚手架:race-demo · snapshot-lock · tri-thread

开始之前

前置条件:第五章 MQTT 控灯可用;第四章温控与串口命令已验收。本章把采样、控风扇、MQTT 通信拆成三条线程(同一进程里并行跑的几条执行流),并处理共享数据与 Ctrl+C 干净停。每节约 90–120 分钟。

你需要准备什么

荔枝派 4A

DHT22(IO1_6)、继电器/风扇(IO1_5)、外接 LED(IO1_4);MQTT Broker 可达。板端原生 gcc 或主机交叉工具链。

主机 gcc

跑 race-demo 时用本机 gcc -fsanitize=thread(ThreadSanitizer,检查谁在抢同一变量)。

代码

race-demo/ 丢更新;snapshot-lock/ 成对快照加锁;tri-thread/ 产品骨架。

本章你会学到什么

6.1 从单线程过渡到多线程

单线程按顺序做完三件事;等待网络时,采样和风扇判定排在返回之后。

6.2 三线:采集、控制、通信

三条线程各干什么、共享哪份状态。

6.3 共享状态与干净停

共享字段要加锁;Ctrl+C 后关风扇再退出。

讲义 vs 实验:讲义建立拆分与竞态心智。本章实验三个递进:race-demo+TSan → 成对快照加锁 → 三线程主实验;骨架供综合项目复用。
6.1

从单线程过渡到多线程

单线程按顺序做三件事

本章板上要同时做三件事:按周期读 DHT22(第四章)、按阈值开/关风扇(第三、四章)、跑 MQTT 收发(第五章)。这三件事可以放在一个进程、一条线程里,用一个 while 按顺序做完:先处理网络,再读传感器,再判风扇。

第五章的 mqtt-led 在连上 Broker 之后调用 mosquitto_loop_forever。这个调用让执行流停留在套接字等待上。这次调用返回之后,同一条线程才执行排在后面的「读 DHT」「判风扇」。温度在第 0 秒越过 29°C 时,风扇开断要等这次等待返回之后才执行,间隔等于这次等待的时长。

把 mosquitto_loop_forever 换成带超时的 mosquitto_loop(例如每次最多等 50 毫秒就返回),返回后再读 DHT、再判风扇、再进入下一次 mosquitto_loop:仍然是单线程,三件事都能做完。第四章用 select 在一条线程里轮流处理「标准输入可读」和「采样周期到」,也是这一种顺序调度。读 DHT 若内部忙等约 30 毫秒,或两次采样之间 sleep(2),这段时间里执行流停留在采样或休眠上,已经到达的 MQTT 命令要等这段结束才被 mosquitto_loop 处理。灯可以晚 2 秒再亮;风扇按温度关断也会晚这同样一段时间。

本章用三条线程,让三件事按各自的周期进行:采集线程每 2 秒读 DHT22,把结果写入 last_temp;控制线程按自己的周期读 last_temp 和阈值,驱动继电器;通信线程调用 mosquitto_loop,在套接字上等待 MQTT。通信线程停留在等待时,采集线程仍然每 2 秒写入 last_temp,控制线程仍然按自己的周期拉继电器。温度在第 0 秒越过 29°C 时,控制线程仍可在约 200 毫秒量级去开风扇,开断时刻由控制线程自己的周期决定。这是本章要验收的行为。

单线程:一个 while 按顺序做完三件事 while (1) { mosquitto_loop_forever(); /* 执行流停留在等待网络 */ 读 DHT22; 按阈值控风扇; } 等待返回之后,才轮到采样和风扇判定。 多线程:同一个进程里三条执行流,各按自己的周期 采集线程:每 2 秒读 DHT22,写入 last_temp 控制线程:按自己的周期读 last_temp,拉继电器 通信线程:mosquitto_loop() 等待 MQTT 通信线程停留在等待时,采集和控制仍按上面的周期运行。
单线程:网络等待挡住采样和风扇 mosquitto_loop 等待… → 读 DHT → 控风扇 三线程:各跑各的周期 采集 控制 通信 每 2s ~200ms 等 MQTT 风扇不能等网络 — 这就是拆三线的理由
对比图:单线程顺序执行 vs 三线程并行 — 通信阻塞不再拖慢温控

采集线程写 last_temp,控制线程读同一个 last_temp。同一变量上一写一读会交错:控制线程可能读到一次尚未写完的值,日志里的温度和风扇开断对不上。对这一次写和这一次读加同一把互斥锁,控制线程读到的是一次完整写入之后的值。锁的写法在 6.3。两条线程若只访问各自的局部变量、地址不同,这些变量各自保持自己的值,这一对访问用不上这把锁。本课温控闭环把最新温度放在共享的 last_temp 里,所以实验里对这个字段加锁。

和第四章 select 的关系:第四章在一条线程里用 select 轮流处理标准输入和采样周期。本章在同一进程里用三条线程,采集、风扇、MQTT 各一条小循环。两种都是课内要会的调度;本章验收三线程按各自周期运行,以及对 last_temp 的加锁读写。
本节成果:能口述单线程一个 while 如何按顺序做完三件事;能指出 mosquitto_loop_forever 等待期间采样和风扇判定排在返回之后;能说出三线程下通信等待时采集和控制仍按自己的周期运行;知道对同一个 last_temp 的写和读加同一把锁。
6.2

三线:采集、控制、通信

本节标题里的三线,完整说法是:采集线程、控制线程、通信线程。它们在同一进程里并行跑,通过一份共享状态交换数据。

职责拆分

┌─────────────────┐ 写 last_temp ┌─────────────────┐ │ 采集线程 │ ─────────────► │ │ │ 周期读 DHT22 │ │ 共享状态 │ └─────────────────┘ │ + 互斥锁 │ │ │ ┌─────────────────┐ 读温度/写风扇 │ │ │ 控制线程 │ ◄────────────► │ │ │ 按阈值开/关风扇 │ │ │ └─────────────────┘ │ │ │ │ ┌─────────────────┐ 改阈值/发状态 │ │ │ 通信线程 │ ◄────────────► │ │ │ 跑 MQTT 循环 │ └─────────────────┘ └─────────────────┘
采集线程 每 2s 读 DHT22 控制线程 读温度 · 开/关风扇 通信线程 mosquitto_loop 共享状态 last_temp · 阈值 · fan_on MUTEX DHT22 风扇继电器 MQTT Broker 同一进程 · 三条执行流 · 通信等待时采集/控制仍按时运行
架构图:tri-thread 三线通过共享状态 + 互斥锁协作,各连自己的硬件或 Broker

图说明:采集只负责把最新温湿度写进共享状态;控制只负责读温度与阈值、驱动风扇;通信只负责 MQTT 下行改阈值/控灯与上行报状态。谁都不包办别人的活。

线程做什么不做什么
采集线程按周期读传感器,更新 last_temp/last_hum不直接开风扇、不跑网络库
控制线程读温度与阈值,调用风扇 GPIO不阻塞在 MQTT
通信线程跑 MQTT 循环;下行改阈值或灯;上行发布状态不做 DHT 位带采样

骨架一览

chapters/ch06/code/tri-thread/ 给出 pthread 创建、共享结构、三线程函数空壳与信号退出标志。实验中补全各线程循环与加锁读写。退出标志请用 #include <stdatomic.h> 的 atomic_int(勿只写 volatile int)。

struct shared_state {
    pthread_mutex_t lock;   /* 互斥锁:同一时刻只许一条线改关键字段 */
    float last_temp;
    float t_high, t_low;
    int fan_on;
    atomic_int running;   /* Ctrl+C 原子置 0;各线程 load 后退出(勿只用 volatile) */
};
$ cd chapters/ch06/code/tri-thread
$ make clean && make
$ file tri-thread
$ scp tri-thread user@board-ip:~/
线程函数必须是 void *(*)(void *);创建后用 pthread_join 等其结束。风扇与 GPIO 初始化放在开线程之前,避免多线同时申请同一 line。
本节成果:能画出采集、控制、通信三线与共享状态 · 能编译部署 tri-thread 骨架 · 知道各线程边界。
6.3

共享状态与干净停

锁与干净停

采集写温度、控制读温度、通信改阈值时,若两条线同时碰到同一字段,可能读到「写到一半」的值,或丢掉某次更新。用 互斥锁(mutex:同一时刻只许一条线程进入临界区)包住读写:

pthread_mutex_lock(&st->lock);
temp = st->last_temp;
high = st->t_high;
pthread_mutex_unlock(&st->lock);
互斥锁示意:Thread 1 正在访问共享资源,Thread 2 被阻塞
互斥锁:同一时刻只有一条线程进入临界区访问 last_temp;其他线程在锁外等待

干净停:注册 SIGINT(Ctrl+C)处理函数,用 atomic_store 把 running 置 0(信号与各线程并发读写退出标志时,volatile 不够,TSan 仍会报 race);各线程 atomic_load 判断后退出;主线程 join 完毕后关风扇(GPIO 写入失败要报错,勿误报已关)、释放 GPIO、断开 MQTT。验收时必须看到风扇停转再进程结束。

「同一 last_temp 上一写一读如何交错、加锁后如何读到完整写入」,完整步骤在下一节「深入理解」。

本节成果:知道对同一个 last_temp 的写和读加同一把锁 · 能口述干净停步骤 · 带着无锁交错的现象进入深入理解。
深入理解

竞态:板上必现错乱,电脑上用 TSan 指出两行

本节把三个词说清楚:竞态(data race)是两条线程无同步地并发读写同一共享变量,结果不确定甚至写乱;互斥锁(mutex)保证同一时刻只有一条线程进入临界区;ThreadSanitizer(TSan)是编译加上 -fsanitize=thread 的检查器,能在运行时报告「哪两行在抢同一变量」。板上交叉工具链未必开 TSan,所以产品现象在荔枝派 4A 上看,机理用主机上的 race-demo 演示。

三条证据链(必须都做): (1) 板上无锁 → 日志/行为错乱(现象) (2) 板上加锁 → 行为正确(对照) (3) 电脑 TSan → 报告「这两行在抢同一变量」(机理)

图说明:先看 [RACE] 必现,再加锁对照,再用 TSan 指出两行。

(1)(2) 荔枝派 4A:无锁错乱 → 加锁正确

原因:采集线程写共享温度,控制线程读同一份并写 fan_on,通信线程可能同时改 t_high。没有锁时,这些访问可以交错发生。

机制(无锁时):脚手架把同一次采样拆成 temp_a / temp_b 两步写,中间故意留空窗;控制线程读到两值不一致就打印 [RACE]。这是可稳定复现的证据。

机制(加锁后):成对写入落在同一把锁里,空窗消失。[RACE] 不再出现,阈值控温与 MQTT 改阈值恢复一致。

  1. 1
    板上先关锁
    脚手架默认 USE_LOCK 0,跑十几秒,日志应反复出现 [RACE],退出时 race_hits 远大于 0。
  2. 2
    再开锁对照
    改为 USE_LOCK 1;[RACE] 消失,race_hits=0,风扇按阈值动作,MQTT 下行仍正常。
无锁时必现交错(脚手架): 采集: 写 temp_a ──┐ 控制: ├── 读到 a≠b → 打印 [RACE] 采集: 写 temp_b ──┘ 加锁后: 采集: lock → 写 temp_a 与 temp_b → unlock 控制: lock → 读完整一对 → unlock

图说明:锁保证这一次成对写完整结束;三线仍按各自周期运行,碰共享温度时排队。

(3) 电脑:race-demo + ThreadSanitizer 指出两行

chapters/ch06/code/race-demo/ 是故意让两条线程无锁抢同一计数器的小程序(无板、无 GPIO)。先无锁跑一遍,计数会偏小(丢更新);再用 TSan 编译运行,报告会打印发生数据竞争的两处源码行。

$ cd chapters/ch06/code/race-demo
$ make
$ ./race-demo
# 无锁:最终计数明显小于期望(丢更新)

$ sudo sysctl vm.mmap_rnd_bits=28
# 固定步骤:新内核地址随机化位数与 TSan 冲突,调低后 TSan 才能稳定跑通

$ make tsan
$ ./race-demo
# ThreadSanitizer 打印数据竞争报告,标出两处访问行号

保存终端中 TSan 指出的两行,对照源码:那就是「谁和谁在无同步地碰同一变量」。产品路径(tri-thread)验收以加锁版本为准。race-demo 只用于在电脑上保存 TSan 报告。

示意:TSan 报告在说什么 WARNING: ThreadSanitizer: data race Write of size 4 at 0x... by thread T1: #0 incr race-demo.c:42 ← 这一行在写 Previous read of size 4 at 0x... by thread T2: #0 incr race-demo.c:42 ← 同一变量,另一线程也在碰 (或另一行号:读/写交错)

图说明:TSan 不替你「修」程序,它只把竞态从「感觉偶尔不对」变成「具体是这两行」。你回去加 mutex,再跑应不再报同一竞争。

验收话术:「板上 USE_LOCK 0 时日志反复出现 [RACE];USE_LOCK 1 后 race_hits=0、风扇按阈值动作;race-demo 经 TSan 指出抢同一变量的两行。共享温度成对写入进互斥锁。」
race-demo 故意无锁,只用于教学演示。产品路径(tri-thread)以加锁版本验收。跑 TSan 前执行 sudo sysctl vm.mmap_rnd_bits=28 是本课固定步骤。
本节成果:能复述无锁→加锁→TSan 三步证据链 · 能解释 mutex、竞态与 mmap_rnd_bits 一步 · 带着验收清单进入本章实验。