📚 Linux 内核专栏 · 持续更新中
本系列从底层原理到源码实战,逐篇拆解内核机制。
👉 想系统追更,戳这里查看完整目录:
【Linux 内核专栏 00】总目录
本篇定位:Linux 并发比裸机复杂百倍——SMP 多核 + 抢占 + 中断 + 软中断同时存在,共享数据不加保护 = 竞态 = 灵异 bug。 本篇把"Linux 同步"对你已知的(如FreeRTOS)增量讲透:多核要自旋锁(不是信号量)、关抢占/关中断的组合、RCU 独门、percpu 免锁、内存屏障。读完能选对同步原语、能读懂 RCU、能定位死锁/竞态。
FreeRTOS 同步是单核版,Linux 是多核版,核心增量是"自旋锁"
FreeRTOS 信号量/互斥:单核,临界区靠"关中断"实现。Linux 多核(SMP),关一个核的中断没用,另一个核照样访问——多核要"自旋锁"(其他核忙等)。这是最大转折。另外 Linux 有抢占/软中断/硬中断多套上下文,每套有对应的"关"组合。理解"自旋锁 + 上下文组合",就理解 Linux 同步大半。
一、Linux 并发的来源(为什么比裸机复杂)
Linux 共享数据可能被以下并发访问:
| 并发源 | 说明 |
|---|---|
| SMP 多核 | 多核同时跑,访问同一数据 |
| 抢占 | 内核可抢占,任务 A 执行中被 B 抢占 |
| 硬中断 | 随时打断当前代码 |
| 软中断(softirq/tasklet) | 中断返回时执行,打断普通代码 |
| 用户进程syscall | 多进程通过 syscall 进内核,并发 |
任一共享数据,只要可能被上述任两者并发访问,就要保护。
1.1 临界区与竞态
- 临界区(critical section):访问共享数据的代码段
- 竞态(race condition):并发访问导致结果依赖时序
- 竞态症状:数据错乱/链表破坏/计数器错/偶发死机
1.2 对照 FreeRTOS
| FreeRTOS(单核) | Linux(SMP+抢占) | |
|---|---|---|
| 并发源 | 任务切换 + 中断 | 多核 + 抢占 + 中断 + 软中断 |
| 临界区 | 关中断(taskENTER_CRITICAL) | 自旋锁(多核)/关抢占(单核上下文) |
| 信号量 | 阻塞等(切其他任务) | 信号量(可睡)/自旋锁(忙等) |
| 互斥 | mutex(优先级继承) | mutex(可睡,优先级继承) |
二、自旋锁(spinlock)⭐ 核心
2.1 什么是自旋锁
- 多核同步原语
- 锁被占时,其他核忙等(循环测试锁,不睡)
- 持锁期间不能睡(忙等 + 睡 = 死锁,因为睡切走,锁不释放,其他核永远等)
2.2 基本用法
spinlock_tmy_lock;spin_lock_init(&my_lock);// 临界区spin_lock(&my_lock);// 访问共享数据(不能睡!)shared_data++;spin_unlock(&my_lock);2.3 自旋锁的变种(关中断/关软中断/关抢占)
自旋锁本身防多核并发,但还要防"本核中断/软中断打断":
| 变种 | 关什么 | 场景 |
|---|---|---|
spin_lock() | 关抢占(SMP)+ 持锁 | 普通上下文(进程上下文) |
spin_lock_irqsave() | 关抢占 +关本核硬中断 | 共享数据会被硬中断访问 |
spin_lock_bh() | 关抢占 +关本核软中断 | 共享数据会被 softirq/tasklet 访问 |
spin_lock_irq() | 关抢占 + 关中断(已知中断状态) | 确知中断已开 |
2.4 怎么选
共享数据会被硬中断访问? 是 ──> spin_lock_irqsave / spin_lock_irq 否 ──> 会被软中断(softirq/tasklet)访问? 是 ──> spin_lock_bh 否 ──> spin_lock(普通,进程上下文)2.5 irqsave 为什么要存 flags
unsignedlongflags;spin_lock_irqsave(&lock,flags);// 临界区spin_unlock_irqrestore(&lock,flags);irqsave保存当前中断状态到 flags(可能已关中断的嵌套场景)irqrestore恢复原状态- 防嵌套关中断时把"本来关着的"开成"开着"
2.6 持自旋锁的禁忌
- ❌不能睡(msleep/mutex/kmalloc GFP_KERNEL/schedule)
- ❌ 不能持锁太久(忙等浪费 CPU,影响实时性)
- ❌ 不能递归持同锁(死锁)
- ❌ 不能持锁时调可能睡的函数(哪怕不明显,如 copy_from_user 可能缺页睡)
持自旋锁不能睡是铁律
这是 Linux 比 FreeRTOS 严格的点。FreeRTOS 临界区(关中断)里能调阻塞 API(切其他任务)。Linux 持自旋锁绝对不能睡——睡了 schedule 切走,锁不释放,其他核忙等死锁。你写驱动持锁的临界区,反复检查每行不能有睡的可能。
三、互斥锁(mutex,可睡)
3.1 什么时候用 mutex
- 临界区需要睡(等 IO/分配 GFP_KERNEL/调可能睡的函数)
- 临界区较长(自旋锁忙等浪费,mutex 让等待者睡)
3.2 用法
structmutexmy_mutex;mutex_init(&my_mutex);mutex_lock(&my_mutex);// 临界区(可睡!)process_data();// 内部可 msleep/kmalloc GFP_KERNELmutex_unlock(&my_mutex);mutex_lock:拿不到就睡,等解锁唤醒mutex_unlock:唤醒一个等待者- 持 mutex 时可睡(和自旋锁最大区别)
3.3 mutex 的特性
- 优先级继承:高优先级等 mutex 时,持有者优先级提升到等待者级别(防优先级反转)
- 只能进程上下文用(不能在中断/softirq 里,因为它们不能睡)
- debug 友好:CONFIG_MUTEX_DEBUG 检测死锁/未释放
3.4 mutex vs spinlock 决策
| 维度 | spinlock | mutex |
|---|---|---|
| 等待方式 | 忙等 | 睡眠 |
| 持锁能睡 | ❌ | ✅ |
| 用在哪 | 任何上下文(含中断) | 仅进程上下文 |
| 临界区 | 短 | 长 |
| 多核 | 必备 | 可用 |
| 优先级继承 | ❌ | ✅ |
[!important] 选 spinlock 还是 mutex
- 临界区短 + 不能睡(中断上下文/数据结构操作)→spinlock
- 临界区长或要睡(等 IO/分配/用户交互)→mutex
- 能睡就用 mutex(省 CPU),不能睡才自旋
四、信号量(semaphore)
4.1 信号量是什么
- 计数同步原语
down()减 1,为 0 则睡;up()加 1,唤醒等待者- 可用于互斥(初值 1)或资源计数(初值 N)
4.2 用法
structsemaphoresem;sema_init(&sem,1);// 二值(互斥)down(&sem);// 拿,拿不到睡(可睡)// 临界区up(&sem);// 释放// 变种down_interruptible(&sem);// 可被信号打断(返回 -ERESTARTSYS)down_trylock(&sem);// 非阻塞,拿不到立即返回down_timeout(&sem,HZ);// 超时4.3 mutex vs semaphore
- mutex 是"二值信号量"的特化,但mutex 有优先级继承,semaphore 没有
- mutex 强调"谁加锁谁解锁"(同上下文),semaphore 可跨上下文(up 和 down 不必同人)
- 新代码互斥用 mutex,信号量用于"资源计数"(如限制同时 N 个访问)
嵌入式视角:对照 FreeRTOS 信号量
FreeRTOS 二值/计数信号量,Linuxsemaphore概念同。但Linux mutex 比 semaphore 更适合互斥(优先级继承/debug/约束)。FreeRTOS 习惯信号量做互斥,Linux 改用 mutex。
五、原子操作(atomic_t)
5.1 单变量原子操作
atomic_tcounter=ATOMIC_INIT(0);atomic_inc(&counter);// ++counter(原子)atomic_dec(&counter);// --counteratomic_add(5,&counter);// += 5atomic_sub(3,&counter);intv=atomic_read(&counter);// 读atomic_set(&counter,10);// 写// 条件操作if(atomic_dec_and_test(&counter)){// --,测试是否 0// counter 归 0}// CAS(Compare-And-Swap)intold=atomic_cmpxchg(&counter,5,10);// 若==5 则设 10,返回旧值5.2 64 位原子
atomic64_tbig;atomic64_inc(&big);5.3 位原子
set_bit(5,&flags);// 置位clear_bit(5,&flags);test_bit(5,&flags);test_and_set_bit(5,&flags);// 测试并置位(原子)5.4 何时用原子
- 单变量操作(计数器/标志位),不需要锁
- 比锁快(无锁,靠原子指令如 RISC-V
amoadd.w)
原子操作用 RISC-V amo 指令
[[08-扩展生态与 march-mabi 匹配]] 学的 A 扩展(amoadd.w/amoswap.w)——Linux atomic_t 在 RISC-V 上就靠这些实现。atomic_inc→amoadd.w。你已经懂底层,这里看上层 API。
六、RCU(Read-Copy-Update)⭐ Linux 独门
6.1 RCU 是什么
- 读无锁(读者不加锁,极快)
- 写者复制:写时复制一份,改副本,再原子替换指针
- 延迟回收:等所有"旧读者"退出,才释放旧数据
适合读多写少的数据(路由表/设备列表/配置)。
6.2 RCU 基本模式
structdata*p=rcu_dereference(global_ptr);// 读(无锁)use(p->field);// 改structdata*new=create_new();rcu_assign_pointer(global_ptr,new);// 原子替换synchronize_rcu();// 等旧读者退出kfree(old);// 安全释放6.3 RCU API
| API | 用途 |
|---|---|
rcu_read_lock()/rcu_read_unlock() | 进入/退出 RCU 读临界区 |
rcu_dereference(p) | 读 RCU 保护指针 |
rcu_assign_pointer(p, v) | 写 RCU 保护指针 |
synchronize_rcu() | 等所有旧读者退出(阻塞) |
call_rcu(callback) | 异步等退出后调 callback(不阻塞) |
list_for_each_entry_rcu | RCU 遍历链表 |
list_add_rcu/list_del_rcu | RCU 链表操作 |
6.4 RCU 读侧极快
rcu_read_lock();p=rcu_dereference(gptr);// 无锁,只防编译器重排do_something(p);rcu_read_unlock();- 读侧不持锁,不睡(读临界区不能睡,否则 grace period 拖久)
- 多核读并发,零开销
6.5 RCU 写侧
new=kmalloc(...);*new=*old;// 复制new->field=newval;// 改副本rcu_assign_pointer(gptr,new);// 原子换指针synchronize_rcu();// 等旧读者kfree(old);// 释放- 写者开销大(复制 + 等 grace period)
- 但读者极快,适合读多写少
RCU 是 Linux 性能法宝
FreeRTOS 无此物(单核不需要)。Linux 多核服务器,路由表/进程列表/设备列表读极频繁,用自旋锁读会堵。RCU 让读无锁,大幅提速。你读内核代码(尤其 net/fs),RCU 满地都是。你写驱动读多写少的数据(如配置),考虑 RCU。
七、percpu 变量(免锁)
7.1 percpu 是什么
- 每个 CPU 一份变量副本
- 各核访问自己的副本,无竞争,免锁
- 适合"每核统计/计数器"
7.2 静态 percpu
staticDEFINE_PER_CPU(int,my_counter);// 静态定义// 本核访问this_cpu_inc(my_counter);// 本核 ++intv=this_cpu_read(my_counter);// 读本核// 所有核求和inttotal=0;intcpu;for_each_possible_cpu(cpu)total+=per_cpu(my_counter,cpu);7.3 动态 percpu
int*p=alloc_percpu(int);this_cpu_inc(*p);free_percpu(p);7.4 何时用 percpu
- 计数器/统计(每核独立累加,要总时汇总)
- 避免高频计数器的锁竞争
嵌入式视角:percpu 是多核免锁利器
FreeRTOS 单核无此概念。Linux 多核,高频计数器(如网络包计数)用自旋锁会堵,用 percpu 每核独立累加,汇总时才读全部——免锁且快。你写多核驱动高频统计,优先 percpu。
八、关抢占 / 关中断
8.1 关抢占
preempt_disable();// 本核不会切走(但中断还来)preempt_enable();// 重新检查 need_resched- 防本核被抢占(单核上保护数据)
- 不能睡(睡了 schedule 切走,但关了抢占切不回)
- 持自旋锁隐含关抢占
8.2 关中断
unsignedlongflags;local_irq_save(flags);// 关本核中断,保存状态// 临界区(本核中断不来)local_irq_restore(flags);// 或已知状态local_irq_disable();local_irq_enable();- 最强保护(本核绝对安静)
- 影响实时性(中断延迟),慎用长临界区
8.3 关软中断
local_bh_disable();// 关本核软中断(softirq/tasklet)// 临界区local_bh_enable();- 防软中断打断
spin_lock_bh隐含此操作
8.4 组合决策表
| 保护需求 | 用什么 |
|---|---|
| 防本核抢占(进程上下文短临界区) | preempt_disable 或 spin_lock |
| 防本核硬中断 | local_irq_save 或 spin_lock_irqsave |
| 防本核软中断 | local_bh_disable 或 spin_lock_bh |
| 防多核并发 | spin_lock(任何变种) |
| 全防护(多核+中断) | spin_lock_irqsave |
九、内存屏障(Memory Barrier)
9.1 为什么需要屏障
你 [[06b-内存架构深挖 PMA-ePMP-Cache-CMO-MMU-RVWMO]] 学过 RVWMO 弱内存模型——CPU/编译器可能重排内存访问。多核同步场景,重排会破坏逻辑。
// 生产者data=42;flag=1;// 期望 data 先于 flag// 无屏障,硬件可能先写 flag 再写 data,消费者看到 flag=1 但 data 旧9.2 Linux 内存屏障 API
| 屏障 | 作用 |
|---|---|
smp_mb() | 全内存屏障(读写都保序) |
smp_rmb() | 读屏障(读不跨屏障) |
smp_wmb() | 写屏障(写不跨屏障) |
smp_store_release(p, v) | release 写(之前操作先于此) |
smp_load_acquire(p) | acquire 读(此操作先于之后) |
barrier() | 编译器屏障(只防编译器重排,不防 CPU) |
// 用屏障修正上面data=42;smp_wmb();// 写屏障:保证 data 写先于 flagflag=1;9.3 何时用
- 自定义同步原语(不靠锁,自己实现发布-等待)
- 无锁数据结构
- 大部分情况:用锁/RCU/atomic 就隐含了正确屏障,不用手动加
优先用现成原语,少手动加屏障
自旋锁/mutex/atomic/RCU 内部已加正确屏障。你写驱动 99% 不用手动 smp_mb——用现成原语即可。只在写无锁数据结构/性能极致优化时才手动加,且要极其小心。你 06b RVWMO 知识在这里是"理解为什么需要",不是"天天用"。
十、completion(完成量)
10.1 什么时候用
- 等一次性事件(如线程启动完成、初始化完成)
- 比 semaphore 更语义化
structcompletiondone;init_completion(&done);// 等待方wait_for_completion(&done);// 阻塞等// 完成方complete(&done);// 唤醒等待者10.2 典型场景
// 内核线程启动后等structcompletionstartup_done;init_completion(&startup_done);kthread_run(thread_fn,&startup_done,"my");wait_for_completion(&startup_done);// 等线程就绪// 线程里intthread_fn(void*arg){structcompletion*d=arg;init_my_stuff();complete(d);// 通知主线程我好了...}十一、死锁与调试
11.1 死锁类型
| 死锁 | 原因 |
|---|---|
| 自递归 | 同核持锁后又去取同锁 |
| AB-BA | 核A持锁1等锁2,核B持锁2等锁1 |
| 持锁睡 | 持自旋锁睡(schedule 切走,锁不释放) |
| 中断里持锁 | 中断打断持锁者,中断里又取同锁 |
11.2 lockdep(锁依赖检测)
# 内核配置 CONFIG_LOCKDEP# 开启后,内核跟踪所有锁的获取顺序,检测潜在死锁dmesg|grep-ilockdep# 报告:# [ BUG: possible deadlock ] 潜在 AB-BA# [ BUG: sleeping in atomic context ] 原子上下文睡了11.3 调竞态
- 开 CONFIG_DEBUG_ATOMIC_SLEEP(检测原子上下文睡)
- 开 CONFIG_PROVE_LOCKING(lockdep)
- 用 KASAN(内存竞态检测)
- stress test + ftrace 跟踪
嵌入式视角:lockdep 是你调试利器
调 FreeRTOS 死锁靠肉眼 + 经验。Linuxlockdep 自动检测潜在死锁——开发时开 lockdep 跑一遍,大部分死锁自动暴露。你 BSP 开发驱动,务必开 lockdep 跑测试,省下大量调试时间。
十二、同步原语选择决策树
要睡吗? 是 ──> mutex(进程上下文,优先级继承) 否 ──> 临界区多长? 短(<几行)──> 看并发源 多核/中断/软中断? 多核 ──> spinlock 仅本核抢占 ──> preempt_disable 中断会访问 ──> spin_lock_irqsave 软中断会访问 ──> spin_lock_bh 长/读多写少 ──> RCU(读无锁) 单变量? ──> atomic_t / bit ops 每核统计? ──> percpu 等一次性事件? ──> completion十三、对照总表
| 概念 | FreeRTOS | RISC-V bare-metal | Linux |
|---|---|---|---|
| 临界区 | taskENTER_CRITICAL(关中断) | 关中断 | spinlock/preempt_disable |
| 互斥 | mutex(优先级继承) | 关中断 | mutex(可睡) |
| 信号量 | 二值/计数 | 你写 | semaphore(可睡) |
| 自旋锁 | N/A(单核) | N/A | spinlock(多核忙等) |
| 原子 | __atomic | amoadd.w | atomic_t(amoadd.w) |
| RCU | N/A | N/A | RCU(读无锁) |
| percpu | N/A | N/A | percpu(每核一份) |
| 持锁能睡 | 临界区能睡 | 关中断不能 | 自旋不能,mutex 能 |
| 死锁检测 | 无 | 无 | lockdep |
十四、本篇小结
- Linux 并发源:SMP 多核 + 抢占 + 硬中断 + 软中断,任一共享数据可能被并发访问
- 自旋锁:多核忙等,持锁不能睡;变种
spin_lock_irqsave(关中断)/_bh(关软中断) - mutex:可睡,优先级继承,仅进程上下文用,长临界区
- semaphore:计数,可睡,资源计数用;互斥改用 mutex
- atomic_t:单变量原子,靠 amo 指令
- RCU:读无锁,写复制延迟回收,读多写少法宝
- percpu:每核一份,免锁统计
- 关抢占(preempt_disable)/关中断(local_irq_save)/关软中断(local_bh_disable)组合
- 内存屏障smp_mb/rmb/wmb:防重排,优先用现成原语(隐含屏障)
- completion:等一次性事件
- 死锁:lockdep 自动检测;开发必开
- 持自旋锁/原子上下文绝对不能睡
速查表
| 场景 | 用什么 |
|---|---|
| 短临界区不能睡,多核 | spin_lock |
| 短临界区,会被中断访问 | spin_lock_irqsave |
| 短临界区,会被软中断访问 | spin_lock_bh |
| 长临界区或要睡 | mutex |
| 单变量计数 | atomic_inc/atomic_add |
| 读多写少 | RCU(rcu_read_lock + rcu_dereference) |
| 每核统计 | percpu(this_cpu_inc) |
| 等一次性事件 | completion |
| 关本核抢占 | preempt_disable(不能睡) |
| 关本核中断 | local_irq_save |
| 关本核软中断 | local_bh_disable |
| 检测死锁 | lockdep(CONFIG_LOCKDEP) |
| 检测原子上下文睡 | CONFIG_DEBUG_ATOMIC_SLEEP |
💡技术之路漫漫,分享是为了更好地交流。如果本文的内容对你有启发,希望能得到你的点赞 👍和收藏 ⭐。
如果你在调试过程中遇到了其他问题,欢迎在评论区 💬留言,我们一起探讨。也欢迎关注 👀我,一起交流底层开发的那些事儿。