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 死锁:四个条件与检测方法

死锁是每个嵌入式工程师的噩梦。四个必要条件,缺一不可:

  1. 互斥条件——资源一次只能被一个线程占用
  2. 持有并等待——线程拿着一个资源,还去申请另一个
  3. 不可剥夺——资源只能由持有者主动释放
  4. 循环等待——两个线程互相等对方释放资源

我曾经在一个多核项目中调试死锁,两个核上的线程互相等锁,系统完全卡死。最后用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个线程同时读),用信号量。如果你需要严格的排他访问(一次只能一个线程写),用互斥锁加优先级继承。死锁预防则是设计阶段就要想好的事,别等到调试阶段再头疼。