做GPU KMD(内核态驱动)开发的兄弟一定深有体会,写驱动的难点不在那几个IO操作,而在并发与同步。自旋锁、信号量、工作队列这三座大山,是每次处理 GPU 异步任务时都绕不开的坎。这一篇是“零基础学GPU KMD”系列第6章的第2节,专门盯着“同步与并发”这个主题讲清楚:为什么内核对并发这么敏感,GPU KMD场景下它们各自怎么用,以及怎么用它们搭出可靠的异步处理链路。适合刚开始接触内核态编程,或者被驱动并发bug折磨得想砸键盘的同学。
我会尽量用大白话把内核里这些看似吓人的概念讲透,同时给出可以直接“抄作业”的伪代码和分析思路。你不需要多深的硬件基础,只要会Linux字符驱动的基本套路,看完这篇就能在GPU驱动里合理选择自旋锁、信号量和workqueue,少踩几个坑。
1. 内核态并发环境的基本认识与GPU KMD的特殊性
1.1 并发来源:多核、中断、抢占,到底谁在打架
我们先做一个基础层面的梳理。内核态代码和用户态代码最大的区别之一,就是内核态代码运行在CPU的privileged mode下,拥有对硬件的直接访问权,同时它随时可能被各种事件打断。很多同学写用户态多线程时,遇到并发问题可以靠加锁解决,但到了内核里,你会发现自己面对的不只是“多线程并发”,而是“多CPU并发、中断并发、软中断并发、抢占并发”同时存在的复杂场景。
具体来说,在GPU KMD里,并发压力主要来自这样几个方向:
- 多核并行:现在的CPU都是多核,GPU驱动可能会同时被多个用户的ioctl调用、多个CPU核上的需求提交同时进入,这叫SMP并发。
- 中断异步:GPU执行完任务后,会通过PCIe中断通知CPU,此时中断处理函数会在一个专门的上下文里运行。它会和正在执行ioctl的进程抢同一个数据结构。
- 内核抢占:Linux内核为了实时性,允许高优先级任务抢占正在运行的内核代码,如果临界区没锁好,会导致无法预料的错乱。
所以“并发环境”并不是工程师挑出来的,而是内核运行方式的天然属性。GPU驱动和普通字符设备驱动相比,又增加了一层复杂性:GPU任务的完成是异步的。用户程序把命令提交给驱动,驱动并不立刻等待执行完,而是把命令包进ring buffer,然后通过门铃机制通知GPU去取。而GPU真正执行完一个fence时,中断才会回来,驱动需要在中断路径里找到对应的任务并唤醒等待的进程。这个“提交线程”和“中断线程”之间的数据同步,就是自旋锁和信号量的主战场。
1.2 GPU KMD 的特殊并发场景:命令提交、门铃、fence
我们要先在心里建立起一个简单的模型,后续所有锁和工作队列都是在让这个模型稳定运行。
假设我们有一个GPU设备,用户态每次提交一个渲染任务,流程是:
- 用户态通过
ioctl进入内核,调用amdgpu_ring_submit之类提交函数。 - 驱动把命令写入驱动和GPU共享的ring buffer中,更新写指针wptr。
- 驱动通过MMIO写门铃寄存器,通知GPU“有新活干了”。
- GPU硬件调度器自己去取命令执行,执行完成后,它会设置某个fence对象的顺序值。
- 当fence被硬件触发时,GPU会发起中断,中断处理函数检查哪个fence完成了,再唤醒等待该fence的用户态任务。
这个流程中,ring buffer的读写模型是典型的生产者消费者模型:用户态是生产者,GPU中断处理是消费者。写指针和读指针都存放在共享内存里,多个CPU核可能同时对指针进行修改,所以必须加锁。与此同时,fence对象也可能被多个等待任务同时访问,这也需要保护。
正因如此,GPU KMD内核对同步原语的要求非常具体:有些临界区非常短,比如更新一个指针,我们绝不能在里面睡觉,用sleep会阻塞整个系统;而有些临界区可能持续较长时间,比如等待GPU真正执行完一个任务,这时候睡眠等待是合理的。这些不同场景,恰恰需要我们分门别类地使用自旋锁、信号量和workqueue。
2. 自旋锁:短期临界区的首选
2.1 自旋锁原理与适用条件,为什么GPU命令环上总能看到它
自旋锁可能是内核对并发最直接的答案。它的核心思想是:当一个CPU核试图获取某个锁时,如果锁被别的核持有,它就不停地“原地打转”,循环检查锁是否释放,直到拿到锁为止。这就像你去健身房跑步机跟前,发现机器被人占了,你也不会走开,而是站在旁边等到人家跑完,然后立刻冲上去。
因为它“原地打转”,CPU时间就被白白消耗了,所以自旋锁只适合保护非常短、非常快的临界区。这个“短”是什么概念?经验法则是临界区执行时间应该远小于一次上下文切换的时间,最好几百个CPU周期以内搞定。绝不允许在持有自旋锁的临界区里调用任何会导致睡眠的函数,比如copy_to_user、mutex_lock、msleep,连kmalloc(GFP_KERNEL)都要避免。一旦睡眠,其他CPU核拿不到锁就会一直空转,睡眠者自己又醒不过来,系统直接死锁或崩溃。
在GPU KMD里,自旋锁最典型的应用场景就是保护ring buffer的读写指针。我们看一个常见的结构体:
struct amdgpu_ring { struct rwlock_t lock; unsigned int wptr; unsigned int rptr; unsigned int ring_size; };这里用wptr和rptr来维护命令环的生产消费进度。提交命令时,生产者需要更新wptr;中断处理时,消费者需要更新rptr。两个CPU核如果同时写这两个指针,可能出现命令环重叠等灾难性错误,所以需要自旋锁把操作保护起来。临界区只有几次32位整型的赋值,非常适合自旋锁。
2.2 使用自旋锁的注意事项:中断上下文必须使用irqsave
自旋锁的使用远远不止调用spin_lock和spin_unlock这么简单。在GPU KMD里,同一个锁可能同时被进程上下文和中断上下文访问。如果你在进程上下文里获取了锁,然后中断来了,中断处理函数又去尝试获取同一个锁,就会立刻发生死锁:中断处理占着CPU,进程上下文即使拿到了锁也永远无法继续释放,系统就卡死了。
所以内核提供了spin_lock_irqsave和spin_unlock_irqrestore,它们在获取锁的同时会关闭本CPU的中断,保存中断标志位,等释放锁时再恢复。这听起来很粗暴,但也最安全。我的建议是,在GPU驱动里,只要你没法明确100%确认这个锁绝不会在中断路径里被访问,就一律使用irqsave版本。多花一点CPU开销,换来的是不用再去排查那些玄学死锁。
另一个很隐蔽的坑是per-CPU锁和缓存一致性。自旋锁本身使用了原子操作指令,比如x86上的lock cmpxchg,它会强制锁定总线或缓存行,频繁争抢自旋锁会造成内存总线流量变大。在GPU驱动里,命令提交路径本来就是高性能要求的关键路径,如果多个核频繁争抢同一个锁,可能提交性能不升反降。因此,有时我们会把ring buffer的指针按CPU分成多个per-CPU队列,用Per-CPU自旋锁来降低争抢。
2.3 实操示例:用自旋锁保护GPU命令提交中的rptr/wptr
我写一个典型的生产者侧代码框架,给大家演示一下自旋锁在GPU环管理里的用法:
void gpu_ring_submit(struct amdgpu_ring *ring, struct gpu_job *job) { unsigned long flags; // 计算新命令写入位置 spin_lock_irqsave(&ring->lock, flags); ring->wptr += job->cmdsize; if (ring->wptr >= ring->ring_size) ring->wptr -= ring->ring_size; memcpy(ring->ptr + ring->wptr, job->cmd_buf, job->cmdsize); // 把写指针的最新值写到GPU看得见的内存位置 writel(ring->wptr, ring->wptr_addr); spin_unlock_irqrestore(&ring->lock, flags); // 通知GPU启动调度 writel(1, ring->doorbell); }这里的关键点是,wptr的更新、内存复制和门铃触发顺序不能乱。必须等wptr更新完成,并写到GPU可见地址后,才能触发门铃。否则GPU可能读到旧指针,或者读到中间态。这算是一个小小的硬性约束,我用注释标出来提醒大家。另外,在消费者侧,中断处理函数里也要用spin_lock_irqsave访问同一个ring结构来获取rptr。虽然中断本身已经在该CPU上关闭了本CPU的中断,但如果不保存标志位,后续中断恢复可能出错,所以还是统一用irqsave。
3. 信号量(semaphore):适合长期等待与同步GPu完成
3.1 信号量在内核态的角色,以及与mutex的区别
很多初学者会把信号量和互斥锁搞混,因为听起来都像“锁”。但实际上它们解决的问题完全不同。信号量本质是一个计数器,支持down和up操作:down是递减计数,如果计数为负数则睡眠等待;up是递增计数,并唤醒可能等待的任务。它允许一个资源被多个持有者同时访问(计数>1的场景),也允许生产者消费者模式用来计算可用资源数量。
但内核里普遍使用的其实是struct semaphore,不过新版内核已经慢慢用mutex代替大部分二进制信号量,因为mutex拥有所有者概念,支持锁调试、优先级继承等更完善的功能。然而信号量依然有它不可替代的地方:它可以在中断上下文中安全地执行up操作。mutex的unlock并不允许在中断上下文调用,因为那可能触发睡眠动作。而信号量则可以在中断处理函数里通过up来唤醒阻塞进程。
现代GPU驱动里,直接使用semaphore的场景不太多了,我们更多会在硬件fence的实现里看到它。因为硬件中断完成后,需要唤醒等待该fence的不同进程,而这个唤醒动作正好发生在中断上下文。这时如果用mutex,就会违反复用内核接口的规则,导到scheduling while atomic的异常;而up就可以安全完成唤醒。
3.2 在GPU KMD中的典型用法:等待硬件完成与固件交互
还有一个常见场景是GPU固件交互。比如我们向GPU发送一个控制命令,等待固件把某个寄存器状态写到位,这个等待过程可能需要几十甚至几百毫秒。如果用自旋锁,会让CPU空转浪费大量时间;如果用mutex,在中断上下文又不能用;这时的选择就是用信号量,或者wait_queue_head搭配complete机制。
不过,在真实源码里,大家更常用completion而不是semaphore,因为completion是专门为“等待一次完成事件”设计的,更加贴合GPU固件交互场景。但既然本文标题强调信号量,我们就直接讲一版信号量方式:
struct gpu_fence { struct semaphore sem; int done; }; void gpu_fence_init(struct gpu_fence *fence) { sema_init(&fence->sem, 0); } // 进程上下文等待GPU完成任务 int gpu_fence_wait(struct gpu_fence *fence, long timeout) { return down_interruptible_timeout(&fence->sem, timeout); } // 中断上下文通知fence完成 void gpu_fence_signal(struct gpu_fence *fence) { fence->done = 1; up(&fence->sem); }注意,down_interruptible_timeout是可中断的睡眠等待。在用户进程被Ctrl+C终止时,这个函数会返回错误,我们随后需要清理fence状态。这个伪代码把done标记和信号量绑定使用,是因为信号量本身没有即时返回“是否已完成”的能力,配合一个标志位就能在等待前先判断。
3.3 实操心得:信号量计数的坑与超时处理
用信号量的时候,我最常犯的错是忘记初始化计数。sema_init(&sem, 0)意味着一开始没有资源,任何down都会睡眠,直到有人调用up。如果初始化为1,那就成了一把可用信号量,初次down不会睡眠。GPU任务提交时的fence应该以0初始化,因为硬件还没完成。我曾经把一个fence信号量初始化成1,结果所有提交任务都瞬间以为完成,导致用户态还在画第2帧时,驱动已经认为第3帧渲染结束,整个画面乱成一团的经典bug。
另一个经验是超时处理。在GPU驱动里,等待硬件完成永远不要无限等,因为GPU可能因为超频、固件错误或驱动bug而永远不回来。无限等待会让用户态进程变成D状态,卡死在down上,甚至触发hung task检测。我用down_interruptible_timeout时,通常会传入一个比GPU任务预期最长执行时间稍长的超时值;如果超时了,就走错误恢复路径,重新初始化GPU引擎,而不是傻等。这种设计在一个商业驱动的稳定性评级里特别重要。
4. 工作队列:把耗时任务推迟到进程上下文
4.1 为什么要工作队列:中断下半部、tasklet还是workqueue
中断处理函数对执行时间有极其严格的限制。虽然GPU中断不是网络那种百Gbps超高频率,但它依然不能花太多时间。在中断里做fence信号量唤醒也许还快,但要是在中断里去遍历所有作业的回调函数,甚至去更新用户态内存的映射关系,那就会拖垮系统整体调度。
Linux提供了三种下半部机制:软中断、tasklet和工作队列。其中tasklet运行在软中断上下文,不能睡眠,适合短小的“后处理”;而工作队列运行在进程上下文,可以睡眠,能调用任何阻塞型API。GPU KMD的fence回调往往需要做很多耗时操作,比如释放DMA buf、更新fence树、带有通知用户态语义的唤醒,这些操作有的会睡眠,有的需要获取其他锁,所以工作队列几乎是唯一合理的选择。
具体说,中断处理函数做的事情应该是:
- 快速从GPU硬件状态里读出哪些fence被触发。
- 在安全的情况下,把fence标记为complete,并唤醒等待进程。
- 把fence的后续清理、回调工作放进工作队列。
- 迅速返回。
注意,这里第2步依然需要快速完成,不能拖沓。如果你发现fence回调牵扯到复杂的锁竞争,那就把“回调触发”这一步也放进工作队列,只做最原子化的up(sem)。这样中断被拉得更短,系统也更稳定。
4.2 工作队列的种类与选择:system_wq、自定义还是ordered
工作队列本身也有讲究。Linux内核提供全局的system_wq,你可以用schedule_work直接调度一个work,也可以创建自己私有的工作队列,用queue_work调度。甚至还有alloc_ordered_workqueue创建有序工作队列,保证work的执行顺序与入队顺序一致。
GPU KMD里强调顺序的地方特别多。比如一个执行队列上的多个fence,它们可能在硬件里已经按顺序完成,但在驱动软件里,如果我们随意乱序处理,会造成并发用户态任务之间的误判。这时候最好使用ordered workqueue来保证处理顺序,或者使用多个workqueue分别处理不同类型的事件。
不过我要提醒,ordered workqueue虽然是串行执行,但它的并发度很低,如果你把太多与会话相关的耗时任务塞进去,可能造成后续提交任务排队数毫秒。所以实际分配时要考虑隔离,比如:
gpu_ring->work:处理命令提交完成后的软恢复,可以ordered。gpu_fence->work:处理fence回调,涉及用户态唤醒,可以独立到per-device workqueue。gpu_badpage->work:GPU异常恢复,这是低频任务,用system_wq也未尝不可。
很多驱动实际会为每个device创建一个priv->wq,然后所有fence回调都放在里面。这样设备卸载时,我们可以通过cancel_work_sync或flush_workqueue来清空所有未处理的任务,保证没有遗留工作项。
4.3 实操示例:中断里只用workqueue做fence唤醒
我来写一个典型的GPU中断处理流程,体现工作队列的使用方式:
struct gpu_fence_work { struct work_struct work; struct gpu_fence *fence; }; static void gpu_fence_work_func(struct work_struct *work) { struct gpu_fence_work *fw = container_of(work, struct gpu_fence_work, work); gpu_fence_signal(fw->fence); } irqreturn_t gpu_irq_handler(int irq, void *dev_id) { struct gpu_device *gdev = dev_id; u32 status = gpu_read_status(gdev); struct gpu_fence_work *fw = &gdev->fence_work; // 快速检查中断来源 if (!(status & GPU_IRQ_FENCE)) return IRQ_NONE; // 记录fence状态,但不在这里做复杂清理 gdev->fence_completed = gpu_read_fence_id(gdev); // 唤醒具体等待的进程,也可以触发work if (gdev->need_callback) schedule_work(&fw->work); return IRQ_HANDLED; }注意,gpu_fence_signal在work_func里执行,意味着它运行在进程上下文,可以安全调用up()甚至wake_up_all,还可以稍后再做内存回收。这比直接在中断里调用sleep类函数安全得多。但work本身无法被无限排队,每次中断只触发一个work,如果中断频率超过work执行频率,有些fence信号可能“合并”了。因此,在work函数里,我们要把当前状态和fence_completed一起检查,确保所有已完成fence都被处理。
5. 三者联动与GPU异步任务处理实战
5.1 一次GPU任务从ioctl到中断的完整生命周期
现在我们把前面三个手段串起来,看一个完整任务如何处理,这才是真正的“异步处理”设计。
用户态调用ioctl(fd, AMDGPU_CS, args)提交命令。进入内核后,驱动执行如下步骤:
- 在进程上下文创建任务结构体,初始化fence,信号量计数置0。
- 获取ring的自旋锁,更新wptr,把命令复制进ring buffer,释放自旋锁。
- 写门铃,通知GPU开始执行。
- 用户进程随即通过
ioctl调用wait_fence,进入内核态等待:down_interruptible_timeout(&fence->sem)。 - GPU执行命令,完成时写一个硬件fence值,并通过MSI中断通知CPU。
- 驱动中断处理函数读取fence完成值,调用
gpu_fence_signal,也就是up(&fence->sem)。 - 用户态等待返回,任务结束。
整个过程中,进程上下文负责提交和等待,中断上下文负责快速置完成标志并唤醒。自旋锁只保护wptr/rptr等极短临界区;信号量用来把“硬件完成”语义同步到软件等待者;工作队列用来补充更耗时的后续清理动作。三者没有重叠冲突,因为自旋锁不会在中断处理里长期占用,信号量的up可以在中断里执行,而工作队列中的清理操作绝不在中断里做。
5.2 怎么把自旋锁、信号量、工作队列组合起来而不出死锁
组合使用这三个东西时,最容易出的问题就是锁顺序不一致。在内核里有个叫做lockdep的调试机制,它会自动检测出“环形依赖”。比如:
- 持有A锁的时候,去拿B锁;
- 持有B锁的时候,又去拿A锁。
这就会导致死锁。GPU KMD里的锁层级其实很清晰,我通常按这样的顺序拿锁:
- ring lock(自旋锁)
- fence lock(可能是自旋锁或mutex)
- workqueue本身不参与锁,但工作队列函数里如果要拿锁,则统一在函数开头拿,最好不再嵌套。
如果某个work函数需要访问ring指针,那就必须先把ring自旋锁拿上,处理完马上释放,绝不在work函数里再持有另一个锁去拿ring锁。我在实际调试中遇到过一个死锁,就是一个kworker线程在调用gpu_ring_submit时,试图拿ring的自旋锁,而同时用户线程在gpu_fence_wait里持有着fence锁去等待queue work执行。这个等待顺序把fence锁排在自旋锁前面,但中断里又反过来,让lockdep直接报错。后来我们把fence等待独立出去,才解决。
5.3 性能与正确性的权衡:锁粒度怎么选
锁粒度也值得单独说说。自旋锁粒度太大,会导致命令提交的吞吐量暴跌;粒度太小,又可能造成数据竞争。一般我们对ring buffer的wptr/rptr操作使用同一个自旋锁,因为它们在逻辑上高度耦合,且单次操作极快。如果你试图把一个提交任务拆成多个锁,反而可能因为多次加锁/解锁的原子操作让吞吐量更差。
信号量的粒度则体现在等待时长的误判上。不要在持有自旋锁的情况下调用down,因为down可能睡眠。拿锁的顺序一定要先规划好:先自旋锁保护指针,再释放,再进入睡眠等待。如果拿着自旋锁后想直接睡,这绝对是大忌。而工作队列的粒度则体现在“把多少逻辑放进同一个work里”。如果把所有fence回调都丢进同一个work,CPU核数虽多但workqueue只能串行跑,GPU完成后驱动处理会成为瓶颈。我通常按PCIe的功能切片(比如不同的执行队列)拆成多个workqueue,这样并发度更高。
6. 常见问题与排查技巧实录
6.1BUG: scheduling while atomic:信号量还是自旋锁的锅?
内核新人最常在日志里看到BUG: scheduling while atomic。这个错误一般是你在原子上下文里调用了睡眠函数。举例来说,如果你在持有自旋锁时调用down_interruptible_timeout,就必然触发这个bug。排查思路很简单:看调用栈里spin_lock是否在down之前调用,如果是,就把等待逻辑移到释放锁之后,或者把锁改成mutex。在GPU KMD里,这个错误的罪魁祸首往往是在提交路径里想同时做“检查ring空间”和“等待空间足够”,你就不自觉地先拿锁再等待了。正确做法是:先算一下预估空间,如果不够,就放掉自旋锁,进入睡眠等待,等空间确实够时再重新拿锁并再次检查。
6.2hung task timeout:信号量没被唤醒怎么办
另一个常见问题就是进程D状态卡死,日志里报告hung task timeout。这多半是信号量或fence等待没有完成。排查思路是去中断处理函数里断GPU是否真的产生了中断。我经历过一种情况:GPU执行命令时固件崩溃,中断永远不来,用户态调用down_interruptible_timeout等到超时函数返回非0,但错误处理路径没写好,导致进程陷入僵尸状态。解决方法是,所有等待都必须有超时,并且超时后要触发GPU恢复流程,而不是停留在原处继续等待。
如果确认中断来了,但信号量计数没变,那问题可能出在中断处理里没有提供正确的完成值。比如GPU完成的是fence A,中断处理却只检查了fence B。这时用ftrace对gpu_fence_signal做调用跟踪,很快就能比对出完成值和等待值不一致。
6.3 lockdep报出死锁:怎么顺着报告修正锁顺序
lockdep是内核给驱动开发者最好的礼物。当你在运行驱动时开了lockdep(一般debug config会默认开),一旦检测到潜在死锁,它会打印一大串possible circular locking dependency detected。虽然报错信息看着吓人,但只要抓住两点:它列出的是锁依赖链的起点和终点。你把每个锁标记上“哪里拿,哪里放”,然后调整代码顺序,保证所有路径的锁获取顺序一致即可。
我遇到过最经典的一个GPU锁依赖问题:某驱动在gpu_job_free里拿job_lock,再调gpu_free_idle去拿ring_lock;而gpu_ring_submit里先拿ring_lock,再去查job_lock。这两个路径反向,lockdep立刻报警。修正方法很简单,把gpu_job_free里的job_lock放到ring_lock之前或之后,不一刀切,但必须统一成先ring_lock再job_lock,把所有反向的路径改成一致。
6.4 使用ftrace和内核事件跟踪中断到唤醒的完整链路
最后分享一个我在调试GPU异步处理时常用的工具组合:ftrace。你可以用trace-cmd record -e 'irq_handler_entry' -e 'irq_handler_exit'抓中断入口,也可以用trace-cmd record -e 'workqueue:workqueue_execute_start'来跟踪work队列执行。把中断、fence signal、workqueue执行时间点拉同一条时间轴上对比,能很直观地看到:
- 中断什么时候发生;
- 中断处理函数有没有卡住;
- 信号量
up是否在所有等待进程被唤醒前发生; - workqueue延迟了多少毫秒才执行。
有一次我们优化GPU尾延迟性能,就是用这个组合发现workqueue调度延迟竟然占了整个使用时间的30%。后来把fence signal从workqueue里单独提出来,直接在中断里做,同时把复杂的回调保留在workqueue,这才把延迟降下去。这就是工具的价值。
在内核驱动开发这个行当待久了,我越来越觉得同步与并发才是真正的门槛。自旋锁、信号量、工作队列每一个单独看都不算难,难的是把它们放在GPU驱动的真实场景里,理解它们各自的边界,然后组合成一条不会死锁、不会长时间卡住的高效异步链路。我个人实际调试中最大的体会是:先保证正确,再谈性能。用lockdep把锁依赖检查干净,所有等待都有超时,所有work都可以被flush和cancel,这三点做到,哪怕驱动不完美,基本也不会惹出难以定位的大乱子。希望这篇内容能让你在看完之后,对自己驱动的异步处理之路有更清晰的把握。