4.1 目的

自旋锁是在Linux内核中实现的,以保护数据结构,并允许锁持有者专有地访问受自旋锁保护的结构。

自旋锁的设计既快速又简单。

只允许有限的锁嵌套(任何嵌套都需要正确记录!),没有死锁防止机制,也没有显式的争用管理。

存在多种自旋锁类型来处理并发中断或底部处理程序。

自旋锁的这些专门版本在此不予讨论,因为它们会使描述过于复杂。

主要用于界定关键部分的两个自旋锁功能是:

spin_lock(spinlock_t *lock);

spin_unlock(spinlock_t *lock);

显示自旋锁用法的典型代码示例:

spin_lock(&mmlist_lock);

list_add(&dst_mm->mmlist, &src_mm->mmlist);

spin_unlock(&mmlist_lock);

获取mmlist_lock以保护mmlist。

然后执行对mmlist的操作(添加了另一个list元素)-这是不能同时运行的关键部分-并且再次释放了锁(请注意,此处讨论的列表不是RCU列表!)。

自旋锁可确保列表上没有并发操作。

这包括前面讨论过的无锁列表操作无法提供的并发 writer 和 reader。

实现(Implementation)

请在以下讨论中牢记,自旋锁保护数据结构。

对于自旋锁保护哪些数据元素,通常应该隐含理解,应该在自旋锁的声明附近的注释中的某个位置记录这些数据元素。

例如,以下是 task_struct 定义的摘录:

/* Protection of (de-)allocation: mm, files, fs, tty, keyrings */
spinlock_t alloc_lock;

/* Protection of proc_dentry: nesting proc_lock, dcache_lock, write_lock_irq(&tasklist_lock); */
spinlock_t proc_lock;

/* context-switch lock */
spinlock_t switch_lock;

常见的误解是认为自旋锁保护了关键部分。

可能存在多个关键部分,可以操纵相同的数据,并因此出于各种目的使用相同的锁来获得对数据结构的排他性访问。

自旋锁是使用0(未锁定)或1(已锁定)的单个32位值在Itanium上实现的。

使用CMPXCHG从0-> 1进行状态更改。

然后,需要一个读取屏障来确保受锁保护的数据(在获取锁时可能已更改的数据)会被处理器重新读取,以便获得可能完成另一个关键部分后的状态。

解锁仅仅是防止在锁看来可用之前其他人可以看到锁中所做的修改的写障碍。

然后将零写入锁中。

如果尝试使用CMPXCHG获取锁失败,那么我们将进入等待循环。

ACMPXCHG要求处理器获取包含锁的高速缓存行,以进行独占访问。

如果操作失败,则持有该锁的其他处理器很可能会读取或写入高速缓存行中的数据,因此必须将独占访问权转移回持有该锁的处理器,从而导致高速缓存行来回弹跳。

如果仅在发生故障时重试CMPXCHG,则很有可能高速缓存行将在持有锁的处理器与处理器(实际上是处理器,因为多个处理器可能需要该锁!)之间连续跳动,试图获取该锁,直到锁被锁定为止。 由另一个处理器获取。

为了限制高速缓存行的跳动,重试仅读取锁定值,然后在锁定值的内容不为零时使用常规加载指令等待。

如果内容为零,则尝试另一个CMPXCHG获取锁。

可以通过共享的缓存行读取锁定值。

然后,多个处理器可以使用共享的缓存行同时等待更改为锁定状态。

这样做的目的是避免在第一个CMPXCHG之后出现任何附加的缓存行反弹。

但是,自旋锁的持有者对高速缓存行的任何写入都需要再次获得对高速缓存行的排他访问。

然后,试图获取锁的处理器将立即将高速缓存行强制返回共享模式,因为它们都在读取循环中旋转。

因此,每次写入高速缓存行时都会产生不同类型的高速缓存行反弹。

在竞争激烈的环境中,最好将锁与锁保护的数据放置在不同的缓存行中。

linux-spinlock

如果释放了竞争激烈的锁,则在进行简单读取时,多个节点可能会看到该锁变为零。

节点将获得相同的共享缓存行,且锁定值为零。

然后,所有处理器将同时尝试CMPXCHG来获取该锁,这将导致多个高速缓存行反弹,因为每个处理器都需要对高速缓存行进行独占访问以执行原子信号量操作,然后才能开始使用读取进行旋转。

在每个 CMPXCHG 之后,每个处理器都需要再次以共享模式获取高速缓存行。

4.3 效能

现有的自旋锁实现有很多优点。

自旋锁由于其简单性而非常有效。

一条指令通常足以获取和释放锁。

已知该方案对于有限数量的处理器而言效果很好。

但是,自旋锁通常只能保持很短的一段时间。

释放和重新获取很频繁。

获取和发布将始终要求所有参与的处理器获取对缓存行的独占访问权。

争夺相同锁的大量处理器会增加NUMA互连上的流量,直到高速缓存行协商活动使链接饱和为止,这将导致整个系统的性能显着下降。

性能

图1显示了在pagefault处理程序中用于匿名页面错误的时间。

页面错误处理程序通常两次获取page_table_lock。

黄色是每个故障在故障处理程序中花费的总时间。

红色是分配页面的时间(可能包括获取另一个我们在本次讨论中不会考虑的自旋锁),蓝色是将页面清零的时间。

对于一个处理器和两个处理器,在错误处理程序中花费的时间主要取决于在提供应用程序访问权限之前必须将页面调零。

当除归零和分配所花费的时间增加时,情况对于4个处理器略有变化。

这是尝试获取page_table_lock所花费的时间。

如果有8个处理器争用该锁,则将花费超过50%的处理时间来获取该锁,这意味着处理器忙于使高速缓存行来回反弹,而在完成预期的工作方面没有取得太大进展。

随着处理器数量的增加,花费在锁获取上的时间呈指数增长。

该图不包含超过16个处理器的条形图,因为它们不再适合页面。

自旋锁最多只能有效使用4个处理器。

超过4个处理器后,系统将在锁定获取上花费大量资源。

大部分时间将花在弹跳包含锁定的高速缓存行上。

这包括获取对缓存行的独占访问权以及将缓存行转换为共享模式。

小结

本文主要讲述了自旋锁的实现原理,最后的性能对比展示了自旋锁表现不佳的问题,为后续的新内容做好铺垫。

参考资料

linux 锁实现