28、SMP用户态支持:用户态线程、信号处理、进程间通信
好,咱们今天聊点跟用户打交道的部分。前面几章我们一直在内核态里折腾,什么调度器、中断亲和性、内存管理,说白了都是系统底层的事。但用户态支持这块,才是真正让多核能力“落地”的关键。
你想想看,用户写一个多线程程序,他根本不在乎你的调度器是O(1)还是CFS,他只关心:我的线程能不能跑在不同的核上?信号能不能正确送达?两个进程之间能不能高效通信?这些,就是SMP用户态支持要解决的问题。
28.1 用户态线程的SMP亲和性
先说说用户态线程。在RT-Thread的SMP环境下,用户态线程默认是可以在所有核心上自由迁移的。但我在项目中遇到过一个问题:某个实时性要求很高的用户线程,频繁被调度到不同的核心上,导致缓存命中率暴跌,性能反而下降了。
为什么会这样?因为每个核心都有自己的L1、L2缓存。线程在核0上跑了一会儿,缓存里全是它的数据。结果下次调度到了核1,缓存全部失效,得重新加载。对于计算密集型的任务,这个开销可不小。
所以,我们引入了线程亲和性的概念。说白了,就是告诉调度器:“这个线程,你就别乱动了,固定在某几个核上跑。”
核心思路:用户态线程可以通过系统调用设置CPU亲和性掩码(cpumask),调度器在分配核心时会优先考虑这个掩码。
RT-Thread里怎么用?我习惯这样写:
#include <rtthread.h>
/* 创建一个用户态线程 */
rt_thread_t tid = rt_thread_create("user_task",
thread_entry,
RT_NULL,
4096,
20,
10);
/* 设置亲和性:只允许在核0和核2上运行 */
rt_uint8_t cpumask = 0x05; // 二进制 0000 0101
rt_thread_control(tid, RT_THREAD_CTRL_BIND_CPU, &cpumask);
/* 启动线程 */
rt_thread_startup(tid);
嗯,这里要注意:RT_THREAD_CTRL_BIND_CPU这个控制命令只在SMP版本中有效。如果你用的是单核版本,调用它会返回错误。我曾经在移植代码时踩过这个坑,排查了半天才发现是配置问题。
小技巧:对于I/O密集型的用户线程,建议不要绑定核心,让调度器自由调度反而更高效。因为I/O线程经常阻塞,绑定核心反而浪费了其他核心的空闲时间。
28.2 信号处理在多核下的挑战
信号处理,在单核时代很简单:信号来了,中断当前执行,去处理信号处理函数,处理完回来继续。但在多核环境下,事情就复杂了。
想象一下:线程A在核0上跑,线程B在核1上跑。现在要给线程A发一个SIGUSR1信号。问题是——谁去处理这个信号?是核0上的调度器?还是核1上的调度器?如果线程A正在执行一个系统调用,信号该不该打断它?
RT-Thread的SMP实现中,信号处理遵循以下原则:
- 信号发送是全局的:任何核心都可以向任何线程发送信号
- 信号处理是局部的:信号最终由目标线程当前所在的核心处理
- 信号屏蔽是每线程的:每个线程有自己的信号屏蔽字,不受核心影响
具体实现上,当一个核心发送信号时,它会检查目标线程当前在哪个核心上运行。如果目标线程正在运行,就通过IPI(核间中断)通知那个核心去处理信号。如果目标线程处于就绪态或阻塞态,就把信号挂到线程的信号队列里,等它下次被调度时再处理。
注意:信号处理函数是在用户态执行的,但信号递送机制涉及内核态。这意味着信号处理过程中会发生两次上下文切换:用户态→内核态(信号递送)→用户态(信号处理函数)→内核态(信号返回)→用户态(原程序继续)。这个开销在实时系统中需要仔细评估。
我曾经在一个项目中,用户线程通过信号来同步数据采集。结果发现信号处理延迟不稳定,有时候几十微秒,有时候几百微秒。后来定位到问题:信号发送时目标线程正在另一个核心上执行长系统调用,IPI被延迟了。解决方案?改用实时信号(RT signals),优先级更高,递送更及时。
28.3 进程间通信的SMP优化
进程间通信(IPC)在SMP环境下,最大的瓶颈就是锁竞争。你想想看,多个核心上的进程同时调用send()和recv(),肯定要抢同一个消息队列的锁。核心越多,竞争越激烈,性能反而可能下降。
RT-Thread支持多种IPC机制,在SMP下各有各的优化策略:
| IPC机制 | SMP优化策略 | 适用场景 |
|---|---|---|
| 消息队列 | 使用无锁队列(lock-free queue)替代互斥锁 | 高频小消息传递 |
| 共享内存 | 配合内存屏障(memory barrier)使用,避免缓存一致性问题 | 大数据量传输 |
| 信号量 | 采用每核心本地计数,减少全局原子操作 | 资源同步 |
| 事件集 | 使用位图操作,配合硬件原子指令 | 多条件等待 |
我重点说说消息队列。在单核时代,消息队列的实现很简单:一个链表,一把锁。但在SMP下,这种实现会导致严重的缓存乒乓(cache bouncing)。
RT-Thread的SMP版本引入了多生产者多消费者无锁队列。它的核心思想是:
- 使用环形缓冲区(ring buffer)代替链表,避免动态内存分配
- 使用原子操作(CAS)代替互斥锁,避免上下文切换
- 每个生产者/消费者维护自己的读写指针,减少共享变量
代码实现大致是这样的:
struct rt_smp_mq {
rt_uint32_t head; /* 读指针 */
rt_uint32_t tail; /* 写指针 */
rt_uint32_t size; /* 缓冲区大小,必须是2的幂 */
void *buffer[]; /* 消息缓冲区 */
};
/* 无锁入队操作 */
rt_inline rt_err_t rt_smp_mq_send(struct rt_smp_mq *mq, void *msg)
{
rt_uint32_t tail, next;
do {
tail = mq->tail;
next = (tail + 1) & (mq->size - 1);
/* 检查队列是否已满 */
if (next == mq->head) {
return -RT_EFULL;
}
} while (rt_hw_cas(&mq->tail, tail, next) != tail);
/* CAS成功,写入消息 */
mq->buffer[tail] = msg;
/* 使用写屏障确保消息写入完成 */
rt_hw_wmb();
return RT_EOK;
}
经验之谈:无锁队列虽然性能好,但调试起来非常痛苦。我建议先在单核环境下验证逻辑正确性,再开启SMP。另外,无锁队列的缓冲区大小一定要是2的幂,这样取模运算可以用位与(&)代替,性能提升明显。
28.4 用户态与内核态的交互
最后聊聊用户态和内核态的交互。在SMP环境下,系统调用的处理也需要特别小心。
RT-Thread使用svc指令(ARM架构)或syscall指令(RISC-V架构)来陷入内核。在SMP下,每个核心都有自己的一套系统调用入口。但内核中的全局资源(比如进程列表、文件描述符表)是共享的,所以必须加锁。
我个人的建议是:尽量减少系统调用的频率。在SMP环境下,一次系统调用的开销比单核大得多,因为涉及核间同步和缓存刷新。如果可能,把多次系统调用合并成一次,或者使用批处理接口。
举个例子,如果你要发送多个消息,不要这样写:
/* 低效方式:每次发送都陷入内核 */
for (int i = 0; i < 100; i++) {
send(mq, &msg[i], sizeof(msg[i]));
}
而是这样:
/* 高效方式:一次系统调用发送批量消息 */
send_batch(mq, msg, 100, sizeof(msg[0]));
嗯,这个优化在单核下可能只提升10%,但在SMP下能提升30%以上。因为减少了核间中断和锁竞争的次数。
总结一下:SMP用户态支持的核心,就是让用户程序能充分利用多核能力,同时屏蔽掉底层的复杂性。线程亲和性让你控制计算分布,信号处理让你处理异步事件,IPC让你实现核间通信。这三者配合好了,你的多核程序才能真正跑起来、跑得快。
下一章,我们会聊聊SMP下的调试和性能分析工具。到时候我会分享一些实际项目中排查多核问题的经验,包括怎么用trace工具定位核间竞争、怎么分析缓存命中率等等。敬请期待。