引言

林 昊翔 (linhaoxiang@hotmail.com), Linux 内核爱好者
林昊翔,计算机科学硕士,毕业于清华大学计算机系,Linux 内核爱好者
秦 君 (qinjun@cn.ibm.com), 软件工程师, IBM

自旋锁(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 申请操作描述如下:

  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;
else
return 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 一定能大展宏图。

高性能自旋锁 MCS Spinlock 的设计与实现(来自IBM)相关推荐

  1. 自旋锁(spinlock)

    自旋锁(spinlock) 自旋锁与互斥锁有点类似,只是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋" ...

  2. Linux 内核同步(二):自旋锁(Spinlock)

    自旋锁 内核当发生访问资源冲突的时候,可以有两种锁的解决方案选择: 一个是原地等待 一个是挂起当前进程,调度其他进程执行(睡眠) Spinlock 是内核中提供的一种比较常见的锁机制,自旋锁是&quo ...

  3. Nginx学习之四-Nginx进程同步方式-自旋锁(spinlock)

    自旋锁简介 Nginx框架使用了三种消息传递方式:共享内存.套接字.信号. Nginx主要使用了三种同步方式:原子操作.信号量.文件锁. 基于原子操作,nginx实现了一个自旋锁.自旋锁是一种非睡眠锁 ...

  4. 自旋锁(spinlock) 解释得经典,透彻

    自旋锁与互斥锁有点类似,只是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名. 由于 ...

  5. php 自旋锁,自旋锁、排队自旋锁、MCS锁、CLH锁(转)

    自旋锁(Spin lock) 转:http://coderbee.net/index.php/concurrent/20131115/577 自旋锁是指当一个线程尝试获取某个锁时,如果该锁已被其他线程 ...

  6. 自己动手实现自旋锁(spinlock)

    大多数的并行程序都需要在底层使用锁机制进行同步,简单来讲,锁无非是一套简单的原语,它们保证程序(或进程)对某一资源的互斥访问来维持数据的一致性,如果没有锁机制作为保证,多个线程可能同时访问某一资源,假 ...

  7. 如何设计一款高性能分布式锁,实现数据的安全访问?

    查看原文:如何设计一款高性能分布式锁,实现数据的安全访问? 随着互联网技术的飞速发展,分布式已经成为一个绕不开的话题,分布式环境下,"高并发访问共享资源"的场景并不少见,带来的问题 ...

  8. 【转】自旋锁-SpinLock(.NET 4.0+)

    短时间锁定的情况下,自旋锁(spinlock)更快.(因为自旋锁本质上不会让线程休眠,而是一直循环尝试对资源访问,直到可用.所以自旋锁线程被阻塞时,不进行线程上下文切换,而是空转等待.对于多核CPU而 ...

  9. C++性能优化(十二)——自旋锁

    一.互斥锁 1.互斥锁简介 互斥锁属于sleep-waiting类型锁.Linux Kernel 2.6.x稳定版开始,Linux的互斥锁都是futex (Fast Usermode Mutex)锁. ...

最新文章

  1. Shell、内外部命令——Linux基本命令(2)
  2. VUE指令篇_不常用指令
  3. sql基础教程亚马逊_针对Amazon,Apple,Google的常见SQL面试问题
  4. 报名截止仅剩5天!50万冠军大奖,错过再等一年!
  5. 全国计算机一级会考分类汇总吗,全国计算机一级考试分布
  6. 正则表达式的贪婪匹配与懒惰匹配
  7. 读Zepto源码之Callbacks模块
  8. 世界最小的QI标准无线充电接收模块,亲测不错。
  9. 驾驶模拟器之CARLA篇:An Open Urban Driving Simulator
  10. 根据自己的词汇量阅读英语原著
  11. 【VB.net】大地测量——白塞尔大地解算程序设计
  12. c语言编写单片机密码锁程序,51单片机密码锁制作的程序和流程图(很详细)
  13. Android WiFi热点监听
  14. 黑苹果(Hackintosh)简单步骤教程
  15. install falled update incompatible
  16. 我的jquery之路(二)
  17. HTML中的大于号和小于号怎样用代码打出来?
  18. 深入浅出聊聊Java函数式编程思想
  19. 英语时态和完成时详解
  20. 服务器的表格修复原来的内容,数据恢复 数据修复 远程数据恢复 EXCEL文件修复 XLS表格修复 WORD文档修复 DOC档案修复 - 慧龙软件服务中心...

热门文章

  1. 解决mac安装anaconda后无法在命令行调用conda,jupyter等
  2. python模块化编程_Python模块化编程
  3. 红帽linux免费吗,红帽宣布面向16个系统以下的小型生产环境免费提供RHEL
  4. linux io测试陈旭,130242014076+陈旭+第2次实验(示例代码)
  5. 自揣允许域名访问,不允许IP访问的方法和意义
  6. 洛谷P1494 [国家集训队]小Z的袜子
  7. android崩溃拦截给出提示显示日志
  8. Visual Studio 安装OpenCV及问题总结
  9. TX2017秋招笔试题之编码
  10. 微信小程序开发-微信登陆流程