对大部分刚接触内核态编程的人而言,尤其是像我一样盯着 GPU 驱动源码发过呆的人,一上来拦住去路的往往不是那些晦涩的寄存器,而是另外一件事:自旋锁、信号量、工作队列这些同步原语到底怎么用、什么时候用哪一把。GPU KMD(GPU 内核态驱动)之所以在这件事上特别难,是因为驱动面对的不是一个按部就班的硬件,而是两条同时前进的时间线——CPU 在提交命令,GPU 在疯狂执行指令,两者之间隔着环形缓冲区、原子操作和扇小小的门铃寄存器。这篇是"零基础学 GPU KMD"系列第 6 章第 2 节,内容聚焦内核态编程里的同步与并发,把自旋锁、信号量、工作队列放在 GPU 任务异步处理的真实场景里讲,不光讲 API,更讲清楚"为什么这里该用锁、那里该排队、中断来了该做什么"。无论你是刚开始读 amdgpu 代码,还是已经在内核驱动里写过几个字符设备,这节内容都能帮你把零散的并发概念串成一条完整的链路。
1. GPU 异步模型:为什么内核驱动必须要管好并发
1.1 CPU 和 GPU:两条时间线的交汇
先建立一个最关键的认知模型:GPU 和 CPU 不是"主从关系",而是两个独立的执行单元。CPU 通过 PCIe 总线把命令写入显存中的环形缓冲区(ring buffer),然后向一个寄存器写入数据(门铃 doorbell),GPU 收到门铃后才开始取命令、执行命令。执行过程中 GPU 完全不依赖 CPU,执行完一个任务后在内存里更新一个标记,并通过中断告诉 CPU"我干完了"。
这个模型意味着什么?意味着 CPU 侧的提交路径和 GPU 侧的执行路径是真正并行的。你提交了第 100 条命令时,GPU 可能才执行到第 30 条;你正准备回收命令缓冲区时,GPU 可能还在读里面的内容。如果把编写普通块设备驱动比作"一个人在流水线上拧螺丝",那编写 GPU KMD 就是"流水线上同时站着两个工人,其中一个还经常不按你的节奏来"。
所以内核驱动必须解决的第一个问题不是性能,而是正确性:CPU 和 GPU 之间共享的所有内存状态,都必须有清晰的同步规则,否则就会出现"CPU 把命令缓冲区释放了,GPU 还在执行里面的命令"这种灾难性错误。
1.2 共享状态散落点:ring buffer、fence、页表与寄存器
GPU KMD 里需要并发保护的共享状态,主要散落在这么几个地方:
- 环形缓冲区本身:CPU 维护 tail 指针表示生产到哪里,GPU 维护 head 指针表示消费到哪里。两个指针在同一个内存页里,CPU 写 tail 时要保证与 GPU 读 head 的时序一致。
- fence 对象:这是 GPU 任务完成状态的载体。一个命令提交后会分配一个 fence,GPU 在完成时更新 fence 的 sequence 值,CPU 侧通过轮询或中断来感知完成。fence 的链表、回调函数、引用计数都需要锁保护。
- 硬件页表:GPU 有自己的 MMU,页表的更新必须等到 GPU 不再使用旧映射之后才能进行,这本身就是一整套同步协议。
- 寄存器与门铃:MMIO 写入的顺序、次数、以及和内存屏障的配合,都属于并发控制的一部分。
看清楚这些共享状态,就明白为什么 GPU KMD 是内核里并发编程的"重灾区"了。一个驱动里自旋锁、mutex、工作队列、等待队列、内存屏障往往全部用上,而且每一样都用得比普通驱动更密集。你只要有一步用错了,结果不是系统卡死就是显存内容被踩坏,debug 起来非常痛苦。
2. 同步原语选型逻辑:临界区长度决定一切
2.1 三个决定性判据
内核里能用的同步原语一大把,但选型逻辑其实可以收敛成三条判据,拿到一个场景先问自己这三个问题:
- 这个临界区能睡眠吗?如果能睡眠,比如要调用 kmalloc(GFP_KERNEL)、要等 I2C 总线响应、要操作可能阻塞的寄存器,那你只能用 mutex、信号量这类允许睡眠的锁;反过来,如果临界区在硬中断上下文、NMI 上下文或调度器禁用的路径里,你连睡眠的资格都没有,只能自旋锁。
- 临界区有多长?这是最容易被人忽略的一条。自旋锁忙等期间不能做任何有用的事,如果临界区超过几微秒,等锁的时间比一次调度还长,那用 mutex 让出 CPU 反而更快。
- 运行的上下文是什么?是进程上下文、软中断上下文、硬中断上下文还是 NMI?上下文直接决定了你能不能用会睡眠的锁。很多新手犯的经典错误,就是在硬中断里调用 mutex_lock,内核直接报 "sleeping function called from invalid context"。
这三条判据的顺序不能乱:先看能不能睡眠,再看值不值得睡眠,最后看运行位置。三者都满足才轮到考虑性能。
2.2 三种原语的适用边界总览
我把内核里几个常见原语的特性放在一起对比,方便后面展开:
| 原语 | 能否睡眠 | 适用临界区规模 | 典型运行上下文 | GPU KMD 典型用途 |
|---|---|---|---|---|
| 自旋锁(spinlock) | 否 | 极短(几十 ns 到几 us) | 进程、软硬中断均可(需选对变体) | 保护 ring buffer tail、fence 链表的短操作 |
| 信号量(semaphore) | 是 | 任意,但语义偏计数器 | 进程上下文 | 资源计数,驱动里已较少直接使用 |
| mutex | 是 | 微秒到毫秒级 | 进程上下文 | 上下文创建/销毁、模式切换、电源管理 |
| 工作队列(workqueue) | 是 | 可以把长任务延后处理 | 进程上下文(内核线程) | 中断下半部、fence 回调、GPU hang 检测 |
这张表的核心逻辑其实就一句话:能睡眠的锁等的是"资源",不能睡眠的锁等的是"CPU 指令"。GPU KMD 里两类需求都存在,所以必须两套都要会。后半篇文章我就分别展开讲,讲清楚每一样在 GPU 场景下的真实用法和那些文档里很少写的坑。
3. 自旋锁:守护 GPU 命令提交的短临界区
3.1 环形缓冲区的 tail 指针为什么必须用自旋锁
先别看 API,先看场景。在 amdgpu 这类驱动的提交路径里,CPU 侧要做的事情是:把命令拷贝到 ring buffer 的可用空间,然后更新 tail 指针,最后写 doorbell。这个临界区有多短?几个内存拷贝加一次 MMIO 写,快的时候几十纳秒。
你可能会想,这么短的操作直接用原子变量不就行了吗?问题在于,tail 指针的更新不是简单的原子加一,它需要先检查剩余空间(head 和 tail 的关系),再决定把命令写到哪个偏移,最后再更新 tail。整个序列是一个"读-判断-写"的复合操作,必须保证多个生产者之间的互斥。
这时候用 mutex 行不行?行是行,但代价极其惨重。mutex_lock 在争用不严重时虽然也很快,但一旦发生争用就会触发调度、睡眠、唤醒这一整套流程,光这些开销就比临界区本身大出几个数量级。更要命的是,提交路径可能是从 ioctl 进程上下文进来的,也可能是从 GPU 重置的 recovery 路径进来的,还可能被中断上下文打断。如果提交路径里持有一个普通 mutex,而中断处理路径也需要访问同一个 ring,那死锁就找上门了。
自旋锁就是为这种场景设计的:它假定临界区极短,竞争者忙等一小会就能拿到锁,整个等待过程不睡眠、不调度,开销就是一个原子比较加交换的事。在 GPU 命令提交路径上,这是唯一合理的选择。
3.2 GPU 驱动里自旋锁的正确姿势与变体选择
内核里的自旋锁不是只有 spin_lock/spin_unlock 这一种姿势,更重要的是你怎么处理中断。推荐的做法是:
unsigned long flags; spin_lock_irqsave(&ring->lock, flags); /* 检查剩余空间、拷贝命令、更新 tail */ spin_unlock_irqrestore(&ring->lock, flags);这里为什么必须用 irqsave/irqrestore,而不是裸的 spin_lock/spin_unlock?因为你要保护的 tail 指针可能在三种上下文中被访问:普通进程提交命令、某个定时器或软中断里做状态检查、以及 GPU 中断处理函数里读取完成状态。如果你在进程上下文里持锁时,中断来了打断了你,而中断处理函数里也尝试拿同一把锁,就会死锁——因为中断处理函数不会因为进程没释放锁就放弃执行,它只会一直自旋等你。
spin_lock_irqsave 的作用就是在拿锁的同时把本地 CPU 的中断关掉,确保当前 CPU 上不会出现"自己打自己"的递归死锁。注意它只关本地 CPU 的中断,其他 CPU 的中断不受影响,所以其他 CPU 上的中断处理函数依然可能在等这把锁,但只要临界区够短,等待时间就是可接受的。
还有一个细节很多新手会忽略:flags这个变量的生命周期。irqsave 保存的是进入临界区之前的中断状态,所以 irqrestore 恢复的也是之前的状态,而不是简单地把中断打开。如果你的代码里出现了"拿锁时保存 flags,结果 flags 是个全局变量"这种写法,恭喜你,你会收获一个极其隐蔽的并发 bug。这个 flags 必须是栈上的局部变量。
3.3 自旋锁踩坑实录
我在调驱动时见过不少自旋锁相关的翻车现场,挑三个最典型的说说。
第一个坑是在自旋锁临界区里调用会睡眠的函数。比如有人在锁里写了 kmalloc(size, GFP_KERNEL),或者更夸张的,写了 mutex_lock。自旋锁持锁期间,当前 CPU 是不允许调度的,如果你睡眠了,内核会 panic 或者在 RT 内核里直接报 "BUG: scheduling while atomic"。正确做法是,临界区只做必须原子完成的动作,需要分配内存就提前用 GFP_ATOMIC,或者把分配动作放到锁外面。
第二个坑是锁粒度过大。我见过把整个命令提交、fence 注册、甚至部分调度逻辑全部塞进一把锁里的,结果一跑起来 CPU 的锁争用率飙到 90% 以上,GPU 利用率反而更低。自旋锁的粒度要小到"临界区比一次调度更便宜"才算合格。如果发现自己的锁临界区超过了几百行代码,大概率要重新设计。
第三个坑比较进阶,是preemption 与 RT 内核的影响。在高精度实时内核(PREEMPT_RT)里,自旋锁会被改造成可睡眠的互斥锁(rt_mutex),语义发生变化。如果你的驱动代码里在自旋锁临界区里做了依赖"不调度"假设的操作,在 RT 内核上就会出问题。所以即便用了自旋锁,也要养成"临界区里什么都不做,除了必要的寄存器读写"的习惯。
顺带提一句,如果你平时也会翻 macOS 的内核源码,会发现 XNU 里的 lck_spin_t 和古老的 OSSpinLock 跟 Linux 这边思路一致,本质上都是共享内存上的原子操作加屏障,换到哪个平台都逃不开这几下子。
4. 信号量与 mutex:让等待 GPU 的进程体面地睡觉
4.1 从 semaphore 到 mutex:内核锁的演进逻辑
很多内核新手看到"信号量"这个词,以为它和 mutex 是一回事,其实两者语义差别很大。经典信号量是一个计数器,允许多个持有者同时进入临界区,P 操作(down)把计数减一,V 操作(up)把计数加一,计数为 0 时再 down 就要等待。它天然适合"资源池有几个空位"这种场景——比如 GPU 驱动里某个硬件队列最多允许 N 个任务在途,就可以用信号量计数。
但现实是,在内核驱动里直接用信号量的情况越来越少了。原因很简单:大部分驱动临界区要的是互斥而不是计数,而 mutex 在互斥场景下语义更严格——它强制同一时刻只能有一个持有者,还内置了所有权检查、优先级继承等机制,能更好地避免优先级反转问题。所以现在的驱动代码里你很少看到 struct semaphore,看到更多的是 struct mutex。
那么信号量是不是就没用了?也不全是。一是在少数计数型资源池场景还能用;二是 GPU 领域里"信号量"这个词还有另一层含义——GPU 内部的同步信号量(比如 amdgpu 里的 semaphore,用于 GPU 队列之间的执行顺序同步),它和内核的 struct semaphore 是完全不同的东西,读代码时千万别混。本文后面讲的主要是内核态的 CPU 侧同步原语。
4.2 GPU 驱动中的典型长临界区场景
GPU KMD 里有哪些长临界区是必须让进程睡眠等待的?我举几个实际例子:
- GPU 上下文(context)的创建和销毁:涉及分配显存、初始化页表、注册 fence 上下文,整个流程可能耗时几十微秒甚至更久,临界区里还可能要调用可能阻塞的函数,显然不能自旋等。
- 显示模式切换(mode set):要停掉当前的显示管线、重新计算时钟、加载新的伽马表,操作链非常长,中间还可能和显示控制器硬件交互等待 vblank。
- 电源管理状态切换:GPU 进入/退出低功耗模式时,需要等待硬件完成状态保存和恢复,这个过程可能长达毫秒级,CPU 只能睡眠等待。
这些场景的共性是什么?临界区足够长,长到睡眠等待的代价远远小于忙等;同时又跨了多个子系统,无法用原子操作或自旋锁完成。此时 mutex 就是标准答案。
4.3 中断版本的加锁与死锁规避
在 GPU 驱动里用 mutex,最容易踩的坑就是锁顺序和持锁期间被中断打断后的二次加锁。
先看代码姿势。GPU 驱动里几乎不会用裸的 mutex_lock,而是用:
if (mutex_lock_interruptible(&adev->ctx_lock)) { return -ERESTARTSYS; }这里的关键是_interruptible。GPU 操作有个特点:它可能真的会挂很久,比如命令队列阻塞、GPU hang 等待恢复,可能几十秒都没反应。如果用 mutex_lock(TASK_UNINTERRUPTIBLE),用户进程就只能干等着,连 Ctrl+C 都杀不掉,最后变成一个 D 状态僵尸进程留在系统里。用 interruptible 版本,等待锁的进程可以被信号唤醒,返回 -ERESTARTSYS 或者 -EINTR,用户态就能正常处理中断了。
然后在写驱动的时候,一定要给自己定一个铁律:锁顺序永远一致。比如你规定"先拿 ctx_lock,再拿 ring->lock",那所有路径都必须按这个顺序来。GPU 驱动里特别容易出现"提交路径拿 A 再拿 B,重置路径拿 B 再拿 A"这样的反向顺序,一旦两条路径在并发时相遇,就是教科书级的 ABBA 死锁。这类问题在测试时很难稳定复现,但开着 CONFIG_PROVE_LOCKING(lockdep)跑一轮压力测试,往往五分钟内就能暴露。
最后一点经验之谈:mutex 保护的区域也要控制规模。有人图省事,把整个 ioctl 的入口到出口全部用一把大锁包住,确实不会出并发 bug 了,但代价是 GPU 驱动彻底退化成了单线程——两个进程同时提交命令互相等待,性能直接腰斩。这种锁的粒度应该只覆盖"共享状态的变更",而不是"操作的全部时间"。驱动代码的并发度设计,和锁本身一样重要。
5. 工作队列:GPU 中断下半场的最佳去处
5.1 为什么不用 tasklet 处理 fence 回调
现在聊异步处理的重头戏:中断来了之后怎么办。GPU 执行完一个 batch 任务,会通过 MSI/MSI-X 向 CPU 发中断。中断处理函数里有两种事要做:一是必须快速完成的事,比如清中断、读一下完成状态;二是可以稍后做的事,比如释放命令缓冲区、回调用户态的同步对象、更新调度器的统计信息。
老派做法是用 tasklet 或 softirq。tasklet 跑在软中断上下文里,介于硬中断和进程之间,好处是延迟低,坏处是不能睡眠。而 GPU 驱动的 fence 回调恰恰经常要睡眠:释放缓冲区要调用可能阻塞的内存回收、唤醒用户进程要走等待队列、更新调度状态可能要拿 mutex。如果你在 tasklet 里做这些事,内核会直接骂 "BUG: sleeping function called from invalid context"。
所以现代 GPU 驱动几乎都转向了工作队列(workqueue)。工作队列里的代码跑在内核线程的进程上下文中,可以睡眠、可以拿 mutex、可以放心大胆地调用各类阻塞 API,代价是延迟比 tasklet 高一点点,但换来的是极大的便利性和安全性。更关键的一点是,tasklet 跑在 softirq 的上下文中,如果某块 CPU 上有大量 fence 回调节点堆积,会占据整个 CPU 的处理时间,拖累系统的整体响应;而工作队列由内核线程执行,可以被调度器合理分配优先级和 CPU 资源。这两者的差异在压力测试里非常明显——同样规模的 batch 提交,tasklet 方案在重负载下系统交互延迟很难看,workqueue 方案则稳得多。
5.2 工作队列在 GPU KMD 中的典型用法
内核里有两种使用工作队列的方式。最简单的是使用系统默认的工作队列 system_wq,提交方法就是:
struct work_struct *work; INIT_WORK(work, fence_work_handler); schedule_work(work);这适合那些"赶紧做掉就行、不需要独享执行资源"的任务,比如释放一个已经不用的命令缓冲区。但系统默认队列是所有驱动共享的,万一某个驱动在 work 里干了耗时很长的活,其他驱动的工作就会跟着遭殃。GPU 驱动这种重负载场景,一般更喜欢创建自己的专用队列:
struct workqueue_struct *fence_wq; fence_wq = alloc_workqueue("gpu-fence", WQ_UNBOUND | WQ_HIGHPRI, 0); queue_work(fence_wq, work);这两个 flag 的作用需要展开讲一下。WQ_UNBOUND 表示工作项可以在任意 CPU 上执行,不受创建时所在 CPU 的限制。对 GPU 驱动来说这通常更合适,因为 fence 回调往往要访问设备内存并触发后续的 DMA 操作,绑定到特定 CPU 反而容易造成局部热点。WQ_HIGHPRI 则是让这个队列的内核线程以高优先级运行,保证 fence 完成信号能尽快传到等待的进程——GPU 场景下,fence 晚处理几毫秒,用户态那批等待的帧就得晚显示几毫秒,这个延迟对交互体验的影响很大。
5.3 工作队列使用的几个隐蔽坑
用工作队列也有几个容易翻车的地方,我自己和身边同事都踩过。
第一个坑:在 work 函数里等待自己的另一个 work 完成。比如你在某个 work 中调用了 cancel_work_sync() 或者 flush_work(),而目标 work 恰好正在同一个 worker 线程上执行——如果这两个 work 在同一个工作队列里,就可能造成"自己等自己"的死锁,worker 线程直接卡死。遇到这种情况,要么拆到不同队列,要么干脆重新设计流程。
第二个坑:不知道 work 可能并发执行。默认情况下,同一个 work_struct 如果被 schedule_work 或 queue_work 多次提交,只有当它没在等待执行时才会再次排队,内核会保证同一个 work 不会同时跑在两个 CPU 上。但如果你创建了两个不同的 work_struct,它们之间共享的数据没有加锁,那并发问题照样存在。workqueue 只是一个执行环境,并不自动帮你做互斥。
第三个坑:在中断处理函数里做太多事再提交 work。很多人以为把任务丢给工作队列就万事大吉,结果在 ISR 里还是忍不住做了解析、验证、分配内存这些工作。ISR 应该精简到极致:读必要的状态、标记完成、queue_work、然后立即返回。你要抢占的是中断处理的"最小窗口",而不是把所有调度逻辑都塞进去。
我在实际项目中还有一个习惯:用 request_threaded_irq 配合 hardirq + threaded handler 的方式处理 GPU 中断。硬中断里只清中断源,threaded handler 里做 fence 状态更新和 work 提交。这比手写 tasklet 清晰得多,也能天然享受进程上下文的便利,推荐大家去看一下这个 API。
6. 完整链路:一次 GPU 命令提交的异步之旅
6.1 从 ioctl 到 doorbell:提交侧
前面几节分别讲了三种同步原语,这里把它们放进同一条链路里串一遍,你会看到一个完整的 GPU 任务是怎么"从 CPU 出发、经过 GPU、再回到 CPU"的。
用户态程序调用驱动提交一个渲染任务,一路走到内核的 ioctl 处理函数中。驱动先做权限检查、参数校验,然后尝试分配一个命令缓冲区,并把这个任务交给调度器排队。调度器决定任务可以提交后,进入硬件的 ring buffer 写入路径。这时候第一把锁登场了:
spin_lock_irqsave(&ring->lock, flags); /* 检查 head/tail 剩余空间,必要时等待 GPU 消费 */ /* 把命令拷贝到 ring buffer,更新 tail */ spin_unlock_irqrestore(&ring->lock, flags); /* 内存屏障:确保命令写入对 GPU 可见后,再敲 doorbell */ wmb(); writel(tail, doorbell_addr);这里用自旋锁的原因在前面已经说透了——它只保护几十纳秒的临界区,而且必须防中断打断。你可能会注意到我特意写了wmb(),这是另一个无数人踩过的坑:doorbell 是 MMIO 写,CPU 有可能会把内存写重排到 MMIO 写之后。如果没有内存屏障,GPU 收到门铃时可能还没看到最新的命令数据,于是执行了上一次的旧命令或半新半旧的数据,这种 bug 非常难查。
6.2 中断回传与 fence 唤醒:完成侧
GPU 执行完命令后,会在内存更新 fence 的 sequence 值,然后触发中断。中断处理函数进入后,修改 fence 的完成状态,唤醒等待这个 fence 的进程,并把需要延迟处理的任务交给工作队列:
/* 硬中断处理 */ static irqreturn_t gpu_isr(int irq, void *dev_id) { struct gpu_device *gdev = dev_id; /* 读完成状态,清除中断源 */ seq = gpu_read_fence_seq(gdev); /* fence 更新需要和提交路径用同一把锁 */ spin_lock(&gdev->fence_lock); dma_fence_signal_locked(gdev->fence); spin_unlock(&gdev->fence_lock); /* 把耗时的后续工作扔给专用 workqueue */ queue_work(gdev->fence_wq, &gdev->fence_cleanup_work); return IRQ_HANDLED; }这里第一层同步是 fence_lock,它保护 fence 对象的并发修改。第二层同步就是工作队列:fence_cleanup_work 的 handler 里会释放命令缓冲区、向用户态发送 completion 事件、更新调度器状态,这些操作都可能在进程上下文里做,任何一把需要睡眠的锁(比如 mutex)在这里都可以合法使用了。
而被唤醒的等待进程呢?它在提交命令时可能正在 fence 的等待队列上 sleep。GPU 完成中断唤醒了它之后,它会检查 task 的完成状态,然后继续执行后续逻辑——比如把渲染结果交给显示模块、或者直接返回用户态。这一整套流程里,自旋锁保护了最脆弱的共享状态,mutex 保护了长临界区,工作队列承载了异步处理,等待队列实现了进程睡眠与唤醒,每一样各司其职。
6.3 验证方法:lockdep 与 bpftrace
说完了链路,最后分享两个我自己验证同步逻辑最常用的工具。
第一个是 lockdep。在内核启动参数里加上lockdep.compatible=none之类的配置没什么用,你需要的是编译时开启 CONFIG_PROVE_LOCKING。lockdep 会在运行时自动检测锁顺序反转、irq 不安全、未初始化锁等问题,一旦发现问题会打印一大段报告。我每次写完驱动代码都会开着 lockdep 跑一遍压力测试,它帮我抓到的死锁隐患远比我肉眼 review 抓到的多。
第二个是 bpftrace。它可以在线观察锁的等待时间、自旋次数和 workqueue 的延迟情况。比如我想看自旋锁等待是不是过长,可以写一个小的 bpftrace 脚本 kprobe 挂到 spin_lock 上,统计每次拿锁的耗时分布。GPU driver 的性能问题很多时候不是 CPU 计算量大,而是锁争用导致提交路径排队,这种问题靠看代码很难定位,bpftrace 一跑延迟分布就清楚了。
写到这里,回到最初那句话:GPU KMD 的并发复杂,根源在异步。如果你能把自旋锁、mutex、工作队列各自该扛的活儿分清楚,能画出从命令提交到 fence 唤醒的完整路径,那这部分的核心要点就算是真正吃透了。我自己的体会是,学这些同步原语最好的方式不是背 API,而是拿着 amdgpu 或 Intel i915 的源码,一行一行追"一条 fence 的一生",追完一遍再回头看锁,你会看到完全不一样的风景。