一种广泛应用的同步技术是加锁。当内核控制路径必须访问共享数据结构或进入临界区时,就需要为自己获取一把”锁”。由锁机制保护的资源非常类似于限制于房间内的资源,当某人进入房间时,就把门锁上。如果内核控制路径希望访问资源,就试图获取钥匙”打开门”。当且仅当资源空闲时,它才能成功。然后,只要它还想使用这个资源。门就依然锁着。当内核控制路径释放了锁时,门就打开,另一个内核控制路径就可以进入房间。
图5-1显示了锁的使用。5个内核控制路径(P0,P1,P2,P3和P4)试图访问两个临界区(C1和C2)。内核控制路径P0正在C1中,而P2和P4正等待进入C1。同时,P1正在C2中,而P3正在等待进入C2。注意P0和P1可以并行运行。临界区C3的锁现在打开着,因为没有内核控制路径需要进入C3。
自旋锁是用来在多处理器环境中工作的一种特殊的锁。如果内核控制路径发现锁”开着”,就获取锁并继续自己的执行。相反,如果内核控制路径发现锁由运行在另一个CPU上的内核控制路径”锁着”,就在周围”旋转”,反复执行一条紧凑的循环指令,直到锁被释放。
自旋锁的循环指令表示”忙等”。即使等待的内核控制路径无事可做,它也在CPU上保持运行。不过,自旋锁通常非常方便,因为很多内核资源只锁1毫秒的时间片段;所以说,释放CPU和随后又获得CPU都不会消耗多少时间。
一般来说,由自旋锁所保护的每个临界区都是禁止内核抢占的。在单处理器系统上,这种锁本身并不起锁的作用。自旋锁原语仅仅是禁止或启用内核抢占。请注意,在自旋锁忙等时间,内核抢占还是有效的,因此,等待自旋锁释放的进程有可能被更高优先级的进程替代。
在linux中,每个自旋锁都用spinlock_t结构表示,其中包含两个字段:
slock
该字段表示自旋锁的状态;值为1表示”未加锁”状态,而任何负数和0都表示”加锁”状态。
break_lock
表示进程正在忙等自旋锁。
表5-7所示的六个宏用于初始化,测试及设置自旋锁。所有这些宏都是基于原子操作的,这样可以保证即使有多个运行在不同CPU上的进程试图同时修改自旋锁,自旋锁也能够被正确地更新。
具有内核抢占的spin_lock宏
让我们来详细讨论用于请求自旋锁的spin_lock宏。下面的描述都是针对支持SMP系统的抢占式内核的。该宏获取自旋锁的地址sip作为它的参数,并执行下面的操作:
调用preempt_disable()以禁用内核抢占
调用函数__raw_spin_trylock(),它对自旋锁的slock字段执行原子性的测试和设置操作。该操作首先执行等价于下列汇编片段的一些指令:
movb$0,%a1
xchgb%al,slp->slock
汇编语言指令xchg原子性地交换8位寄存器%al和slp->slock指示的内存单元的内容。随后,如果存放在自旋锁中的旧值是正数,函数就返回1,否则返回0。
如果自旋锁中的旧值是正数,宏结束;内核控制路径已经获得自旋锁。
否则,内核控制路径无法获得自旋锁,因此宏必须执行循环一直到在其它CPU上运行的内核控制路径释放自旋锁。调用preempt_enable()递减在第1步递增了的抢占计数器。如果在执行spin_lock宏之前内核抢占被启用,那么其它进程此时可以取代等待自旋锁的进程。
如果break_lock字段等于0,则把它设置为1。通过检测该字段,拥有锁并在其它CPU上运行的进程可以知道是否有其它进程在等待这个锁。如果进程把持某个自旋锁的时间太长,它可以提前释放锁以便等待相同自旋锁的进程能够继续向前运行。
执行等待循环:
while(spin_is_locked(sip)&& slp->break_lock)
cpu_relax();
宏cpu_relax()简化为一条pause汇编语言指令。在Pentium4模型中引入了这条指令以优化自旋锁循环的执行。通过引入一个很短的延迟,加快了紧跟在锁后面的代码的执行并减少了能源消耗。pause与早先的80x86微处理器模型是向后兼容的,因为它对应rep;nop指令,也就是对应空操作。
跳转回第1步,再次试图获取自旋锁
非抢占式内核中的spin_lock宏
如果在内核编译时没有选择内核抢占选项,spin_lock宏就与前面描述的spin_lock宏有很大的区别。在这种情况下,宏生成一个汇编语言程序片段,它本质上等价于下面紧凑的忙等待:
1:lock;decbslp->lock
jns3f
2:pause
cmpb$0,slp->slock
jle2b
jmp1b
3;
汇编语言指令decb递减自旋锁的值,该指令是原子的,因为它带有lock字节前缀。随后检测符号标志,如果它被清0,说明自旋锁被设置为1,因此从标记3处继续正常执行。否则,在标签2处执行紧凑循环直到自旋锁出现正值。然后从标签1处开始重新执行,因为不检查其它的处理器是否抢占了锁就继续执行是不安全的。
spin_unlock宏
spin_unlock宏释放以前获得的自旋锁,它本质上执行下列汇编语言指令;
movb$1,slp->slock
并在随后调用preempt_enable(),注意,因为现在的80x86微处理器总是原子地执行内存中的只写访问,所以不使用lock字节。
读/写自旋锁
读/写自旋锁的引入是为了增加内核的并发能力。只要没有内核控制路径对数据结构进行修改,读/写自旋锁就允许多个内核控制路径同时读同一数据结构。如果一个内核控制路径想对这个结构进行写操作,那么它必须首先获取读/写锁的写锁,写锁授权独占访问这个资源。当然,允许对数据结构并发读可以提高系统性能。
图5-2显示有两个受读/写锁保护的临界区(C1和C2)。内核控制路径R0和R1正在同时读取C1中的数据结构,而W0正等待获取写锁。内核控制路径W1正对C2中的数据结构进行写操作,而R2和W2分别等待获取读锁和写锁。
每个读/写锁都是一个rwlock_t结构,其lock字段是一个32位的片段,分为两个不同的部分:
24位计数器,表示对受保护的数据结构并发地进行读操作的内核控制路径的数目。这个计数器的二进制补码存放在这个字段的0-23位。
“未锁”标志字段,当没有内核控制路径在读或写时设置该位,否则清0。这个”未锁”标志存放在lock字段的第24位。
注意,如果自旋锁为空,那么lock字段的值为0x01000000;如果写者已经获得自旋锁,那么lock字段的值为0x00000000;如果一个、两个或多个进程因为读获取了自旋锁,那么,lock字段的值为0x00ffffff,0x00fffffe等。与spinlock_t结构一样,rwlock_t结构也包括break_lock字段。
rwlock_init宏把读/写自旋锁的lock字段初始化为0x01000000,把break_lock初始化为0。
为读获取和释放一个锁
read_lock宏,作用于读/写自旋锁的地址rwlp,与前面一节所描述的spin_lock宏非常相似。如果编译内核时选择了内核抢占选项,read_lock宏执行与spin_lock()非常相似的操作,只有一点不同:该宏执行_raw_read_trylock()函数以在第2步有效地获取读/写自旋锁。
Int_rawread_trylock(rwlock_t *lock)
{
atomic_t*count =(atomic_t *)lock->lock;
atomic_dec(count);
if(atomic_read(count)>=0)
return1;
atomic_inc(count);
return0;
}
读/写锁计数器lock字段是通过原子操作来访问的。注意,尽管如此,便整个函数对计数器的操作并不是原子性的。例如,在用if语句完成对计数器值的测试之后并返回1之前,计数器的值可能发生变化。不过,函数能够正常工作:实际上,只有在递减之前计数器的值不为0或负数的情况下,函数才返回1,因为计数器等于0x01000000表示没有任何进程占用锁,等于0x00ffffff表示有一个读者,等于0x00000000表示有一个写者。
如果编译内核时没有选择内核抢占选项,read_lock宏产生下面的汇编语言代码:
movl$rwlp->lock,%eax
lock;subl$l,(%eax)
jnslf
call__read_lock_failed
l:
这里,__read_lock_failed()是下列汇编语言函数;
__read_lock_failed;
lock;incl(%eax)
l:pause
cmpl$l,(%eax)
jslb
lock;decl(%eax)
js__read_lock_failed
ret
read_lock宏原子地把自旋锁的值减1。由此增加读都的个数。如果递减操作产生一个非负值,就获得自旋锁,否则,调用__read_lock_failed()函数。该函数原子地增加lock字段以取消由read_lock宏执行的递减操作,然后循环,直到lock字段变为正数。接下来,__read_lock_failed()又试图获取自旋锁。
释放读自旋锁是相当简单的,因为read_unlock宏只需使用汇编语言指令简单地增加lock字段的计数器:
lock;incl rwlp->lock
以减少读者的计数,然后调用preempt_enable()重机关报启用内核抢占。
为写获取和释放一个锁
write_lock宏实现的方式与spin_lock()和read_lock()相似。例如,如果支持内核抢占,则该函数禁用内核抢占并通过调用__raw_write_trylock()立即获得锁。如果该函数返回0,说明锁已经被占用,因此该像前面章节描述的那样重新启用内核抢占并开始忙等待循环:
函数_raw_wirte_typelock()从读/写自旋锁的值中减去0x01000000,从而清除未上锁标志。如果减操作产生0值,则获取锁并返回1;否则,函数原子地在自旋锁的值上加0x01000000,以取消减操作。
释放写锁同样非常简单,因为write_unlock宏只需使用汇编语言指令lock;addl$0x01000000,rwlp把lock字段中的”未锁”标识置位。然后再调用preempt_enable()即可。
顺序锁
当使用读/写自旋锁时,内核控制路径发出的执行read_lock或write_lock操作的请求具有相同的优先权;读者必须等待,直到写操作完成。同样地,写者也必须等待,直到读操作完成。
Linux2.6中引入顺序锁,它与读/写自旋锁非常相似,只是它为写者赋予了较高的优先级;事实上,即使在读者正在读的时候也允许写者继续运行。这种策略的好处是写者永远不会等待,缺点是有些时候读者不得不反复多次读相同的数据直到它获得有效的副本。
每个顺序锁都是包括两个字段的seqlock_t结构;一个类型为spinlock_t的lock字段和一个整型的sequence字段,第二个字段是一个顺序计数器。每个读者必须在读数据前后两次读顺序计数器,并检查两次读到的值是否相同,如果不相同,说明新的写都已经开始写并增加了顺序计数器,因此暗示读者刚读到的数据是无效的。
通过把SEQLOCK_UNLOCKED赋给变量seqlock_t或执行seqlock_init宏,把seqlock_t变量初始化为”未上锁”。写者通过调用write_seqlock()和write_sequnlock()获取和释放顺序锁。第一个函数获取seqlock_t数据结构中的自旋锁,然后使顺序计数器加1;第二个函数再次增加顺序计数器,然后释放自旋锁。这样可以保证写者在写的过程中,计数器的值是奇数,并且当没有写者在改变数据的时候,计数器的值是偶数。读者执行下面的临界区代码:
unsigned int seq
do{
seq = read_seqbegin(&seqlock);
}while(read_seqretry(&seqlock,seq));
read_seqbegin()返回顺序锁的当前顺序号;如果局部变量seq的值是奇数,或seq的值与顺序锁的顺序计数器的当前值不匹配,read_seqretry()就返回1。
注意,当读者进入临界区时,不必禁用内核抢占;另一方面,由于写者获取自旋锁。所以它进入临界区时自动禁用内核抢占。
并不是每一种资源都可以使用顺序锁来保护。一般来说,必须在满足下述条件时才能使用顺序锁:
被保护的数据结构不包括被写者修改和被读者间接引用的指针。
读者的临界区代码没有副作用。
此外,读者的临界区代码应该简短,而且写者应该不常获取顺序锁,否则,反复的读访问会引起严重的开销。在Linux2.6中,使用顺序锁的典型例子包括保护一些与系统时间处理相关的数据结构。
读-拷贝-更新
读-拷贝-更新是为了保护在多数情况下被多个CPU读的数据结构而设计的另一种同步技术。RCU允许多个读者和写者并发执行。而且,RCU是不使用锁的,就是说,它不使用被所有CPU共享的锁或计数器,在这一点上与读/写自旋锁和顺序锁相比,RCU具有更大的优势。
RCU是如何不使用共享数据结构而令人惊讶地实现多个CPU同步呢?其关键的思想包括限制RCP的范围,如下所述:
RCU只保护被动态分配并通过指针引用的数据结构。
在被RCU保护的临界区中,任何内核控制路径都不能睡眠。
当内核控制路径要读取被RCU保护的数据结构时,执行宏rcu_read_lock(),它等同于preempt_disable()。接下来,读者间接引用该数据结构指针所对应的内存单元并开始读这个数据结构。正如在前面所强调的,读者在完成对数据结构的读操作之前,是不能睡眠的,用等同于preempt_enable()的宏rcu_read_unlock()标记临界区的结束。
我们可以想象,由于读者几乎不做任何事情来防止竞争条件的出现,所以写者不得不做得更多一些。事实上,当写者要更新数据结构时,它间接引用指针并生成整个数据结构的副本。接下来,写者修改这个副本。一但修改完毕,写者改变指向数据结构的指针,以使它指向被修改后的副本。由于修改指针值的操作是一个原子操作。所以旧副本和新副本对每个读者或写者都是可见的,在数据结构中不会出现数据崩溃。尽管如此,还需要内存屏障来保证:只有在数据结构被修改之后,已更新的指针对其它CPU才是可见的。如果把自旋锁与RCU结合起来以禁止定者的并发执行,就隐含地引入了这样的内存屏障。
然后,使用RCU技术的真正困难在于:写者修改指针时不能立即释放数据结构的旧副本。实际上,写者开始修改时,正在访问数据结构的读者可能还在读旧副本。只有在CPU上的所有读者都执行完宏rcu_read_unlock()之后,才可以释放旧副本。内核要求每个潜在的读者在下面的操作之前执行rcu_read_unlock()宏。
CPU执行进程切换
CPU开始在用户态
CPU执行空循环
对上述每种情况,我们说CPU已经经过了静止状态。
写者调用函数call_rcu()来释放数据结构的旧副本。当所有的CPU都通过静止状态之后,call_rcu()接受rcu_head描述符的地址和将要调用的回调函数的每CPU就周期性地检查本地CPU是否经过了一个静止状态。如果所有CPU都经过了静止状态,本地tasklet就执行链表中的所有回调函数。
RCU是Linux2.6中新加的功能,用在网络层和虚拟文件系统中.