linux内核中的各种“任务”都能看到内核地址空间,因而它们之间也需要同步和互斥。linux内核支持的同步/互斥手段包括:
技术 |
功能 |
作用范围 |
每CPU变量 |
为每个CPU复制一份数据 |
所有CPU |
原子操作 |
原子的读-修改-写一个计数器的指令 |
所有CPU |
内存屏障 |
避免指令被重新排序 |
本地CPU或所有CPU |
自旋锁 |
上锁并忙等待 |
所有CPU |
信号量 |
上锁并阻塞等待(sleep) |
所有CPU |
顺序锁 |
基于访问计数器上锁 |
所有CPU |
RCU |
不上锁的情况下通过指针访问共享数据结构 |
所有CPU |
completion |
通知/(等待另)一个任务完成 |
所有CPU |
关闭本地中断 |
在单个CPU上关闭中断(本CPU) |
本地CPU |
关闭本地软中断 |
在单个CPU(本CPU)上禁止可延迟函数的执行 |
本地CPU |
一、每CPU变量
首先必须明确最好的同步/互斥技术就是不许要同步/互斥。所有的同步/互斥技术都有性能上的代价。
每-CPU变量是最简单的同步手段,它实际上是数据结构的数组,系统的每个CPU对应数组中的一个元素。
使用每CPU变量时,每个CPU只能访问与它相关联的元素,因此每-CPU变量只能在特殊情形下被使用。
每-CPU变量会在主存中对其以确保它们会映射到不同的硬件cashe行。这样就可以确保并发访问每-CPU变量不会导致高速缓存的snooping和invalidation(这种操作会带来高昂的系统开销)。
虽然每CPU变量可以保护从不同CPU的并发访问,但是它并不能保护异步访问,比如中断和可延迟函数。另外,如果支持内核抢占,则每CPU变量可能会存在竞态。因而内核在访问每CPU变量时应该禁止内核抢占。
使用每CPU变量的宏和函数:
- DEFINE_PER_CPU(type, name) :该宏静态的分配一个名字为name类型为type的每-CPU变量。
- per_cpu(name, cpu):该宏选取名字为name的每CPU变量的对应于指定的cpu的元素
- _ _get_cpu_var(name) :该宏选择名字为name的每CPU变量的对应于本地cpu的元素
- get_cpu_var(name) :该宏关闭内核抢占,然后选择名字为name的每CPU变量的对应于本地cpu的元素
- put_cpu_var(name) :该宏打开内核抢占,未使用name
- alloc_percpu(type) :该宏动态分配一个类型为type的每CPU变量并返回其地址
- free_percpu(pointer) :该宏释放动态分配的每CPU变量,pointer为每CPU变量的地址
- per_cpu_ptr(pointer, cpu):该宏返回存放于地址pointer的每CPU变量对应于cpu的元素的地址
二、原子操作
有不少汇编指令是"读-修改-写"的类型的,也就是说这种指令要访问内存两次,一次读来获取旧的值,一次写来写入新的值。如果有两个或两个以上CPU同时发起了这种类型的操作,最终的结构就可能是错误的(每个CPU都读到了旧的值,然后做修改再写,这样最后的写会取胜,如果是两次加1的话,这种情形下,最终只会加一次1)。最简单的避免这种问题的方式是在芯片级保证这种操作是原子的。
当我们写代码时,我们无法确保编译器会使用原子的指令。因此lnux提供了一种特殊的类型atomic_t以及一些特殊的函数和宏,这样函数和宏作用于atomic_t的类型,并且被实现为单独的、原子的汇编指令。
linux中的原子操作:
- atomic_read(v) :返回*v的值
- atomic_set(v,i) :设置*v的值为i
- atomic_add(i,v) :将*v的值加i
- atomic_sub(i,v):将*v的值减i
- atomic_sub_and_test(i, v) :将*v的值减i并检查更新后的*v是否是0,如果是0则返回1
- atomic_inc(v) :将*v的值加1
- atomic_dec(v):将*v的值减1
- atomic_dec_and_test(v):将*v的值减1并检查更新后的*v是否是0,如果是0则返回1
- atomic_inc_and_test(v) :将*v的值加1并检查更新后的*v是否是0,如果是0则返回1
- atomic_add_negative(i, v) :将*v的值加i并检查更新后的*v是否是负值,如果是则返回1
- atomic_inc_return(v):将*v的值加1并返回更新后的*v的值
- atomic_dec_return(v):将*v的值减1并返回更新后的*v的值
- atomic_add_return(i, v) :将*v的值加i并返回更新后的*v的值
- atomic_sub_return(i, v) :将*v的值减i并返回更新后的*v的值
还有一些原子操作作用于位掩码:
- test_bit(nr, addr) :返回*addr的第nr比特
- set_bit(nr, addr) :设置*addr的第nr比特为1
- clear_bit(nr, addr) :将 *addr的第nr比特清为0
- change_bit(nr, addr):将*addr的第nr比特取反
- test_and_set_bit(nr, addr) :将*addr的第nr比特设置为1,并返回其旧值
- test_and_clear_bit(nr, addr):将*addr的第nr比特设置为0,并返回其旧值
- test_and_change_bit(nr, addr): 将*addr的第nr比特取反,并返回其旧值
- atomic_clear_mask(mask, addr) :将*addr中对应于mask的所有比特都清0
- atomic_set_mask(mask, addr):将*addr中对应于mask的所有比特都设置为1
三、优化和内存屏障
如果启用了编译器优化,指令的执行顺序和其在代码中的顺序不一定相同。此外,现代CPU通常会并行执行多条指令,并且可能重新安排内存访问。
然而在涉及同步时,指令重排可能会带来问题,如果放在同步原语之后的指令在同步原语之前被执行了,就可能会出问题。事实上所有的同步原语都起优化和内存屏障的作用。
优化屏障原语用于告诉编译器,保存在CPU寄存器中、在屏障之前有效的所有内存地址,在屏障之后都将失效。因而编译器在屏障之前发出的读写请求完成之前,不会处理屏障之后的任何读写请求。barrier( )宏是linux中的优化屏障原语。注意,这个原语并不保证CPU执行它们的顺序(由于并行执行的特性,后执行的指令可能先结束)。
内存屏障原语确保放在原语之前的语句在原语之后的语句开始执行之前结束执行。
linux使用了几个内存屏障原语,这些内存屏障原语也可以作为优化屏障。读内存屏障只适用于读操作,写内存屏障只适用于写操作。
- mb( ):用作单处理器以及多处理器架构上的内存屏障
- rmb( ) :用作单处理器以及多处理器架构上的内存读屏障
- wmb( ) :用作单处理器以及多处理器架构上的内存写屏障
- smp_mb( ):用作多处理器架构上的内存屏障
- smp_rmb( ) :用作多处理器架构上的内存读屏障
- smp_wmb( ):用作多处理器架构上的内存写屏障
四、自旋锁
1.自旋锁
自旋锁是广泛使用的同步技术,当内核要访问共享数据结构或者进入临界区时就要自己获取一把锁。当内核想要访问由锁保护的资源时,就要尝试获取这把锁,如果没有人当前持有这把锁,则它就能获得这把锁,然后它就可以访问这个资源了;如果有人已经持有了这把锁,则它就无法获取这把锁,也就无法访问这个资源了。很显然锁是协作性质的,即要求访问资源的所有任务都遵循先获取允许,再使用,再释放资源的原则。
自旋锁是用在多处理环境下的特殊的锁。使用自旋锁时,如果当前锁被锁住而无法获取锁,则请求锁的任务一直循环等待该锁被释放(表现为当前CPU一直循环等待锁的释放)。
一般来说,由自旋锁保护的临界区要禁止内核抢占。在单处理器系统上,自旋锁不起锁的作用,此时自旋锁原语仅仅是禁止或启用内核抢占。另外需要注意的是在自旋锁忙等期间,内核抢占还是有效的,因此等待自旋锁被释放的任务可能被更高优先级的任务所替代。
自旋锁除了忙等之外,还有另外一个需要注意的影响:由于自旋锁主要是在SMP之间进行同步,因而操作自旋锁的CPU都需要看到自旋锁所在的内存的最新的值,因而它对高速缓存也有影响。自旋锁只适用于保护短的代码片段。
2.自旋锁的数据结构和宏、函数
Linux自旋锁由spinlock_t数据结构表示,它主要包括一个域:
- slock: 表示自旋锁的状态,1表示“未加锁”状态,0和负值都表示“加锁”状态
自旋锁相关的宏(这些宏都基于原子操作):
- spin_lock_init( ) :将自旋锁初始化为1
- spin_lock( ):获取自旋锁,如果没办法获取就一直循环等待直到获取到自旋锁
- spin_unlock( ) :释放自旋锁
- spin_unlock_wait( ) :等待自旋锁被释放
- spin_is_locked( ) :如果自旋锁是上锁的,则返回0,否则返回1
- spin_trylock( ) :尝试获取自旋锁,如果无法获取就立即返回而不阻塞。获取到锁时会返回非0;否则返回0
除了这些版本外,还有可用于中断和软中断环境下的版本(中断版本:spin_lock_irq,会保存中断状态字的中断版本:spin_lock_irqsave,软中断版本:spin_lock_bh)。
3. 读写自旋锁
读写自旋锁是为了提高内核的并发能力。只要没有内核路径在修改数据结构,就可以允许多个内核路径同时读该数据结构。如果有内核路径想写该数据结构就必须获得写锁。简单的说就是写独占,读共享。
读写自旋锁由rwlock_t数据结构表示,它的lock域是一个32比特的字段,并且可以分为两个部分:
- 一个24比特的计数器,表示对受保护的数据结构并发的进行读访问的内核控制路径的个数,计数器的补码放在比特0-23。
- “未锁”标志字段,当没有内核控制路径在读或写时设置该位,否则清0。位于比特24
因而0x1000000表示未上锁,0x00000000表示写上锁,0x00ffffff表示一个读者,0xfffffe表示两个读者...
4.读写自旋锁的相关函数
- read_lock:为读获取自旋锁,它类似于spin_lock(也会禁止内核抢占),区别在于它运行并发读。它原子的把自旋锁的值减1,如果得到一个非负值,就获得自旋锁,否则就原子的增加自旋锁的值以取消减去的1,然后循环等待lock的值变为正值,lock的值变为正值后会继续尝试获取读自旋锁。
- read_unlock :为读释放自旋锁。它原子的减小lock字段的值,然后重新使能内核抢占。
注意:内核可能不支持抢占,这个时候可以忽略禁止和使能内核抢占的动作
- write_lock :为写获取自旋锁,它类似于spin_lock( ) 和read_lock( )(也会禁止内核抢占)。它原子的从lock字段减去0x1000000,如果得到一个0,就获得写锁,否则函数原子的在自旋锁的值上加0x1000000以取消减操作。接着等待lock的值变为0x01000000,条件满足后会继续尝试获取读自旋。
- write_unlock:为写释放自旋锁,它原子的给lock字段加上0x1000000,然后重新使能内核抢占。
和自旋锁类似,读写自旋锁也存在适用于中断和软中断的版本(中断版本:read_lock_irq,会保存中断状态字的中断版本:read_lock_irqsave,软中断版本:read_lock_bh)。