GPU内核态驱动同步与并发:自旋锁、信号量与工作队列实战

发布时间:2026/10/3 3:20:46
GPU内核态驱动同步与并发:自旋锁、信号量与工作队列实战 做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是可中断的睡眠等待。在用户进程被CtrlC终止时这个函数会返回错误我们随后需要清理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-workGPU异常恢复这是低频任务用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可能是自旋锁或mutexworkqueue本身不参与锁但工作队列函数里如果要拿锁则统一在函数开头拿最好不再嵌套。如果某个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回调都丢进同一个workCPU核数虽多但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这三点做到哪怕驱动不完美基本也不会惹出难以定位的大乱子。希望这篇内容能让你在看完之后对自己驱动的异步处理之路有更清晰的把握。