您当前的位置:首页 > 生活 > 内容

什么叫自旋锁(自旋锁的发展历史与使用方法)

什么叫自旋锁(自旋锁的发展历史与使用方法)

作者:程雷,一线码农,在一家手机公司做系统开发工程师。他喜欢每天研究内核的基本原理。

目录

一、自旋锁的发展历史

二、原始旋转锁

2.1定义和初始化2.2锁定操作2.3解锁操作三、银行号旋转锁

3.1定义和初始化3.2锁定操作3.3解锁操作四、MCS自旋锁

4.1定义和初始化4.2锁定操作4.3解锁操作五、队列自旋锁

5.1定义和初始化5.2锁定操作5.3解锁操作六、自旋锁的使用

6.1自旋锁的应用场景6.2结合禁用伪并发使用自旋锁6.3使用raw _ spin _ lock

一、自旋锁的发展历史自旋锁是Linux内核中最常用的锁之一。自旋锁的概念很简单,就是如果锁定失败,是用休眠等待还是忙等待?如果是忙着等待,那就是自旋锁,这也是自旋锁名字的由来。自旋锁的逻辑是自旋锁保护的临界区要足够小,不可能在临界区睡觉。因此,当自旋锁失败时,意味着其他关键区域正在被执行。因为自旋锁的临界区足够小,不会休眠,我们可以等待其他临界区退出,所以不需要休眠,因为休眠涉及很多操作。如果我们忙着等,对方很快就会退出临界区,我们就能很快得到自旋锁。

自旋锁与UP和SMP的关系;

按照自旋锁的逻辑,自旋锁的临界区是不能休眠的。在UP之下,只有一个CPU。如果我们执行到临界区,自旋锁此时是不可能被锁定的。因为我们在占用CPU,而且没有其他CPU,所以其他临界段要么还没到,要么已经执行了。所以我们肯定可以得到自旋锁,所以自旋锁对于UP来说没有意义。但为了UP和SMP下的代码一致性,UP下也有自旋锁,只是自旋锁的定义变成了空结构,自旋锁的锁定操作退化为禁用抢占,自旋锁的解锁操作退化为打开抢占。因此,自旋锁仅适用于SMP,但它也提供UP下的兼容操作。

起初,自旋锁的实现非常简单。后来随着众核时代的到来,自旋锁的公平性成了大问题,于是内核实现了票自旋锁来保证锁的公平性。后来发现票号的自旋锁存在很大的性能问题,于是努力解决自旋锁的性能问题。首先开发了MCS自旋锁,确实解决了性能问题,但是改变了自旋锁的接口,所以没有办法替代。然后有人修改了MCS自旋锁,开发了队列自旋锁。队列自旋锁既解决了自旋锁的性能问题,又保持了自旋锁原有的接口,非常完美。现在内核使用的自旋锁是队列自旋锁。让让我们用一张图来总结一下自旋锁(x86平台)的历史。

注意:MCS自旋锁被锁定在内核中,但由于不兼容的接口和卷问题,它不取代票号自旋锁。

我们会按照自旋锁的发展顺序一步步来解释。本文中的代码都是按照x86平台来解释的,删除了代码,删除了一些调试数据或者无关的数据和代码。

二、原自旋锁我们在《深入理解Linux线程同步》讲了简单自旋锁。原来自旋锁的原理和它一样,只是实现细节更复杂。本节解释内核版本2.6.24的代码。

2.1定义和初始化

让让我们先来看看原始自旋锁的定义。

Linux-src/include/Linux/spin lock _ types . h

typedef struct { raw _ spin lock _ t raw _ lock;} spin lock _ t;

对于删除,所有与调试相关的配置数据都已被删除。

Linux-src/include/ASM-x86/spin lock _ types . h

typedef struct { unsigned int slock;} raw _ spin lock _ t;

我们可以看到,原来自旋锁的定义很简单,本质上就是一个无符号整数。

让让我们看看自旋锁变量的定义和初始化。自旋锁的定义和初始化可以分为静态和动态。静态意味着自旋锁在编译时分配空间并初始化数据,这通常是一个全局自旋锁变量。动态意味着自旋锁是在运行时创建的,然后用函数初始化。在这种情况下,自旋锁嵌入在一个结构中,并随着该结构的创建而创建,因此它需要用一个函数进行初始化。

静态定义和初始化如下:

Linux-src/include/Linux/spin lock _ types . h

# DEFINE DEFINE _ SPIN LOCK(x)SPIN LOCK _ t x=_ _ SPIN _ LOCK _ UNLOCKED(x)# DEFINE _ _ SPIN _ LOCK _ UNLOCKED(lockname)(SPIN LOCK _ t){。RAW _ LOCK=_ _ RAW _ SPIN _ LOCK _ UNLOCKED }

Linux-src/include/ASM-x86/spin lock _ types . h

# define _ _ RAW _ SPIN _ LOCK _ UNLOCKED { 1 }

自旋锁的动态初始化如下:

Linux-src/include/Linux/spin lock . h

# define SPIN _ LOCK _ init(LOCK)do { *(LOCK)=SPIN _ LOCK _ UNLOCKED;} while (0)

这里的Do while(0)用于将宏视为函数。当我们调用一个函数时,我们总是加一个;分号,如果没有do while(0),我们在末尾加一;分号,语法不对。如果你不添加它,宏不会这看起来不像函数调用。有了do while(0),这个问题就解决了。

静态初始化在编译时给变量赋值,而动态初始化在运行时给变量赋值。

2.2锁定操作

让让我们来看看旋转锁的锁定操作:

Linux-src/include/Linux/spin lock . h

# define spin _ lock(lock)_ spin _ lock(lock)

linux-src/kernel/spinlock.c

void _ _ lock func _ spin _ lock(spin lock _ t * lock){ preempt _ disable();LOCK _ contained(LOCK,_raw_spin_trylock,_ raw _ spin _ LOCK);}

Linux-src/include/Linux/lock dep . h

# define LOCK _ contained(_ LOCK,try,lock) lock(_lock)

Linux-src/include/Linux/spin lock . h

# define _ raw _ spin _ lock(lock)_ _ raw _ spin _ lock((lock)-raw _ lock)

Linux-src/include/ASM-x86/spin lock _ 32 . h

静态内联void _ _ raw _ spin _ lock(raw _ spin lock _ t * lock){ ASM volatile(1:'锁定前缀decb % 0 'jns 3f '2:''repnop 'cmpb $0,% 0 'jle 2b 'jmp 1b '3:' 'm (锁锁):记忆);}

Linux-src/include/ASM-x86/spin lock _ 64 . h

静态内联void _ _ raw _ spin _ lock(raw _ spin lock _ t * lock){ ASM volatile(1:'锁定前缀decl % 0 'jns 2f '3:''repnop 'cmpl $0,% 0 'jle 3b 'jmp 1b '2:' '=m (锁锁):记忆);}

可以看到,spin_lock的最终实现是__raw_spin_lock,在架构代码里。在x86上,分为32位和64位实现,都使用嵌入式汇编代码。对于嵌入式程序集,可以查询gcc 的官方文件GCC嵌入式汇编语言。

这两个嵌入式汇编代码的意思是一样的,只是比较晦涩,所以我们把它们转换成C代码。

静态内联void _ _ raw _ spin _ lock(raw _ spin lock _ t * lock){ while(1){ if(-lock-slack==0)//汇编代码中有一个锁指令前缀,这个操作是原子的返回;while((int)lock-slock=0){} }}

转换成C代码后就很好理解了。原来的自旋锁用1表示没有锁。在无限循环中,我们首先原子性地将锁变量减1,比较是否等于0。如果等于0,说明刚才锁变量是1,现在变成了0。我们已经成功锁定,所以直接返回。如果锁变量不等于0,也就是说刚才锁变量是0,现在是负数,那么我们就无限循环,直到锁变量小于等于0,也就是等于1,结束这个循环,在大循环中再次尝试锁。为什么要把锁变量强制成int?因为lock变量的定义是无符号数,而且在汇编代码中是作为有符号数使用的,所以加一个int强制。为什么内循环小于等于0而不是小于0?首先,当我们第一次到达内循环的时候,意味着我们没能抓住锁,锁变量必须小于0。在内循环的执行过程中,如果有人释放了锁,又有人马上去抢,那么锁变量还是0。这个时候我们结束内循环去抢锁是没有意义的,抢锁肯定会失败,回到内循环。因此,只有当锁变量大于0,即等于1时,才意味着锁是空闲的,此时结束内循环是有意义的。

2.3解锁操作

让让我们来看看解锁操作:

Linux-src/include/Linux/spin lock . h

# define spin _ unlock(lock)_ spin _ unlock(lock)

linux-src/kernel/spinlock.c

void _ _ lock func _ spin _ unlock(spin lock _ t * lock){ _ raw _ spin _ unlock(lock);preempt _ enable();}

Linux-src/include/Linux/spin lock . h

# define _ raw _ spin _ unlock(lock)_ _ raw _ spin _ unlock((lock)-raw _ lock)

Linux-src/include/ASM-x86/spin lock _ 32 . h

静态内联void _ _ raw _ spin _ unlock(raw _ spin lock _ t * lock){ ASM volatile(movb $1,% 0 'm (锁锁):记忆);}

Linux-src/include/ASM-x86/spin lock _ 64 . h

静态内联void _ _ raw _ spin _ unlock(raw _ spin lock _ t * lock){ ASM volatile(movl $1,% 0 '=m (锁锁):记忆);}

可以看出,解锁操作也是在架构代码中实现的,也使用了嵌入式汇编代码。这段代码比较简单,就是把锁变量赋值为1,我们就不转换成C代码了。

三、票号自旋锁在看了上面的原始自旋锁实现后,我们发现自旋锁没有排队机制。如果有很多人争夺锁,谁能拿到锁是不确定的。在CPU核数还比较少的时候,这个问题并不突出,内核也没有解决。后来随着CPU核越来越多,核越来越复杂,锁竞争越来越激烈,自旋锁的不公平性越来越突出。有人做过一个实验。在8核CPU计算机上,一些线程没有不要连续一百万次获得自旋锁。这种不公平太严重了,迫切需要解决自旋锁的公平性问题。

为了解决自旋锁的公平性问题,内核开发了票号自旋锁。它的原理和我们去银行办业务差不多。以前,没有叫号机。我们每个人都盯着业务窗口,发现一个人走了,马上就一窝蜂过去,谁抢到位置谁就依次来办业务。人多的时候,有的人可能早上十点就来了,但是他们没有下午5: 00没有机会。如何做到这一点?它太不公平了,所以银行买了一台呼叫机。进来的人都先取个号,然后坐着等。业务员做完一个人会广播下一个要处理的票号的业务。大家要时刻关注广播,当发现广播里叫的号码和自己手里的号码一样时,就轮到自己做生意了。

票号自旋锁实现时,原自旋锁的整形变量被拆分成两部分,一部分是owner,代表当前服役的票号,另一部分是next,代表下一个得到该号码的人的号码。每次锁的时候,先取数,定义一个局部变量int ticket=next。你取的数就是next的值,然后在next的值上加1。然后你不断的比较你的票和拥有者的价值。如果不相等,你会一直比较,直到相等。如果它们相等,则锁定成功,因此它是你做生意的时候了。做生意相当于临界区。做生意后离开临界区时,解锁自旋锁意味着owner加1。这时候如果别人在转,他加1后发现车主等于自己的票,就结束了转,说明他已经成功锁了锁。让让我们画一张图来演示:

票号旋转锁的状态与原始旋转锁的状态非常不同。原始旋转锁为1表示未锁定,为0表示锁定。它可以看不出有多少线程在排队等待锁。票号是spin锁定的,owner和next相等意味着解锁,不一定等于0。next和owner之差等于排队等待锁的线程数。

让让我们用内核4.1版来解释一下。在x86的实现中,所有者称为head,下一个称为tail。事实上,它这样称呼它是有道理的。从头到尾,恰好是所有锁定的人的队列。head是队列的头,并且已经获得了锁。tail是队列的尾部,是下一个来的人的序号。

3.1定义和初始化

让让我们先来看看票号自旋锁的定义:

Linux-src/include/Linux/spin lock _ types . h

typedef struct spin lock { struct raw _ spin lock r lock;} spin lock _ t;typedef struct raw _ spin lock { arch _ spin lock _ t raw _ lock;} raw _ spin lock _ t;

Linux-src/arch/x86/include/ASM/spin lock _ types . h

typedef struct arch _ spin lock { union { _ _ ticket pair _ t head _ tail;struct _ _ raw _ tickets { _ _ ticket _ t head,tail}门票;};} arch _ spin lock _ t;# if(CONFIG _ NR _ CPU(256/_ _ TICKET _ LOCK _ INC))typedef u8 _ _ TICKET _ t;typedef u16 _ _ ticket pair _ t;# elsetypedef u16 _ _ ticket _ t;typedef u32 _ _ ticket pair _ t;#endif

此外,这里删除了一些调试数据代码。Spinlock包含raw_spinlock,raw_spinlock包含arch_spinlock_t,之前只有两层。S pinlock是对外接口,raw _ spinlock是各种架构的实现。为什么现在又多了一个arch _ s pinlock _ t?原因和RTLinux有关。为了配合RTLinux的实现,Linus决定将原来的raw_spinlock改为arch_spinlock,将原来的spinlock改为raw _ spinlock,然后实现一个新的spinlock,以包含raw_spinlock。这样,arch_spinlock就是每个架构的实现。标准Linux下spinlock和raw_spinlock的含义没有区别,但在RTLinux下是不同的。详情请见第6.3节的解释。

arch_spinlock中使用了公共体,可以将头尾作为一个变量处理,也可以将它们分别作为两个变量处理。

让让我们来看看票号旋转锁的初始化。

静态初始化如下:

Linux-src/include/Linux/spin lock _ types . h

# DEFINE DEFINE _ SPIN LOCK(x)SPIN LOCK _ t x=_ _ SPIN _ LOCK _ UNLOCKED(x)# DEFINE _ _ SPIN _ LOCK _ UNLOCKED(lockname)(SPIN LOCK _ t)_ _ SPIN _ LOCK _ INITIALIZER(lockname)# DEFINE _ _ SPIN _ LOCK _ INITIALIZER(lockname){。rlock=_ _ RAW _ SPIN _ LOCK _ INITIALIZER(LOCK name)} } # define _ _ RAW _ SPIN _ LOCK _ INITIALIZER(LOCK name){。raw _ LOCK=_ _ ARCH _ SPIN _ LOCK _ UNLOCKED }

Linux-src/arch/x86/include/ASM/spin lock _ types . h

# define _ _ ARCH _ SPIN _ LOCK _ UNLOCKED { { 0 } }

动态初始化如下:

Linux-src/include/Linux/spin lock . h

# define spin _ lock _ init(_ lock)do { raw _ spin _ lock _ init((_ lock)-rlock);} while(0)# define RAW _ SPIN _ LOCK _ init(LOCK)do { *(LOCK)=_ _ RAW _ SPIN _ LOCK _ UNLOCKED(LOCK);} while (0)

Linux-src/include/Linux/spin lock _ types . h

# define _ _ RAW _ SPIN _ LOCK _ UNLOCKED(lockname)(RAW _ SPIN LOCK _ t)_ _ RAW _ SPIN _ LOCK _ INITIALIZER(lockname)# define _ _ RAW _ SPIN _ LOCK _ INITIALIZER(lockname){。raw _ LOCK=_ _ ARCH _ SPIN _ LOCK _ UNLOCKED }

静态初始化和动态初始化都将票号的自旋锁初始化为{head:0,tail:0}的状态。相同的头部和尾部表示该锁当前未被锁定。

3.2锁定操作

下面我们来看一下票号自旋锁的加锁操作:

Linux-src/include/Linux/spin lock。h

静态内联void spin _ lock(spin lock _ t * lock){ raw _ spin _ lock(lock-r lock);} # define raw _ spin _ lock(lock)_ raw _ spin _ lock(lock)

Linux-src/内核/锁定/自旋锁。c

void _ _ lock func _ raw _ spin _ lock(raw _ spin lock _ t * lock){ _ _ raw _ spin _ lock(lock);}

Linux-src/include/Linux/spin lock _ API _ SMP。h

静态内联void _ _ raw _ spin _ lock(raw _ spin lock _ t * lock){ preempt _ disable();LOCK _ contained(LOCK,do_raw_spin_trylock,do _ raw _ spin _ LOCK);}

inux-src/include/Linux/lock dep。h

# define LOCK _ contained(_ LOCK,try,lock) lock(_lock)

Linux-src/include/Linux/spin lock。h

静态内联void do _ raw _ spin _ lock(raw _ spin lock _ t * lock)_ _ acquisites(lock){ arch _ spin _ lock(lock-raw _ lock);}

Linux-src/arch/x86/include/ASM/spin lock。h

static _ _ always _ inline void arch _ spin _ lock(arch _ spin lock _ t * lock){ register struct _ _ raw _ tickets Inc={ .tail=TICKET _ LOCK _ INC };inc=xadd(lock-tickets,Inc);if(可能(Inc . head==Inc . tail))goto out;for(;){无符号计数=SPIN _ threshold do { Inc . head=READ _ ONCE(锁票。头);if (__tickets_equal(inc.head,Inc . tail))goto clear _ slow path;CPU _ relax();} while(-count);__ticket_lock_spinning(lock,Inc . tail);} clear _ slow path:_ _ ticket _ check _ and _ clear _ slow path(lock,Inc . head);out:barrier();}

通过层层调用,自旋锁最终调用架构下的函数拱形旋转锁。拱形_旋转_锁定函数和我们前面讲的逻辑是一样的,只不过是代码实现需要稍微解释一下。首先定义了一个局部变量股份有限公司公司的初始值是尾巴为1,然后通过xadd函数把自旋锁的尾巴加1,并返回原自旋锁的值,xadd函数是原子操作,此时得到的股份有限公司的尾巴值就是我们的票号门票。先判断一下我们的车票(包括车尾)是否和所有者(公司负责人)相等,如果相等代表我们加锁成功了,去外面。如果不相等,就进入一个无限为循环,不停地读取锁票。头的值,和自己的票比较,如果不相等就一直比较,如果相等则代表我们加锁成功了,退出循环。为了避免其它代码有问题而产生死锁,上述操作是在为循环里面又加了个做什么循环,只循环一定的次数。

3.3 解锁操作

下面我们看一下票号自旋锁的解锁操作:

Linux-src/include/Linux/spin lock。h

静态内联void spin _ unlock(spin lock _ t * lock){ raw _ spin _ unlock(lock-r lock);} # define raw _ spin _ unlock(lock)_ raw _ spin _ unlock(lock)

Linux-src/内核/锁定/自旋锁。c

void _ _ lock func _ raw _ spin _ unlock(raw _ spin lock _ t * lock){ _ _ raw _ spin _ unlock(lock);}

Linux-src/include/Linux/spin lock _ API _ SMP。h

静态内联void _ _ raw _ spin _ unlock(raw _ spin lock _ t * lock){ do _ raw _ spin _ unlock(lock);preempt _ enable();}

Linux-src/include/Linux/spin lock。h

静态内联void do _ raw _ spin _ unlock(raw _ spin lock _ t * lock)_ _ releases(lock){ arch _ spin _ unlock(lock-raw _ lock);}

Linux-src/arch/x86/include/ASM/spin lock。h

static _ _ always _ inline void arch _ spin _ UNLOCK(arch _ spin LOCK _ t * LOCK){ _ _ add(LOCK-tickets。head,TICKET_LOCK_INC,UNLOCK _ LOCK _ PREFIX);}

解锁操作还是挺简单的,最终只是把头也就是物主加一了而已。

四、MCS自旋锁上面的票号自旋锁完美的解决了公平性问题,逻辑简单,代码简洁。但是还有一个严重的问题,就是锁竞争激烈的时候,大家都在旋转头变量,会导致缓存颠簸,严重降低CPU的性能。为了解决这个问题,我们应该设计一种锁,把所有等待锁的线程放在一个队列中,每个线程旋转自己的节点,这样就不会出现缓存碰撞问题,是一个公平锁。这就是MCS锁的设计方式。锁本身是一个指针,指向队列的末尾。每个申请加锁的线程都要自己创建一个锁节点,然后把自己放在这个队列的末尾让锁指针指向自己,最后在自己的节点上自旋。当一个线程被解锁时,查看它的下一个指针是否为空。如果不是空的,说明有人在等锁。将下一个节点设置为锁定状态,这样下一个线程将获得自旋锁。让让我们画一幅图来看看:

图片演示了MCS自旋锁的基本锁定和解锁操作,但是有一个细节没有考虑,将在代码中分析。

让用内核4.1版来解释。

4.1定义和初始化

让让我们先来看看MCS旋转锁的定义:

Linux-src/kernel/locking/MCS _ spin lock . h

struct MCS _ spin lock { struct MCS _ spin lock * next;int锁定;};

这个定义非常简单,没有复杂的嵌套定义。需要注意的是,MCS lock本身是struct mcs_spinlock *,是一个指针,而struct mcs_spinlock本身不是锁,只是锁定时的一个队列节点。我们称之为锁节点,与大多数锁不同。大多数锁只有一个锁变量,不需要锁线程分配一个锁节点,而MCS锁需要锁线程分配一个锁节点。

MCS自旋锁没有特定的初始化,只定义了一个空指针。

struct mcs _ spinlock * lock=NULL

空指针表示锁是空闲的。

4.2锁定操作

让让我们来看看MCS旋转锁的锁定操作:

Linux-src/kernel/locking/MCS _ spin lock . h

static inline void MCS _ spin _ lock(struct MCS _ spin lock * * lock,struct MCS _ spin lock * node){ struct MCS _ spin lock * prev;node-lock=0;node-next=NULL;prev=xchg(锁,节点);if(likely(prev==NULL)){ return;} WRITE_ONCE(prev-next,node);arch _ MCS _ spin _ lock _ contained(节点锁定);}

用这个线程的锁节点的地址值原子地交换锁变量的初始值,返回锁变量的初始值并保存到prev变量。如果prev的值是一个空指针,就意味着锁变量之前是空闲的。我们第一个锁定,直接拿到锁,直接返回。如果prev不为NULL,则意味着有人已经获得了锁。我们只能等待,让普雷夫下一个指针指向我们自己,然后我们自己旋转上锁。

4.3解锁操作

让让我们来看看解锁操作:

Linux-src/kernel/locking/MCS _ spin lock . h

static inline void MCS _ spin _ unlock(struct MCS _ spin lock * * lock,struct MCS _ spin lock * node){ struct MCS _ spin lock * next=READ _ ONCE(node-next);如果(有可能(!next)) { if (likely(cmpxchg(lock,node,NULL)==node))返回;while(!(next=READ _ ONCE(node-next)))CPU _ relax _ low latency();} arch _ MCS _ spin _ unlock _ contained(next-locked);}

先读出你的下一个指针。如果是空的,说明我们是最后一个线程,可以直接返回。但是在返回之前,将锁变量设置为空指针,这意味着锁现在是空闲的。但是这里并没有直接设置,而是使用了原子交换CAS。只有当锁变量指向自身时,锁变量才设置为null。这样做是为了避免与锁定操作冲突。如果操作成功,说明锁释放成功。直接返回。如果操作失败,则意味着一个线程正在同时执行锁定操作。它将设置我们的next指针指向它,然后旋转它的locked,所以我们必须等到我们的next被设置,也就是说,当它不为空时,才设置next-locked为1。如果我们的next指针一开始不为空,那么只需将next-locked设置为1。当下一个线程发现它的locked为1时,它将结束自旋,从而获得锁。

五、队列自旋锁MCS锁有一个很大的问题就是它改变了自旋锁的接口,这是一个很严重的问题。内核中使用自旋锁的地方很多,如果把所有的自旋锁都改成MCS自旋锁会很麻烦。同时,MCS的另一个问题是体积增大了,这也是一个非常严重的问题。为了解决MCS自旋锁的问题,内核开发了队列自旋锁。它结合了MCS锁的优点,但做了很大的改进,同时优化了锁竞争少的场景。队列锁优化MCS自旋锁的原理是一个系统最多有NR_CPU个自旋锁同时运行,不需要每个锁线程自己分配一个锁节点。我们可以在系统中全局预分配NR_CPU锁节点,使用对应的锁节点,CPU在其上执行自旋锁。这是针对只有线程的情况。其实还有软中断、硬中断、NMI,都可以抢占前者和线程,所以整个系统实际总共需要NR_CPU * 4个锁节点就够了。旋转锁还优化了只有两个线程获取锁的情况,在这种情况下不会使用MCS的排队逻辑。

让让我们用一个类比来讨论队列旋转锁的整体逻辑。我们把锁的位置比作王座,抢了王座就意味着锁定成功,进入临界区。第一个抢锁的人就是直接抢锁抢皇位。第二个来抢锁的人发现王座被抢了。他拿了退而求其次的东西,夺取了皇位,然后不停的旋转皇位。一旦皇帝驾崩,放弃皇位,就立刻夺取皇位。第三个来抢锁的人发现皇位和皇太子都被抢了。别无选择,只能抢占孙泰的位置,然后同时旋转王储和王位。当王位空缺时,王子将被取代王位。这时,王子职位空缺,但孙泰不会夺取王位。他将留在孙泰直到王位和王子同时空缺,他将直接从孙泰成为皇帝的宝座。第四个人来了,发现皇位、皇太子、孙儿都被抢走了,只能霸占皇位。从第四个人开始,后面来的都是孙子,所有的孙子依次排队等待被抱进孙子。事实上,孙泰可以被视为一个帝王的孙子。孙泰是第一皇孙,其他都是普通皇孙。孙凰也在转圈,只是孙凰在他自己的房子前面转圈。他一直在等最后一个外孙来自己家门口叫自己。当发现皇位和皇太子同时空缺,他就继承皇位,同时通知二帝孙,的皇位空缺,你可以当了。然后第二个皇帝的孙子成了玄孙,他成了玄孙之后,还同时去旋皇太子和皇位。当他成为皇帝后,他还会通知身后的第二个皇帝孙儿,让他接替孙儿的位置。然后,逻辑继续,后面来的人只有发现有孙儿,才能排队当孙儿,然后在自己家门前打转。在这个过程中,王储一直空缺。除非最后一个孙儿登基后发现没有孙儿,这个时候,没有人抱他成孙儿。如果这个时候别人抢了位子,王座还被占着,他就去抢王座。

上面说的逻辑很复杂。让让我们再总结一下。只有两个人抢锁的时候,一个人占据王位,也就是抢锁成功,一个人一边旋转王位一边占据王位。也就是说,当同时有少于或等于两个人抢锁时,不会使用排队机制。如果第三个人来抢锁,就会启动排队机制。他是队列的第一个,第一个孙儿,也叫孙泰,后面来的都是普通的孙儿,要依次排队。孙子们在自家门前旋转,等着以前的孙子们通知他们,他们是作为孙子被抬来的。孙泰的逻辑是最复杂的。他想同时旋转皇太子和皇位。只有当皇太子和皇位都空缺的时候,孙泰才会一步登天成为皇帝,然后通知第二个皇帝孙晋他就是孙泰。解锁的逻辑很简单,你只需要把王座设置为0,你不什么都不用担心,因为王子和孙子可以自己旋转王位。

自旋锁的实现是将原来的锁变量int分成三部分,一个锁字节,一个挂起字节,一个尾双字节,指向孙凰队列的末尾,孙凰队列的头是太孙。Tail不是指针,是逻辑指针,通过编码指向队列的末尾。每个孙对应一个锁节点。系统预分配NR_CPU * 4个锁节点,NR_CPU代表1个CPU。为什么要乘以4?因为一个CPU最多可以同时嵌套4个执行流,分别是线程、软中断、硬中断和无屏蔽中断。Tail有16位,分两部分编码,其中2位用于编码哪个执行流,14位用于编码CPU索引。CPU编码加1,因为CPU索引从0开始,tail等于0,有特殊含义。它代表一个空指针,也就是没有皇帝和孙子的竞争,所以加1来区分。当一个线程(执行流)在竞争一个锁的时候,如果皇太子被抢或者孙子太多,就需要加入皇孙队列。加入皇家孙子队列的方法是根据其CPU索引和其执行流级别得到一个锁节点,将锁节点加入队列,并旋转锁节点。

让让我们画一幅画来看看。

现在您应该熟悉队列旋转锁的逻辑了。让下面以内核版本5.15.28为例,看看队列旋转的代码实现。

5.1定义和初始化

让让我们先看看队列旋转锁的定义:

Linux-src/include/Linux/spin lock _ types . h

typedef struct spin lock { struct raw _ spin lock r lock;} spin lock _ t;

Linux-src/include/Linux/spin lock _ types _ raw . h

typedef struct raw _ spin lock { arch _ spin lock _ t raw _ lock;} raw _ spin lock _ t;

Linux-src/include/ASM-generic/qspinlock _ types . h

typedef struct qspinlock { union { atomic _ t val;struct { u8lockedu8待定;};struct { u16locked _ pendingu16tail};};} arch _ spin lock _ t;

可以看出,队列自旋锁的定义最终与原始自旋锁和票号自旋锁的大小相同。queue的自旋锁也使用了共享体的技术,将一个4字节的int拆分成1字节的locked、1字节的pending和2字节的tail。

让让我们来看看初始化,首先是静态初始化:

Linux-src/include/Linux/spin lock _ types . h

# DEFINE DEFINE _ SPIN LOCK(x)SPIN LOCK _ t x=_ _ SPIN _ LOCK _ UNLOCKED(x)# DEFINE _ _ SPIN _ LOCK _ UNLOCKED(lockname)(SPIN LOCK _ t)_ _ SPIN _ LOCK _ INITIALIZER(lockname)# DEFINE _ _ SPIN _ LOCK _ INITIALIZER(lockname){。rlock=_ _ _ SPIN _ LOCK _ INITIALIZER(LOCK name)} } # define _ _ _ SPIN _ LOCK _ INITIALIZER(LOCK name){。raw _ LOCK=_ _ ARCH _ SPIN _ LOCK _ UNLOCKED,}

Linux-src/include/ASM-generic/qspinlock _ types . h

# define _ _ ARCH _ SPIN _ LOCK _ UNLOCKED { {。val=ATOMIC_INIT(0) } }

看动态初始化。

Linux-src/include/Linux/spin lock . h

# define SPIN _ LOCK _ init(_ LOCK)do { *(_ LOCK)=_ _ SPIN _ LOCK _ UNLOCKED(_ LOCK);} while (0)

静态初始化或动态初始化都会将lock变量的整个int初始化为0。

5.2锁定操作

让让我们先来看看锁节点的定义和相关操作:

Linux-src/kernel/locking/qspinlock . c

struct qnode { struct MCS _ spin lock MCS;};static DEFINE _ PER _ CPU _ ALIGNED(struct q node,q NODES[MAX _ NODES]);#定义最大节点数4

可以看到,锁节点使用了MCS自旋锁的锁节点类型,然后定义了一个per CPU变量。每个CPU有4个节点,代表4层执行流、线程、软中断、硬中断和屏蔽中断。与MCS自旋锁不同,MCS自旋锁要求每个线程在申请锁时提供自己的锁节点,而队列自旋锁是预先定义的全局静态变量。每个执行流在申请锁时根据其CPU索引和执行流级别使用相应的锁节点,锁成功后锁节点默认放回。在使用锁节点的时候,执行一个查询操作就够了,不需要做什么就可以把锁节点放回去,这是由自旋锁的特性决定的。因为自旋锁可以 sleep,自旋锁的临界区是一口气执行的,它不会不能被其他线程切掉以申请旋转锁。一个CPU最左边嵌套了4层执行流,所以整个系统最多可以同时有NR_CPU * 4个自旋锁应用。所以系统预定义NR_CPU * 4个锁节点就够了。用的时候可以直接用,不用当你用完的时候就不用担心了。

让让我们看看锁节点的编码和搜索:

Linux-src/kernel/locking/qspinlock . c

/* *我们必须能够区分0:0处的无尾部和尾部,*因此将cpu号增加1。*/static inline _ _ pure u32 encode _ tail(int CPU,int idx){ u32 tail;TAIL=(CPU 1)_ Q _ TAIL _ CPU _ OFFSET;TAIL |=idx _ Q _ TAIL _ IDX _ OFFSET;返回尾部;} static inline _ _ pure struct MCS _ spin lock * decode _ TAIL(u32 TAIL){ int CPU=(TAIL _ Q _ TAIL _ CPU _ OFFSET)-1;int idx=(TAIL _ Q _ TAIL _ IDX _ MASK)_ Q _ TAIL _ IDX _ OFFSET;返回per_cpu_ptr(qnodes[idx])。mcs,CPU);}静态内联_ _ pure struct MCS _ spin lock * grab _ MCS _ node(struct MCS _ spin lock * base,int idx){ return((struct qnode *)base idx)-MCS;}

可以看到知道CPU索引和执行流级别可以编码tail,知道tail可以解码CPU索引和执行流级别,可以在全局变量qnodes中找到对应的锁节点。如果已经知道CPU索引对应的锁节点库,也可以根据执行流的级别找到对应的锁节点。

让让我们来看看队列旋转锁的锁定操作:

Linux-src/include/Linux/spin lock . h

static _ _ always _ inline void spin _ lock(spin lock _ t * lock){ raw _ spin _ lock(lock-r lock);} # define raw _ spin _ lock(lock)_ raw _ spin _ lock(lock)

Linux-src/kernel/locking/spin lock . c

void _ _ lock func _ raw _ spin _ lock(raw _ spin lock _ t * lock){ _ _ raw _ spin _ lock(lock);}

Linux-src/include/Linux/spin lock _ API _ SMP . h

静态内联void _ _ raw _ spin _ lock(raw _ spin lock _ t * lock){ preempt _ disable();L

linux-src/include/linux/lockdep.h

linux-src/include/linux/spinlock.h

linux-src/include/asm-generic/qspinlock.h

linux-src/include/asm-generic/qspinlock_types.h

linux-src/kernel/locking/qspinlock.c

加锁的时候要首先看一下是不是锁变量的整个int都是0,如果是的话,说明皇位、太子位、太孙位都是空的,锁现在是空闲的,没有任何人竞争,我们直接把锁变量设为1(用的是原子操作),代表我们抢锁成功,直接返回。如果整个锁变量不为0,说明存在锁竞争,我们要走慢速路径。

在慢速路径里,首先处理的是如果遇到太子正在登基,则自旋等待太子登基成功。然后查看太子位是否被占,如果被占,则goto queue,也就是进入皇孙排队流程(这个后面再讲)。如果太子位没被占,则尝试占领太子位。如果抢占太子失败,说明有其它线程也在抢太子位,我们抢失败了,则我们则goto queue,也就是进入皇孙排队流程(这个后面再讲)。如果抢占太子位成功,则自旋皇帝位,一直自旋到皇帝驾崩把锁置为0,则我们结束自旋,原子地占领皇位释放太子位,然后return。

接下来是皇孙排队逻辑,每一个新来的皇孙都要排到队尾。队尾是用锁变量中的tail来记录的。我们要先生成自己的队尾编码tail,找到自己对应的锁节点。此时再尝试一下加锁操作,因为有可能现在太子太孙皇位都是空的,如果尝试成功就结束流程,如果失败则继续往下走。然后原子地交换锁变量的tail和自己的tail,这样我们就成为新的队尾了。然后我们再看old tail,分两种情况,如果old tail是空,则说明我们是第一个皇孙,也就是太孙,走太孙逻辑,如果old tail不空,则说明我们是普通皇孙,走皇孙排队逻辑。我们先说皇孙排队逻辑。皇孙排队先解码old tail,找到其对应的锁节点prev,然后让prev的next指向自己,这样我们就加入了排队队列。然后我们就在自己家里自旋,也就是自旋自己的node->locked。我们的自旋是在等待prev先成为太孙,然后当他登基称帝之后,他就会来解除我们的自旋,然后我们就成为了太孙。

下面我们讲太孙的逻辑,太孙的来源有两种,一种是上面说的old tail为空,则我们直接就是太孙,是第一位太孙。第二种来源是普通皇孙进位为太孙。不管哪种来源的太孙,操作都是一样的。太孙首先自旋太子位和皇位,当太子位和皇位同时空缺的时候才会结束自旋。结束自旋之后,先看看自己是不是唯一的皇孙,如果是的话则原子地加锁。如果加锁成功则结束流程,如果加锁失败则说明刚才发生了冲突,又有了新的皇孙加入。如果自己不是唯一的皇孙或者又有新的皇孙加入,则自己先抢占皇位,然后通知next皇孙结束自旋,next皇孙就会成为新的太孙,继续执行太孙的流程。

5.3 解锁操作

下面我们看一下队列自旋锁的解锁操作:

linux-src/include/linux/spinlock.h

linux-src/kernel/locking/spinlock.c

linux-src/include/linux/spinlock_api_smp.h

linux-src/include/linux/spinlock.h

linux-src/include/asm-generic/qspinlock.h

linux-src/include/asm-generic/qspinlock.h

可以看到队列自旋锁的解锁确实很简单,只需要让出皇位也就是把locked字节设为0就可以了。

前面几节我们讲了自旋锁的发展历史,以及每一代自旋锁的实现原理。现在我们来讲一讲自旋锁的使用问题,包括自旋锁的适用场景、自旋锁与禁用伪并发的配合使用问题,还有spinlock_t、raw_spin_lock该如何选择的问题。

6.1 自旋锁的适用场景

内核里有很多同步措施,我们什么时候应该使用自旋锁呢,使用自旋锁应该注意什么呢?首先自旋锁适用那些临界区比较小的地方,具体要多小呢,并没有绝对的标准,我记的有的书上说要小于1000个指令或者100行代码。其次临界区内不能休眠,也就是不能有阻塞操作,如果临界区内某些函数调用可能会阻塞,那就不能使用自旋锁。使用自旋锁要注意的点也是临界区不能调用阻塞函数。但是很多时候并不太好判断,有些函数明显就是阻塞函数,肯定不能调用。但是有些函数自己不是阻塞的,而它层层调用的函数中有阻塞的,这就不太好发现了。

线程是可调度的,所以线程可以用互斥锁、信号量,也能用自旋锁。但是中断(包括硬中断和软中断)是不可调度的,也就是说,是不能休眠的,所以只能使用自旋锁。

6.2 自旋锁与禁用伪并发的配合使用

内核里有四种不同类型的执行流,线程、软中断、硬中断、NMI中断,前者不能抢占后者,但是后者能抢占前者。自旋锁能防止两个CPU同时进入临界区,但是并不能防止本CPU的临界区被高级的执行流所抢占。所以当两个关联临界区在不同类型的执行流的时候,只使用自旋锁是不够的,低级执行流还得临时禁止高级执行流的抢占才行。由于NMI中断是不可禁止的,而且NMI中断发生的概率非常低,一般我们的代码也不会与NMI中断发生关联,所以NMI中断就不考虑了。现在只剩下线程、软中断、硬中断三种情况了。组合下来有6种情况,我们依依说明。线程对线程,自旋锁内部已经禁用了线程抢占,所以两个线程之间的临界区直接使用自旋锁就可以了。线程对软中断,线程会被软中断抢占,所以线程中要自旋锁加禁用软中断,而软中断不会被线程抢占,所以软中断中只使用自旋锁就可以了。线程对硬中断,线程会被硬中断抢占,所以线程中要自旋锁加禁用硬中断,而硬中断不会被线程抢占,所以硬中断中只使用自旋锁就可以了。软中断对软中断,软中断中发生硬中断,硬中断返回时发现正在软中断中,不会再去执行软中断,只会排队软中断,所以软中断对软中断只使用自旋锁就可以了。软中断对硬中断,由于硬中断会抢占软中断,所以软中断中要禁用硬中断,硬中断中直接使用自旋锁就可以了。硬中断对硬中断,现在内核里已经禁止中断嵌套了,所以只使用自旋锁就可以了。

我们下面来看一下它们的接口与实现。

自旋锁并禁用软中断,软中断在这里就是下半部。

void spin_lock_bh(spinlock_t *lock)

void spin_unlock_bh(spinlock_t *lock)

实现如下,只分析加锁部分,解锁部分就不再分析了。

linux-src/include/linux/spinlock.h

inux-src/kernel/locking/spinlock.c

linux-src/include/linux/spinlock_api_smp.h

可以看到自旋锁的实现部分是一样的,只是加了一个禁用软中断的调用,禁用软中断本身也会禁用线程抢占,所以这里没有再去禁用抢占。

自旋锁并禁用硬中断,禁用软中断本身是带计数功能的,可以嵌套调用,但是禁用硬中断本身是没有计数的,不能嵌套调用,所以内核提供了两个版本,irq版lock会禁用中断,unlock会开启中断,irqsave版lock会禁用中断并保存现在的中断状态,unlock会恢复之前保存的中断状态。

void spin_lock_irq(spinlock_t *lock)

void spin_unlock_irq(spinlock_t *lock)

void spin_lock_irqsave(lock, flags)

void spin_unlock_irqsave(lock, flags)

实现如下,只分析加锁部分,解锁部分就不再分析了。

spin_lock_irq

linux-src/include/linux/spinlock.h

linux-src/kernel/locking/spinlock.c

linux-src/include/linux/spinlock_api_smp.h

spin_lock_irqsave

linux-src/include/linux/spinlock.h

linux-src/kernel/locking/spinlock.c

linux-src/include/linux/spinlock_api_smp.h

linux-src/include/linux/spinlock.h

可以看到自旋锁的实现部分是一样的,只是加了一个禁用硬中断和禁止抢占的调用。

6.3 raw_spin_lock的使用问题

可能很多人在看到内核代码时会感到有些奇怪,为啥有些地方用的是spinlock_t,有些地方用的却是raw_spinlock_t?raw_spinlock_t不是spinlock_t的实现细节吗,我们不是应该只使用接口性的东西,而不要使用实现性的东西吗?再仔细看spinlock_t和raw_spinlock_t的实质逻辑,好像也没啥区别啊?要回答这个问题,我们就要先从一件事情谈起,RTLinux。什么是RTLinux,什么是实时性?实时性是指一个系统对外部事件响应的及时性。很多嵌入式系统的OS都是实时OS,它们可以快速地对外部事件进行响应。这倒不是因为它们有多厉害,而是因为嵌入式系统都比较简单,它们面临的环境比较简单,要做的事情也比较简单,所以能做到及时性。而Linux是一个通用操作系统内核,通用这个词就代表Linux要面临很多情况,处理很多问题,所以就很难做到及时性。做到及时性最根本的一点就是要及时处理中断,因为中断代表的就是外部事件。但是在Linux内核里,有很多需要同步的地方都会禁用中断,这就导致中断不能及时响应。Linux在处理中断的时候也会禁用中断,Linux在这方面已经想了很多办法来解决,比如尽可能地缩小中断处理程序,把事情尽量都放到软中断或者线程里面去做。当很多中断处理的事情都被放到线程中去执行了,我们又面临着另外一个问题,如何尽快地让这些线程去抢占CPU立马获得执行。当一个非常不紧急的线程正好执行到自旋锁的临界区时,我们的非常着急的中断处理线程想获得CPU却没有办法,因为自旋锁的临界区不能休眠也就是说不可抢占,我们只能干等着。因此把自旋锁变得可休眠就成为了提高Linux的实时性的重要方法。为此Ingo Molnar等人开发了一个项目RTLinux,专门来提高Linux的实时性。其中一个很重要的方法就是把自旋锁替换为可休眠锁。但是有些临界区是确实不能休眠的,那怎么办呢?这些临界区就用raw_spinlock_t,raw_spinlock_t还保持原来的自旋语义,不会休眠。到目前为止(内核版本5.15.28),RTLinux还没有合入标准内核,所以目前的标准内核里raw_spinlock_t和spinlock_t效果是一样的。但是大家在内核编程的时候还是要尽量使用spinlock_t,除非你的临界区真的不能休眠,才去使用raw_spinlock_t。

审核汤梓红


声明:本文版权归原作者所有,转载文章仅为传播更多信息之目的,如作者信息标记有误,请第一时间联系我们修改或删除,谢谢。

上一篇: xperia系统官网(Xperiafirm能不能刷旧版本固件)

下一篇: 三星s5360手机套软(那位好心人 .. 求三星S5360 ROOT软件~~ 邮箱 sun163136@163.com)



猜你感兴趣

推荐阅读

网站内容来自网络,如有侵权请联系我们,立即删除! | 软文发布 | 粤ICP备2021106084号