线程并发深度解读
# 04.线程并发深度解读
Qt 的
QThread底层就是 pthread——理解原生线程 API 和同步原语,才能在嵌入式场景下做出正确的并发架构选择,而不是"不管三七二十一全部上 QtConcurrent"。
# 目录介绍
# 4.1 案例引入
# 4.1.1 仪表盘渲染线程莫名其妙卡死
某车载仪表盘使用 Qt 渲染,主线程负责 QML UI,一个 pthread 后台线程负责 CAN 数据读取。测试发现运行 3 小时后渲染线程卡死,但 CAN 线程仍在正常接收数据。
出问题的代码(简化):
// CAN 线程
pthread_mutex_t can_mutex = PTHREAD_MUTEX_INITIALIZER;
can_data_t latest_can;
void *can_thread(void *arg) {
while (1) {
can_frame_t frame = read_can_bus();
pthread_mutex_lock(&can_mutex);
parse_can_frame(&latest_can, &frame);
// ⚠ 在锁内调用了非线程安全的函数
char *ip = inet_ntoa(latest_can.gateway_addr.sin_addr);
log_can_ip(ip);
pthread_mutex_unlock(&can_mutex);
}
}
// 渲染线程
void *render_thread(void *arg) {
while (1) {
pthread_mutex_lock(&can_mutex); // ← 等锁
display_can_data(&latest_can);
pthread_mutex_unlock(&can_mutex);
render_qml_frame();
}
}
排查发现:CAN 线程持有互斥锁 can_mutex,在锁内调用了 inet_ntoa——它内部使用静态缓冲区,在 glibc 实现中这个缓冲区由另一个内部锁保护。而渲染线程正等待 can_mutex。问题在 inet_ntoa 的内部锁重入——这不是典型的 ABBA 死锁,而是"锁内调用非线程安全函数"导致的隐蔽死锁。
inet_ntoa 的线程陷阱:
// glibc 中 inet_ntoa 的简化实现
char *inet_ntoa(struct in_addr in) {
static char buf[16]; // ← 静态缓冲区!所有线程共享
// 某些实现有内部锁保护此 buf
snprintf(buf, sizeof(buf), "%d.%d.%d.%d",
in.s_addr & 0xFF, (in.s_addr >> 8) & 0xFF,
(in.s_addr >> 16) & 0xFF, (in.s_addr >> 24) & 0xFF);
return buf;
}
正确做法——用线程安全版本 inet_ntop:
char ip_str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &latest_can.gateway_addr.sin_addr, ip_str, sizeof(ip_str));
log_can_ip(ip_str); // ip_str 是局部变量,天然线程安全
# 4.1.2 死锁的四个必要条件分析
死锁的发生必须具备四个条件(同时满足):
| 条件 | 含义 | 本案例中 |
|---|---|---|
| 互斥 | 资源不能共享,同一时间只能一个线程持有 | ✅ can_mutex 是互斥锁 |
| 持有并等待 | 线程持有一个资源的同时等待另一个资源 | ✅ CAN 线程持有 can_mutex 等 inet_ntoa 的内部锁 |
| 不可剥夺 | 资源不能被强制拿走,只能由持有者主动释放 | ✅ 互斥锁不能被外部 unlock |
| 循环等待 | 多个线程形成等待环路 | ✅ 渲染线程等 can_mutex → CAN 线程等内部锁 → 内部锁受另一个未知持有者 |
破坏任一条件即可防止死锁。本案例的修复属于破坏条件 2——不在锁内调用任何可能再获取锁的函数。
预防死锁的四条实战铁律:
- 锁内不调外部函数——除非确认该函数不获取任何锁
- 固定加锁顺序——所有线程以相同顺序获取多把锁(
lock(A); lock(B),不要lock(B); lock(A)) - 用
pthread_mutex_trylock+ 回退——获取失败就释放已持有的锁,稍后再试 - 用
PTHREAD_MUTEX_ERRORCHECK——检测同一线程重复加锁(提前暴露潜在死锁)
# 4.1.3 核心问题
- 不同锁类型的适用场景——什么时候用
pthread_mutex,什么时候用spinlock? pthread_cond_wait为什么必须写在while循环里?- 嵌入式单核 CPU 上的多线程真的是并行吗?锁策略有什么不同?
- 什么是"线程安全"和"可重入"?
errno如何在多线程下正确工作?
# 4.2 线程的生命周期
# 4.2.1 pthread_create 参数全景
pthread_create 是 POSIX 线程的入口——底层在 Linux 上调用 clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD, ...):
#include <pthread.h>
int pthread_create(pthread_t *thread, // [out] 线程 ID
const pthread_attr_t *attr, // 线程属性(NULL = 默认)
void *(*start_routine)(void*), // 线程入口函数
void *arg); // 传给入口函数的参数
pthread_attr_t 可定制的五项属性:
| 属性 | 设置函数 | 默认值 | 说明 |
|---|---|---|---|
| 栈大小 | pthread_attr_setstacksize | 8 MB (glibc) | 嵌入式须大幅缩小 |
| 栈地址 | pthread_attr_setstackaddr | 内核分配 | 极少使用 |
| 分离状态 | pthread_attr_setdetachstate | PTHREAD_CREATE_JOINABLE | detach 后不可 join |
| 调度策略 | pthread_attr_setschedpolicy | SCHED_OTHER | 实时线程用 SCHED_FIFO/SCHED_RR |
| 调度优先级 | pthread_attr_setschedparam | 0 | 范围 1-99(实时策略) |
| 继承调度 | pthread_attr_setinheritsched | PTHREAD_EXPLICIT_SCHED | 是否继承父线程调度 |
完整创建示例:
pthread_t tid;
pthread_attr_t attr;
pthread_attr_init(&attr);
// 嵌入式定制:缩小栈到 128 KB
pthread_attr_setstacksize(&attr, 128 * 1024);
// 分离线程:不需要 pthread_join
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
if (pthread_create(&tid, &attr, can_thread_func, NULL) != 0) {
perror("pthread_create");
}
pthread_attr_destroy(&attr);
Qt 侧对应:
// QThread(底层用 pthread_create)
class CanThread : public QThread {
void run() override { /* can_thread_func 的逻辑 */ }
};
CanThread *t = new CanThread;
t->setStackSize(128 * 1024); // Qt 封装的栈大小设置
t->start();
// QtConcurrent(底层用 QThreadPool + pthread)
QtConcurrent::run(&readCANData);
# 4.2.2 线程退出与 pthread_join/detach
线程退出的四种方式:
| 方式 | 说明 | 后果 |
|---|---|---|
return (从线程函数返回) | 最干净 | 返回值可通过 pthread_join 获取 |
pthread_exit(retval) | 显式退出当前线程 | 返回值传 retval |
pthread_cancel(tid) | 被其他线程取消 | 返回 PTHREAD_CANCELED |
进程终止 (exit / 信号) | 整个进程退出 | 所有线程一并消失 |
pthread_join——等一个线程结束并收尸:
void *result;
int rc = pthread_join(tid, &result);
if (rc == 0) {
printf("thread returned: %p\n", result);
}
pthread_join 相当于进程的 waitpid——不 join 的线程会变成"线程僵尸"(占用少量内核资源,类似进程僵尸)。
pthread_detach——告诉内核"我不会 join 它,它退出后自动回收":
// 方式 1:创建时指定
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
pthread_create(&tid, &attr, func, NULL);
// 方式 2:创建后 detach
pthread_detach(tid);
join vs detach 选择:
| 场景 | 推荐 |
|---|---|
| 需要获取线程返回值 | join |
| 线程退出需要同步点 | join |
| 一次性任务(fire-and-forget) | detach |
| 长期运行的后台线程 | detach |
Qt 的 QThread | Qt 框架内部已处理,不需手动 join/detach |
# 4.2.3 线程取消机制 pthread_cancel
pthread_cancel 向目标线程发送"取消请求",线程在下一个取消点(cancellation point)检查该请求并退出。
// 请求取消
pthread_cancel(tid);
pthread_join(tid, NULL); // 等待真正退出
取消状态和类型:
// 设置是否接受取消
pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL); // 默认
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); // 推迟取消(临界区保护)
// 设置取消时机
pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED, NULL); // 默认:推迟到取消点
pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, NULL); // 立即取消(危险!)
异步取消的危险:
// ⚠ 灾难性代码
pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, NULL);
pthread_mutex_lock(&mutex); // 可能在这里被取消!
// ... 受保护的资源 ...
pthread_mutex_unlock(&mutex); // ← 永远执行不到,锁泄漏!
POSIX 定义的取消点(部分常用函数):
| 类别 | 函数 |
|---|---|
| I/O | open, read, write, close, fsync, select, poll, sleep |
| 同步 | pthread_mutex_lock, pthread_cond_wait, pthread_join |
| 标准库 | printf, fprintf, fread, fwrite, getchar, putchar |
安全取消的最佳实践——清理处理器:
void cleanup_mutex(void *arg) {
pthread_mutex_unlock((pthread_mutex_t *)arg);
}
void *safe_thread(void *arg) {
pthread_mutex_lock(&mutex);
// 注册清理函数——如果在这里被取消,cleanup 会自动 unlock
pthread_cleanup_push(cleanup_mutex, &mutex);
// ... 危险操作,可能在取消点被取消 ...
pthread_cleanup_pop(1); // 参数 1 = 执行 cleanup 函数
// pthread_mutex_unlock(&mutex); // cleanup 已执行,不需要手动 unlock
return NULL;
}
嵌入式最佳实践:尽量不用 pthread_cancel。用标志位 + 条件变量通知线程自行退出——可控且安全。
volatile int quit = 0;
void *worker(void *arg) {
while (!quit) {
do_work();
}
cleanup_resources();
return NULL;
}
// 主线程通知退出:
quit = 1;
pthread_join(tid, NULL); // 等线程安全退出
# 4.2.4 嵌入式 CPU 亲和性 (affinity)
CPU 亲和性(affinity)——把线程绑定到指定的 CPU 核心上,减少内核调度带来的缓存失效。
#define _GNU_SOURCE
#include <sched.h>
#include <pthread.h>
// 绑定到 CPU 0
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(0, &cpuset);
pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);
// 绑定到 CPU 0 或 CPU 1(调度器二选一)
CPU_ZERO(&cpuset);
CPU_SET(0, &cpuset);
CPU_SET(1, &cpuset);
pthread_setaffinity_np(tid, sizeof(cpuset), &cpuset);
嵌入式 affinity 的典型策略:
4 核 ARM (CPU 0-3)
CPU 0: 实时 CAN 线程 (SCHED_FIFO, prio 90) ← 必须响应 < 1ms
CPU 1: 渲染线程 (SCHED_OTHER) ← 需要稳定的 GPU 调度
CPU 2: I/O + 日志线程 (SCHED_OTHER) ← 不与 CAN 抢 CPU 0
CPU 3: 通用线程池 (SCHED_OTHER) ← 剩下的杂活
affinity 的好处与陷阱:
| 好处 | 说明 |
|---|---|
| 避免缓存失效 | 线程不跨核心迁移,L1/L2 缓存热点保持 |
| 关键线程隔离 | CAN/实时线程独占核心,不被中断/其他线程干扰 |
| 减少调度延迟 | SCHED_FIFO + 独占核心 = 确定性时延 |
| 陷阱 | 说明 |
|---|---|
| 过热风险 | 独占核心一直工作,芯片过热→降频→性能更差 |
| 误配负载 | 把太多线程绑到一个核心,其他核心闲置 |
| tickless 冲突 | 独占核心不触发 tick,但 sleep/nanosleep 依赖 tick |
# 4.3 互斥机制全对比
# 4.3.1 pthread_mutex 类型与 PTHREAD_MUTEX_ERRORCHECK
四种互斥锁类型:
| 类型 | 重复 lock | 跨线程 unlock | 行为 |
|---|---|---|---|
PTHREAD_MUTEX_NORMAL | 死锁 | 未定义 | 最基础,无检查(性能最优) |
PTHREAD_MUTEX_ERRORCHECK | 返回 EDEADLK | 返回 EPERM | 开发/调试阶段推荐 |
PTHREAD_MUTEX_RECURSIVE | 允许(计数) | 返回 EPERM | 递归锁(需要同一函数多次锁) |
PTHREAD_MUTEX_DEFAULT | 未定义 | 未定义 | 行为取决于实现(= NORMAL) |
错误检查锁的用法(开发阶段兜底):
pthread_mutex_t mutex;
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_ERRORCHECK);
pthread_mutex_init(&mutex, &attr);
// 同一线程重复 lock:
pthread_mutex_lock(&mutex); // 第 1 次 → 成功
int rc = pthread_mutex_lock(&mutex); // 第 2 次 → 返回 EDEADLK,不死锁!
if (rc == EDEADLK) {
fprintf(stderr, "BUG: recursive lock detected!\n");
// 立即发现潜在的递归加锁 bug
}
为什么 ERRORCHECK 是开发期神器?
在你加 assert(rc != EDEADLK) 的那一刻,第一次重复加锁就会在测试阶段暴露——而不是上线后变成"不崩但卡死"的幽灵 bug。
递归锁的典型场景——模板方法模式:
// 公共接口(需要加锁)
void update_config(const char *key, const char *val) {
pthread_mutex_lock(&mutex); // 第 1 次 lock
_update(key, val);
pthread_mutex_unlock(&mutex);
}
// 内部实现(也加锁——保证直接调用时线程安全)
void reload_all_config() {
pthread_mutex_lock(&mutex); // 第 2 次 lock(同一线程,递归)
for (all keys) _update(key, read_from_disk(key));
pthread_mutex_unlock(&mutex);
}
这里如果不用 RECURSIVE 锁,reload_all_config 里调 _update 之前已经持锁,再调 update_config(内部也试图 lock)就会死锁。
# 4.3.2 读写锁 pthread_rwlock 的适用场景
读写锁允许多个读者同时读,但写者独占:
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
// 多个线程可以同时持有读锁
void read_data() {
pthread_rwlock_rdlock(&rwlock);
display(latest_can);
pthread_rwlock_unlock(&rwlock);
}
// 写者需要等所有读者释放
void write_data() {
pthread_rwlock_wrlock(&rwlock);
update_can_data(&latest_can);
pthread_rwlock_unlock(&rwlock);
}
读写锁的适用场景判据:
| 条件 | 用 rwlock | 用 mutex |
|---|---|---|
| 读远多于写(> 10:1) | ✅ | ❌(mutex 阻止并发读) |
| 临界区极短(< 1μs) | ❌ | ✅(rwlock 内部开销更大) |
| 读/写比例接近 | ❌ | ✅(rwlock 无收益 + 写者饥饿风险) |
| 必须高优先级写者优先 | ❌ | ✅(默认 rwlock 可能读者饿写者) |
rwlock 的性能陷阱——rwlock 内部维护读者计数和等待队列,本身比 mutex 重。如果临界区只有几个纳秒,rwlock 的内部开销反而更大。
实测数据(ARM Cortex-A7,临界区 = 10 ns):
mutex lock/unlock: ~50ns
rwlock rdlock/unlock: ~80ns ← 反而慢
rwlock wrlock/unlock: ~90ns
结论:rwlock 适用于读占 90%+ 且临界区 > 1 μs的场景。否则用 mutex。
# 4.3.3 自旋锁 pthread_spinlock 与互斥锁的性能分水岭
自旋锁(spinlock)——拿不到锁时不睡眠,而是在 CPU 上忙等(一直while 检查锁状态)。
pthread_spinlock_t spin;
pthread_spin_init(&spin, PTHREAD_PROCESS_PRIVATE);
pthread_spin_lock(&spin);
// ← 如果锁被持有,当前 CPU 进入 busy-wait 循环
// 不会触发上下文切换
critical_section();
pthread_spin_unlock(&spin);
spinlock vs mutex 的选择分水岭:
| 条件 | 用 spinlock | 用 mutex |
|---|---|---|
| 临界区极短(< 1 μs) | ✅ | ❌(上下文切换开销 > 忙等开销) |
| 临界区有 I/O 或睡眠 | ❌ | ✅(忙等期间一直占 CPU) |
| 持锁时间有保证 | ✅ | ❌ |
| 单核 CPU | ❌ | ✅(忙等期间其他线程无法运行) |
| 不确定持锁时间 | ❌ | ✅ |
| 必须在中断上下文中用锁 | ✅(只有 spinlock 可用) | ❌(中断中不能睡眠) |
性能关键数据(x86_64 典型值):
| 操作 | 耗时 |
|---|---|
| 一次上下文切换 | ~1-3 μs |
| mutex lock (无竞争) | ~25 ns |
| mutex lock (有竞争) | ~1-3 μs(一次 futex 系统调用) |
| spinlock lock (无竞争) | ~10 ns |
| spinlock lock (有竞争,等 100ns) | ~110 ns |
| spinlock lock (有竞争,等 1μs) | ~1 μs |
决策规则:临界区 < 上下文切换开销 → spinlock;临界区 > 上下文切换开销 → mutex。
// ✅ spinlock 适用:仅仅是一个原子更新
pthread_spin_lock(&spin);
counter++; // 1 条指令
pthread_spin_unlock(&spin);
// ✅ mutex 适用:涉及 I/O
pthread_mutex_lock(&mutex);
write(fd, buf, len); // 可能阻塞几 ms
pthread_mutex_unlock(&mutex);
# 4.3.4 嵌入式单核 vs 多核的锁策略差异
单核 CPU 上的锁几乎全是 mutex——因为在单核上 spinlock 意味着"唯一的核心在忙等,其他线程无法运行",直到内核时间片耗尽。spinlock 在单核上几乎没价值。
ARM Cortex-A 内核的 LDREX/STREX 指令——这是所有锁的硬件基础:
; ARM 上的原子 compare-and-swap (LDREX/STREX 对)
ldrex r1, [r0] ; 独占加载
cmp r1, #0 ; 比较旧值
bne fail
mov r2, #1
strex r3, r2, [r0] ; 独占存储——如果失败,r3=1
cmp r3, #0
bne retry ; 失败 → 重试
x86 LOCK CMPXCHG 的差异:
; x86 上更简单——LOCK 前缀保证原子性
lock cmpxchg [rdi], rsi ; 原子 compare-and-swap
; LOCK 指令自动处理缓存一致性和内存屏障
单核 vs 多核锁策略对比:
| 方面 | 单核 | 多核 |
|---|---|---|
| spinlock 价值 | 几乎无(竞品不可运行) | 有(其他核心可并行) |
| mutex 实现 | 只需禁用抢占 | 需要 futex + 内核调度 |
| 伪共享(false sharing) | 无 | 严重时性能下降 10x |
| 缓存一致性协议 | 无 | MESI 协议会自动在核心间广播 |
多核下的伪共享(false sharing)问题:
// ❌ 两个相邻变量被不同核上的线程频繁修改
struct {
volatile int counter_a; // CPU 0 的线程写
volatile int counter_b; // CPU 1 的线程写
} counts; // 两个变量在同一缓存行内(64 字节)
// 每次 counter_a 写 → CPU 1 的缓存行失效 → counter_b 读要重新加载
// 性能可能下降 10-50 倍
// ✅ 修复:用 __attribute__((aligned(64))) 或 padding
struct {
volatile int counter_a;
char __pad[60]; // 填充到 64 字节
volatile int counter_b;
char __pad2[60];
} counts;
# 4.4 条件变量与同步
# 4.4.1 pthread_cond_wait 的正确写法(while 循环)
条件变量解决"生产者-消费者"问题——消费者等数据就绪,生产者通知消费者。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
can_frame_t buffer;
int buffer_ready = 0;
// 生产者
void produce(can_frame_t frame) {
pthread_mutex_lock(&mutex);
buffer = frame;
buffer_ready = 1;
pthread_cond_signal(&cond); // 唤醒一个消费者
pthread_mutex_unlock(&mutex);
}
// 消费者 —— 正确写法
void *consumer(void *arg) {
pthread_mutex_lock(&mutex);
while (!buffer_ready) { // ← while,不是 if!
pthread_cond_wait(&cond, &mutex); // 原子操作:unlock mutex + 睡眠
// 被唤醒后自动 re-lock mutex
}
process_frame(buffer);
buffer_ready = 0;
pthread_mutex_unlock(&mutex);
}
为什么必须是 while 而不是 if?
两个原因:
- 虚假唤醒(见 4.4.2)
- 多消费者竞争——
signal只唤醒一个线程,但被唤醒后发现buffer_ready已被另一个消费者清 0
// ❌ 错误写法
if (!buffer_ready) {
pthread_cond_wait(&cond, &mutex);
}
// ← 被唤醒后 buffer_ready 可能还是 0!
// 因为另一个线程抢先消费了
// ✅ 正确写法
while (!buffer_ready) {
pthread_cond_wait(&cond, &mutex);
}
// ← 被唤醒后重新检查条件,不满足继续等
pthread_cond_wait 的三步原子语义:
pthread_cond_wait(&cond, &mutex)
├─ 1. 原子地:unlock mutex + 把当前线程放入 cond 等待队列
├─ 2. 线程睡眠,等待被 signal 唤醒
└─ 3. 被唤醒时:自动 re-lock mutex,然后返回
这保证在"unlock → 睡眠"之间没有窗口让 signal 丢失。
# 4.4.2 虚假唤醒 (spurious wakeup)
虚假唤醒——pthread_cond_wait 返回,但没有线程调用过 signal 或 broadcast。这在 POSIX 标准中是合法的行为,原因是:
- 多核架构:内核为了简化实现,可能被中断/信号干扰提前返回
- 性能优化:某些 OS 实现中,broadcast 可能导致多个线程同时被唤醒,其中只有一个能真正消费数据
// ❌ 如果这样写,虚假唤醒会导致用无效数据处理
if (!buffer_ready)
pthread_cond_wait(&cond, &mutex);
// 假设虚假唤醒发生——跳过了 wait
// buffer_ready 还是 0 —— 但代码以为数据已就绪!
process_frame(buffer); // ← 处理未初始化的数据!
// ✅ while 循环同时防御虚假唤醒和竞争唤醒
while (!buffer_ready)
pthread_cond_wait(&cond, &mutex);
pthread_cond_broadcast——唤醒所有等待线程(而非只唤醒一个):
// 场景:资源池扩容,所有等待线程都可以重试
resource_count += 10;
pthread_cond_broadcast(&cond); // 唤醒所有人
broadcast 被唤起的线程会同时竞争 re-lock mutex——只有一个拿锁,其余继续在 mutex 上等。
# 4.4.3 屏障 pthread_barrier
屏障(barrier)——让多个线程在同一个点同步。所有线程都到达屏障后,才一起继续执行。
pthread_barrier_t barrier;
pthread_barrier_init(&barrier, NULL, 3); // 3 个线程参与
void *worker(void *arg) {
int phase = *(int *)arg;
printf("Phase %d: working\n", phase);
do_phase_work(phase);
// 所有线程在这里等——等 3 个线程都到达才一起继续
pthread_barrier_wait(&barrier);
printf("Phase %d: all synced, proceeding\n", phase);
return NULL;
}
屏障的典型应用——并行矩阵乘法:
#define N 512
double A[N][N], B[N][N], C[N][N];
void *matmul_worker(void *arg) {
int tid = *(int *)arg;
int rows_per_thread = N / NUM_THREADS;
int start = tid * rows_per_thread;
int end = start + rows_per_thread;
// 阶段 1:每个线程计算自己负责的行
for (int i = start; i < end; i++)
for (int j = 0; j < N; j++)
for (int k = 0; k < N; k++)
C[i][j] += A[i][k] * B[k][j];
pthread_barrier_wait(&barrier); // 等所有线程完成阶段 1
// 阶段 2:汇总结果 —— 此时 C 全部计算完毕
// ...
}
嵌入式场景:屏障常用于多传感器同步采集——所有传感器线程同时开始采样,确保数据时间戳一致。
# 4.5 线程安全与可重入
# 4.5.1 线程局部存储 (TLS)
TLS(Thread-Local Storage)——每个线程拥有变量的独立副本,互不干扰。
方式 1:__thread 关键字(GCC/Clang)
// 声明为 TLS——每个线程一个独立副本
__thread int errno_stub = 0;
__thread char log_buffer[256];
void *worker(void *arg) {
snprintf(log_buffer, sizeof(log_buffer), "thread %lu", pthread_self());
// 不同线程的 log_buffer 互不影响
}
方式 2:pthread_key_t(动态 TLS)
pthread_key_t key;
// 创建 key,可选的析构函数在线程退出时清理
pthread_key_create(&key, free); // free 是析构函数
// 每个线程设置自己的值
void *worker(void *arg) {
char *my_data = malloc(64);
pthread_setspecific(key, my_data);
// ...
char *data = pthread_getspecific(key);
printf("[%lu] %s\n", pthread_self(), data);
}
TLS 的两大局限:
| 局限 | 说明 |
|---|---|
__thread 只能用于 POD 类型 | 不能用于 C++ 带构造函数的对象(C++ 用 thread_local) |
| 动态 TLS 有数量限制 | PTHREAD_KEYS_MAX,通常 128-1024 |
# 4.5.2 errno 的线程安全实现
errno 是 TLS 的经典应用——每个线程有自己的 errno 副本:
// 宏展开后的等价代码(简化):
extern __thread int __errno_location;
#define errno (*&__errno_location)
为什么 errno 必须是 TLS?
// 如果没有 TLS,多线程下 errno 会被覆盖:
Thread A: open("/foo", O_RDONLY) → 失败 → errno = ENOENT
Thread B: write(fd1, buf, 100) → 成功 → errno = 0
Thread A: perror("open") → errno 已经是 0 → 打印 "Success" ← 错!
正确取 errno 的姿势——立即保存:
int fd = open("/foo", O_RDONLY);
int saved_errno = errno; // ← 立刻保存!下一行可能覆盖
if (fd < 0) {
fprintf(stderr, "open failed: %s\n", strerror(saved_errno));
}
# 4.5.3 信号安全的函数列表
信号处理器中能调的函数极为有限——POSIX 定义了"async-signal-safe"列表。在信号处理器中调用非安全的函数 = 未定义行为。
async-signal-safe 函数速查:
| 类别 | 函数 |
|---|---|
| 基础 I/O | _exit, write, read, close, open, fsync |
| 信号 | signal, sigaction, kill, raise, sigprocmask |
| 时间 | time, clock_gettime |
| Unix | getpid, getuid, getppid, unlink, rename, mkdir |
| socket | socket, bind, connect, accept, send, recv |
不安全的函数(不能在信号处理器中用):
printf, malloc, free, fopen, fclose, pthread_mutex_lock,
system, popen, exec, exit (用 _exit 代替)
ARM 上信号处理器的额外限制——某些 ARM 核在信号处理器中用浮点运算导致寄存器损坏(因为 FPU 寄存器不在信号栈上自动保存)。用 SA_ONSTACK + 汇编在信号处理器入口保存/恢复 FPU 状态。
# 4.6 嵌入式线程调优
# 4.6.1 线程栈大小设置与溢出检测
glibc 默认线程栈 = 8 MB——在嵌入式 128 MB 内存设备上,10 个线程就吃掉 80 MB 虚拟地址空间。必须缩小:
pthread_attr_t attr;
pthread_attr_init(&attr);
// 嵌入式典型设置(根据实际栈用量调整)
size_t stack_size = 128 * 1024; // 128 KB
pthread_attr_setstacksize(&attr, stack_size);
pthread_create(&tid, &attr, thread_func, NULL);
确定栈大小的经验公式:
栈大小 >= 最深调用链栈帧总和 + 安全余量 (至少 2 KB)
测量方法:
1. 运行时用 pthread_attr_getstack 或 /proc/<tid>/maps 查实际使用
2. 用编译器 -fstack-usage 得到每个函数的栈帧大小
3. 用 valgrind --tool=massif --stacks=yes 跟踪实际栈增长
栈溢出检测方案:
// 方式 1:守护页(guard page)——默认已启用
// 栈底放置 1 页不可访问内存,溢出时 → SIGSEGV
pthread_attr_setguardsize(&attr, 4096); // 默认 4 KB
// 方式 2:编译器栈检查(Development build)
// GCC: -fstack-protector-strong
// 在函数入口放入 canary,返回时检查
// 溢出 → __stack_chk_fail → abort
// 方式 3:运行时检测
void check_stack_usage() {
pthread_attr_t attr;
void *stack_addr;
size_t stack_size;
pthread_getattr_np(pthread_self(), &attr);
pthread_attr_getstack(&attr, &stack_addr, &stack_size);
// 读取当前 SP(粗略估计栈使用深度)
void *sp;
asm volatile("mov %0, sp" : "=r"(sp));
size_t used = (char *)stack_addr + stack_size - (char *)sp;
printf("stack used: %zu / %zu KB\n", used / 1024, stack_size / 1024);
}
# 4.6.2 QoS 与优先级翻转问题
优先级翻转(Priority Inversion)——高优先级线程因等低优先级线程持有的锁而阻塞,而中等优先级线程可能在两者之间抢占 CPU,导致高优先级线程无限期等待。
经典场景:
Thread H (prio 90, 实时): 要拿 mutex → 阻塞(等 L 释放)
Thread M (prio 50, 普通): 计算密集 → 运行中
Thread L (prio 10, 最低): 持有 mutex → 被 M 抢占 → L 得不到 CPU → 不放锁
→ H 永远被 M 阻塞 ← 优先级翻转!
解决方案——优先级继承(PI, Priority Inheritance):
// 创建支持优先级继承的互斥锁
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT); // ← 关键
pthread_mutex_init(&mutex, &attr);
PI 机制下:当 H 阻塞等 L 的锁时,L 的优先级自动提升到 90(H 的优先级)→ M 无法抢占 L → L 快速完成释放锁 → H 拿到锁。
Mars Pathfinder 的真实教训(1997 年 NASA 火星探测器):
信息总线线程 (prio 高): 等气象数据互斥锁 → 阻塞
通信线程 (prio 中): 长期运行 → 一直占 CPU
气象线程 (prio 低): 持锁 → 被通信线程抢占 → 一直不放锁
→ 信息总线线程被看门狗认为是"死掉" → 系统重置
→ 修复:开启优先级继承(从地球上打补丁到火星)
# 4.6.3 线程池在低内存设备上的实现
线程池避免频繁创建/销毁线程的开销。嵌入式版本——固定线程数 + 任务队列 + 条件变量:
#define MAX_THREADS 4
#define MAX_TASKS 128
typedef struct {
void (*func)(void *);
void *arg;
} task_t;
typedef struct {
pthread_mutex_t lock;
pthread_cond_t work_cond;
pthread_cond_t done_cond;
pthread_t threads[MAX_THREADS];
task_t tasks[MAX_TASKS];
int head, tail, count;
int active_count;
int quit;
} thread_pool_t;
// 工作线程
void *pool_worker(void *arg) {
thread_pool_t *pool = (thread_pool_t *)arg;
while (1) {
pthread_mutex_lock(&pool->lock);
while (pool->count == 0 && !pool->quit) {
pthread_cond_wait(&pool->work_cond, &pool->lock);
}
if (pool->quit && pool->count == 0) {
pthread_mutex_unlock(&pool->lock);
break;
}
task_t task = pool->tasks[pool->head];
pool->head = (pool->head + 1) % MAX_TASKS;
pool->count--;
pool->active_count++;
pthread_mutex_unlock(&pool->lock);
// 执行任务
task.func(task.arg);
pthread_mutex_lock(&pool->lock);
pool->active_count--;
if (pool->count == 0 && pool->active_count == 0) {
pthread_cond_signal(&pool->done_cond); // 通知 wait_all
}
pthread_mutex_unlock(&pool->lock);
}
return NULL;
}
// 提交任务
void pool_submit(thread_pool_t *pool, void (*func)(void*), void *arg) {
pthread_mutex_lock(&pool->lock);
int tail = (pool->head + pool->count) % MAX_TASKS;
pool->tasks[tail].func = func;
pool->tasks[tail].arg = arg;
pool->count++;
pthread_cond_signal(&pool->work_cond); // 唤醒一个工作线程
pthread_mutex_unlock(&pool->lock);
}
嵌入式线程池的关键参数:
| 参数 | 推荐值 | 原因 |
|---|---|---|
| 线程数 | CPU 核数 | 超过核数只会增加上下文切换,无收益 |
| 队列大小 | 128-256 | 大了浪费内存,小了的任务会被阻塞 |
| 线程栈 | 64-128 KB | 减小内存占用 |
| 空闲回收 | 不回收 | 嵌入式通常以设备生命周期为单位,无重启 |
Qt 侧直接用 QThreadPool ——它内建了全局线程池,且已在 ARM 上充分优化:
QThreadPool::globalInstance()->setMaxThreadCount(4);
QtConcurrent::run(&processCANData);
# 4.7 关键结论与速查表
核心结论五条:
- 线程共享地址空间,进程隔离地址空间——这是最根本的区别。线程的并发优势(低开销、共享数据)和并发劣势(数据竞争、死锁)都源于此
- 锁的选择 = 临界区长度 + 竞争强度:临界区 < 1 μs → spinlock;临界区 > 10 μs → mutex;读占 90%+ 且临界区长 → rwlock
- 条件变量必须
while——虚假唤醒和竞争唤醒都会让条件在 wait 返回时不再成立 - 嵌入式单核上 spinlock 几乎无意义——忙等期间唯一的核心被占用,其他线程无法取得进展
pthread_cancel是异步炸弹——用标志位 + 条件变量让线程主动退出,永远更安全
三种锁速查表:
| 锁类型 | 持锁期间是否睡眠 | 竞争时行为 | 开销 | 适用 |
|---|---|---|---|---|
pthread_mutex | 可以 | futex 阻塞 | 中等 | 通用,临界区 > 1 μs |
pthread_spinlock | 绝对不能 | CPU 忙等 | 极低 | 临界区 < 1 μs,多核 |
pthread_rwlock | 读锁不影响其他读 | 写者阻塞 | 较高 | 读多写少 |
pthread_mutex(RECURSIVE) | 可以 | futex 阻塞 | 中等 | 同一线程需要重入 |
pthread_mutex(ERRORCHECK) | 可以 | 死锁 → 返回 EDEADLK | 中等 | 开发/调试阶段 |
线程属性速查表:
| 属性 | 推荐嵌入式默认 | 说明 |
|---|---|---|
| 栈大小 | 64-128 KB | 够绝大多数传感器/日志线程 |
| detach 状态 | PTHREAD_CREATE_DETACHED | 无需 join 时用 |
| CPU 亲和性 | CAN/实时线程绑核,其他自由 | 避免热缓存失效 |
| 调度策略 | SCHED_OTHER(普通) | 实时需 CAP_SYS_NICE 权限 |
| 优先级继承 | PTHREAD_PRIO_INHERIT | 总是开启 |
常见陷阱速查:
| 陷阱 | 根因 | 解决 |
|---|---|---|
| 死锁 | 多锁获取顺序不一致 | 固定加锁顺序 + ERRORCHECK |
| 优先级翻转 | 低优先线程持锁被中优先抢占 | 开启 PI mutex |
| 伪共享 | 多核访问同一缓存行的不同变量 | aligned(64) 或 padding |
| 锁内调非线程安全函数 | 函数使用全局静态缓冲区 | 改用线程安全版本(_r 后缀) |
pthread_cond_wait 用 if | 虚假唤醒或竞争唤醒 | 改用 while |
| 栈溢出 | 嵌入式默认 8 MB 太浪费 | 缩小到 64-128 KB + guard page |
下一步:在你的 Qt 仪表盘项目里用 pthread_mutexattr_setprotocol(PTHREAD_PRIO_INHERIT) 替换现有的普通 mutex——然后跑一轮压力测试,看 CAN 线程的调度延迟是否从"偶发的几十 ms"降到"稳定 < 1 ms"。
延伸阅读:
man 7 pthreads—— POSIX 线程概览man 3 pthread_mutexattr_setprotocol—— 优先级继承man 2 futex—— mutex 的底层内核机制- 《Programming with POSIX Threads》(David Butenhof) —— 线程编程圣经
- 《The Linux Programming Interface》(Kerrisk) —— 第 29-33 章