十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

第 4 章 osq_lock:乐观自旋队列

第 4 章 osq_lock:乐观自旋队列 前三章的锁都有一个共同点它们是直接面向使用者的锁——你spin_lock、我read_lock拿到就进临界区。本章的osq_lockoptimistic spin queue lock乐观自旋队列锁却是个例外它几乎从不被业务代码直接调用而是深藏在mutex第 5 章和rwsem第 6 章内部充当它们乐观自旋的公共底座。要理解后两章睡眠锁“为什么明明能睡却先自旋一会儿”就必须先看懂这把垫在底下的队列锁。它的源码只有一个文件——kernel/locking/osq_lock.c——却浓缩了两个关键思想一是第 2 章qspinlock那套 MCS 队列的“各自本地自旋”二是本章新增的、qspinlock所没有的能力——中途可取消的自旋。说明以 x86-64 为主线。osq_lock与第 2 章的 MCS 同源建议对照 2.7 节阅读它的直接用户是mutex/rwsem本章 4.8 节会点明衔接处。4.1 什么是乐观自旋睡眠锁为何要先自旋要理解osq_lock存在的意义得先跳到它的用户——睡眠锁——面临的一个抉择。睡眠锁mutex/rwsem在争锁失败时本可以直接睡眠让出 CPU。但睡眠不是免费的一次“睡下去再被唤醒”要经历上下文切换、调度器排队、缓存失效开销往往是几百上千个时钟周期。如果锁的持有者此刻正在另一个 CPU 上运行而且临界区很短那么它很可能马上就放锁——这种情况下与其花大代价睡一觉不如像自旋锁那样原地转几圈等它放锁反而更快。这就是乐观自旋optimistic spinning乐观地赌“持锁者马上就好”先自旋等待只有当这个赌注不成立时持锁者也睡了、或自己该让出 CPU 了才退回去老老实实睡眠。但这里有个新问题如果多个 CPU 同时对同一把mutex做乐观自旋它们又会退化成第 2 章那个“所有人挤在一条缓存行上”的窘境。于是自然的想法是——给这些乐观自旋者也套一个 MCS 队列让它们各自在本地节点上自旋。osq_lock正是这个“专为乐观自旋定制的 MCS 队列”。4.2 它与第 2 章 MCS 的异同osq_lock和qspinlock内嵌的 MCS 队列是同源的核心机制一致等待者排成一条链各自自旋在自己的节点上前驱放锁时点亮后继的标志完成交棒。但二者有两处关键差异恰恰对应睡眠锁的特殊需求可取消cancellable。这是最本质的区别。qspinlock的 MCS 排上队就必须等到锁到手中途不能反悔。osq_lock却允许一个正在排队的自旋者中途退出队列——当它发现“不该再自旋了”需要重新调度或前驱被抢占不再运行就把自己从链上摘掉返回失败让上层睡眠锁转去睡眠。为支持这种摘链osq_lock的节点是双向链表同时有next和prev而第 2 章的 MCS 只需单向的next。每 CPU 只用一个节点。第 2 章qspinlock为每 CPU 准备了 4 个节点应对进程/软中断/硬中断/NMI 的嵌套。osq_lock只需一个因为睡眠锁不允许在中断上下文调用不存在嵌套抢占同一队列的问题而且自旋期间抢占是关闭的。看懂这两点再读源码就顺理成章了。4.3 数据结构一个尾指针 每 CPU 双向节点锁本身小得出奇include/linux/osq_lock.hstructoptimistic_spin_queue{atomic_ttail;};#defineOSQ_UNLOCKED_VAL(0)整把锁就是一个tail——队尾那个节点所在 CPU 的编号编码后。空闲时为OSQ_UNLOCKED_VAL0。注意它和第 2 章qspinlock的tail字段异曲同工锁字里存的不是“谁持锁”而是“队尾在哪”新来者靠它找到自己的前驱。节点则是每 CPU 一份kernel/locking/osq_lock.cstructoptimistic_spin_node{structoptimistic_spin_node*next,*prev;intlocked;/* 1 if lock acquired */intcpu;/* encoded CPU # 1 value */};staticDEFINE_PER_CPU_SHARED_ALIGNED(structoptimistic_spin_node,osq_node);next/prev构成双向链locked是本地自旋的那面标志前驱置 1 即交棒cpu存自己的编码编号。编码规则很简单——因为 0 被用作“无 CPU”所以真实 CPU 号一律加一staticinlineintencode_cpu(intcpu_nr){returncpu_nr1;}于是tail存的是cpu 1解码时减一即可通过decode_cpu拿到对应 CPU 的osq_node指针。4.4 加锁入队并在本地节点自旋osq_lock的主干和第 2 章 MCS 的入队几乎一模一样boolosq_lock(structoptimistic_spin_queue*lock){structoptimistic_spin_node*nodethis_cpu_ptr(osq_node);structoptimistic_spin_node*prev,*next;intcurrencode_cpu(smp_processor_id());intold;node-locked0;node-nextNULL;node-cpucurr;oldatomic_xchg(lock-tail,curr);if(oldOSQ_UNLOCKED_VAL)returntrue;prevdecode_cpu(old);node-prevprev;smp_wmb();WRITE_ONCE(prev-next,node);if(smp_cond_load_relaxed(node-locked,VAL||need_resched()||vcpu_is_preempted(node_cpu(node-prev))))returntrue;/* unqueue ... */逐步看初始化本地节点把curr用atomic_xchg换进lock-tail同时拿回旧尾old。这一步的xchg兼具 acquire 与 releaserelease 保证刚初始化的节点字段对他人可见acquire 配对解锁侧。若old OSQ_UNLOCKED_VAL说明队列原本是空的——当前 CPU 直接成为队头拿锁成功返回true。这是无争用快路径。否则解码出前驱prev设node-prev prev用smp_wmb()第 1 章 1.5 节的写屏障保证prev赋值先于下一步公布再WRITE_ONCE(prev-next, node)把自己挂到前驱后面。然后在本地节点的locked字段上自旋——smp_cond_load_relaxed等前驱交棒把它置 1。这一步和第 2 章 MCS 的arch_mcs_spin_lock_contended是同一思想各自盯自己的缓存行无争用。到此为止与qspinlock的 MCS 别无二致。真正的分水岭在第 4 步那个自旋条件里多出来的两项——need_resched()与vcpu_is_preempted(...)。4.5 可取消的自旋什么时候放弃排队回看第 4 步的自旋条件smp_cond_load_relaxed(node-locked,VAL||need_resched()||vcpu_is_preempted(node_cpu(node-prev)))smp_cond_load_relaxed会一直自旋直到括号里的条件为真。这里有三个出口VAL即node-locked变成非零——前驱交棒了拿锁成功函数返回true。need_resched()——当前任务被标记需要重新调度比如时间片用尽、有更高优先级任务就绪。继续赖在这儿自旋就是耽误正事应当放弃自旋、去睡眠。vcpu_is_preempted(...)——在虚拟化环境里前驱所在的虚拟 CPU 被宿主机抢占、根本没在运行。既然前驱都停摆了等它交棒遥遥无期也应当放弃。后两个条件为真时node-locked其实还是 0——自旋是被“中途叫停”的。这时函数不会返回true而是往下走进 unqueue退出队列逻辑最终返回false。这个false就是给上层睡眠锁的信号乐观自旋没赌赢请转去睡眠。这正是osq_lock区别于第 2 章 MCS 的灵魂——一把可以“半路下车”的队列锁。顺带点明一个第 1 章的呼应这里用的是smp_cond_load_relaxedrelaxed而非第 2 章交棒时的smp_cond_load_acquire。因为osq_lock只负责给乐观自旋者排队真正保护临界区的 acquire/release 语义在上层mutex的 owner 字段上队列本身不需要那份内存序用 relaxed 即可省一分是一分。把 4.4 与 4.5 两节合成一张图osq_lock“可取消”的精髓就一目了然——入队后在本地节点自旋然后从三个出口中选一拿锁成功或因need_resched/ 前驱 vCPU 被抢占而中途下车是否node-locked 变 1(前驱交棒)need_resched()或前驱 vCPU 被抢占osq_lock(lock)初始化本地节点old atomic_xchg(tail, curr)old 0?队列原本为空拿锁成功 return true挂到前驱后面WRITE_ONCE(prev-next, node)在本地 node-locked 上自旋smp_cond_load_relaxed自旋出口判定unqueue 三步摸链return false信号转去睡眠4.6 退出队列三步摘链“半路下车”并不轻松。当一个节点决定退出时它可能同时被前驱的解锁、后继的入队所牵动摘链必须小心处理这些并发。源码把它拆成注释里标注的三步此处只述其要细节可对照源码Step A——稳定前驱。用cmpxchg(prev-next, node, NULL)把自己从前驱的next上摘下。若这一步竞争输给了并发的解锁smp_load_acquire(node-locked)读到 1说明其实已经被交棒那就顺势返回true拿锁走人。Step B——稳定后继。用osq_wait_next()等到自己的后继稳定下来或者把lock-tail从自己改回前驱自己就是队尾的情形。Step C——接合链表。把前驱与后继直接连起来next-prev prev; prev-next next自己彻底脱链返回false。这套三步走的复杂度全部是为“可取消”这一个特性买单——第 2 章的 MCS 因为不可取消完全没有这段逻辑。这也从反面说明队列锁的复杂度往往来自它要支持的“反悔”能力。4.7 解锁与交棒osq_unlock与第 2 章 MCS 的放锁如出一辙voidosq_unlock(structoptimistic_spin_queue*lock){structoptimistic_spin_node*node,*next;intcurrencode_cpu(smp_processor_id());/* Fast path for the uncontended case. */if(atomic_try_cmpxchg_release(lock-tail,curr,OSQ_UNLOCKED_VAL))return;nodethis_cpu_ptr(osq_node);nextxchg(node-next,NULL);if(next){WRITE_ONCE(next-locked,1);return;}nextosq_wait_next(lock,node,OSQ_UNLOCKED_VAL);if(next)WRITE_ONCE(next-locked,1);}无争用快路径若队列里只有自己lock-tail仍等于curr用atomic_try_cmpxchg_release把tail换回OSQ_UNLOCKED_VAL一步收工。release 语义配对加锁侧的 acquire。有后继xchg取出后继指针WRITE_ONCE(next-locked, 1)点亮后继的本地标志——正在 4.4 节第 4 步自旋的后继立刻读到 1、结束自旋拿锁。这就是交棒。若next暂时还没挂上来就用osq_wait_next等它稳定再交棒。这三条分支画成判定树更直观是否是否后继还没挂稳是否osq_unlock(lock)tail 仍等于 curr?队列里只有自己atomic_try_cmpxchg_releasetail → 0收工next xchg(node-next, NULL)next 已挂上?WRITE_ONCE(next-locked, 1)点亮后继交棒osq_wait_next 等它稳定拿到 next?无后继结束4.8 它如何嵌进 mutex 与 rwsem绕了一大圈回到起点osq_lock到底怎么被用在mutex/rwsem的乐观自旋逻辑里模式大致是这样/* 伪代码示意 mutex 乐观自旋的骨架 */if(!osq_lock(lock-osq))gotosleep;/* 排队被取消转去睡眠 */while(锁的 owner 仍在另一个 CPU 上运行){if(try 一把 cmpxchg 抢到 owner)/* 第 1 章的 CAS */{osq_unlock(lock-osq);return成功;}if(need_resched())break;cpu_relax();}osq_unlock(lock-osq);gotosleep;/* 没抢到转去睡眠 */osq_lock在这里扮演的角色是给所有乐观自旋者排队同一时刻只有队头那个 CPU 在真正盯着mutex的 owner 字段做 CAS 抢锁其余的都安静地在各自本地节点上自旋不去骚扰 owner 那条缓存行。一旦osq_lock返回false该让出 CPU 了或自旋中发现 owner 睡了就跳出去走睡眠路径。乐观自旋的“乐观”体现在赌 owner 快放锁osq_lock则保证这场赌局是有序、可扩展、且随时能体面退出的。这套机制第 5、6 章会具体展开。4.9 误用与调试osq_lock是内核内部基础设施正常情况下驱动与子系统代码不应直接调用它而应通过mutex/rwsem。关于它的要点主要是理解性的它不能在中断上下文使用。整个设计前提就是“睡眠锁不从中断调用”因此每 CPU 只留一个节点。若误在中断里触发单节点会被并发嵌套破坏。自旋期间抢占必须关闭。osq_lock依赖“当前 CPU 在自旋期间不被调度走”这一前提用smp_processor_id()定位本地节点调用方在进入乐观自旋前会preempt_disable。返回值必须检查。osq_lock返回booltrue才是拿到false表示被取消、必须走后备的睡眠路径。忽略返回值会导致逻辑错误。调试上乐观自旋相关的争用与延迟可通过CONFIG_LOCK_STAT与内核锁事件跟踪trace_contention_*观测osq_lock本身不参与 lockdep 的死锁检测它不是一把会形成锁序环的“语义锁”只是排队设施。本章小结osq_lock不是给业务代码用的独立锁而是mutex/rwsem乐观自旋的底座当睡眠锁发现持锁者正在别的 CPU 上运行、很可能马上放锁时与其付出上下文切换的代价去睡眠不如先自旋等一等而osq_lock就是把这群乐观自旋者组织成 MCS 队列让它们各自在本地节点上自旋、不争同一条缓存行。它与第 2 章 MCS 同源但多了一个灵魂特性——可取消当need_resched()或前驱 vCPU 被抢占时自旋者能从队列中途摘链退出返回false把控制权交还给上层去睡眠。为支持这种摘链它的节点是双向链表加锁失败时要走 Step A/B/C 三步稳链。回到第 1 章的公式“锁 原子操作 屏障 等待策略”osq_lock的原子操作是atomic_xchg/cmpxchg屏障因“真正的临界区语义在上层”而降到 relaxed它真正的价值全在等待策略——一个可扩展、且随时能体面退出的乐观自旋队列。下一章我们就登堂入室看第一把睡眠锁mutex如何用 owner 指针、MUTEX_FLAG低位编码、以及这里打好的乐观自旋底座在自旋与睡眠之间取得平衡。
返回列表