目录
一、等待的艺术------互斥锁(Mutex)与自旋锁(Spinlock)
[1. 互斥锁 (Mutex)](#1. 互斥锁 (Mutex))
[2. 自旋锁 (Spinlock)](#2. 自旋锁 (Spinlock))
[1、 共享资源的竞争与保护](#1、 共享资源的竞争与保护)
[1.1. 开关中断(最底层的保护)](#1.1. 开关中断(最底层的保护))
[1.2. 自旋锁(Spinlock)](#1.2. 自旋锁(Spinlock))
[1.3. 自旋锁 + 开关中断(多核场景)](#1.3. 自旋锁 + 开关中断(多核场景))
[2、 中断子系统与底半部](#2、 中断子系统与底半部)
[2.1. 中断的拆分:顶半部 vs 底半部](#2.1. 中断的拆分:顶半部 vs 底半部)
[2.2. 底半部的实现机制对比](#2.2. 底半部的实现机制对比)
[2.3. 关键函数与概念](#2.3. 关键函数与概念)
一、等待的艺术------互斥锁(Mutex)与自旋锁(Spinlock)
应用场景:用于在多线程或中断环境中保护共享资源,防止数据竞争。它们最核心的区别在于当锁被占用时,等待锁的线程/进程的行为方式不同。
1. 互斥锁 (Mutex)
核心行为:睡眠等待
当线程尝试获取一个已被占用的互斥锁时,该线程会被操作系统挂起(进入睡眠状态),并放入等待队列。直到锁被释放时,操作系统才会唤醒其中一个等待线程。
-
适用场景 :锁可能被持有较长时间(例如,执行复杂的文件操作、网络请求)。
-
优点:等待期间不占用CPU,节省资源。
-
缺点:线程切换(睡眠和唤醒)有较大的开销。
-
类比 :你在银行柜台前排队,发现前面有人正在办理复杂的业务。你选择取个号,然后坐在旁边的椅子上睡觉(睡眠)。等叫到你的号时,工作人员再把你叫醒。
2. 自旋锁 (Spinlock)
核心行为:忙等待
当线程尝试获取一个已被占用的自旋锁时,该线程会在一个循环中不断检查锁的状态("自旋"),直到锁被释放。在此期间,线程始终处于运行状态,持续占用CPU。
-
适用场景 :锁被持有的时间极短(例如,仅修改一个指针或一个整数),且不允许睡眠的上下文(如中断处理程序)。
-
优点:避免了线程切换的开销,响应速度极快。
-
缺点:在锁被长期占有时,会白白浪费CPU周期。
-
类比 :你在超市收银台前排队,前面的人只是忘记拿一个商品,马上回来。你选择站在原地,不停地探头看他回来了没有(自旋),而不是离开队伍。
3.对比表格
| 特性 | 互斥锁 (Mutex) | 自旋锁 (Spinlock) |
|---|---|---|
| 等待机制 | 睡眠等待。线程被挂起,不占用CPU。 | 忙等待(自旋)。线程持续运行并检查锁状态。 |
| 开销 | 上下文切换开销大(睡眠、唤醒操作)。 | 无上下文切换开销,但空转消耗CPU。 |
| 持有时间 | 适用于锁持有时间较长的场景(>上下文切换时间)。 | 适用于锁持有时间极短的场景(<上下文切换时间)。 |
| 可睡眠性 | 可以睡眠。获取锁后,线程允许被调度出去。 | 禁止睡眠。持有自旋锁的线程必须尽快完成操作并释放锁。 |
| 使用上下文 | 可用于进程上下文(如用户态线程)。 | 主要用于中断上下文、内核底层代码等不允许睡眠的场景。 |
| 实现级别 | 通常由操作系统内核提供调度支持。 | 通常依赖CPU的原子指令(如CAS, TAS)实现。 |
| 可能导致的问题 | 死锁、优先级反转。 | CPU使用率飙升、死锁(尤其在单核上需禁用中断)。 |
4.如何选择
记住一个简单的原则:"让等待者睡觉,除非你确信它很快就能醒来"。
-
用互斥锁,如果:
-
你在用户态编程。
-
锁被持有的时间不确定或可能较长。
-
你可以接受睡眠(即不在中断处理程序、原子上下文中)。
-
-
用自旋锁,如果:
-
你在内核态或中断上下文中编程(这些地方不能睡眠)。
-
你100%确定 锁只会被持有极短的时间(比如几条指令)。
-
你追求极致的性能 ,且锁竞争不激烈。
-
5.总结
互斥锁是"等不了就睡",自旋锁是"死等到底" 。选择哪一个,取决于你愿意用"切换开销"换"CPU时间",还是反过来。
二、中断环境下的同步挑战
在嵌入式 Linux 驱动开发中,并发(Concurrency) 和 **中断(Interrupt)** 是两个最核心的概念。当多个执行单元(进程、线程、中断)同时访问共享资源时,如果没有妥善的同步机制,就会引发不可预知的"竞态条件"。
结合学习笔记,以下将从资源竞争处理 和中断子系统 两个方面,梳理 Linux 内核是如何优雅地解决这些问题的。
1、 共享资源的竞争与保护
在多核 CPU 或单核开启了抢占的环境下,内核开发者必须考虑对共享数据(如全局变量、硬件寄存器)的保护。
1.1. 开关中断(最底层的保护)
这是最快、最直接的手段,主要用于单核处理器环境下的临界区保护。
-
local_irq_enable/local_irq_disable:直接开启或关闭本地 CPU 的中断。简单粗暴,但如果在关闭期间发生中断,中断将永远丢失(直到重新开启),因此通常只在极短时间内使用。 -
local_irq_save/local_irq_restore:保存当前的中断状态并关闭中断,恢复时还原之前的状态。这是更安全的做法,常用于嵌套临界区。
1.2. 自旋锁(Spinlock)
当临界区代码执行时间较短时,如果让线程去"睡眠"(如互斥锁那样),线程切换的开销反而比执行代码本身还大。这时候就需要自旋锁。
-
原理:当获取不到锁时,线程会在原地循环(Busy-waiting),不断尝试获取锁,就像一个人守着门口反复确认"好了吗?好了吗?"。
-
特点 :忙等。线程不会让出 CPU,响应极快,但会消耗 CPU 资源。
-
适用场景:临界区极小,且不能发生进程调度或睡眠的场合。
1.3. 自旋锁 + 开关中断(多核场景)
在多核系统中,单纯的关中断只能保护自己所在的核。如果多个核之间也会竞争同一个变量,就需要结合自旋锁。
-
spin_lock_irqsave/spin_unlock_irqrestore:这是最常见的组合。它不仅锁定自旋锁,还关闭中断。 -
作用 :既防止了多核之间的并发,又防止了单核上的中断打断当前的临界区操作。这是驱动开发中最常用的锁机制之一。
2、 中断子系统与底半部
Linux 内核将中断的处理分为上下两部分,以兼顾中断响应的实时性和处理任务的复杂度。
2.1. 中断的拆分:顶半部 vs 底半部
当一个硬件中断发生时,内核的执行流程如下:
-
顶半部(Top Half / 上半部) :"必要操作"。
-
这是中断触发后立刻执行的代码。
-
必须快速完成,主要任务是登记中断、清除中断标志位、读取关键状态。
-
限制 :不允许睡眠、不允许调度、不允许阻塞。
-
-
底半部(Bottom Half / 下半部) :"费时操作"。
-
将顶半部中耗时的、非紧急的操作推迟到底半部执行。
-
特点:可以睡眠、可以延时、可以被调度。
-
2.2. 底半部的实现机制对比
底半部主要有两种实现方式,它们最大的区别在于执行上下文:
-
Tasklet(小任务)
-
上下文 :中断上下文。
-
特点 :执行时机不确定(通常是最近的可调度时机),但在中断上下文中运行意味着它依然不能睡眠、不能调度。
-
适用性 :适用于耗时较短,且绝对不能睡眠的任务。
-
-
Workqueue(工作队列)
-
上下文 :进程上下文。
-
特点 :Workqueue 是将任务交给内核线程(kworker)去执行。因为在进程上下文中,它可以睡眠、可以延时、可以被更高优先级的进程抢占。
-
适用性 :适用于耗时较长,或者可能需要调用可能导致睡眠的函数(如申请内存、文件读写)的任务。
-
2.3. 关键函数与概念
-
request_irq :用于向内核注册一个中断处理函数(通常就是顶半部)。
-
中断上下文 (Interrupt Context):执行中断服务程序的过程。此时 CPU 正在处理硬件事件,没有对应的进程 PCB,因此不能调度其他进程。
-
进程上下文 (Process Context):普通进程或内核线程的运行状态。此时 CPU 正在执行某个进程的指令,可以进行任务切换。
2.4.总结
在实际的驱动开发中,我们需要根据场景选择合适的内核机制:
-
防并发 :如果只是简单修改变量,考虑原子操作;如果需要保护大段代码,根据是否关中断的需求选择自旋锁。
-
处理中断 :紧急事情放顶半部 (硬中断),耗时处理放底半部 。如果底半部需要睡眠,果断选择 Workqueue ;如果必须马上执行且不能睡,选择 Tasklet。