内核同步机制

1. 临界区和竞争条件

所谓临界区,就是多任务系统中访问和操作共享资源的代码段。如果某个共享资源在同一时刻被多个进程访问和操作,也就是有多个进程同时进入了同一个临界区,这种情况就叫做竞争条件。一旦发生了竞争条件,就有可能出现不同进程之间相互覆盖共享数据,使得共享资源处于不一致、不确定的状态——很多时候,会导致严重的后果,并且难以跟踪和调试。所谓内核同步,就是指通过某种手段保护共享资源,以避免竞争条件的出现。

2. 原子性和顺序性

同步机制种类繁多,但是基本的原理很简单。首先要保证临界区的操作具有原子性——就是一次操作要么完整的发生,要么压根不发生;要么100%,要么0。如果某一时刻进程A正在访问某个共享资源r,那么所有其他的进程B,C,D...都被禁止访问r,直到A完事了,其他的进程才有可能访问r;不能出现A访问了一半,被其它进程抢了的情况。

除了原子性之外,有时(注意,只是有时)还需要保证指令之间的顺序性——即便两条或多条指令出现在独立的执行线程中,甚至不同的CPU中,他们之间仍然按照固有的顺序来执行。编译器和CPU为了提高效率,经常会对指令进行重排序,大部分情况下这是无害的,但有的时候只有程序员才知道什么是正确的顺序,编译器和CPU的重排序可能会导致程序出错。这种情况下同步机制就有必要了——程序员需要显式的告诉编译器和CPU:这里不要重排序。内核提供了一种叫做barrier的同步机制来保证顺序性。

3. 锁的原理

内核通过锁来实现原子性。通过使用锁,可以确保一次只能有一个线程访问共享数据。任何线程试图访问共享数据之前都要占住一个锁,其他线程试图访问同一个共享数据时,会发现它已经被锁定了,因此访问被禁止;当访问者访问完毕,解除锁定之后,其他线程才可以访问共享数据。值得强调的是,锁并不是针对代码,而是针对共享资源的。如果一个数据可能被别的执行线程访问,就要给这个数据加锁。

锁的使用完全依赖于程序员的主观能动性,并没有任何机制来强制这一点。但如果程序员没能在需要锁的时候正确的使用锁,就会导致竞争条件的发生,产生严重的后果。此外,锁最好在开始设计代码的时候就考虑清楚,而不是等到问题发生了再追加,那样会事倍功半。

内核中实现了许多种锁,它们各自适用于不同的场合。按照锁被其他进程占有时请求者进程的表现,可以将锁划分为忙等锁和睡眠锁两类:如果一个忙等锁被占用,请求者会简单的执行忙等待;如果一个睡眠锁被占用,请求者会被插入等待队列,也就是进入睡眠。另外不同的锁的加锁粒度也是不同的。加锁粒度用来表示被加锁保护的共享数据的规模。例如,一个粗粒度的锁可能会保护大块的数据,比如一个子系统用到的所有的数据结构,而一个细粒度的锁可能只用来保护某个链表中的一个节点。粒度越粗,调用者需要做的事情就越简单,但是锁可能会被高度争用(lock contention)——同一时刻太多人因为需要访问共享数据而被锁住,这显然会严重影响系统性能,这个时候可以通过降低加锁粒度来降低锁争用程度,因为只锁定一个节点肯定比锁定整个链表造成的争用要小。不过粒度太细的话,锁机制的复杂度就会上升,并加大开销,特别是在小型SMP或UP机器上,如果争用不频繁就不需要太精细的锁。一般的设计思路是初期时设计尽量简单,有需要的时候再进一步细化加锁粒度。

说到锁就不得不提死锁。不过关于死锁已经是老生常谈了,这里只简单说一下,为了避免死锁发生,当需要嵌套的使用多个锁时,必须按照相同的顺序去获取它们,这样可以防止致命拥抱。也就是说,如果两个或更多的锁在某个执行线程被请求,那么以后其他执行线程请求它们时也要按照相同的加锁顺序。

4. 几种内核同步机制概述

1) 原子操作
内核提供的原子操作接口,可以保证指令以原子方式执行——执行过程不可以被打断。共有两种原子操作接口,一组针对整数,一组针对单独的比特位进行操作。针对整数的原子操作有一个专门的目标数据类型:atomic_t,也就是说,只能对atomic_t类型的数据进行整型原子操作。atomic_t定义如下:

typedef struct{ 
    volatile int counter;
} atomic_t;

volatile关键字确保了编译器不对counter值进行访问优化,每次都要从内存中读取counter值,而不是从某个寄存器里。

原子操作最常见的用途是用来实现计数器,使用atomic_inc()或者atomic_dec()就可以实现计数器了。原子操作比其他的锁机制开销都小,所以能用原子操作就尽量不要用复杂的加锁机制。

2) per-cpu variables
当创建一个per-cpu variable时,每个CPU都创建一个副本。这么做看起来有些奇怪,但在某些方面有优势。访问per-cpu variable几乎不需要加锁,因为每个CPU都有自己的工作副本。 并且per-cpu variables能够驻留在对应processor的cache中,对于频繁访问能带来显著的性能提升。

网络子系统中可以看到per-cpu variable的应用。在网络子系统中,内核会对每一种收到的数据包维护一个计数器,用来统计包的个数。因为计数器的++操作没有尽头,每一秒钟计数器可能++成千上万次,如果需要锁机制的话就太不划算,于是就采用per-cpu variable来实现——无需锁,非常快。如果用户空间需要查看计数器(这种事情发生频率很低),只要把各个CPU的计数器求和就可以了。

访问per-cpu variable基本上不需要锁机制,但是由于2.6内核允许抢占,所以必须防止访问per-cpu variable的时候发生下面两种情形:CPU被抢占;或者进程被转移到别的CPU上。这个时候就需要显式的调用宏get_cpu_var来获取当前CPU的per-cpu variable副本,并在完事时调用宏put_cpu_var。get_cpu_var返回的是当前CPU的per-cpu variable副本的左值,调用的同时还会禁止抢占;相应的,put_cpu_var会重新激活抢占。例如,网络子系统的计数器可以用下面两句话来++:

get_cpu_var(sockets_in_use)++;
put_cpu_var(sockets_in_use);

3) spinlock
自旋锁是一种轻量级忙等锁,它可以防止多于一个执行线程同时进入一个临界区。自旋锁最多只能由一个可执行线程持有。如果一个执行线程试图获取一个已经被占用的自旋锁(也就是发生了争用),那么线程会一直忙循环而不会让出CPU,直到该锁重新可用。如果锁未被占用,则线程可以立即得到它,并访问相关的共享资源。

由于自旋锁会让请求它的线程在锁重新可用之前忙等待,所以自旋锁不应该被长时间占有,只适用于短时间轻量级加锁。也因此,自旋锁可以在中断处理程序中使用。自旋锁的使用如下面的例子:

DEFINE_SPINLOCK(my_spinlock);
spin_lock(&my_spinlock);
spin_unlock(&my_spinlock);

在多处理器系统上,自旋锁经常与禁止本地中断结合使用。禁止本地中断(local interrupt disabling),使得即使硬件设备产生一个IRQ信号时,内核控制路径也能继续执行,从而保护了中断处理程序也访问的数据结构。然而禁止中断并不能阻止运行在另一个CPU上的中断处理程序对数据结构的并发访问,这时候仍需要加锁。内核提供了一个禁止中断和同时请求锁的接口:

DEFINE_SPINLOCK(my_spinlock);
unsigned long flags;
spin_lock_irqsave(&my_spinlock,flags);//保存中断的当前状态,并禁止本地中断,然后去获取指定的自旋锁
spin_unlock_irqstore(&my_spinlock,flags);//对指定的锁进行解锁,让中断恢复到加锁前的状态

最后还有一点需要提的:自旋锁不可递归,如果一个线程试图获取一个它正在持有的锁,那么它会把自己锁死。

4) 读写自旋锁
当某种共享数据的操作可以明确划分为读/写两类,也就是符合消费者/生产者模型的时候,可以采用更专业的读写自旋锁。一个或多个读任务可以并发持有读者锁;而写者锁一次只能由一个写任务占有,并且此时不能有并发的读操作。读锁和写锁的操作一般位于不同的代码段中,也就是读锁位于读任务中,写锁位于写任务中。相比写者而言,读写自旋锁更偏爱读者一些:当有人在读时,写者只能等待;而多个读者可以同时读。要小心,大量读者可能会让写者饥饿。

和普通自旋锁一样,读写自旋锁也是种轻量级忙等锁。概括起来,自旋锁提供了一个简单快速的加锁机制。如果代码不需要睡眠,加锁时间也不长(比如在中断处理程序中),那么自旋锁是最佳选择。如果加锁时间比较长,或者代码在持有锁的时候有可能睡眠,那么就需要使用信号量了。

5) semaphore
信号量是一种睡眠锁。如果一个执行线程试图获取一个已经被占用的信号量,它将让出CPU,并被插入到该信号量的等待队列中睡眠,直到这个信号量可用时才被重新唤醒,并获得该信号量。由于是睡眠而不是忙等,因而系统可以获得更高的CPU利用率;不过信号量比自旋锁开销大一些,需要折中考虑。

将信号量跟自旋锁对比是很有意义的事情。信号量适用于锁被长期持有的情形,如果只是被短期持有,那么信号量不划算,因为维护等待队列、睡眠和唤醒操作都需要开销,耗时甚至可能比锁被占用的时间还长。由于执行线程在等待信号量时会睡眠,所以只能在进程上下文获取信号量,不能在中断上下文中获取,中断上下文只能获取自旋锁。当需要和用户空间同步时,代码往往需要睡眠,这个时候只能使用信号量。信号量不同于自旋锁的另外一点是,它不会禁止内核抢占,所以持有信号量的代码可以被抢占。最后值得一提的是,信号量可以同时允许多个锁持有者,而自旋锁一个时刻最多只能被一个任务持有。信号量同时允许的持有者数目可以在声明信号量时指定,如果这个数目大于1,就叫做计数信号量(counting semaphore),如果数目等于1,一般被称为互斥信号量。计数信号量允许同一时刻多个进程访问临界区,内核中出现这种情况并不多,大部分情况下都是使用互斥信号量。

6) 读写信号量
读写信号量首先是一个互斥信号量,同时它类似于读写自旋锁,允许拍他的一个写者和并发的多个读者;但在信号量再次变为可用之前,等待进程进入睡眠而不是自旋。如果访问操作可以明白的区分读和写,那么读写信号量比普通信号量更合适。

7) mutex
mutex是一种用来实现互斥的特定睡眠锁,其实也是一个互斥信号量,但是更专业,实现更高效,接口更简单,使用限制也更多。

mutex具体讲有下面一些特点:

* 任何时刻只能有一个任务持有mutex,这是不言而喻的;
* 给mutex上锁者负责解锁——不能在一个上下文中上锁,在另一个上下文中解锁,这个限制使得mutex不适合内核和用户空间复杂的同步情形。通常的使用方式是在同一上下文中加锁和解锁。
* mutex不可递归。不能试图获取一个已经获取的mutex,也不能试图解锁一个已经解锁的mutex。
* 当持有一个mutex时,进程不可退出。
* mutex不可在中断或者下半部中使用,即使使用mutex_trylock()也不行。
* mutex只能通过官方API管理。

至于mutex,信号量以及自旋锁的比较,原则上讲如果既能用mutex又能用信号量,那要优先选择mutex。如果是需要低开销加锁,短期加锁,以及中断上下文加锁,则不能使用mutex或者信号量,需要使用自旋锁。

8) completion
不同于mutex,completion可以用于任务间的同步。当内核中的一个任务需要发出信号通知另一个任务发生了某个事件,completion是个不错的选择。例如,任务A正在执行一些操作,任务B在通过调用wait_for_completion(struct completion*)来等待。当A完成操作之后,会通过complete(struct completion*)调用还发送信号唤醒任务B。

9) seqlock
顺序锁类似于读写自旋锁,用于读者/写者之间同步数据。不同的是,顺序锁更偏爱写者——即使读者正在读,写者也可以写;除非有人正在写,否则写者随时可写。读者相对来说要做出牺牲,有时候不得不反复读取,直到读到有效的数据。每个reader都必须在读数据前后两次读顺序计数器,并检查两次读到的值是否相同,如果相同,说明读操作没有被写操作打断过,数据仍然是有效的;如果不同,说明两次读取的时间间隔中发生了写操作,暗示reader刚读到的数据现在已经失效,需要重新读。此外,如果读到的是偶数,那表明写操作没有发生,因为锁的初值为0,写操作会使值变成奇数。

写锁的方法如下:

write_lock(&seq_lock);
write_unlock(&seq_lock);

加锁,解锁都是直接对seq_lock->seq++,并且需要spinlock保护这个seq字段,其实也隐含了写者操作时是关抢占的。这里还有两个限制,如果加锁解锁的次数比较多,可能造成溢出;同时由于写者是关抢占的,需要写者操作CRITICAL SECTION时不能执行睡眠操作。

读锁的方法如下:

unsigned long seq;
do{
    seq = read_seqbegin(&seq_lock);   
}while(read_seqretry(&seq_lock, seq));

read_seqbegin要达到的功能是,如果有写者,则等待写者完成,否则直接返回seq:

read_seqbegin(struct seqlock_t* lock){
    do{
         ret = lock->seq;
    }while(ret&1);//seq变成奇数,表示有人进行了写操作,循环等待
    return ret;
}

read_seqretry要实现的功能是,如果读者在读的过程中,有写者进入并对seq进行了修改,则返回1,要求重新读取,读者对seq字段不会修改。

read_seqretry(struct seqlock_t* lock, unsigned long seq){
    if(lock->seq!=seq)
        return 1;
    else
        return 0;
}

10) barrier
不同于以上的内核同步机制,barrier并非为了保障原子性,而是为了保障顺序性。程序员可以通过barrier来指示编译器不要对给定点周围的指令进行重排序。例如,mb()方法就是一种barrier,他可以确保读(load)和写(store)操作都不会跨越mb()进行重排序:rmb()也是种barrier,它确保load操作不会跨越rmb()进行重排序。见下面例子:

线程1      线程2
a=3;        c=b;
mb();       rmb();
b=4;        d=a;

这里mb()确保了a,b之间的顺序,rmb()确保了c,d之间的顺序。由此可见,barrier可以防止编译器跨屏障对load和store操作进行优化。

5. 并发,抢占,中断与同步的关系

正是因为有并发,所以才需要同步,同步的目的是避免因为并发而导致的竞争条件。无论是多处理器上的真并发,还是单处理器上通过scheduler实现的伪并发,都有可能产生竞争条件。除此之外,中断、软中断、tasklet、内核抢占等等都存在不同执行线程并发访问共享数据的风险,因此都是需要同步的。

如果你在临界区中有可能被抢占(或中断),也就是说在访问某个共享资源的时候有可能被抢占(或中断),那是非常危险的。因为抢占者进程或者中断服务程序有可能试图访问同一个共享资源(当然,他会被锁在门外,然后等待somebody释放钥匙);问题在於你被抢占是没有机会交出钥匙的,于是死锁就发生了。

所以,答案就是,在进入临界区之前要先看一看有没有可能被抢占或中断;如果有可能,就禁止抢占和中断。几个例子:
1) 自旋锁是非抢占区域的标志。如果一个任务持有自旋锁,那么这个任务就不能被抢占(而持有信号量的任务是可以被抢占的,因为可以睡眠);
2) 当下半部和进程上下文有共享数据时,由于下半部可能会抢占进程上下文,所以需要对进程上下文的共享数据进行保护,在获取锁之前还要禁止下半部;
3) 由于中断处理程序有可能抢占下半部,所以如果中断处理程序跟下半部共享数据,下半部就需要在进入临界区之前禁止中断;
4) 由于同类的tasklet不能同时运行,所以同类tasklet中的共享数据不需要加锁。不过如果不同类的tasklet共享数据,那就需要锁机制了。这里不需要禁止下半部,因为同一个CPU上不会出现tasklet相互抢占的情况;
5) 对于软中断,由于同种类型的软中断也可能同时运行(在不同CPU上),所以锁机制是必须的。不过同一个CPU上不会出现软中断相互抢占的情况,所以也不需要禁止下半部。

关于禁止抢占,有必要多说几句。可以通过preempt_disable()来禁止内核抢占,通过preempt_enable()来取消禁止。preempt_disable()是可以嵌套调用的,可以连续调用任意次,但只有调用同样次数的preempt_enable()之后,内核抢占才会被激活。抢占计数用来统计被持有自旋锁的数目加上preempt_disable()的调用次数,如果它等于0,则可以抢占,否则不能抢占。函数preempt_count()返回这个值。

总结一下:有共享就要加锁;如果有共享又有抢占(或中断),那么在加锁之前,还要禁止抢占(或中断)。

6. 如何避免麻烦的同步

同步很麻烦,又要加锁又要屏障,有没有什么办法可以避免同步呢?办法只有一个,就是避免使用共享资源——如果你不跟别的进程有瓜葛,自然就少了很多麻烦事。为了避开共享资源,你的代码需要做到以下几点:
1) 不得使用静态(被static修饰)的数据类型;
2) 不得调用malloc()或free();
3) 不得调用标准I/O函数;
4) 不得调用其他的共享资源等。
当然,完全避开是不可能的,不过能避开还是尽量避开吧。

7. 参考资料

linux kernel development, 3rd edtion, robert love

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值