高性能自旋锁 MCS Spinlock 的设计与实现

来源:互联网 发布:讲文明知礼仪例子 编辑:程序博客网 时间:2024/04/30 19:06

高性能自旋锁 MCS Spinlock 的设计与实现

自旋锁(Spinlock)是一种在 Linux 内核中广泛运用的底层同步机制。排队自旋锁(FIFO Ticket Spinlock)是 Linux 内核 2.6.25 版本中引入的一种新型自旋锁,它解决了传统自旋锁由于无序竞争导致的“公平性”问题。但是由于排队自旋锁在一个共享变量上“自旋”,因此在锁竞争激烈的多核或 NUMA 系统上导致性能低下。MCS Spinlock 是一种基于链表的高性能、可扩展的自旋锁,本文详细剖析它的原理与具体实现。

林 昊翔 (linhaoxiang@hotmail.com), Linux 内核爱好者

秦 君, 高级软件工程师, IBM

2011 年 1 月 24 日 (最初于 2008 年 9 月 30 日)

  • expand内容

引言

自旋锁(Spinlock)是一种在 Linux 内核 [1] 中广泛运用的底层同步机制,长期以来,人们总是关注于自旋锁的安全和高效,而忽视了自旋锁的“公平”性。排队自旋锁(FIFO Ticket Spinlock)是内核开发者 Nick Piggin 在Linux Kernel 2.6.25 版本中引入的一种新型自旋锁,它通过保存执行线程申请锁的顺序信息解决了传统自旋锁的“不公平”问题 [4]。

排队自旋锁仍然使用原有的 raw_spinlock_t 数据结构,但是赋予 slock 域新的含义。为了保存顺序信息,slock 域被分成两部分,低位部分保存锁持有者的票据序号(Ticket Number),高位部分则保存未来锁申请者的票据序号。只有 Next 域与 Owner 域相等时,才表明锁处于未使用状态(此时也无执行线程申请该锁)。排队自旋锁初始化时 slock 被置为 0,即 Owner 和 Next 置为 0。内核执行线程申请自旋锁时,原子地将 Next 域加 1,并将原值返回作为自己的票据序号。如果返回的票据序号等于申请时的 Owner 值,说明自旋锁处于未使用状态,则直接获得锁;否则,该线程忙等待检查 slock 的 Owner 部分是否等于自己持有的票据序号,一旦相等,则表明锁轮到自己获取。线程释放锁时,原子地将 Owner 域加 1 即可,下一个线程将会发现这一变化,从忙等待状态中退出。线程将严格地按照申请顺序依次获取排队自旋锁,从而完全解决了“不公平”问题。

但是在大规模多处理器系统和 NUMA系统中,排队自旋锁(包括传统自旋锁)存在一个比较严重的性能问题:由于执行线程均在同一个共享变量 slock 上自旋,申请和释放锁的时候必须对 slock 进行修改,这将导致所有参与排队自旋锁操作的处理器的缓存变得无效。如果排队自旋锁竞争比较激烈的话,频繁的缓存同步操作会导致繁重的系统总线和内存的流量,从而大大降低了系统整体的性能。

MCS Spinlock 的原理

为了解决自旋锁可扩展性问题,学术界提出了许多改进版本,其核心思想是:每个锁的申请者(处理器)只在一个本地变量上自旋。MCS Spinlock [2] 就是其中一种基于链表结构的自旋锁(还有一些基于数组的自旋锁)。MCS Spinlock的设计目标如下:

  1. 保证自旋锁申请者以先进先出的顺序获取锁(FIFO Ordering)。
  2. 只在本地可访问的标志变量上自旋。
  3. 在处理器个数较少的系统中或锁竞争并不激烈的情况下,保持较高性能。
  4. 自旋锁的空间复杂度(即锁数据结构和锁操作所需的空间开销)为常数。
  5. 在没有处理器缓存一致性协议保证的系统中也能很好地工作。

MCS Spinlock采用链表结构将全体锁申请者的信息串成一个单向链表,如图 1 所示。每个锁申请者必须提前分配一个本地结构 mcs_lock_node,其中至少包括 2 个域:本地自旋变量 waiting 和指向下一个申请者 mcs_lock_node 结构的指针变量 next。waiting 初始值为 1,申请者自旋等待其直接前驱释放锁;为 0 时结束自旋。而自旋锁数据结构 mcs_lock 是一个永远指向最后一个申请者 mcs_lock_node 结构的指针,当且仅当锁处于未使用(无任何申请者)状态时为 NULL 值。MCS Spinlock 依赖原子的“交换”(swap)和“比较-交换”(compare_and_swap)操作,缺乏后者的话,MCS Spinlock 就不能保证以先进先出的顺序获取锁,从而可能造成“饥饿”(Starvation)。

图 1. MCS Spinlock 示意图
MCS Spinlock 示意图

MCS Spinlock 申请操作描述如下:

  1. 申请者 B 使用原子交换操作将自旋锁 mcs_lock 指向自己的mcs_lock_node 结构以确定在链表中的位置,并返回 mcs_lock原来的值 pre_mcs_lock。即使多个执行线程同时申请锁,由于交换操作的原子性,每个执行线程的申请顺序将会被唯一确定,不会出现不一致的现象。
  2. 如果 pre_mcs_lock 为 NULL,表明锁无人使用,B 立即成为锁的拥有者,申请过程结束。
  3. 如果 pre_mcs_lock 不为 NULL,则表明 pre_mcs_lock 指向申请者 B 的直接前驱 A 的 mcs_lock_node 结构,因此必须通过pre_mcs_lock 来修改 A 的 next 域指向 B 自己,从而将链表构建完整。
  4. 然后 B 一直在自己的mcs_lock_node 结构的 waiting 域上自旋。当 B 的直接前驱 A 释放自旋锁时,A 只须通过 next 域将 B 的 waiting 域修改为 0 即可。

MCS Spinlock 释放操作描述如下:

  1. 释放自旋锁时,锁的拥有者 A 必须十分小心。如果有直接后继 B,即 A 的 mcs_lock_node 结构的 next 域不为 NULL,那么只须将 B 的 waiting 域置为 0 即可。
  2. 如果 A 此时没有直接后继,那么说明 A “可能”是最后一个申请者(因为判断是否有直接后继和是否是最后一个申请者的这两个子操作无法原子完成,因此有可能在操作中间来了新的申请者),这可以通过使用原子比较-交换操作来完成,该操作原子地判断 mcs_lock 是否指向 A 的 mcs_lock_node 结构,如果指向的话表明 A 是最后一个申请者,则将mcs_lock 置为 NULL;否则不改变 mcs_lock 的值。无论哪种情况,原子比较-交换操作都返回 mcs_lock 的原值。
  3. 如果A 不是最后一个申请者,说明中途来了新的申请者 B,那么 A必须一直等待 B 将链表构建完整,即 A 的 mcs_lock_node 结构的 next 域不再为 NULL。最后 A 通过 next 域将 B 的 waiting 域置为 0。

MCS Spinlock 的实现

目前 Linux 内核尚未使用 MCS Spinlock。根据上节的算法描述,我们可以很容易地实现 MCS Spinlock。本文的实现针对x86 体系结构(包括 IA32 和 x86_64)。原子交换、比较-交换操作可以使用带 LOCK 前缀的 xchg(q),cmpxchg(q)[3] 指令实现。

为了尽量减少工作量,我们应该重用现有的自旋锁接口[4]。下面详细介绍 raw_spinlock_t 数据结构,函数__raw_spin_lock、__raw_spin_unlock、 __raw_spin_is_locked 和 __raw_spin_trylock 的实现。

raw_spinlock_t 数据结构

MCS Spinlock 的申请和释放操作需要涉及同一个mcs_lock_node 结构,这个mcs_lock_node 结构独立于锁的数据结构。为了重用 Linux Kernel 现有的自旋锁接口函数,我们使用了一个简单的方法,在raw_spinlock_t 数据结构中为每个处理器预备一个 mcs_lock_node 结构(因为申请自旋锁的时候会关闭内核抢占,每个处理器上至多只有一个执行线程参与锁操作,所以只需要一个 mcs_lock_node)。在 NUMA 系统中,mcs_lock_node 结构可以在处理器所处节点的内存中分配,从而加快访问速度。为简化代码,本文的实现使用 mcs_lock_node 数组。

清单 1. raw_spinlock_t 数据结构
typedef struct _mcs_lock_node {volatile int waiting;struct _mcs_lock_node *volatile next;} ____cacheline_aligned_in_smp mcs_lock_node;typedef mcs_lock_node *volatile mcs_lock;typedef struct {mcs_lock slock;mcs_lock_node nodes[NR_CPUS];} raw_spinlock_t;

因为 waiting 和 next 会被其它处理器异步修改,因此必须使用 volatile 关键字修饰,这样可以确保它们在任何时间呈现的都是最新的值。加上 ____cacheline_aligned_in_smp 修饰在多处理器环境下会增加mcs_lock_node 结构的大小,但是可以使其按高速缓存管线(cache line)大小对齐以消除 False Sharing[5]。这是因为由于 mcs_lock_node 结构比较小,每个等待的处理器在自己的 mcs_lock_node 的 waiting 域上自旋的时候,相邻处理器的 mcs_lock_node 结构会一齐放在同一个高速缓存管线中(一般 L1,L2 的高速缓存管线为 64 字节),一旦锁拥有者处理器在释放锁阶段修改其直接后继的 waiting 域时,会无效化整个高速缓存管线,因此可能造成一些后续等待者处理器的相应高速缓存管线也被迫更新,增加了系统总线的无谓开销。

__raw_spin_lock 函数

清单 2. __raw_spin_lock 函数
static __always_inline void __raw_spin_lock(raw_spinlock_t *lock){int cpu;mcs_lock_node *me;mcs_lock_node *tmp;mcs_lock_node *pre;cpu = raw_smp_processor_id();                              (a)me = &(lock->nodes[cpu]);tmp = me;me->next = NULL;pre = xchg(&lock->slock, tmp);                              (b)if (pre == NULL) {/* mcs_lock is free */return;                                                (c)}me->waiting = 1;                                               (d)smp_wmb();                                                      (e)pre->next = me;                                                (f)while (me->waiting) {                                         (g)asm volatile (“pause”);}}
  1. raw_smp__processor_id() 函数获得所在处理器的编号,用以索引 mcs_lock_node 结构。但是此处直接使用 raw_smp__processor_id() 函数会有头文件循环依赖的问题。这是因为 raw_smp_processor_id 在 include/asm-x86/smp.h 中实现,该头文件最终会包含 include/asm-x86/spinlock.h,即 __raw_spin_lock 所在的头文件。我们可以简单地将 raw_smp__processor_id() 的代码复制一份到 spinlock.h 中来解决这个小问题。
  2. 将 lock->slock 指向本地的 mcs_lock_node 结构,使用原子交换操作。因为 me 变量随后还要使用,故用一局部变量 tmp 与 lock->slock 互换值。
  3. 锁处于空闲状态,直接返回。
  1. 增加“write memory barrier”,确保对 waiting 的赋值语句 (d) 一定在 (f) 语句之前完成。这是因为 (d) 和 (f) 没有相关性,处理器可以乱序执行。如果处理器 A 先执行 (f),然后另一个处理器 B 马上执行 __raw_spin_unlock 函数的 (d) 语句,最后 A 执行 (d),这将导致 A 永远跳不出 (g) 处的循环,造成死锁。
  2. 设置前驱的 next 指针。
  3. 在本地 waiting 域上自旋。在忙等待中插入 pause 指令可以优化处理器的执行效率 [3]。

__raw_spin_trylock 函数

清单 3. __raw_spin_trylock 函数
static __always_inline int __raw_spin_trylock(raw_spinlock_t *lock){int cpu;mcs_lock_node *me;cpu = raw_smp_processor_id();me = &(lock->nodes[cpu]);me->next = NULL;if (cmpxchg(&lock->slock, NULL, me) == NULL)             (a)return 1;elsereturn 0;}
  1. 该函数的语义是:如果锁空闲,则获得锁并返回 1;否则直接返回 0。当且仅当 lock->slock 为 NULL 时表明锁空闲,所以使用原子比较-交换操作测试lock->slock 是否为 NULL,如是则与 me 变量交换值。

__raw_spin_unlock 函数

清单 4. __raw_spin_unlock 函数
static __always_inline void __raw_spin_unlock(raw_spinlock_t *lock){int cpu;mcs_lock_node *me;mcs_lock_node *tmp;cpu = raw_smp_processor_id();me = &(lock->nodes[cpu]);tmp = me;if (me->next == NULL) {                                      (a)if (cmpxchg(&lock->slock, tmp, NULL) == me) {   (b)/* mcs_lock I am the last. */return;}while (me->next == NULL)                            (c)continue;}/* mcs_lock pass to next. */me->next->waiting = 0;                                       (d)}
  1. 判断是否有后继申请者。
  2. 判断自己是否是最后一个申请者,若是的话就将 lock->slock 置为 NULL。
  3. 中途来了申请者,自旋等待后继申请者将链表构建完成。
  4. 通知直接后继结束自旋。

__raw_spin_is_locked 函数

清单 5. __raw_spin_is_locked 函数
static inline int __raw_spin_is_contended(raw_spinlock_t *lock){return (lock->slock != NULL);                               (a)}
  1. lock->slock 为 NULL 就表明锁处于空闲状态。

总结

MCS Spinlock 是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,直接前驱负责通知其结束自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销。笔者使用 Linux 内核开发者 Nick Piggin 的自旋锁压力测试程序对内核现有的排队自旋锁和 MCS Spinlock 进行性能评估,在 16 核 AMD 系统中,MCS Spinlock 的性能大约是排队自旋锁的 8.7 倍。随着大规模多核、NUMA 系统的广泛使用,MCS Spinlock 一定能大展宏图。

参考资料

学习

  • 参考文章:False sharing。
  • 大家可以从 kernel.org 下载 Linux Kernel 2.6.25 Source Code。
  • 《 Algorithms for Scalable Synchronization on Shared-Memory Multiprocessors 》,John M. Mellor-Crummey and Michael L. Scott。这篇论文提出了 MCS Spinlock。
  • Intel® 64 and IA-32 Architectures Software Developer's Manuals描述了 Intel 64 和 IA-32 处理器的架构和编程环境。
  • 《Linux 内核的排队自旋锁 (FIFO Ticket Spinlock) 》,林昊翔、秦君。这篇文章详细介绍了 Linux 内核排队自旋锁的设计与实现。
  • 在 developerWorks Linux 专区 寻找为 Linux 开发人员(包括 Linux 新手入门)准备的更多参考资料,查阅我们 最受欢迎的文章和教程。
  • 在 developerWorks 上查阅所有 Linux 技巧 和 Linux 教程。
  • 随时关注 developerWorks 技术活动和网络广播。

讨论

  • 欢迎加入 My developerWorks 中文社区。

条评论

请 登录 或 注册 后发表评论。

注意:评论中不支持 HTML 语法


剩余 1000 字符

 共有评论 (2)

honkiko, 文章中的代码那样写有2个原因:1. 在线程没有前驱的情况下避免了一次无用的赋值以及cache invalidation。2. 不同的体系结构实现了不同的memory consistency model,因此xchg原子指令并不总是保证一个memory write barrier(对于IA架构的处理器而言,原子指令隐含memory read and write barriers)

PS: IA架构的处理器不会乱序执行内存写操作,因此smb_wmb被定义成compiler barrier,没有什么开销。

由 damm 于 2013年02月21日发布

把me->waiting = 1;挪到xchg的前面,可以省掉smp_wmb()而同样保证me->waiting和pre->next的write order。
另外,这两者的order似乎不需要保证,乱序也不会出现问题。

0 0
原创粉丝点击
热门问题 老师的惩罚 人脸识别 我在镇武司摸鱼那些年 重生之率土为王 我在大康的咸鱼生活 盘龙之生命进化 天生仙种 凡人之先天五行 春回大明朝 姑娘不必设防,我是瞎子 开淘宝店客服怎么办 买到翻新机器怎么办 捡的貔貅需要怎么办 阿里巴巴店铺宝贝侵权怎么办 被告ktv歌曲侵权怎么办 淘宝盗买家图怎么办 淘宝身份复核不过怎么办 商家不退押金怎么办 保证金单子丢了怎么办 商场不退押金怎么办 淘宝直播前期没人怎么办 孕产妇保健手册怎么办 淘宝新店橱窗少怎么办 客服被客户骂怎么办 跟客户说错话怎么办 如果店家拒绝退款怎么办 淘宝发错地址怎么办 店家没发货退款怎么办 淘宝被拒绝退款怎么办 蛲虫药膏买不到怎么办 买家号违规了怎么办 淘宝违规扣分了怎么办 快递包裹出现异常怎么办 农行卡状态异常怎么办 农行卡无法使用怎么办 牙根下面有囊肿怎么办 qq登录异常是怎么办 马云创业失败怎么办 身上还有20块钱怎么办 如果一分钱都没想创业怎么办 投资云联惠的钱怎么办 钱宝网投资的钱怎么办 投资钱要不回来怎么办 面对选择总纠结怎么办 如果客户说没钱怎么办 淘宝客订单失效怎么办 喜宝米粉不含铁怎么办 淘宝直播一直卡怎么办 淘宝直播很卡怎么办 贷款没办法还怎么办 淘宝直播间被主播禁言了怎么办?