ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

零基础学GPU内核驱动:自旋锁、信号量与工作队列的并发实践

零基础学GPU内核驱动:自旋锁、信号量与工作队列的并发实践 对大部分刚接触内核态编程的人而言尤其是像我一样盯着 GPU 驱动源码发过呆的人一上来拦住去路的往往不是那些晦涩的寄存器而是另外一件事自旋锁、信号量、工作队列这些同步原语到底怎么用、什么时候用哪一把。GPU KMDGPU 内核态驱动之所以在这件事上特别难是因为驱动面对的不是一个按部就班的硬件而是两条同时前进的时间线——CPU 在提交命令GPU 在疯狂执行指令两者之间隔着环形缓冲区、原子操作和扇小小的门铃寄存器。这篇是零基础学 GPU KMD系列第 6 章第 2 节内容聚焦内核态编程里的同步与并发把自旋锁、信号量、工作队列放在 GPU 任务异步处理的真实场景里讲不光讲 API更讲清楚为什么这里该用锁、那里该排队、中断来了该做什么。无论你是刚开始读 amdgpu 代码还是已经在内核驱动里写过几个字符设备这节内容都能帮你把零散的并发概念串成一条完整的链路。1. GPU 异步模型为什么内核驱动必须要管好并发1.1 CPU 和 GPU两条时间线的交汇先建立一个最关键的认知模型GPU 和 CPU 不是主从关系而是两个独立的执行单元。CPU 通过 PCIe 总线把命令写入显存中的环形缓冲区ring buffer然后向一个寄存器写入数据门铃 doorbellGPU 收到门铃后才开始取命令、执行命令。执行过程中 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 任务完成状态的载体。一个命令提交后会分配一个 fenceGPU 在完成时更新 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_lockTASK_UNINTERRUPTIBLE用户进程就只能干等着连 CtrlC 都杀不掉最后变成一个 D 状态僵尸进程留在系统里。用 interruptible 版本等待锁的进程可以被信号唤醒返回 -ERESTARTSYS 或者 -EINTR用户态就能正常处理中断了。然后在写驱动的时候一定要给自己定一个铁律锁顺序永远一致。比如你规定先拿 ctx_lock再拿 ring-lock那所有路径都必须按这个顺序来。GPU 驱动里特别容易出现提交路径拿 A 再拿 B重置路径拿 B 再拿 A这样的反向顺序一旦两条路径在并发时相遇就是教科书级的 ABBA 死锁。这类问题在测试时很难稳定复现但开着 CONFIG_PROVE_LOCKINGlockdep跑一轮压力测试往往五分钟内就能暴露。最后一点经验之谈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.compatiblenone之类的配置没什么用你需要的是编译时开启 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 的一生追完一遍再回头看锁你会看到完全不一样的风景。
返回列表