4. 多核处理器基础
信号量和互斥锁,这两个名字你肯定不陌生。但在多核环境下,它们的工作方式跟单核有很大区别。我最早接触QNX时,以为就是简单的加锁解锁,结果在一个四核项目上吃了大亏——两个核同时抢一个资源,系统直接卡死。嗯,从那以后我才真正重视起这些同步机制。
4.1 命名信号量:多核间的"旗语"
信号量本质上是一个计数器。你可以把它想象成停车场门口的显示屏——显示还有多少个空位。QNX的命名信号量(named semaphore)有个好处:所有进程都能通过名字找到它,不管进程跑在哪个核上。
核心区别:无名信号量只能用于线程间同步,命名信号量可以跨进程、跨核。
创建命名信号量的典型方式:
#include <sys/neutrino.h>
#include <semaphore.h>
sem_t *sem;
// 创建一个初始值为3的命名信号量
sem = sem_open("/my_sem", O_CREAT, 0666, 3);
if (sem == SEM_FAILED) {
perror("sem_open failed");
exit(EXIT_FAILURE);
}
// 等待信号量(P操作)
sem_wait(sem);
// 访问共享资源...
// 释放信号量(V操作)
sem_post(sem);
// 用完关闭
sem_close(sem);
我个人习惯把信号量初始值设成资源数量。比如一个共享内存池有5个缓冲区,我就设成5。这样每个核上的线程都能通过sem_wait来申请,用完了再sem_post还回去。
经验之谈:命名信号量的名字要遵循POSIX规范,以"/"开头。我见过有人忘了加斜杠,结果sem_open一直返回失败,查了半天才发现是名字格式问题。
4.2 互斥锁:更严格的排他控制
互斥锁跟信号量不一样。信号量可以允许多个线程同时访问(只要资源够),互斥锁一次只让一个线程进去。说白了,互斥锁就是"一夫当关,万夫莫开"。
QNX的互斥锁使用pthread_mutex_t类型。多核环境下,我建议你用PTHREAD_PROCESS_SHARED属性:
#include <pthread.h>
pthread_mutex_t *mutex;
pthread_mutexattr_t attr;
// 初始化属性
pthread_mutexattr_init(&attr);
// 设置为跨进程共享(多核必备)
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);
// 设置协议为优先级继承
pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT);
// 创建互斥锁
mutex = (pthread_mutex_t *)mmap(NULL, sizeof(pthread_mutex_t),
PROT_READ|PROT_WRITE,
MAP_SHARED|MAP_ANONYMOUS, -1, 0);
pthread_mutex_init(mutex, &attr);
// 加锁
pthread_mutex_lock(mutex);
// 临界区...
// 解锁
pthread_mutex_unlock(mutex);
注意:跨进程共享的互斥锁必须放在共享内存中。我见过有人直接在进程的私有数据段创建,结果另一个进程根本访问不到——锁了个寂寞。
4.3 优先级反转:一个真实的故事
优先级反转是什么?简单说就是:高优先级任务被低优先级任务阻塞,中优先级任务趁机抢跑,高优先级任务反而被晾在一边。
我在一个车载项目中遇到过这种情况。一个高优先级的控制线程(优先级80)要访问共享数据,但锁被一个低优先级线程(优先级30)占着。低优先级线程正执行着呢,突然来了个中优先级线程(优先级50)抢走了CPU。结果呢?高优先级线程干等着,中优先级线程优哉游哉地跑,低优先级线程一直没机会释放锁。
这就是典型的优先级反转。高优先级任务的实际响应时间从预期的1毫秒变成了100毫秒——这在实时系统里是致命的。
4.4 优先级继承协议:QNX的解决方案
QNX怎么解决这个问题?靠优先级继承协议。说白了就是:当低优先级线程持有高优先级线程需要的锁时,系统临时把低优先级线程的优先级提升到跟高优先级一样高。
这样低优先级线程就能尽快跑完、释放锁,高优先级线程才能拿到锁继续执行。中优先级线程?它抢不过了,因为低优先级线程已经被临时提权了。
| 协议类型 | 行为描述 | 适用场景 |
|---|---|---|
| PTHREAD_PRIO_NONE | 不处理优先级反转 | 单核、无优先级竞争 |
| PTHREAD_PRIO_INHERIT | 持有锁的线程继承等待者的最高优先级 | 多核实时系统,推荐使用 |
| PTHREAD_PRIO_PROTECT | 持有锁的线程提升到预设的优先级上限 | 已知锁的最大等待优先级 |
我个人推荐在多核项目里用PTHREAD_PRIO_INHERIT。它不需要你事先知道谁会来抢锁,系统自动处理。不过要注意:优先级继承会增加上下文切换的开销,但跟系统崩溃比起来,这点开销完全可以接受。
关键配置:在QNX中启用优先级继承,只需要在创建互斥锁时设置pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT)。就这么简单。
4.5 死锁:四个条件与检测方法
死锁是每个嵌入式工程师的噩梦。四个必要条件,缺一不可:
- 互斥条件——资源一次只能被一个线程占用
- 持有并等待——线程拿着一个资源,还去申请另一个
- 不可剥夺——资源只能由持有者主动释放
- 循环等待——两个线程互相等对方释放资源
我曾经在一个多核项目中调试死锁,两个核上的线程互相等锁,系统完全卡死。最后用QNX的pthread_mutex_timedlock加超时检测才定位到问题。
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += 2; // 2秒超时
int ret = pthread_mutex_timedlock(mutex, &ts);
if (ret == ETIMEDOUT) {
printf("死锁检测:锁等待超时!\n");
// 这里可以做恢复操作
}
避坑指南:我曾经在代码里用pthread_mutex_lock不加超时,结果死锁后系统完全无响应。后来我改成pthread_mutex_timedlock,配合日志输出,死锁问题一目了然。
4.6 死锁预防:设计层面的策略
预防死锁比检测死锁更靠谱。我总结了几个实用策略:
- 锁顺序一致——所有线程按相同顺序申请锁。比如先锁A再锁B,不要有的先A后B,有的先B后A
- 锁粒度控制——不要一把大锁锁所有东西。拆成细粒度锁,减少竞争
- 避免嵌套锁——能不用嵌套锁就不用。实在要用,确保顺序一致
- 使用trylock——申请不到锁就放弃,不要死等
你想想看,如果所有线程都按"先锁共享内存、再锁硬件寄存器"的顺序来,循环等待的条件就被打破了。死锁自然就不会发生。
重要提醒:多核环境下,死锁的影响范围更大。单核死锁可能只卡住一个线程,多核死锁可能让整个系统瘫痪。所以设计阶段就要把死锁预防考虑进去。
这张图把本章的核心逻辑串起来了。左边是信号量,中间是互斥锁,右边是死锁处理。三者共同服务于一个目标:保证多核环境下的数据一致性,同时避免优先级反转和死锁。
嗯,信号量和互斥锁的选择其实看场景。如果你需要控制并发访问的数量(比如最多允许3个线程同时读),用信号量。如果你需要严格的排他访问(一次只能一个线程写),用互斥锁加优先级继承。死锁预防则是设计阶段就要想好的事,别等到调试阶段再头疼。