Linux Kernel Development——内核同步方法

  1. 云栖社区>
  2. 博客>
  3. 正文

Linux Kernel Development——内核同步方法

文艺小青年 2017-07-14 23:37:00 浏览610
展开阅读全文

在现代操作系统里,同一时间可能有多个内核执行流在执行,因此内核其实象多进程多线程编程一样也需要一些同步机制来同步各执行单元对共享数据的访问。尤其是在多处理器系统上,更需要一些同步机制来同步不同处理器上的执行单元对共享的数据的访问。在主流的Linux内核中包含了几乎所有现代的操作系统具有的同步机制,这些同步机制包括:原子操作、信号量(semaphore)、读写信号量(rw_semaphore)、spinlock、BKL(Big Kernel Lock)、rwlock、brlock(只包含在2.4内核中)、RCU(只包含在2.6内核中)和seqlock(只包含在2.6内核中)。

1、内核如何为不同的请求提供服务

[1]内核抢占

如果进程在执行内核函数时允许发生内核切换(被替换的进程是正执行内核函数的进程),这个内核就是抢占的。

抢占内核的主要特点 是:一个在内核态运行的进程,可能在执行内核函数期间被另外一个进程取代

使内核可抢占的目的是减少用户态进程的分配延迟(即从进程变为可执行状态到它实际开始运行之间的时间间隔)。内核抢占对执行及时被调度的任务(如电影播放器)的进程确实是有好处的,因为它降低了这种进程被另一个运行在内核态的进程延迟的风险。内核抢占会引起不容忽视的开销。因此Linux2.6独具特色地允许用户在编译内核时通过设置选项来禁用或启用内核抢占。

什么时候内核是可抢占的?

有几种情况Linux内核不应该被抢占,除此之外Linux内核在任意一点都可被抢占。这几种情况是:
内核正进行中断处理;
内核正在进行中断上下文的Bottom Half(中断的底半部)处理。硬件中断返回前会执行软中断,此时仍然处于中断上下文中;
内核的代码段正持有spinlock自旋锁、writelock/readlock读写锁等锁,处干这些锁的保护状态中;
内核正在执行调度程序Scheduler。抢占的原因就是为了进行新的调度,没有理由将调度程序抢占掉再运行调度程序;
内核正在对每个CPU“私有”的数据结构操作(Per-CPU date structures)。在SMP中,对于per-CPU数据结构未用spinlocks保护,因为这些数据结构隐含地被保护了(不同的CPU有不一样的per-CPU数据,其他CPU上运行的进程不会用到另一个CPU的per-CPU数据)。但是如果允许抢占,但一个进程被抢占后重新调度,有可能调度到其他的CPU上去,这时定义的Per-CPU变量就会有问题,这时应禁抢占。

为保证Linux内核在以上情况下不会被抢占,抢占式内核使用了一个变量preempt_ count,称为内核抢占锁。这一变量被设置在进程的PCB结构task_struct中。每当内核要进入以上几种状态时,变量preempt_ count就加1,指示内核不允许抢占。每当内核从以上几种状态退出时,变量preempt_ count就减1,同时进行可抢占的判断与调度。

从中断返回内核空间的时候,内核会检查need_resched和preempt_count的值。如果need_ resched被设置,并且preempt count为0的话,这说明可能有一个更为重要的任务需要执行并且可以安全地抢占,此时,调度程序就会被调用。如果preempt-count不为0,则说明内核现在处干不可抢占状态,不能进行重新调度。这时,就会像通常那样直接从中断返回当前执行进程。如果当前进程持有的所有的锁都被释放了,那么preempt_ count就会重新为0。此时,释放锁的代码会检查need_ resched是否被设置。如果是的话,就会调用调度程序。

内核抢占发生的时机

内核抢占可能发生在:

当从中断处理程序正在执行,且返回内核空间之前。
当内核代码再一次具有可抢占性的时候,如解锁及使能软中断等。
如果内核中的任务显式的调用schedule()
如果内核中的任务阻塞(这同样也会导致调用schedule())

[2]什么时候同步是必需的

当计算的结果依赖于两个或两个以上的交叉内核控制路径的嵌套方式时,可能出现竞争条件。临界区是一段代码,在其他的内核控制路径能够进入临界区前,进入临界区的内核控制路径必须全部执行完这段代码。

交叉内核控制路径使内核开发者的工作变得复杂:他们必须特别小心地识别出异常处理程序、中断处理程序、可延迟函数和内核线程中的临界区。一旦临界区被确定,就必须对其采用适当的保护措施,以确保在任何时刻只有一个内核控制路径处于临界区。

如果是单CPU的系统,可以采取访问共享数据结构时关闭中断的方式来实现临界区,因为只有在开中断的情况下,才可能发生内核控制路径的嵌套。

另外,如果相同的数据结构仅被系统调用服务例程所访问,而且系统中只有一个CPU,就可以非常简单地通过在访问共享数据结构时禁用内核抢占功能来实现临界区。

正如你们所预料的,在多处理器系统中,情况要复杂得多。由于许多CPU可能同时执行内核路径,因此内核开发者不能假设只要禁用内核抢占功能,而且中断、异常和软中断处理程序都没有访问过该数据结构,就能保证这个数据结构能够安全地被访问。

[3]什么时候同步是不必需的

所有的中断处理程序响应来自PIC的中断并禁用IRQ线。此外,在中断处理程序结束之前,不允许产生相同的中断事件。

中断处理程序、软中断和tasklet既不可以被抢占也不能被阻塞,所以它们不可能长时间处于挂起状态。在最坏的情况下,它们的执行将有轻微的延迟,因此在其执行的过程中可能发生其他的中断(内核控制路径的嵌套执行)

执行中断处理的内核控制路径不能被执行可延迟函数或系统调用服务例程的内核控制路径中断

软中断和tasklet不能在一个给定的CPU上交错执行

同一个tasklet不可能同时在几个CPU上执行。

简化的例子:

中断处理程序和tasklet不必编写成可重入的函数
仅被软中断和tasklet访问的每CPU变量不需要同步
仅被一种tasklet访问的数据结构不需要同步

2、同步原语

我们考察一下在避免共享数据之间的竞争条件时,内核控制路径是如何交错执行的。

[1]每CPU变量

事实上每一种显式的同步原语都有不容忽视的性能开销。

最简单也是最重要的同步技术包括把内核变量声明为每CPU变量(per-cpuvariable)每CPU变量主要是数据结构的数组,系统的每个CPU对应数组的一个元素。

一个CPU不应该访问与其他CPU对应的数组元素,另外,它可以随意读或修改它自己的元素而不用担心出现竞争条件,因为它是唯一有资格这么做的CPU,但是,这也意味着每CPU变量基本上只能在特殊情况下使用,也就是当它确定在系统的CPU上的数据在逻辑上是独立的时候。

每CPU的数组元素在主存中被排列以使每个数据结构存放在硬件高速缓存的不同行,因此,对每CPU数组的并发访问不会导致高速缓存行的窃用和实效

虽然每CPU变量为来自不同CPU的并发访问提供保护,但对来自异步函数(中断处理程序和可延迟函数)的访问不提供保护,在这种情况下需要另外的同步原语。

总的来看每CPU变量的特点有:

用于多个CPU之间的同步,如果是单核结构,每CPU变量没有任何用处。
每CPU变量不能用于多个CPU相互协作的场景。(每个CPU的副本都是独立的)
每CPU变量不能解决由中断或延迟函数导致的同步问题
访问每CPU变量的时候,一定要确保关闭进程抢占,否则一个进程被抢占后可能会更换CPU运行,这会导致每CPU变量的引用错误

[2]原子操作

所谓原子操作,就是该操作绝不会在执行完毕前被任何其他任务或事件打断,也就说,它的最小的执行单位,不可能有比它更小的执行单位,因此这里的原子实际是使用了物理学里的物质微粒的概念。

若干汇编语言指令具有“读-修改-写”类型--也就是说,它们访问存储器单元两次,第一次读原值,第二次写新值。假定运行在两个CPU上的两个内核控制路径试图通过执行非原子操作来同时“读-修改-写”同一存储器单元。首先,两个CPU都试图读同一单元,但是存储器仲裁器(对访问RAM芯片的操作进行串行化的硬件电路)插手,只允许其中的一个访问而让另一个延迟。然而,当第一次读操作已经完成后,延迟的CPU从那个存储器单元正好读到同一次被存储器仲裁器串行化,最终,两个写操作都成功。但是,全局的结果是不对的,因为两个CPU写入同一(新)值。因此,两个交错的“读-修改-写”操作成了一个单独的操作。

避免由于“读-修改-写”指令引起的竞争条件的最容易的办法,就是确保这样的操作在芯片级是原子的。任何一个这样的操作都必须以单个指令执行,中间不能中断,且避免其他的CPU访问同一存储器单元。这些很小的原子操作可以建立在其他更灵活机制的基础之上以创建临界区。

在你编写C代码程序时,并不能保证编译器会为a=a+1或甚至像a++这样的操作使用一个原子指令。这是因为原子操作需要硬件的支持,它是架构相关的,其API和原子类型的定义都定义在内核源码树的include/asm/atomic.h文件中,它们都使用汇编语言实现,而C语言并不能实现这样的操作。

原子操作主要用于实现资源计数,很多引用计数(refcnt)就是通过原子操作实现的。

原子类型的定义:

typedef struct { 
int counter; 
} atomic_t;

针对原子类型,内核定义了一些列操作函数,包括:

static inline void atomic_set(atomic_t *v, int i);
static inline void atomic_add(int i, atomic_t *v);
static inline void atomic_sub(int i, atomic_t *v);
static inline int atomic_sub_and_test(int i, atomic_t *v);
static inline void atomic_inc(atomic_t *v);
static inline void atomic_dec(atomic_t *v);
static inline int atomic_dec_and_test(atomic_t *v);
static inline int atomic_inc_and_test(atomic_t *v);
static inline int atomic_add_negative(int i, atomic_t *v);
static inline int atomic_add_return(int i, atomic_t *v);
static inline int atomic_sub_return(int i, atomic_t *v);
static inline int atomic_xchg(atomic_t *v, int new);

[3]优化和内存屏蔽

当使用优化的编译器时,你千万不要认为指令会严格按它们在源代码中出现的顺序执行。例如,编译器可能重新安排汇编语言指令以使寄存器以最优的方式使用。此外,现代CPU通常并行地执行若干条指令,且可能重新安排内存访问。这种重新排序可以极大地加速程序的执行

然而,当处理同步时,必须避免指令重新排序。如果放在同步原语之后的一条指令在同步原语本身之前执行,事情很快就会变得失控。事实上,所有的同步原语起优化和内存屏蔽的作用。

优化屏障(optimizationbarrier)原语保证编译程序不会混淆放在原语操作之前的汇编语言指令和放在原语操作之后的汇编语言指令,这些汇编语言指令在C中都有对应的语句 。在Linux中,优化屏障就是barrier()宏。volatile关键字禁止编译器把asm指令与程序中的其他指令重新组合。memory关键字强制编译器假定RAM中的所有内存单元已经被汇编语言指令修改;因此编译器不能使用存放在CPU寄存器中的内存单元的值来优化asm指令前的代码。注意,优化屏障并不保证不使当前CPU把汇编语言指令混在一起执行---这是内存屏障的工作

内存屏障(memorybarrier)原语确保,在原语之后的操作开始执行之前,原语之前的操作已经完成。因此,内存屏蔽类似于防火墙,让任何汇编语言指令都不能通过。

Linux使用六个内存屏障原语,如下所示:

mb() 适用于MP和UP的内存屏障
rmb() 适用于MP和UP的读内存屏障
wmb() 适用于MP和UP的写内存屏障
smp_mb() 仅适用于MP的内存屏障
smp_rmb() 仅适用于MP的读内存屏障
smp_wmb() 仅适用于MP的写内存屏障

注:“读内存屏障”仅仅作用于从内存读的指令,而“写内存屏障”仅仅作用于写内存的指令。

这些原语也被当作优化屏障,因为我们必须保证编译程序不在屏障前后移动汇编语言指令。

 

[4]自旋锁

当内核控制路径必须访问共享数据结构或进入临界区时,就需要为自己获取一把“锁”。由锁机制保护的资源非常类似于限制于房间内的资源,当某个进入房间时,就把门锁上。如果内核控制路径希望访问资源,就试图获取钥匙“打开门”。当且仅当资源空闲时,它才能成功。然后,只要它还想使用这个资源,门就依然锁着。当内核控制路径释放了锁时,门就打开,另一个内核控制路径就可以进入房间。图5-1

自旋锁(spinlock)是用来在多处理器环境中工作的一种特殊的锁。如果内核控制路径发现自旋锁“开着”就获取并继续自己的执行。相反,如果内核控制路径发现锁由运行在另一个CPU上的内核控制路径“锁着”,就在周围“旋转”,反复执行一条紧凑的循环指令,直到锁被释放。

一般来说,由自旋锁所保护的每个临界区都禁止内核抢占的。在单处理器系统上,这种锁本身并不起锁的作用,自旋锁语言仅仅是禁止或启用内核抢占。请注意,在自旋锁忙等期间,内核抢占还是有效的,因此,等待自旋锁释放的进程有可能被更高优先级的进程替代。

自旋锁的操作

(1)具有内核抢占的spin_lock宏

操作 :
1、调用preempt_disable()以禁用内核抢占
2、调用函数_raw_spin_trylock(),它对自旋锁的slock字段执行原子性的测试和设置操作
3、如果自旋锁中的旧值是正数,宏结束:内核控制路径已经获得自旋锁
4、否则,内核控制路径无法获得自旋锁,因此宏必须执行循环一直到其他CPU上运行的内核控制路径释放自旋锁。
5、如果break_lock字段等于0,则把它设置为1.通过检测该字段,拥有锁并在其他CPU上运行的进程可以知道是否有其他进程在等待这个锁。如果进程把持某个自旋锁时间太长,它可以提前释放锁以使等待相同自旋锁的进程能够继续向前运行
6、执行等待循环
7、跳转回到第1步,再次试图获取自旋锁

(2)非抢占式内核中的spin_lock宏

如果在内核编译时没有选择内核抢占选项,spin_lock宏就与前面描述的spin_lock宏有很大的区别。在这种情况下,宏生成一个汇编语言程序片段,它本质上等价于下面紧凑的忙等待

1:lock;decb slp->slock
jns 3f
2: pause
cmpb $0,slp->slock
jle 2b
jmp 1b

汇编语言指令decb递减自旋锁的值,该指令是原子的,因为它带有lock字节前缀。随后检测符号标志,如果它被清0,说明自旋锁被设置1(未锁),因此从标记3处继续正常执行(后缀f表示标签是“向前的”,它在程序的后面出现)。否则,在标签2处(后缀b表示,“向后的”标签)执行紧凑循环直到自旋锁出现正值。然后从标签1处开始重新执行,因为不检查其他的处理器是否抢占了锁就继续执行是不安全的。

获得自旋锁和释放自旋锁有好几个版本,因此让读者知道在什么样的情况下使用什么版本的获得和释放锁的宏是非常必要的。

如果被保护的共享资源只在进程上下文访问和软中断上下文访问,那么当在进程上下文访问共享资源时,可能被软中断打断,从而可能进入软中断上下文来对被保护的共享资源访问,因此对于这种情况,对共享资源的访问必须使用spin_lock_bh和spin_unlock_bh来保护。当然使用spin_lock_irq和spin_unlock_irq以及spin_lock_irqsave和spin_unlock_irqrestore也可以,它们失效了本地硬中断,失效硬中断隐式地也失效了软中断。但是使用spin_lock_bh和spin_unlock_bh是最恰当的,它比其他两个快。
如果被保护的共享资源只在进程上下文和tasklet或timer上下文访问,那么应该使用与上面情况相同的获得和释放锁的宏,因为tasklet和timer是用软中断实现的。
如果被保护的共享资源只在一个tasklet或timer上下文访问,那么不需要任何自旋锁保护,因为同一个tasklet或timer只能在一个CPU上运行,即使是在SMP环境下也是如此。实际上tasklet在调用tasklet_schedule标记其需要被调度时已经把该tasklet绑定到当前CPU,因此同一个tasklet决不可能同时在其他CPU上运行。timer也是在其被使用add_timer添加到timer队列中时已经被帮定到当前CPU,所以同一个timer绝不可能运行在其他CPU上。当然同一个tasklet有两个实例同时运行在同一个CPU就更不可能了。
如果被保护的共享资源只在两个或多个tasklet或timer上下文访问,那么对共享资源的访问仅需要用spin_lock和spin_unlock来保护,不必使用_bh版本,因为当tasklet或timer运行时,不可能有其他tasklet或timer在当前CPU上运行。 如果被保护的共享资源只在一个软中断(tasklet和timer除外)上下文访问,那么这个共享资源需要用spin_lock和spin_unlock来保护,因为同样的软中断可以同时在不同的CPU上运行。
如果被保护的共享资源在两个或多个软中断上下文访问,那么这个共享资源当然更需要用spin_lock和spin_unlock来保护,不同的软中断能够同时在不同的CPU上运行。
如果被保护的共享资源在软中断(包括tasklet和timer)或进程上下文和硬中断上下文访问,那么在软中断或进程上下文访问期间,可能被硬中断打断,从而进入硬中断上下文对共享资源进行访问,因此,在进程或软中断上下文需要使用spin_lock_irq和spin_unlock_irq来保护对共享资源的访问。而在中断处理句柄中使用什么版本,需依情况而定,如果只有一个中断处理句柄访问该共享资源,那么在中断处理句柄中仅需要spin_lock和spin_unlock来保护对共享资源的访问就可以了。因为在执行中断处理句柄期间,不可能被同一CPU上的软中断或进程打断。但是如果有不同的中断处理句柄访问该共享资源,那么需要在中断处理句柄中使用spin_lock_irq和spin_unlock_irq来保护对共享资源的访问。
在使用spin_lock_irq和spin_unlock_irq的情况下,完全可以用spin_lock_irqsave和spin_unlock_irqrestore取代,那具体应该使用哪一个也需要依情况而定,如果可以确信在对共享资源访问前中断是使能的,那么使用spin_lock_irq更好一些,因为它比spin_lock_irqsave要快一些,但是如果你不能确定是否中断使能,那么使用spin_lock_irqsave和spin_unlock_irqrestore更好,因为它将恢复访问共享资源前的中断标志而不是直接使能中断。当然,有些情况下需要在访问共享资源时必须中断失效,而访问完后必须中断使能,这样的情形使用spin_lock_irq和spin_unlock_irq最好。
需要特别提醒读者,spin_lock用于阻止在不同CPU上的执行单元对共享资源的同时访问以及不同进程上下文互相抢占导致的对共享资源的非同步访问,而中断失效和软中断失效却是为了阻止在同一CPU上软中断或中断对共享资源的非同步访问。

(3)spin_unlock宏

释放以前获得的自旋锁

[5]读写自旋锁

读写自旋锁的引入是为了增加内核的并发能力。只要没有内核控制路径对数据结构进行修改,读写自旋锁就允许多个内核控制路径同时读同一数据结构。如果一个内核控制路径想对这个结构进行写操作,那么它必须首先获取读写锁得写锁,写锁授权独占访问这个资源。当然,允许对数据结构并发读可以提高系统性能。

每个读写自旋锁都是是一个rwlock_t结构,其lock字段是一个32位的字段,分为两个不同的部分:
24位计数器,表示对受保护的数据结构并发地进行读操作的内核控制路径的数目。这个计数器的二进制补码存放在这个字段的0-23位
“未锁”标志字段,当没有内核控制路径在读或写时设置该位,否则清0.这个“未锁”标志存放在lock字段的第24位

读写自旋锁操作函数:

(1)为读获取和释放一个锁:read_lock宏
(2)为写获取和释放一个锁:write_lock宏

[6]顺序锁

当使用读写自旋锁时,内核控制路径发出的执行read_lock或write_lock操作的请求具有相同的优先权:读者必须等待,直到写操作完成。同样地,写者也必须等待,直到读操作完成

Linux2.6中引入了顺序锁(seqlock),它与读写自旋锁非常相似,只是它为写者赋予了较高的优先级:事实上,即使在读者正在读的时候也允许写者继续运行。这种策略的好处是写者永远不会等待(除非另外一个写者正在写),缺点是有些时候读者不得不反复多次读相同的数据直到它获得有效的副本

每个顺序锁都是包括两个字段的seqlock_t结构 ;一个类型为spinlock_t的lock字段和一个整型的sequence字段,第二个字段是一个顺序计数器

注意:
(1)当读者进入临界区时,不必禁用内核抢占;另一方面,由于写者获取自旋锁,所以它进入临界区时自动禁用内核抢占
(2)并不是每一种资源都可以使用顺序锁来保护。一般来说,必须在满足下述条件时才能使用顺序锁:
被保护的数据结构不包括被写者修改和被读者间接引用的指针
读者的临界区代码没有副作用
(3)读者的临界区代码应该简短,而且写着应该不常获取顺序锁,否则,反复的读访问会引起严重的开销


[7]读-拷贝-更新

读-拷贝-更新(RCU)是为了保护在多数情况下被多个CPU读的数据结构而设计的另一种同步技术。RCU允许多个读者和写者并发执行(相对于只允许一个写者执行的顺序锁有了改进)。而且,RCU是不使用锁的,就是说,它不使用被所有CPU共享的锁或计数器,在这一点上与读写自旋锁和顺序锁(由于高速缓存行窃用和失效而有很高的开销)相比,RCU具有更大的优势。

关键的思想包括限制RCU的范围,如下:

1、RCU只保护被动态分配并通过指针引用的数据结构
2、在被RCU保护的临界区中,任何内核控制路径都不能睡眠

当内核控制路径要读取被RCU保护的数据结构时,执行宏rcu_read_lock(),它等同于preempt_disable()

我们还可以想象,由于读者几乎不做任何事情来防止竞争条件的出现,所以写者不得不做得更多一些。事实上,当写者要更新数据结构时,它间接引用指针并生成整个数据结构的副本。接下来,写者修改这个副本。一旦修改完毕,写者改变指向数据结构的指针,以使它指向被修改后的副本。由于修改指针值的操作是一个原子操作,所以旧副本和新副本对每个读者或写者都是可见的,在数据结构中不会出现数据崩溃。尽管如此,还需要内存屏蔽来保证:只有在数据结构被修改之后,已更新的指针对其他CPU才是可见的。如果把自旋锁与RCU结合起来以禁止写者的并发执行,就隐含地引入了这样的内存屏蔽。然后,使用RCU技术的真正困难在于:写者修改指针时不能立即释放数据结构的旧副本。实际上,写着开始修改时,正在访问数据结构的读者可能还在读旧副本。只有在CPU上的所有(潜在的)读者都执行完宏rcu_read_unlock()之后,才可以释放旧副本。内核要求每个潜在的读者在下面的操作之前执行rcu_read_unlock()宏:

CPU执行进程切换
CPU开始在用户态执行
CPU执行空循环

对上述每种情况,我们说CPU已经经过了静止状态。

[8]内核信号量

从本质上说,内核信号量实现了一个枷锁原语,即让等待者睡眠,直到等待的资源变为空闲。

内核信号量在创建时需要设置一个初始值,表示同时可以有几个任务可以访问该信号量保护的共享资源,初始值为1就变成互斥锁(Mutex),即同时只能有一个任务可以访问信号量保护的共享资源。一个任务要想访问共享资源,首先必须得到信号量,获取信号量的操作将把信号量的值减1,若当前信号量的值为负数,表明无法获得信号量,该任务必须挂起在该信号量的等待队列等待该信号量可用;若当前信号量的值为非负数,表示可以获得信号量,因而可以立刻访问被该信号量保护的共享资源。当任务访问完被信号量保护的共享资源后,必须释放信号量,释放信号量通过把信号量的值加1实现,如果信号量的值为非正数,表明有任务等待当前信号量,因此它也唤醒所有等待该信号量的任务。

注意:
(1)内核信号量与System V IPC信号量毫不相干:内核信号量由内核控制路径使用,而System V IPC信号量由用户态进程使用;
(2)只有可以睡眠的函数才能获取内核信号量,中断处理函数不能使用内核信号量。

内核信号量是struct semaphore类型的对象:
/* Please don't access any members of this structure directly */ 
struct semaphore { 
raw_spinlock_t lock; 
unsigned int count; 
struct list_head wait_list; 
};

可以用init_MUTEX()和init_MUTEX_LOCKED()函数来初始化互斥访问所需的信号量:这两个宏分别把count字段设置成1(互斥访问的资源空闲)和0(对信号量进行初始化的进程当前互斥访问的资源忙)。宏DECLEAR_MUTEX和DECLARE_MUTEX_LOCKED完成同样的功能,但它们也静态分配semaphore结构的变量。注意也可以把信号量中的count初始化为任意的正数值n,在这种情况下,最多有n个进程可以并发地访问这个资源。

获取和释放信号量

当进程希望释放内核信号量锁时,就调用up()函数
up()函数增加*sem信号量count字段的值,然后,检查它的值是否大于0. 
当进程希望获取内核信号量锁时,就调用down()函数
down()函数减少*sem信号量的count字段的值,然后检查该值是否为负。


[9]读写信号量

读写信号量类似于读写自旋锁,不同的是:在读写信号量再次变为打开之前,等待进程挂起而不是自旋。

读写信号量对访问者进行了细分,或者为读者,或者为写者,读者在保持读写信号量期间只能对该读写信号量保护的共享资源进行读访问,如果一个任务除了需要读,可能还需要写,那么它必须被归类为写者,它在对共享资源访问之前必须先获得写者身份,写者在发现自己不需要写访问的情况下可以降级为读者。读写信号量同时拥有的读者数不受限制,也就说可以有任意多个读者同时拥有一个读写信号量。如果一个读写信号量当前没有被写者拥有并且也没有写者等待读者释放信号量,那么任何读者都可以成功获得该读写信号量;否则,读者必须被挂起直到写者释放该信号量。如果一个读写信号量当前没有被读者或写者拥有并且也没有写者等待该信号量,那么一个写者可以成功获得该读写信号量,否则写者将被挂起,直到没有任何访问者。因此,写者是排他性的,独占性的。

读写信号量有两种实现,一种是通用的,不依赖于硬件架构,因此,增加新的架构不需要重新实现它,但缺点是性能低,获得和释放读写信号量的开销大;另一种是架构相关的,因此性能高,获取和释放读写信号量的开销小,但增加新的架构需要重新实现。在内核配置时,可以通过选项CONFIG_RWSEM_GENERIC_SPINLOCK去控制使用哪一种实现:

(1)arch specific implementation:

#ifdef CONFIG_RWSEM_GENERIC_SPINLOCK 
#include <linux/rwsem-spinlock.h> /* use a generic implementation */
#else 
/* All arch specific implementations share the same struct */ 
struct rw_semaphore { 
long count; 
raw_spinlock_t wait_lock; 
struct list_head wait_list; 
#ifdef CONFIG_DEBUG_LOCK_ALLOC 
struct lockdep_map dep_map; 
#endif 
};

(2)generic implementation:

/*
* the rw-semaphore definition
* - if activity is 0 then there are no active readers or writers
* - if activity is +ve then that is the number of active readers
* - if activity is -1 then there is one active writer
* - if wait_list is not empty, then there are processes waiting for the semaphore
*/
struct rw_semaphore {
__s32 activity;
raw_spinlock_t wait_lock;
struct list_head wait_list;
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
};

读写信号量操作接口:

init_rwsem()初始化rw_semaphore, count=0,wait_lock置为未锁,wait_list置空
down_read()和down_write()分别为读或写获取读写信号量
down_read_trylock()和down_write_trylock()类似于上面的两个函数,但它不会导致调用者睡眠,它尽力得到读写信号量sem,如果能够立即得到,它就得到该读写信号量,并且返回1,否则表示不能立刻得到该信号量,返回0
up_read()和up_write() 为读或写释放以前获取的读写信号量
downgrade_write() 把写锁降级为读锁,由于写者是排他性的,将为读者后,其他读者可以获得该资源


[10]补充原语(完成变量)

Linux2.6还使用了另一种类似于信号量的原语:补充(completion)引入这种原语是为了解决多处理器系统上发生的一种微妙的竞争条件,当进程A分配了一个临时信号量变量,把它初始化为关闭的MUTEX,并把其地址传递给进程B,然后在A之上调用down(),进程A打算一但被唤醒就撤销该信号量。随后,运行在不同CPU上的进程B在同一信号量上调用up()。然而,up()和down()的目前实现还允许这两个函数在同一个信号量上并发执行。因此,进程A可以被唤醒并撤销临时信号量,而进程B还在运行up()函数。结果,up()可能试图访问一个不存在的数据结构

当然,也可以改变up()和down()的实现以禁止在同一信号量上并发执行。然而,这种改变可能需要另外的指令,这对于频繁使用的函数来说不是什么好事。

补充是专门设计来解决以上问题的同步原语. completion数据结构包含一个等待队列头和一个标志

与up()对应的函数叫做complete()
与down()对应的函数叫wait_for_completion()

补充原语和信号量之间的真正差别在于如何使用等待队列中包含的自旋锁。在补充原语中,自旋锁用来确保completion()和wait_for_completion()不会并发执行。在信号量中,自旋锁用于避免并发执行的down()函数弄乱信号量的数据结构

[11]禁止本地中断

确保一组内核语句被当作一个临界区处理的主要机制之一就是中断禁止。即使当硬件设备产生了一个IRQ信号时,中断禁止也让内核控制路径继续执行,因此,这就提供了一种有效的方式,确保中断处理程序访问的数据结构页受到保护。然而,禁止本地中断并不保护运行在另一个CPU上的中断处理程序对数据结构的并发访问,因此,在多处理器系统上,禁止本地中断经常与自旋锁结合使用。

宏local_irq_disable()使用cli汇编语言指令关闭本地CPU上的中断,宏local_irq_enable()使用sti汇编语言指令打开被关闭的中断

[12]禁止和激活可延续函数

可延续函数可能在不可预知的时间执行(实际上是在硬件中断处理程序结束时)。因此,必须保护可延续函数访问的数据结构使其避免竞争条件

禁止可延迟函数在一个CPU上执行的一种简单方式就是禁止在那个CPU上的中断。因为没有中断处理程序被激活,因此,软中断操作就不能异步地开始。

内核有时需要只禁止可延续函数而不禁止中断。通过操纵当前thread_info描述符preempt_count字段中存放的软中断计数器,可以在本地CPU上激活或禁止可延续函数。


3、对内核数据结构的同步访问

系统性能可能随所选择同步原语种类的不同而有很大变化。通常情况下,内核开发者采用下述由经验得到的法则:把系统中的并发度保持在尽可能高的程度。

系统中的并发度又取决于两个主要因素:

同时运转的IO设备数
进行有效的工作的CPU数

为了使IO吞吐量最大化,应该使中断禁止保持在很短的时间。

为了有效地的利用CPU,应该尽可能避免使用基于自旋锁的同步原语。当一个CPU执行紧指令循环等待自旋锁打开时,是在浪费宝贵的机器周期 。就像我们面前所描述的,更糟糕的是:由于自旋锁对硬件高速缓存的影响而使其对系统的整体性能产生不利影响

让我们举例说明在下列两种情况下,既可以维持较高的并发度,也可以达到同步:

(1)共享的数据结构是一个单独的整数值,可以把它声明为atomic_t类型并使用原子操作对其更新。原子操作比自旋锁和中断禁止都快, 只有在几个内核控制路径同时访问这个数据结构是速度才会慢下来

(2)把一个元素插入到共享链表的操作决不是原子的,因为这至少涉及两个指针赋值。不过,内核有时并不用锁或禁止中断就可以执行这种插入操作

在C语言中,插入链表通过下列指针赋值实现的:

new->next=list_element->next;
list_element->next=new;

第一条指令建立new元素的next指针,但不修改链表。因此,如果中断处理程序在第一条指令和第二条指令执行的中间查看这个链表,看到的就是没有新元素的链表。如果该处理程序在第二条指令执行后查看链表,就会看到有新元素的链表。关键是,在任一种情况下,链表都是一致的且处于未损坏状态。然后,只有在中断处理程序不修改链表的情况下才能确保这种完整性。如果修改了链表,那么在new元素内刚刚设置的next指针就可能变为无效的

需要加一个写内存屏蔽原语:

new->next=list_element->next;
wmb();
list_element->next=new;


如何在自旋锁、信号量及中断禁止之间选择

同步原语的选取取决于访问数据结构的内核控制路径的种类。记住,只要内核控制路径获得自旋锁(还有读、写锁、顺序锁或RCU“读锁”),就禁用本地中断或本地软中断,自动禁用内核抢占。

(1)保护异常所访问的数据结构

当一个数据结构仅由异常程序访问时,竞争条件通常是易于理解也易于避免的。最常见的产生同步问题的异常就是系统调用服务例程,在这种情况下,CPU运行在内核态而为用户态程序提供服务。因此,仅由异常访问的数据结构通常表示一种资源,可以分配给一个或多个进程。

竞争条件可以通过信号量避免,因为信号量原语允许进程睡眠到资源变为可用。注意,信号量工作方式在单处理器系统和多处理器系统上完全相同。

(2)保护中断所访问的数据结构

假定一个数据结构仅被中断处理程序的“上半部分”访问。中断处理程序本身不能同时多次运行 。因此,访问数据结构就无需任何同步原语。

但是,如果多个中断处理程序访问一个数据结构 ,情况就有所不同了。一个处理程序可以中断另一个处理程序,不同的中断处理程序可以在多个处理器系统上同时运行。没有同步,共享的数据结构就很容易被破坏 。

在单处理器系统上,必须通过在中断处理程序的所有临界区上禁止中断来避免竞争条件。只能用这种方式进行同步,因为其他的同步原语都不能完成这件事。

多处理器系统上,避免竞争条件最简单的方法是禁止本地中断,并获取保护数据结构的自旋锁或读、写自旋锁。注意,这些附加的自旋锁不能冻结系统,因为即使中断处理程序发现锁被关闭,在另一个CPU上拥有锁得中断处理程序最终也释放这个锁。

(3)保护可延迟函数所访问的数据结构

只被可延迟函数访问的数据结构需要那种保护呢?这主要取决于可延迟函数的种类。软中断和tasklet本质上有不同的并发度。

在单处理器系统上不存在竞争条件。这是因为可延迟函数执行总是在一个CPU上串行进行---也就是说,一个可延迟函数不会被另一个可延迟函数中断。因此,根本不需要同步原语。

在多处理器系统上,竞争条件的确存在,因为几个可延迟函数可以并发运行。

由软中断访问的数据结构必须受到保护,通常使用自旋锁进行保护,因为同一个软中断可以在两个或多个CPU上并发运行。相反,仅有一种tasklet访问的数据结构不需要保护,因为同种tasklet不能并发运行。但是,如果数据结构被几种tasklet访问,那么,就必须对数据结构进行保护。

(4)保护异常和中断访问的数据结构

在单处理器系统上,竞争条件的防止是相当简单的,因为中断处理程序不是可重入的且不能被异常中断。只要内核以本地中断禁止访问数据结构,内核在访问数据结构的过程中就不会被中断。不过,如果数据结构正好是被一种中断处理程序访问,那么,中断处理程序不用禁止本地中断就可以自由地访问数据结构。

在多处理器系统上,我们必须关注异常和中断在其他CPU上的并发执行。本地中断禁止还必须外加自旋锁,强制并发的内核控制路径进行等待,直到访问数据结构的处理程序完成自己的工作。

(5)保护异常和可延迟函数访问的数据结构

异常和可延迟函数都访问的数据结构与异常和中断处理程序访问的数据结构处理方式类似。事实上,可延迟函数本质上是由中断的出现激活的,而可延迟函数执行时不可能产生异常。因此,把本地中断禁止与自旋锁结合起来就足够了。

(6)保护中断和可延迟函数所访问的数据结构

类似于中断和异常处理程序访问的数据结构:本地中断禁用与自旋锁

(7)保护异常、中断和可延迟函数所访问的数据结构

类似于前面的情况,禁止本地中断和获取自旋锁几乎总是避免竞争条件所必需的。注意,没有必要显式地禁止可延迟函数,因为当中断处理程序终止执行时,可延迟函数才能被实质激活;因此,禁止本地中断中断就做够了。

4、死锁

产生死锁的四个必要条件:

(1) 互斥条件:一个资源每次只能被一个进程使用。
(2) 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
(3) 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
(4) 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。

那么如何避免的死锁呢,其实只要保证上述四个条件之一不成立即可。下面这些简单的规则对避免死锁很有帮助:
(1)保证加锁的顺序是关键,特别是嵌套的时候一定要保证以相同顺序获得锁;
(2)防止发生饥饿;
(3)不要重复请求同一个锁;
(4)设计简单的加锁方案。

5、避免竞争条件的实例

[1]引用计数器

引用计数器广泛地用在内核中以避免由于资源的并发分配和释放而产生的竞争条件。引用计数器(reference counter)只不过是一个atomic_t计数器,与特定的资源,如内存页,模块或文件相关。当内核控制路径开始使用资源是就原子地减少计数器的值,当内核控制路径使用完资源时就原子地增加计数器。当引用计数器变为0时,说明该资源未被使用,如果必要,就释放该资源

[2]大内核锁BKL

从2.6.11开始,用一个叫做kernel_sem的信号量来实现大内核锁(在较早的版本中,大内核锁是通过自旋锁来实现的)。但是,大内核锁比简单的信号量要复杂一些。

BKL的特性:

BKL是一种递归锁,一个进程可以多次请求BKL
BKL可以睡眠
BKL可以用在进程上下文中
内核中不鼓励使用BKL


[3]内存描述符读/写信号量

mm_struct类型的每个内存描述符mmap_sem字段中都包含了自己的信号量。由于几个轻量级进程之间可以共享一个内存描述符,因此,信号量保护这个描述符以避免可能产生的竞争条件

[4]slab高速缓存链表的信号量

slab高速缓存描述符链表是通过cache_chain_sem信号量保护的,这个信号量允许互斥地访问和修改该链表

[5]索引节点的信号量

Linux把磁盘文件的信息存放在一种叫做索引节点(inode)的内核对象中。相应的数据结构也包括有自己的信号量,存放在i_sem字段中。

在文件系统的处理过程中会出现很多竞争条件。实际上,磁盘上的每个文件都是所有用户共有的一种资源,因此所有进程都(可能)会存取文件的内容、修改文件名或文件位置、删除或复制文件等等。

只要一个程序使用了两个或多个信号量,就存在死锁的可能,因为两个不同的控制路径可能互相死等着释放信号量。一般来说,Linux在信号量请求上很少会发生死锁问题,因为每个内核控制路径通常一次只需要获得一个信号量。然而在有些情况下,内核必须获得两个或更多的信号量锁。索引点信号量倾向于这种情况。例如:在rename()系统调用的服务例程中就会发生这种情况。在这种情况下,操作涉及两个不同的索引节点,因此,必须采用两个信号量。为了避免这样的死锁,信号量的请求按预先确定的地址顺序进行


本文转自feisky博客园博客,原文链接:http://www.cnblogs.com/feisky/archive/2013/03/12/2956162.html,如需转载请自行联系原作者

网友评论

登录后评论
0/500
评论
文艺小青年
+ 关注